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 dfa8da93f4..299de8b522 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -67,6 +67,34 @@ function connection(overrides: Partial & Pick { + it('keeps an Ollama cloud alias distinct and selects it unchanged', async () => { + const { buildCatalogChatModelChoices, pickCatalogDefaultChatModel } = await importModelCatalogChoices(); + const ollama = connection({ + slug: 'ollama-local', + providerType: 'ollama', + defaultModel: 'qwen3.5:cloud', + models: [{ id: 'qwen3.5' }, { id: 'qwen3.5:cloud' }], + modelSource: 'fetched', + modelsFetchedAt: 1_800_000_000_000, + }); + + assert.deepEqual( + buildCatalogChatModelChoices([ollama]).map(({ connectionSlug, providerType, model }) => ({ + connectionSlug, + providerType, + model, + })), + [ + { connectionSlug: 'ollama-local', providerType: 'ollama', model: 'qwen3.5' }, + { connectionSlug: 'ollama-local', providerType: 'ollama', model: 'qwen3.5:cloud' }, + ], + ); + assert.deepEqual(pickCatalogDefaultChatModel(ollama), { + llmConnectionSlug: 'ollama-local', + model: 'qwen3.5:cloud', + }); + }); + it('keeps Chat choices on send-wired providers and filters unsupported Codex ChatGPT models', async () => { const { buildCatalogChatModelChoices } = await importModelCatalogChoices(); diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index da88c0c0b6..f96af230ee 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -16,7 +16,6 @@ export type ProviderRuntimeAdapter = | { kind: 'openai-compatible'; name: 'provider' | 'connection'; - apiKeyFallback?: string; passFetch?: boolean; requireBaseUrl?: boolean; } @@ -590,7 +589,7 @@ const providerRegistry = { fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider', apiKeyFallback: 'ollama' }, + runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'ollama' }, category: 'local', catalogGroup: 'local', diff --git a/packages/headless/src/__tests__/harbor-task-runner.test.ts b/packages/headless/src/__tests__/harbor-task-runner.test.ts index 97233e9309..70d7838176 100644 --- a/packages/headless/src/__tests__/harbor-task-runner.test.ts +++ b/packages/headless/src/__tests__/harbor-task-runner.test.ts @@ -307,38 +307,42 @@ describe('createHarborTaskRunner', () => { }); }); - test('routes no-auth Ollama through the host-side cell without exposing credentials', async () => { - await withRun(async ({ jobsDir, repo }) => { - let harborEnv: Record | undefined; - const captured: { config?: Record } = {}; - const baseUrl = 'http://127.0.0.1:11434/v1'; - const model = 'hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M'; - const runner = createHarborTaskRunner({ - makaRepoPath: repo, - jobsDir, - model: `ollama/${model}`, - provider: 'ollama', - agentEnv: { MAKA_BASE_URL: baseUrl }, - runHarbor: async (request) => { - harborEnv = request.env; - return fakeRunner({ reward: '1\n', captured })(request); - }, + for (const model of [ + 'hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M', + 'qwen3.5:cloud', + ]) { + test(`routes no-auth Ollama model ${model} through the host-side cell without exposing credentials`, async () => { + await withRun(async ({ jobsDir, repo }) => { + let harborEnv: Record | undefined; + const captured: { config?: Record } = {}; + const baseUrl = 'http://127.0.0.1:11434/v1'; + const runner = createHarborTaskRunner({ + makaRepoPath: repo, + jobsDir, + model: `ollama/${model}`, + provider: 'ollama', + agentEnv: { MAKA_BASE_URL: baseUrl }, + runHarbor: async (request) => { + harborEnv = request.env; + return fakeRunner({ reward: '1\n', captured })(request); + }, + }); + + await runner(runInput()); + + assert.equal(harborEnv?.MAKA_HOST_REPO_ROOT, repo); + assert.equal(harborEnv?.MAKA_HOST_BASE_URL, baseUrl); + assert.equal(harborEnv?.MAKA_HOST_NO_AUTH, 'true'); + assert.equal(harborEnv?.MAKA_HOST_API_KEY, undefined); + assert.equal(harborEnv?.MAKA_HOST_API_KEY_FILE, undefined); + const agent = (captured.config?.agents as Array<{ model_name: string; env: Record }>)[0]!; + assert.equal(agent.model_name, model); + assert.equal(agent.env.MAKA_MODEL, model); + assert.equal(agent.env.MAKA_BASE_URL, undefined); + assert.doesNotMatch(JSON.stringify(captured.config), /API_KEY|127\.0\.0\.1:11434/); }); - - await runner(runInput()); - - assert.equal(harborEnv?.MAKA_HOST_REPO_ROOT, repo); - assert.equal(harborEnv?.MAKA_HOST_BASE_URL, baseUrl); - assert.equal(harborEnv?.MAKA_HOST_NO_AUTH, 'true'); - assert.equal(harborEnv?.MAKA_HOST_API_KEY, undefined); - assert.equal(harborEnv?.MAKA_HOST_API_KEY_FILE, undefined); - const agent = (captured.config?.agents as Array<{ model_name: string; env: Record }>)[0]!; - assert.equal(agent.model_name, model); - assert.equal(agent.env.MAKA_MODEL, model); - assert.equal(agent.env.MAKA_BASE_URL, undefined); - assert.doesNotMatch(JSON.stringify(captured.config), /API_KEY|127\.0\.0\.1:11434/); }); - }); + } test('rejects provider secrets in agentEnv even when host-side key file is configured', async () => { await withRun(async ({ jobsDir, repo, keyFile }) => { diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 6656a61610..075ab63112 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -793,94 +793,22 @@ describe('models.dev provider conformance', () => { assert.equal(result.text, 'Echoed hello.'); }); - test('Ollama discovers an exact local model id and completes a no-secret tool-call loop', async () => { - const modelId = 'hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M'; - const requestBodies: Array> = []; - const server = await startJsonServer(async (request, response) => { - if (request.method === 'GET' && request.url === '/api/tags') { - assert.equal(request.headers.authorization, undefined); - respondJson(response, 200, { models: [{ name: modelId, model: modelId }] }); - return; - } - assert.equal(request.method, 'POST'); - assert.equal(request.url, '/v1/chat/completions'); - assert.equal(request.headers.authorization, 'Bearer ollama'); - const body = JSON.parse(await readBody(request)) as Record; - requestBodies.push(body); - if (requestBodies.length === 1) { - respondJson(response, 200, { - id: 'chatcmpl-ollama-tool', - object: 'chat.completion', - created: 1, - model: modelId, - choices: [{ - index: 0, - message: { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call_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-final', - object: 'chat.completion', - created: 2, - model: modelId, - choices: [{ - index: 0, - message: { role: 'assistant', content: 'Echoed hello.' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 }, - }); + for (const testCase of [ + { + label: 'complex local model id', + discoveredModelIds: ['hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M'], + modelId: 'hf.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF:Q4_K_M', + }, + { + label: 'cloud alias distinct from its local model id', + discoveredModelIds: ['qwen3.5', 'qwen3.5:cloud'], + modelId: 'qwen3.5:cloud', + }, + ] as const) { + test(`Ollama preserves an exact ${testCase.label} through local discovery and a no-secret tool-call loop`, async () => { + await assertOllamaModelContract(testCase.discoveredModelIds, testCase.modelId); }); - const connection: LlmConnection = { - slug: 'ollama-local', - name: 'Ollama', - providerType: 'ollama', - baseUrl: `${server.url}/v1`, - defaultModel: modelId, - enabled: true, - createdAt: 1, - updatedAt: 1, - }; - - assert.deepEqual(await fetchProviderModels(connection, ''), [{ id: modelId }]); - - const result = await generateText({ - model: getAIModel({ connection, apiKey: '', modelId }), - prompt: 'Call echo with hello, then report the result.', - tools: { - echo: tool({ - description: 'Echo text', - inputSchema: z.object({ text: z.string() }), - execute: async ({ text }) => ({ text }), - }), - }, - stopWhen: stepCountIs(2), - }); - - assert.equal(result.text, 'Echoed hello.'); - assert.equal(requestBodies.length, 2); - assert.deepEqual(requestBodies.map((body) => body.model), [modelId, modelId]); - assert.deepEqual( - (requestBodies[0]?.tools as Array<{ function: { name: string } }>).map((entry) => entry.function.name), - ['echo'], - ); - const secondMessages = requestBodies[1]?.messages as Array<{ role: string; content: string }>; - const toolMessage = secondMessages.find((message) => message.role === 'tool'); - assert.ok(toolMessage); - assert.deepEqual(JSON.parse(toolMessage.content), { text: 'hello' }); - }); + } test('Mistral discovers exact account model ids and completes its documented tool-call loop', async () => { const requestBodies: Array> = []; @@ -1306,6 +1234,99 @@ describe('models.dev provider conformance', () => { }); }); +async function assertOllamaModelContract( + discoveredModelIds: readonly string[], + modelId: string, +): Promise { + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/api/tags') { + assert.equal(request.headers.authorization, undefined); + respondJson(response, 200, { + models: discoveredModelIds.map((id) => ({ name: id, model: id })), + }); + return; + } + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/chat/completions'); + assert.equal(request.headers.authorization, undefined); + const body = JSON.parse(await readBody(request)) as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + respondJson(response, 200, { + id: 'chatcmpl-ollama-tool', + object: 'chat.completion', + created: 1, + model: modelId, + choices: [{ + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_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-final', + object: 'chat.completion', + created: 2, + model: modelId, + choices: [{ + index: 0, + message: { role: 'assistant', content: 'Echoed hello.' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 }, + }); + }); + const connection: LlmConnection = { + slug: 'ollama-local', + name: 'Ollama', + providerType: 'ollama', + baseUrl: `${server.url}/v1`, + defaultModel: modelId, + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + assert.deepEqual(await fetchProviderModels(connection, ''), discoveredModelIds.map((id) => ({ id }))); + + const result = await generateText({ + model: getAIModel({ connection, apiKey: '', modelId }), + prompt: 'Call echo with hello, then report the result.', + tools: { + echo: tool({ + description: 'Echo text', + inputSchema: z.object({ text: z.string() }), + execute: async ({ text }) => ({ text }), + }), + }, + stopWhen: stepCountIs(2), + }); + + assert.equal(result.text, 'Echoed hello.'); + assert.equal(requestBodies.length, 2); + assert.deepEqual(requestBodies.map((body) => body.model), [modelId, modelId]); + assert.deepEqual( + (requestBodies[0]?.tools as Array<{ function: { name: string } }>).map((entry) => entry.function.name), + ['echo'], + ); + const secondMessages = requestBodies[1]?.messages as Array<{ role: string; content: string }>; + const toolMessage = secondMessages.find((message) => message.role === 'tool'); + assert.ok(toolMessage); + assert.deepEqual(JSON.parse(toolMessage.content), { text: 'hello' }); +} + async function startJsonServer( handler: (request: IncomingMessage, response: ServerResponse) => void | Promise, ): Promise<{ url: string; close(): Promise }> { diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 6b44d28e22..485258d0f0 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -73,7 +73,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV3 { const name = adapter.name === 'connection' ? connection.slug : connection.providerType; return createOpenAICompatible({ name, - apiKey: adapter.apiKeyFallback ? apiKey || adapter.apiKeyFallback : apiKey, + apiKey, baseURL, ...(adapter.passFetch ? { fetch } : {}), }).chatModel(modelId); diff --git a/packages/storage/src/__tests__/connection-store.test.ts b/packages/storage/src/__tests__/connection-store.test.ts index 7ded74f656..7bffb7dd6d 100644 --- a/packages/storage/src/__tests__/connection-store.test.ts +++ b/packages/storage/src/__tests__/connection-store.test.ts @@ -7,6 +7,41 @@ import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { createConnectionStore } from '../connection-store.js'; describe('FileConnectionStore', () => { + test('persists the Ollama provider and exact cloud alias in discovery and selection state', async () => { + await withConnectionStore(async (store, dir) => { + const localModelId = 'qwen3.5'; + const cloudModelId = 'qwen3.5:cloud'; + const created = await store.create({ + slug: 'ollama-local', + name: 'Ollama', + providerType: 'ollama', + defaultModel: localModelId, + }); + + await store.update(created.slug, { + defaultModel: cloudModelId, + models: [{ id: localModelId }, { id: cloudModelId }], + modelSource: 'fetched', + modelsFetchedAt: 1_800_000_000_000, + }); + + const persisted = JSON.parse(await readFile(join(dir, 'llm-connections.json'), 'utf8')) as { + connections: Array<{ + providerType: string; + defaultModel: string; + models: Array<{ id: string }>; + modelSource: string; + modelsFetchedAt: number; + }>; + }; + assert.equal(persisted.connections[0]?.providerType, 'ollama'); + assert.equal(persisted.connections[0]?.defaultModel, cloudModelId); + assert.deepEqual(persisted.connections[0]?.models, [{ id: localModelId }, { id: cloudModelId }]); + assert.equal(persisted.connections[0]?.modelSource, 'fetched'); + assert.equal(persisted.connections[0]?.modelsFetchedAt, 1_800_000_000_000); + }); + }); + test('persists the Fireworks provider id and exact model path', async () => { await withConnectionStore(async (store, dir) => { await store.create({