diff --git a/docs/cn/ai-providers.md b/docs/cn/ai-providers.md index 8ab5b07f..fd73eda7 100644 --- a/docs/cn/ai-providers.md +++ b/docs/cn/ai-providers.md @@ -192,7 +192,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint 可选的自定义 URL: ```bash -OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_BASE_URL=http://localhost:11434/api ``` ### Vercel AI Gateway diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index cce04dcd..ef61e4b4 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -194,7 +194,7 @@ AI_MODEL=llama3.2 Optional custom URL: ```bash -OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_BASE_URL=http://localhost:11434/api ``` ### ModelScope diff --git a/docs/ja/ai-providers.md b/docs/ja/ai-providers.md index 3974a90d..532c1a56 100644 --- a/docs/ja/ai-providers.md +++ b/docs/ja/ai-providers.md @@ -179,7 +179,7 @@ AI_MODEL=llama3.2 任意のカスタム URL: ```bash -OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_BASE_URL=http://localhost:11434/api ``` ### ModelScope diff --git a/env.example b/env.example index 3ababdad..c812e00f 100644 --- a/env.example +++ b/env.example @@ -70,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # AZURE_REASONING_SUMMARY=detailed # Ollama Configuration (Local or Cloud) -# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434) +# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api) # OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3) diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index bf040440..a524b197 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -25,6 +25,7 @@ import { getEnvFallback } from "@/lib/admin/settings" import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection" import { normalizeBaseUrl, + ollamaApiUrl, PROVIDER_INFO, type ProviderName, } from "@/lib/types/model-config" @@ -1025,7 +1026,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { ? PROVIDER_INFO.ollama.defaultBaseUrl : resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL")) model = createOllama({ - ...(baseURL && { baseURL }), + ...(baseURL && { baseURL: ollamaApiUrl(baseURL) }), ...(apiKey && { headers: { Authorization: `Bearer ${apiKey}` }, }), diff --git a/lib/provider-models.ts b/lib/provider-models.ts index a809da75..da3dbf0f 100644 --- a/lib/provider-models.ts +++ b/lib/provider-models.ts @@ -3,6 +3,7 @@ import { getModelInfo } from "@/lib/model-catalog" import { readLimitedBody } from "@/lib/read-limited-body" import { normalizeBaseUrl, + ollamaApiUrl, PROVIDER_INFO, type ProviderName, } from "@/lib/types/model-config" @@ -200,8 +201,11 @@ export async function listProviderModels( break } case "ollama": { - const api = base.endsWith("/api") ? base : `${base}/api` - const data = await getJson(`${api}/tags`, bearer, fetchFn) + const data = await getJson( + `${ollamaApiUrl(base)}/tags`, + bearer, + fetchFn, + ) models = (data.models ?? []).map((m: { name: string }) => ({ id: m.name, })) diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index d8acad4f..4f341aa5 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -622,6 +622,15 @@ export function normalizeBaseUrl(url: string): string { .replace(/\/(?:chat\/completions|completions|messages|responses)$/, "") } +/** + * Ollama's native API root, which the SDK appends /chat to and the model + * list /tags. Users often enter the server address ("http://localhost:11434") + * or its OpenAI-compatible one (".../v1"); both get /api. + */ +export function ollamaApiUrl(baseUrl: string): string { + return `${normalizeBaseUrl(baseUrl).replace(/\/(?:api|v1)$/, "")}/api` +} + /** Where a chat request goes for a base URL, or null when the SDK decides */ export function chatRequestUrl( provider: ProviderName, @@ -630,6 +639,7 @@ export function chatRequestUrl( const url = normalizeBaseUrl(baseUrl) if (!url) return null if (provider === "anthropic") return `${url}/messages` + if (provider === "ollama") return `${ollamaApiUrl(url)}/chat` // These SDKs build their own paths (or, for MiniMax, pick the protocol // from the URL) const ownPaths: ProviderName[] = [ @@ -637,7 +647,6 @@ export function chatRequestUrl( "vertexai", "azure", "bedrock", - "ollama", "gateway", "minimax", "edgeone", diff --git a/tests/unit/ai-providers.test.ts b/tests/unit/ai-providers.test.ts index f01f2195..a8030989 100644 --- a/tests/unit/ai-providers.test.ts +++ b/tests/unit/ai-providers.test.ts @@ -434,7 +434,7 @@ describe("Ollama API key security", () => { expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: "https://my-ollama.com", + baseURL: "https://my-ollama.com/api", headers: { Authorization: "Bearer client-key" }, }), ) @@ -448,7 +448,7 @@ describe("Ollama API key security", () => { expect(createOllamaMock).toHaveBeenCalledWith( expect.objectContaining({ - baseURL: "https://cloud.ollama.com", + baseURL: "https://cloud.ollama.com/api", headers: { Authorization: "Bearer server-key" }, }), ) @@ -483,7 +483,24 @@ describe("Ollama API key security", () => { expect(createOllamaMock).toHaveBeenCalledTimes(1) const callArgs = createOllamaMock.mock.calls[0][0] - expect(callArgs.baseURL).toBe("https://my-ollama.com") + expect(callArgs.baseURL).toBe("https://my-ollama.com/api") expect(callArgs).not.toHaveProperty("headers") }) + + it("sends chat to Ollama's /api for a server address or a /v1 URL", () => { + delete process.env.OLLAMA_API_KEY + + for (const baseUrl of [ + "http://localhost:11434", + "http://localhost:11434/", + "http://localhost:11434/v1", + "http://localhost:11434/api", + ]) { + createOllamaMock.mockClear() + getAIModel({ provider: "ollama", baseUrl, modelId: "llama3.2" }) + expect(createOllamaMock.mock.calls[0][0].baseURL).toBe( + "http://localhost:11434/api", + ) + } + }) }) diff --git a/tests/unit/base-url.test.ts b/tests/unit/base-url.test.ts index 67742fe8..7c84c667 100644 --- a/tests/unit/base-url.test.ts +++ b/tests/unit/base-url.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest" -import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config" +import { + chatRequestUrl, + normalizeBaseUrl, + ollamaApiUrl, +} from "@/lib/types/model-config" describe("normalizeBaseUrl", () => { it("drops spaces, trailing slashes and a pasted endpoint path", () => { @@ -32,6 +36,9 @@ describe("chatRequestUrl", () => { expect( chatRequestUrl("anthropic", "https://proxy.example.com/v1"), ).toBe("https://proxy.example.com/v1/messages") + expect(chatRequestUrl("ollama", "http://localhost:11434")).toBe( + "http://localhost:11434/api/chat", + ) }) it("stays out of the way for SDKs that build their own paths", () => { @@ -40,3 +47,21 @@ describe("chatRequestUrl", () => { expect(chatRequestUrl("glm", " ")).toBeNull() }) }) + +describe("ollamaApiUrl", () => { + it("points at Ollama's /api whatever form the address takes", () => { + for (const url of [ + "http://localhost:11434", + "http://localhost:11434/", + "http://localhost:11434/api", + "http://localhost:11434/api/", + "http://localhost:11434/v1", + "http://localhost:11434/v1/chat/completions", + ]) { + expect(ollamaApiUrl(url)).toBe("http://localhost:11434/api") + } + expect(ollamaApiUrl("https://ollama.com/api")).toBe( + "https://ollama.com/api", + ) + }) +})