diff --git a/apps/desktop/e2e/providers.spec.ts b/apps/desktop/e2e/providers.spec.ts index 150c8a5744..110af33cf5 100644 --- a/apps/desktop/e2e/providers.spec.ts +++ b/apps/desktop/e2e/providers.spec.ts @@ -47,6 +47,29 @@ test('adds Vercel AI Gateway with its exact identity, endpoint, model id, and sh await expect(page.getByRole('textbox', { name: '模型密钥' })).toBeVisible(); }); +test('adds Ollama Cloud as an independent remote provider with the shared Ollama mark', async ({ window: page }) => { + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await page.getByRole('button', { name: '设置' }).click(); + await page.locator('[aria-label="设置分组"]').getByText('模型', { exact: true }).click(); + await page.getByRole('button', { name: '添加服务商' }).click(); + + await page.getByRole('tab', { name: 'API', exact: true }).click(); + await page.getByPlaceholder('搜索服务商').fill('Ollama Cloud'); + const catalogMark = page.locator('.providerCatalogRow[data-provider="ollama-cloud"] .providerLogo svg'); + await expect(catalogMark).toBeVisible(); + await page.getByRole('button', { name: /添加模型供应商:Ollama Cloud/ }).click(); + + await expect(page.getByLabel('模型供应商连接标识')).toHaveValue('ollama-cloud'); + await expect(page.getByLabel('模型供应商服务地址')).toHaveValue('https://ollama.com/v1'); + await expect(page.getByLabel('模型供应商默认模型')).toHaveValue('qwen3.5:397b'); + await page.getByRole('button', { name: '保存供应商' }).click(); + + await expect(page.getByRole('heading', { name: 'Ollama Cloud', exact: true }).first()).toBeVisible(); + await expect(page.locator('.providerSubpageHeader .providerLogo[data-provider="ollama-cloud"] svg')).toBeVisible(); + await expect(page.getByText('qwen3.5:397b', { exact: true }).first()).toBeVisible(); + await expect(page.getByRole('textbox', { name: '模型密钥' })).toBeVisible(); +}); + test('adds Cerebras with its exact snapshot model and API-key credential field', async ({ window: page }) => { await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); diff --git a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts index 335cd65575..de83565ecb 100644 --- a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts +++ b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts @@ -414,7 +414,11 @@ describe('icon + typography governance contract', () => { ), 'the shared Lobe Icons notice must inventory the vendored Ollama asset', ); - assert.match(marks, /case 'ollama':\s*return /); + assert.match( + marks, + /case 'ollama':\s*case 'ollama-cloud':\s*return /, + 'local Ollama and the independent Ollama Cloud identity must reuse the same upstream monochrome mark', + ); assert.match(catalog, //); assert.match( providersPanel, diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 172a7f5fcd..e385417f0a 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -95,6 +95,43 @@ describe('model catalog picker helpers', () => { }); }); + it('keeps the remote Ollama Cloud connection distinct from local cloud aliases', async () => { + const { buildCatalogChatModelChoices, pickCatalogDefaultChatModel } = await importModelCatalogChoices(); + const remote = connection({ + slug: 'ollama-cloud', + providerType: 'ollama-cloud', + defaultModel: 'qwen3.5:397b', + models: [{ id: 'qwen3.5:397b' }, { id: 'gpt-oss:120b' }], + modelSource: 'fetched', + modelsFetchedAt: 1_800_000_000_000, + }); + const local = connection({ + slug: 'ollama-local', + providerType: 'ollama', + defaultModel: 'qwen3.5:cloud', + models: [{ id: 'qwen3.5:cloud' }], + modelSource: 'fetched', + modelsFetchedAt: 1_800_000_000_000, + }); + + assert.deepEqual( + buildCatalogChatModelChoices([remote, local]).map(({ connectionSlug, providerType, model }) => ({ + connectionSlug, + providerType, + model, + })), + [ + { connectionSlug: 'ollama-cloud', providerType: 'ollama-cloud', model: 'qwen3.5:397b' }, + { connectionSlug: 'ollama-cloud', providerType: 'ollama-cloud', model: 'gpt-oss:120b' }, + { connectionSlug: 'ollama-local', providerType: 'ollama', model: 'qwen3.5:cloud' }, + ], + ); + assert.deepEqual(pickCatalogDefaultChatModel(remote), { + llmConnectionSlug: 'ollama-cloud', + model: 'qwen3.5:397b', + }); + }); + it('keeps Chat choices on send-wired providers and filters unsupported Codex ChatGPT models', async () => { const { buildCatalogChatModelChoices } = await importModelCatalogChoices(); diff --git a/apps/desktop/src/renderer/settings/provider-brand-marks.tsx b/apps/desktop/src/renderer/settings/provider-brand-marks.tsx index 8a7321323d..0e7c7efbfe 100644 --- a/apps/desktop/src/renderer/settings/provider-brand-marks.tsx +++ b/apps/desktop/src/renderer/settings/provider-brand-marks.tsx @@ -334,6 +334,7 @@ export function ProviderBrandMark({ type }: { type: ProviderType }): ReactElemen case 'MiniMax-cn': return ; case 'ollama': + case 'ollama-cloud': return ; case 'lm-studio': return ; diff --git a/packages/cli/src/__tests__/connection-target.test.ts b/packages/cli/src/__tests__/connection-target.test.ts index c3ab605c5c..5dd2333e15 100644 --- a/packages/cli/src/__tests__/connection-target.test.ts +++ b/packages/cli/src/__tests__/connection-target.test.ts @@ -362,6 +362,32 @@ describe('default session target resolver', () => { assert.equal(target.model, modelId); }); + test('resolves Ollama Cloud credentials without crossing into local Ollama state', async () => { + const modelId = 'qwen3.5:397b'; + const connection = makeConnection({ + slug: 'ollama-cloud', + name: 'Ollama Cloud', + providerType: 'ollama-cloud', + defaultModel: modelId, + }); + + const target = await resolveDefaultSessionTarget({ + connectionStore: { + getDefault: async () => 'ollama-cloud', + get: async (slug) => slug === 'ollama-cloud' ? connection : null, + }, + credentialStore: { + getSecret: async (slug, kind) => slug === 'ollama-cloud' && kind === 'api_key' + ? 'ollama-cloud-test-key' + : null, + }, + }); + + assert.equal(target.connection.providerType, 'ollama-cloud'); + assert.equal(target.apiKey, 'ollama-cloud-test-key'); + assert.equal(target.model, modelId); + }); + test('resolves Cloudflare Workers AI credentials without rewriting account scope or model id', async () => { const modelId = '@cf/moonshotai/kimi-k2.6'; const baseUrl = 'https://api.cloudflare.com/client/v4/accounts/account-123/ai/v1'; diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 434d9087b9..cec7ddebe8 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -58,6 +58,7 @@ describe('provider compatibility contract', () => { 'volcengine-ark', 'deepinfra', 'cloudflare-workers-ai', + 'ollama-cloud', 'ollama', 'lm-studio', 'localai', @@ -102,6 +103,7 @@ describe('provider compatibility contract', () => { 'stepfun-ai-step-plan', 'cloudflare-workers-ai', 'huggingface', + 'ollama-cloud', ]); assert.deepEqual(CATALOG_PROVIDER_TYPES, [ 'kimi-coding-plan', @@ -139,6 +141,7 @@ describe('provider compatibility contract', () => { 'stepfun-ai-step-plan', 'cloudflare-workers-ai', 'huggingface', + 'ollama-cloud', ]); for (const orderField of ['readyOrder', 'catalogOrder', 'recommendedOrder'] as const) { @@ -388,6 +391,32 @@ describe('provider compatibility contract', () => { assert.ok(!huggingface.fallbackModels.includes('sentence-transformers/all-MiniLM-L6-v2')); }); + it('owns Ollama Cloud direct API separately from the local Ollama daemon', () => { + const providers = PROVIDER_REGISTRY as Partial>; + const cloud = providers['ollama-cloud']; + + assert.ok(cloud, 'Ollama Cloud must be available through the shared provider registry'); + assert.equal(cloud.label, 'Ollama Cloud'); + assert.equal(cloud.baseUrl, 'https://ollama.com/v1'); + assert.equal(cloud.authKind, 'api_key'); + assert.equal(cloud.protocol, 'openai'); + assert.deepEqual(cloud.runtimeAdapter, { + kind: 'openai-compatible', + name: 'provider', + replayAssistantReasoningAs: 'reasoning', + }); + assert.deepEqual(cloud.modelDiscovery, { kind: 'protocol' }); + assert.equal(cloud.category, 'overseas'); + assert.equal(cloud.catalogGroup, 'api'); + assert.equal(cloud.modelsDevId, 'ollama-cloud'); + assert.equal(cloud.fallbackModels[0], 'qwen3.5:397b'); + assert.ok(!cloud.fallbackModels.includes('cogito-2.1:671b'), 'deprecated snapshot models must not be fallback choices'); + assert.notEqual(cloud, providers.ollama); + assert.equal(providers.ollama?.baseUrl, 'http://localhost:11434/v1'); + assert.equal(providers.ollama?.authKind, 'none'); + assert.deepEqual(providers.ollama?.modelDiscovery, { kind: 'ollama' }); + }); + it('owns the complete Together AI provider contract under the stable togetherai id', () => { const together = PROVIDER_REGISTRY.togetherai; diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 1dd94b64eb..ccc21a005c 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -263,6 +263,27 @@ describe('ModelCatalogEntry', () => { assert.ok(!entries.some((entry) => entry.id === 'BAAI/bge-m3')); }); + it('uses the checked-in Ollama Cloud snapshot with exact model ids until discovery succeeds', () => { + const entries = buildConnectionModelCatalogEntries({ + connection: { + slug: 'ollama-cloud', + providerType: 'ollama-cloud', + defaultModel: 'qwen3.5:397b', + }, + }); + + assert.equal(entries[0]?.id, 'qwen3.5:397b'); + assert.equal(entries[0]?.source, 'static_catalog'); + assert.equal(entries[0]?.provenance.modelSource, 'fallback'); + assert.equal(entries[0]?.contextWindow, 262_144); + assert.deepEqual(entries[0]?.capabilities, { + vision: true, + reasoning: true, + functionCalling: true, + }); + assert.ok(entries.some((entry) => entry.id === 'gpt-oss:120b')); + }); + it('uses the checked-in Fireworks snapshot until live discovery succeeds', () => { const entries = buildConnectionModelCatalogEntries({ connection: { diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index 0a3815da18..bd464830de 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -149,6 +149,17 @@ describe('thinkingOptionsForModel', () => { ); }); + test('Ollama Cloud exposes the documented OpenAI-compatible reasoning effort values', () => { + assert.deepEqual( + thinkingOptionsForModel('ollama-cloud', 'qwen3.5:397b'), + { efforts: ['none', 'low', 'medium', 'high'], toggle: true }, + ); + assert.deepEqual( + [...thinkingVariantsForModel('ollama-cloud', 'qwen3.5:397b')], + ['off', 'low', 'medium', 'high'], + ); + }); + test('claude-subscription inherits anthropic thinking options (displayMetadataOnly preserves them)', () => { assert.deepEqual(thinkingOptionsForModel('claude-subscription', 'claude-opus-4-8'), { efforts: ['low', 'medium', 'high', 'xhigh', 'max'] }); assert.deepEqual(thinkingOptionsForModel('claude-subscription', 'claude-haiku-4-5'), { toggle: true, offBehavior: 'anthropic-thinking-disabled' }); diff --git a/packages/core/src/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index 4e88731996..c41774fd97 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -9,6 +9,24 @@ import { import type { LlmConnection } from '../llm-connections.js'; describe('ProviderAuth contract', () => { + test('Ollama Cloud requires its own API key and exposes remote model discovery', () => { + const missing = deriveProviderAuthContract({ + providerType: 'ollama-cloud', + hasSecret: false, + }); + const configured = deriveProviderAuthContract({ + providerType: 'ollama-cloud', + hasSecret: true, + }); + + expect(missing.setupMode).toBe('api_key'); + expect(missing.requiresSecret).toBe(true); + expect(missing.sendMayUseWithoutSecret).toBe(false); + expect(missing.actionAvailability.fetch_models).toBe('hidden'); + expect(configured.actionAvailability.test_credentials).toBe('available'); + expect(configured.actionAvailability.fetch_models).toBe('available'); + }); + test('DeepInfra uses the shared API-key credential and model-discovery flow', () => { const contract = deriveProviderAuthContract({ providerType: 'deepinfra', diff --git a/packages/core/src/model-metadata.generated.ts b/packages/core/src/model-metadata.generated.ts index bbab5a07cc..6d90f89e5b 100644 --- a/packages/core/src/model-metadata.generated.ts +++ b/packages/core/src/model-metadata.generated.ts @@ -2,7 +2,7 @@ // Do not edit by hand; put access-path-specific facts in model-metadata.ts. import type { ModelMetadata } from './model-metadata.js'; -export const GENERATED_MODELS_DEV_METADATA: Record<"anthropic" | "cerebras" | "cohere" | "cloudflare-workers-ai" | "deepinfra" | "deepseek" | "fireworks-ai" | "google" | "gemini-cli" | "huggingface" | "MiniMax" | "MiniMax-cn" | "mistral" | "moonshot" | "nvidia" | "openai" | "siliconflow" | "stepfun" | "stepfun-ai" | "stepfun-ai-step-plan" | "togetherai" | "tencent-coding-plan" | "tencent-token-plan" | "tencent-tokenhub" | "vercel" | "xai" | "zai-coding-plan", Record> = { +export const GENERATED_MODELS_DEV_METADATA: Record<"anthropic" | "cerebras" | "cohere" | "cloudflare-workers-ai" | "deepinfra" | "deepseek" | "fireworks-ai" | "google" | "gemini-cli" | "huggingface" | "MiniMax" | "MiniMax-cn" | "mistral" | "moonshot" | "nvidia" | "ollama-cloud" | "openai" | "siliconflow" | "stepfun" | "stepfun-ai" | "stepfun-ai-step-plan" | "togetherai" | "tencent-coding-plan" | "tencent-token-plan" | "tencent-tokenhub" | "vercel" | "xai" | "zai-coding-plan", Record> = { "anthropic": { "claude-fable-5": {"displayName":"Claude Fable 5","lifecycle":"active","docsUrl":"https://docs.anthropic.com/en/docs/about-claude/models","contextWindow":1000000,"maxOutputTokens":128000,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, "claude-haiku-4-5": {"displayName":"Claude Haiku 4.5 (latest)","lifecycle":"active","docsUrl":"https://docs.anthropic.com/en/docs/about-claude/models","contextWindow":200000,"maxOutputTokens":64000,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, @@ -380,6 +380,51 @@ export const GENERATED_MODELS_DEV_METADATA: Record<"anthropic" | "cerebras" | "c "upstage/solar-10_7b-instruct": {"displayName":"solar-10.7b-instruct","lifecycle":"active","docsUrl":"https://docs.api.nvidia.com/nim/","contextWindow":128000,"maxOutputTokens":8192,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, "z-ai/glm-5.2": {"displayName":"GLM-5.2","lifecycle":"active","docsUrl":"https://docs.api.nvidia.com/nim/","contextWindow":1000000,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, }, + "ollama-cloud": { + "cogito-2.1:671b": {"displayName":"cogito-2.1:671b","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":163840,"maxOutputTokens":32000,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "deepseek-v3.1:671b": {"displayName":"deepseek-v3.1:671b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":163840,"maxOutputTokens":163840,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "deepseek-v3.2": {"displayName":"deepseek-v3.2","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":163840,"maxOutputTokens":65536,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "deepseek-v4-flash": {"displayName":"deepseek-v4-flash","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":1048576,"maxOutputTokens":1048576,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "deepseek-v4-pro": {"displayName":"deepseek-v4-pro","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":1048576,"maxOutputTokens":1048576,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "devstral-2:123b": {"displayName":"devstral-2:123b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, + "devstral-small-2:24b": {"displayName":"devstral-small-2:24b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "gemini-3-flash-preview": {"displayName":"gemini-3-flash-preview","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":1048576,"maxOutputTokens":65536,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "gemma3:12b": {"displayName":"gemma3:12b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":131072,"maxOutputTokens":131072,"capabilities":{"vision":true,"reasoning":false,"functionCalling":false}}, + "gemma3:27b": {"displayName":"gemma3:27b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":131072,"maxOutputTokens":131072,"capabilities":{"vision":true,"reasoning":false,"functionCalling":false}}, + "gemma3:4b": {"displayName":"gemma3:4b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":131072,"maxOutputTokens":131072,"capabilities":{"vision":true,"reasoning":false,"functionCalling":false}}, + "gemma4:31b": {"displayName":"gemma4:31b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "glm-4.6": {"displayName":"glm-4.6","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":202752,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "glm-4.7": {"displayName":"glm-4.7","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":202752,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "glm-5": {"displayName":"glm-5","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":202752,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "glm-5.1": {"displayName":"glm-5.1","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":202752,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "glm-5.2": {"displayName":"GLM-5.2","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":976000,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "gpt-oss:120b": {"displayName":"gpt-oss:120b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":131072,"maxOutputTokens":32768,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "gpt-oss:20b": {"displayName":"gpt-oss:20b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":131072,"maxOutputTokens":32768,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "kimi-k2-thinking": {"displayName":"kimi-k2-thinking","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "kimi-k2:1t": {"displayName":"kimi-k2:1t","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, + "kimi-k2.5": {"displayName":"kimi-k2.5","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "kimi-k2.6": {"displayName":"kimi-k2.6","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "kimi-k2.7-code": {"displayName":"kimi-k2.7-code","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "minimax-m2": {"displayName":"minimax-m2","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":204800,"maxOutputTokens":128000,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "minimax-m2.1": {"displayName":"minimax-m2.1","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":204800,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "minimax-m2.5": {"displayName":"minimax-m2.5","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":204800,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "minimax-m2.7": {"displayName":"minimax-m2.7","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":196608,"maxOutputTokens":196608,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "minimax-m3": {"displayName":"minimax-m3","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":512000,"maxOutputTokens":131072,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "ministral-3:14b": {"displayName":"ministral-3:14b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":128000,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "ministral-3:3b": {"displayName":"ministral-3:3b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":128000,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "ministral-3:8b": {"displayName":"ministral-3:8b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":128000,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "mistral-large-3:675b": {"displayName":"mistral-large-3:675b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":262144,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "nemotron-3-nano:30b": {"displayName":"nemotron-3-nano:30b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":1048576,"maxOutputTokens":131072,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "nemotron-3-super": {"displayName":"nemotron-3-super","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":65536,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "nemotron-3-ultra": {"displayName":"nemotron-3-ultra","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":128000,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "qwen3-coder-next": {"displayName":"qwen3-coder-next","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":65536,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, + "qwen3-coder:480b": {"displayName":"qwen3-coder:480b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":65536,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, + "qwen3-next:80b": {"displayName":"qwen3-next:80b","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":32768,"capabilities":{"vision":false,"reasoning":true,"functionCalling":true}}, + "qwen3-vl:235b": {"displayName":"qwen3-vl:235b","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":32768,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "qwen3-vl:235b-instruct": {"displayName":"qwen3-vl:235b-instruct","lifecycle":"deprecated","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":131072,"capabilities":{"vision":true,"reasoning":false,"functionCalling":true}}, + "qwen3.5:397b": {"displayName":"qwen3.5:397b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":262144,"maxOutputTokens":65536,"capabilities":{"vision":true,"reasoning":true,"functionCalling":true}}, + "rnj-1:8b": {"displayName":"rnj-1:8b","lifecycle":"active","docsUrl":"https://docs.ollama.com/cloud","contextWindow":32768,"maxOutputTokens":4096,"capabilities":{"vision":false,"reasoning":false,"functionCalling":true}}, + }, "openai": { "chatgpt-image-latest": {"displayName":"chatgpt-image-latest","lifecycle":"active","docsUrl":"https://platform.openai.com/docs/models","contextWindow":0,"maxOutputTokens":0,"capabilities":{"vision":true,"reasoning":false,"functionCalling":false}}, "gpt-3.5-turbo": {"displayName":"GPT-3.5-turbo","lifecycle":"active","docsUrl":"https://platform.openai.com/docs/models","contextWindow":16385,"maxOutputTokens":4096,"capabilities":{"vision":false,"reasoning":false,"functionCalling":false}}, @@ -898,7 +943,7 @@ export const GENERATED_MODELS_DEV_METADATA: Record<"anthropic" | "cerebras" | "c }, }; -export const GENERATED_MODELS_DEV_PROVIDER_FACTS: Record<"anthropic" | "cerebras" | "cohere" | "cloudflare-workers-ai" | "deepinfra" | "deepseek" | "fireworks-ai" | "google" | "gemini-cli" | "huggingface" | "MiniMax" | "MiniMax-cn" | "mistral" | "moonshot" | "nvidia" | "openai" | "siliconflow" | "stepfun" | "stepfun-ai" | "stepfun-ai-step-plan" | "togetherai" | "tencent-coding-plan" | "tencent-token-plan" | "tencent-tokenhub" | "vercel" | "xai" | "zai-coding-plan", { id: string; name: string; api?: string; doc: string }> = { +export const GENERATED_MODELS_DEV_PROVIDER_FACTS: Record<"anthropic" | "cerebras" | "cohere" | "cloudflare-workers-ai" | "deepinfra" | "deepseek" | "fireworks-ai" | "google" | "gemini-cli" | "huggingface" | "MiniMax" | "MiniMax-cn" | "mistral" | "moonshot" | "nvidia" | "ollama-cloud" | "openai" | "siliconflow" | "stepfun" | "stepfun-ai" | "stepfun-ai-step-plan" | "togetherai" | "tencent-coding-plan" | "tencent-token-plan" | "tencent-tokenhub" | "vercel" | "xai" | "zai-coding-plan", { id: string; name: string; api?: string; doc: string }> = { "anthropic": {"id":"anthropic","name":"Anthropic","doc":"https://docs.anthropic.com/en/docs/about-claude/models"}, "cerebras": {"id":"cerebras","name":"Cerebras","doc":"https://inference-docs.cerebras.ai/models/overview"}, "cohere": {"id":"cohere","name":"Cohere","doc":"https://docs.cohere.com/docs/models"}, @@ -914,6 +959,7 @@ export const GENERATED_MODELS_DEV_PROVIDER_FACTS: Record<"anthropic" | "cerebras "mistral": {"id":"mistral","name":"Mistral","doc":"https://docs.mistral.ai/getting-started/models/"}, "moonshot": {"id":"moonshotai-cn","name":"Moonshot AI (China)","api":"https://api.moonshot.cn/v1","doc":"https://platform.moonshot.cn/docs/api/chat"}, "nvidia": {"id":"nvidia","name":"Nvidia","api":"https://integrate.api.nvidia.com/v1","doc":"https://docs.api.nvidia.com/nim/"}, + "ollama-cloud": {"id":"ollama-cloud","name":"Ollama Cloud","api":"https://ollama.com/v1","doc":"https://docs.ollama.com/cloud"}, "openai": {"id":"openai","name":"OpenAI","doc":"https://platform.openai.com/docs/models"}, "siliconflow": {"id":"siliconflow","name":"SiliconFlow","api":"https://api.siliconflow.com/v1","doc":"https://cloud.siliconflow.com/models"}, "stepfun": {"id":"stepfun","name":"StepFun (China)","api":"https://api.stepfun.com/v1","doc":"https://platform.stepfun.com/docs/zh/overview/concept"}, diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 01cc5c8189..880f97ebd6 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -196,6 +196,11 @@ const STATIC_MODEL_METADATA: Partial model.lifecycle !== 'deprecated'), +); +const ollamaCloudModelIds = toolCallingModelIds( + 'Ollama Cloud', + ollamaCloudActiveMetadata, + ['qwen3.5:397b', 'gpt-oss:120b'], +); const fireworks = GENERATED_MODELS_DEV_PROVIDER_FACTS['fireworks-ai']; if (fireworks.id !== 'fireworks-ai') { throw new Error('models.dev Fireworks AI provider facts are missing stable id fireworks-ai'); @@ -889,6 +903,29 @@ const providerRegistry = { readyOrder: 33, catalogOrder: 33, }, + 'ollama-cloud': { + label: ollamaCloud.name, + description: 'Ollama-hosted cloud models over the official remote API.', + baseUrl: ollamaCloud.api, + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ollamaCloudModelIds, + status: 'ready', + protocol: 'openai', + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + replayAssistantReasoningAs: 'reasoning', + }, + modelDiscovery: { kind: 'protocol' }, + category: 'overseas', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://ollama.com/settings/keys', + modelsDevId: ollamaCloud.id, + readyOrder: 35, + catalogOrder: 35, + }, ollama: { label: 'Ollama', description: 'Local models from Ollama on localhost.', diff --git a/packages/headless/src/__tests__/harbor-cell.test.ts b/packages/headless/src/__tests__/harbor-cell.test.ts index a172b38f5d..80d1cbb4c4 100644 --- a/packages/headless/src/__tests__/harbor-cell.test.ts +++ b/packages/headless/src/__tests__/harbor-cell.test.ts @@ -2276,6 +2276,34 @@ setTimeout(() => { assert.equal(missing.apiKey, ''); }); + test('resolves Ollama Cloud only from OLLAMA_API_KEY and preserves the exact model id', () => { + const resolved = resolveHarborCellAiSdkEnv({ + provider: 'ollama-cloud', + model: 'qwen3.5:397b', + env: { + OLLAMA_API_KEY: 'ollama-cloud-key', + OPENAI_API_KEY: 'must-not-cross-provider-boundary', + }, + ts: 1, + }); + + assert.equal(resolved.apiKey, 'ollama-cloud-key'); + assert.equal(resolved.connection.providerType, 'ollama-cloud'); + assert.equal(resolved.connection.defaultModel, 'qwen3.5:397b'); + assert.equal(resolved.connection.baseUrl, 'https://ollama.com/v1'); + + const missing = resolveHarborCellAiSdkEnv({ + provider: 'ollama-cloud', + model: 'qwen3.5:397b', + env: { + OPENAI_API_KEY: 'must-not-cross-provider-boundary', + MAKA_CREDENTIALS_PATH: join(tmpdir(), 'maka-headless-ollama-cloud-missing-credentials.json'), + }, + ts: 1, + }); + assert.equal(missing.apiKey, ''); + }); + test('resolves xAI only from xAI credential env without rewriting the model id', () => { const resolved = resolveHarborCellAiSdkEnv({ provider: 'xai', diff --git a/packages/headless/src/__tests__/provider-env.test.ts b/packages/headless/src/__tests__/provider-env.test.ts index 979f5b09fc..0e79d1d245 100644 --- a/packages/headless/src/__tests__/provider-env.test.ts +++ b/packages/headless/src/__tests__/provider-env.test.ts @@ -75,6 +75,14 @@ test('DeepInfra keeps its official provider-scoped credential environment names' }); }); +test('Ollama Cloud keeps its official provider-scoped credential environment names', () => { + assert.deepEqual(providerCredentialEnv('ollama-cloud'), { + apiKeys: ['OLLAMA_API_KEY'], + apiKeyFile: 'OLLAMA_API_KEY_FILE', + baseUrls: [], + }); +}); + test('Cloudflare Workers AI separates account scope from API token credentials', () => { assert.deepEqual(providerCredentialEnv('cloudflare-workers-ai'), { apiKeys: ['CLOUDFLARE_API_KEY'], diff --git a/packages/headless/src/provider-env.ts b/packages/headless/src/provider-env.ts index baab324713..103350836b 100644 --- a/packages/headless/src/provider-env.ts +++ b/packages/headless/src/provider-env.ts @@ -39,6 +39,7 @@ const PROVIDER_CREDENTIAL_ENV = { ), 'fireworks-ai': env('FIREWORKS', ['FIREWORKS_BASE_URL']), nvidia: env('NVIDIA', ['NVIDIA_BASE_URL']), + 'ollama-cloud': env('OLLAMA'), 'tencent-tokenhub': env('TENCENT_TOKENHUB', ['TENCENT_TOKENHUB_BASE_URL']), stepfun: env('STEPFUN', ['STEPFUN_BASE_URL']), 'stepfun-step-plan': env('STEPFUN_STEP_PLAN', ['STEPFUN_STEP_PLAN_BASE_URL']), diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index bd8a079e03..c8d1ae22d7 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -135,6 +135,115 @@ describe('models.dev provider conformance', () => { assert.equal(result.text, 'Echoed hello.'); }); + test('Ollama Cloud authenticates discovery and preserves exact ids and reasoning through a tool loop', async () => { + const modelId = 'qwen3.5:397b'; + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.headers.authorization, 'Bearer ollama-cloud-test-key'); + if (request.method === 'GET' && request.url === '/v1/models') { + respondJson(response, 200, { + object: 'list', + data: [{ id: modelId }, { id: 'gpt-oss:120b' }], + }); + return; + } + + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/chat/completions'); + const body = JSON.parse(await readBody(request)) as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + respondJson(response, 200, { + id: 'chatcmpl-ollama-cloud-tool', + object: 'chat.completion', + created: 1, + model: modelId, + choices: [{ + index: 0, + message: { + role: 'assistant', + content: null, + reasoning: 'I should call echo with the requested text.', + tool_calls: [{ + id: 'call_ollama_cloud_echo', + type: 'function', + function: { name: 'echo', arguments: '{"text":"hello"}' }, + }], + }, + finish_reason: 'tool_calls', + }], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, + }); + return; + } + + respondJson(response, 200, { + id: 'chatcmpl-ollama-cloud-final', + object: 'chat.completion', + created: 2, + model: modelId, + choices: [{ + index: 0, + message: { role: 'assistant', content: 'Echoed hello.' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 14, completion_tokens: 3, total_tokens: 17 }, + }); + }); + const connection: LlmConnection = { + slug: 'ollama-cloud', + name: 'Ollama Cloud', + providerType: 'ollama-cloud', + baseUrl: `${server.url}/v1`, + defaultModel: modelId, + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + assert.deepEqual(await fetchProviderModels(connection, 'ollama-cloud-test-key'), [ + { id: modelId }, + { id: 'gpt-oss:120b' }, + ]); + + const result = await generateText({ + model: getAIModel({ connection, apiKey: 'ollama-cloud-test-key', modelId }), + prompt: 'Call echo with hello.', + providerOptions: buildProviderOptions(connection, modelId, 'high'), + stopWhen: stepCountIs(2), + tools: { + echo: tool({ + description: 'Echo text', + inputSchema: z.object({ text: z.string() }), + execute: async ({ text }) => ({ echoed: text }), + }), + }, + }); + + assert.equal(requestBodies.length, 2); + assert.deepEqual(requestBodies.map((body) => body.model), [modelId, modelId]); + assert.equal(requestBodies[0]?.reasoning_effort, 'high'); + assert.equal(result.steps[0]?.reasoningText, 'I should call echo with the requested text.'); + assert.deepEqual( + (requestBodies[1]?.messages as Array>).find(({ role }) => role === 'assistant'), + { + role: 'assistant', + content: null, + reasoning: 'I should call echo with the requested text.', + tool_calls: [{ + id: 'call_ollama_cloud_echo', + type: 'function', + function: { name: 'echo', arguments: '{"text":"hello"}' }, + }], + }, + ); + assert.deepEqual( + (requestBodies[1]?.messages as Array<{ role: string; content: string }>).find(({ role }) => role === 'tool'), + { role: 'tool', content: '{"echoed":"hello"}', tool_call_id: 'call_ollama_cloud_echo' }, + ); + assert.equal(result.text, 'Echoed hello.'); + }); + test('LocalAI preserves a configured llama.cpp Qwen3 alias and reasoning through a two-stage tool-call loop', async () => { const modelId = 'localai/Qwen3-8B-Instruct-GGUF:Q4_K_M'; const requestBodies: Array> = []; diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index c47373ee04..e48853d206 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -163,8 +163,9 @@ export function buildProviderOptions( }, }; case 'vercel': + case 'ollama-cloud': return level - ? { vercel: { reasoningEffort: level === 'off' ? 'none' : level } } + ? { [openaiCompatibleNamespace(connection.providerType)]: { reasoningEffort: level === 'off' ? 'none' : level } } : {}; case 'google': return { diff --git a/packages/storage/src/__tests__/connection-store.test.ts b/packages/storage/src/__tests__/connection-store.test.ts index 8576f52ad2..0e4565be1b 100644 --- a/packages/storage/src/__tests__/connection-store.test.ts +++ b/packages/storage/src/__tests__/connection-store.test.ts @@ -335,6 +335,34 @@ describe('FileConnectionStore', () => { }); }); + test('persists Ollama Cloud independently from local Ollama with exact model ids', async () => { + await withConnectionStore(async (store, dir) => { + await store.create({ + slug: 'ollama-cloud', + name: 'Ollama Cloud', + providerType: 'ollama-cloud', + defaultModel: 'qwen3.5:397b', + }); + await store.create({ + slug: 'ollama-local', + name: 'Ollama', + providerType: 'ollama', + defaultModel: 'qwen3.5:cloud', + }); + + const persisted = JSON.parse(await readFile(join(dir, 'llm-connections.json'), 'utf8')) as { + connections: Array<{ providerType: string; defaultModel: string }>; + }; + assert.deepEqual( + persisted.connections.map(({ providerType, defaultModel }) => ({ providerType, defaultModel })), + [ + { providerType: 'ollama-cloud', defaultModel: 'qwen3.5:397b' }, + { providerType: 'ollama', defaultModel: 'qwen3.5:cloud' }, + ], + ); + }); + }); + test('persists the Cloudflare Workers AI id, account-scoped base URL, and exact model id', async () => { await withConnectionStore(async (store, dir) => { const baseUrl = 'https://api.cloudflare.com/client/v4/accounts/account-123/ai/v1'; diff --git a/packages/storage/src/__tests__/credential-store.test.ts b/packages/storage/src/__tests__/credential-store.test.ts index 68e26cb7b7..53df221e26 100644 --- a/packages/storage/src/__tests__/credential-store.test.ts +++ b/packages/storage/src/__tests__/credential-store.test.ts @@ -35,6 +35,16 @@ describe('FileCredentialStore', () => { }); }); + test('keeps Ollama Cloud credentials separate from local Ollama state', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('ollama-cloud', 'api_key', 'ollama-cloud-test-key'); + + assert.equal(await store.getSecret('ollama-cloud', 'api_key'), 'ollama-cloud-test-key'); + assert.equal(await store.getSecret('ollama-local', 'api_key'), null); + }); + }); + test('deleteSecret(slug) with no kind clears every kind for that slug only', async () => { await withTempDir(async (dir) => { const store = createFileCredentialStore(dir); diff --git a/scripts/sync-model-metadata.mjs b/scripts/sync-model-metadata.mjs index 665263a0cd..1de82941a7 100644 --- a/scripts/sync-model-metadata.mjs +++ b/scripts/sync-model-metadata.mjs @@ -18,6 +18,7 @@ const PROVIDERS = { mistral: 'mistral', moonshot: 'moonshotai-cn', nvidia: 'nvidia', + 'ollama-cloud': 'ollama-cloud', openai: 'openai', siliconflow: 'siliconflow', stepfun: 'stepfun', diff --git a/scripts/sync-model-metadata.test.mjs b/scripts/sync-model-metadata.test.mjs index c225a435df..c93cbc9107 100644 --- a/scripts/sync-model-metadata.test.mjs +++ b/scripts/sync-model-metadata.test.mjs @@ -7,7 +7,7 @@ import { promisify } from 'node:util'; import test from 'node:test'; const execFileAsync = promisify(execFile); -const PROVIDER_IDS = ['anthropic', 'cerebras', 'cloudflare-workers-ai', 'cohere', 'deepinfra', 'deepseek', 'fireworks-ai', 'google', 'huggingface', 'minimax', 'minimax-cn', 'mistral', 'moonshotai-cn', 'nvidia', 'openai', 'siliconflow', 'stepfun', 'stepfun-ai', 'stepfun-ai-step-plan', 'tencent-coding-plan', 'tencent-token-plan', 'tencent-tokenhub', 'togetherai', 'vercel', 'xai', 'zai-coding-plan']; +const PROVIDER_IDS = ['anthropic', 'cerebras', 'cloudflare-workers-ai', 'cohere', 'deepinfra', 'deepseek', 'fireworks-ai', 'google', 'huggingface', 'minimax', 'minimax-cn', 'mistral', 'moonshotai-cn', 'nvidia', 'ollama-cloud', 'openai', 'siliconflow', 'stepfun', 'stepfun-ai', 'stepfun-ai-step-plan', 'tencent-coding-plan', 'tencent-token-plan', 'tencent-tokenhub', 'togetherai', 'vercel', 'xai', 'zai-coding-plan']; function withRequiredProviders(openai) { return Object.fromEntries(PROVIDER_IDS.map((id) => { @@ -91,6 +91,36 @@ test('sync-model-metadata vendors xAI provider facts and exact model ids', async assert.match(generated, /"grok-4\.5": \{"displayName":"Grok 4\.5"/); }); +test('sync-model-metadata vendors Ollama Cloud provider facts and exact model ids', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-model-metadata-')); + const input = join(directory, 'api.json'); + const output = join(directory, 'generated.ts'); + const catalog = withRequiredProviders({}); + catalog['ollama-cloud'] = { + ...catalog['ollama-cloud'], + name: 'Ollama Cloud', + api: 'https://ollama.com/v1', + doc: 'https://docs.ollama.com/cloud', + models: { + 'qwen3.5:397b': { + id: 'qwen3.5:397b', name: 'Qwen 3.5 397B', reasoning: true, tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 262_144, output: 65_536 }, + }, + }, + }; + await writeFile(input, JSON.stringify(catalog)); + + await execFileAsync(process.execPath, [ + 'scripts/sync-model-metadata.mjs', '--input', input, '--output', output, + ]); + + const generated = await readFile(output, 'utf8'); + assert.match(generated, /"ollama-cloud": \{/); + assert.match(generated, /"ollama-cloud": \{"id":"ollama-cloud","name":"Ollama Cloud","api":"https:\/\/ollama\.com\/v1"/); + assert.match(generated, /"qwen3\.5:397b": \{"displayName":"Qwen 3\.5 397B"/); +}); + test('sync-model-metadata vendors the stable Vercel AI Gateway id and exact creator/model ids', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-model-metadata-')); const input = join(directory, 'api.json');