diff --git a/packages/pi-coding-agent/src/core/model-discovery.test.ts b/packages/pi-coding-agent/src/core/model-discovery.test.ts index 76e36186e..6852f160f 100644 --- a/packages/pi-coding-agent/src/core/model-discovery.test.ts +++ b/packages/pi-coding-agent/src/core/model-discovery.test.ts @@ -255,7 +255,7 @@ describe("singularity-memory discovery", () => { // ─── Ollama Cloud Adapter ─────────────────────────────────────────────────── describe("ollama-cloud discovery", () => { - it("uses the live Ollama /api/tags endpoint", async () => { + it("uses the live Ollama /v1/models endpoint", async () => { const originalFetch = globalThis.fetch; const calls: Array<{ url: string; headers?: RequestInit["headers"] }> = []; globalThis.fetch = (async ( @@ -265,10 +265,7 @@ describe("ollama-cloud discovery", () => { calls.push({ url: String(input), headers: init?.headers }); return new Response( JSON.stringify({ - models: [ - { name: "kimi-k2.5", size: 1 }, - { model: "kimi-k2.6", size: 1 }, - ], + data: [{ id: "kimi-k2.5" }, { id: "kimi-k2.6" }], }), { status: 200 }, ); @@ -278,7 +275,7 @@ describe("ollama-cloud discovery", () => { const adapter = getDiscoveryAdapter("ollama-cloud"); const models = await adapter.fetchModels("test-key"); - assert.equal(calls[0]?.url, "https://ollama.com/api/tags"); + assert.equal(calls[0]?.url, "https://ollama.com/v1/models"); assert.deepEqual(calls[0]?.headers, { Authorization: "Bearer test-key", }); @@ -286,6 +283,41 @@ describe("ollama-cloud discovery", () => { models.map((m) => m.id), ["kimi-k2.5", "kimi-k2.6"], ); + assert.ok(models.every((m) => m.api === "openai-completions")); + assert.ok(models.every((m) => m.baseUrl === "https://ollama.com/v1")); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("falls back to /api/tags when /v1/models is unavailable", async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return new Response("not found", { status: 404 }); + } + return new Response( + JSON.stringify({ + models: [{ name: "glm-5.1" }, { model: "qwen3-coder:480b" }], + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const adapter = getDiscoveryAdapter("ollama-cloud"); + const models = await adapter.fetchModels("test-key"); + + assert.deepEqual(calls, [ + "https://ollama.com/v1/models", + "https://ollama.com/api/tags", + ]); + assert.deepEqual( + models.map((m) => m.id), + ["glm-5.1", "qwen3-coder:480b"], + ); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/pi-coding-agent/src/core/model-discovery.ts b/packages/pi-coding-agent/src/core/model-discovery.ts index 9e991af73..c47bb0612 100644 --- a/packages/pi-coding-agent/src/core/model-discovery.ts +++ b/packages/pi-coding-agent/src/core/model-discovery.ts @@ -117,28 +117,54 @@ class OllamaCloudDiscoveryAdapter implements ProviderDiscoveryAdapter { baseUrl?: string, ): Promise { const root = (baseUrl ?? "https://ollama.com").replace(/\/+$/, ""); - const url = root.endsWith("/api/tags") ? root : `${root}/api/tags`; + const primaryUrl = + root.endsWith("/v1/models") || root.endsWith("/api/tags") + ? root + : `${root}/v1/models`; const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined; - const response = await fetchWithTimeout(url, { headers }); + const response = await fetchWithTimeout(primaryUrl, { headers }); - if (!response.ok) { - throw new Error( - `Ollama Cloud tags API returned ${response.status}: ${response.statusText}`, - ); + if (response.ok) { + return this.mapOpenAIModelsResponse(await response.json()); } - const data = (await response.json()) as { - models: Array<{ name?: string; model?: string; size?: number }>; - }; + if (primaryUrl.endsWith("/v1/models")) { + const fallbackUrl = `${root}/api/tags`; + const fallback = await fetchWithTimeout(fallbackUrl, { headers }); + if (fallback.ok) return this.mapTagsResponse(await fallback.json()); + } - return (data.models ?? []) + throw new Error( + `Ollama Cloud models API returned ${response.status}: ${response.statusText}`, + ); + } + + private mapOpenAIModelsResponse(data: unknown): DiscoveredModel[] { + const models = (data as { data?: Array<{ id?: string }> }).data ?? []; + return models + .map((m) => m.id) + .filter((id): id is string => typeof id === "string" && id.length > 0) + .map((id) => this.toOpenAICompletionsModel(id)); + } + + private mapTagsResponse(data: unknown): DiscoveredModel[] { + const models = + (data as { models?: Array<{ name?: string; model?: string }> }).models ?? + []; + return models .map((m) => m.name ?? m.model) .filter((id): id is string => typeof id === "string" && id.length > 0) - .map((id) => ({ - id, - name: id, - input: ["text" as const], - })); + .map((id) => this.toOpenAICompletionsModel(id)); + } + + private toOpenAICompletionsModel(id: string): DiscoveredModel { + return { + id, + name: id, + api: "openai-completions", + baseUrl: "https://ollama.com/v1", + input: ["text" as const], + }; } } diff --git a/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts b/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts index 16260b879..90e59c73c 100644 --- a/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts +++ b/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts @@ -128,7 +128,7 @@ describe("ModelRegistry — public discovery providers", () => { globalThis.fetch = (async () => new Response( JSON.stringify({ - models: [{ name: "kimi-k2.5" }, { model: "kimi-k2.6" }], + data: [{ id: "kimi-k2.5" }, { id: "kimi-k2.6" }], }), { status: 200 }, )) as typeof fetch; @@ -157,6 +157,37 @@ describe("ModelRegistry — public discovery providers", () => { } }); + it("makes discovered ollama-cloud models available when auth is configured", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [{ id: "kimi-k2.6" }], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const registry = new ModelRegistry( + AuthStorage.inMemory({ + "ollama-cloud": { type: "api_key", key: "ollama-test" }, + }), + undefined, + new ModelDiscoveryCache(join(testDir, "ollama-cloud-auth-cache.json")), + ); + await registry.discoverModels(["ollama-cloud"]); + + assert.ok( + registry + .getAvailable() + .some((m) => m.provider === "ollama-cloud" && m.id === "kimi-k2.6"), + "discovered Ollama Cloud models are available after discovery", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("discovers direct provider models when auth is configured", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => diff --git a/packages/pi-coding-agent/src/core/model-registry.ts b/packages/pi-coding-agent/src/core/model-registry.ts index 98d8be914..295bfcc56 100644 --- a/packages/pi-coding-agent/src/core/model-registry.ts +++ b/packages/pi-coding-agent/src/core/model-registry.ts @@ -839,7 +839,9 @@ export class ModelRegistry { */ getAvailable(providerModelAllow?: ProviderModelAllowList): Model[] { return this.filterProviderModelAllow( - this.models.filter((m) => this.isProviderRequestReady(m.provider)), + this.getAllWithDiscovered().filter((m) => + this.isProviderRequestReady(m.provider), + ), providerModelAllow, ); } diff --git a/src/cli.ts b/src/cli.ts index 395fed8d6..a269952c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -69,6 +69,17 @@ function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { process.exit(1); } +async function warmDiscoveryBackedProviders( + modelRegistry: ModelRegistry, +): Promise { + const providers = ["ollama-cloud"].filter((provider) => + modelRegistry.isProviderRequestReady(provider), + ); + if (providers.length === 0) return; + + await modelRegistry.discoverModels(providers); +} + // --------------------------------------------------------------------------- // Shared helpers used by both the print and interactive code paths // --------------------------------------------------------------------------- @@ -603,6 +614,8 @@ const modelsJsonPath = resolveModelsJsonPath(); const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath); markStartup("ModelRegistry"); +await warmDiscoveryBackedProviders(modelRegistry); +markStartup("ModelRegistry.discovery"); const settingsManager = SettingsManager.create(process.cwd(), agentDir); applySecurityOverrides(settingsManager); markStartup("SettingsManager.create");