diff --git a/packages/hub-client/src/credential-test.ts b/packages/hub-client/src/credential-test.ts index 8dd9f483..25dfb298 100644 --- a/packages/hub-client/src/credential-test.ts +++ b/packages/hub-client/src/credential-test.ts @@ -618,6 +618,20 @@ export async function fetchOllamaModelCapabilities( * to fall back to a heuristic (name-sorting, a curated allowlist) to * tell a chat model from an embedding one (CL-6351/CL-6366). */ +/** + * Ollama's `-cloud` suffix names a model that this instance merely proxies + * to ollama.com — it answers `/api/tags` whether or not the box is signed + * in, but every actual inference call needs an ollama.com account and its + * own credential, which this catalog has no way to supply. Seeding one + * puts a keyed cloud upstream into what is otherwise an unauthenticated + * local chain (CL-6645). Excluded here rather than filtered by capability + * because a signed-in box's `-cloud` model still reports full + * capabilities — the suffix, not the capability probe, is what marks it. + */ +function isCloudProxyModel(modelName: string): boolean { + return modelName.endsWith("-cloud") || modelName.endsWith(":cloud"); +} + export async function fetchOllamaModelCatalog( baseURL: string, fetchImpl: FetchLike = fetch as unknown as FetchLike, @@ -634,8 +648,12 @@ export async function fetchOllamaModelCatalog( if (parsed instanceof type.errors || parsed.models.length === 0) { return undefined; } + const localModels = parsed.models.filter( + (model) => !isCloudProxyModel(model.name), + ); + if (localModels.length === 0) return undefined; return await Promise.all( - parsed.models.map(async (model) => ({ + localModels.map(async (model) => ({ canonicalName: model.name, displayName: model.name, capabilities: await fetchOllamaModelCapabilities( diff --git a/packages/hub-client/test/credential-test.test.ts b/packages/hub-client/test/credential-test.test.ts index 5b90dac6..0edfe07e 100644 --- a/packages/hub-client/test/credential-test.test.ts +++ b/packages/hub-client/test/credential-test.test.ts @@ -539,6 +539,51 @@ describe("fetchOllamaModelCatalog", () => { ]); }); + test("excludes ollama.com cloud-proxy models (a signed-out box can never serve them)", async () => { + const fetchImpl: FetchLike = async (url) => { + if (url.toString().endsWith("/api/tags")) { + return new Response( + JSON.stringify({ + models: [ + { name: "gpt-oss:20b" }, + { name: "qwen3-coder:480b-cloud" }, + { name: "minimax-m2:cloud" }, + ], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + }); + }; + + const models = await fetchOllamaModelCatalog( + "http://localhost:11434", + fetchImpl, + ); + expect(models?.map((model) => model.canonicalName)).toEqual([ + "gpt-oss:20b", + ]); + }); + + test("returns undefined when every model is a cloud-proxy model", async () => { + const fetchImpl: FetchLike = async (url) => { + if (url.toString().endsWith("/api/tags")) { + return new Response( + JSON.stringify({ models: [{ name: "minimax-m2:cloud" }] }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + }); + }; + expect( + await fetchOllamaModelCatalog("http://localhost:11434", fetchImpl), + ).toBeUndefined(); + }); + test("returns undefined when the instance is unreachable", async () => { const fetchImpl: FetchLike = async () => { throw new Error("ECONNREFUSED");