diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index c757d07b..fc2a1ba7 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -47,7 +47,9 @@ other runtime API. - **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`. - **Google** (`type: api`): `generateContent` with `x-goog-api-key`. - Everything else: OpenAI-compatible `/chat/completions` against the - provider's models.dev base URL with `Authorization: Bearer `. + provider's base URL, resolved from (1) `provider..options.baseURL` + in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1` + endpoint, or (3) the provider's `api` field from the models.dev catalog. - `catalog.js` — models.dev catalog via the shared in-process cache (`../opencode/models-metadata.js`, also serving `/api/openchamber/models-metadata`). diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 8fd81166..ff38a1ca 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -1,4 +1,5 @@ import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; +import { readConfig } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; import { getAuthEntryForProvider } from './resolve.js'; @@ -279,13 +280,41 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys return result; }; +// --------------------------------------------------------------------------- +// Custom provider configuration support +// --------------------------------------------------------------------------- + +const readProviderConfig = (workingDirectory, providerID) => { + try { + const config = readConfig(workingDirectory); + const providerCfg = config?.provider?.[providerID]; + if (!providerCfg || typeof providerCfg !== 'object') return null; + const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null; + const apiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null; + return { + baseURL, + // Shape the config-supplied key as a regular api-key auth entry so it + // can win the precedence check below and flow through the dispatch's + // `entry.type === 'api' ? entry.key : ...` branch unchanged. + auth: apiKey ? { type: 'api', key: apiKey } : null, + }; + } catch { + // Provider config is non-essential — continue with catalog-only resolution. + return null; + } +} + // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- -export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) { +export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; - const entry = getAuthEntryForProvider(auth, providerID); + const providerConfig = readProviderConfig(workingDirectory, providerID); + // Match OpenCode's resolveSDK precedence: + // config provider..options.apiKey (providerConfig.auth) wins; the + // auth.json entry is only a fallback. + const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID); if (!entry) { throw new Error(`No OpenCode login found for provider "${providerID}"`); } @@ -343,13 +372,21 @@ export async function callSmallModel({ auth, catalog, providerID, modelID, promp } // Everything else: OpenAI-compatible chat completions against the catalog's - // base URL for that provider (openai itself included). + // base URL for that provider (openai itself included). When a custom provider + // is not in the catalog (e.g. a user-configured OpenAI-compatible proxy), + // fall back to its baseURL from the OpenCode provider config. The openai + // provider also respects provider.openai.options.baseURL — OpenCode itself + // uses the same config for all providers including openai. const provider = getCatalogProvider(catalog, providerID); - const baseURL = providerID === 'openai' - ? 'https://api.openai.com/v1' - : typeof provider?.api === 'string' && provider.api - ? provider.api - : null; + const providerConfigUrl = providerConfig?.baseURL; + const defaultOpenaiUrl = 'https://api.openai.com/v1'; + const baseURL = typeof providerConfigUrl === 'string' && providerConfigUrl + ? providerConfigUrl + : providerID === 'openai' + ? defaultOpenaiUrl + : typeof provider?.api === 'string' && provider.api + ? provider.api + : null; if (!baseURL) { throw new Error(`Provider "${providerID}" has no known API base URL`); } diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js new file mode 100644 index 00000000..b09d29a2 --- /dev/null +++ b/packages/web/server/lib/small-model/call.test.js @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// readConfig reads merged opencode config layers from disk; mock it so each +// test controls the provider config without touching the filesystem. call.js +// imports only readConfig from shared.js, so the rest of that module is left +// untouched for this file. +vi.mock('../opencode/shared.js', () => ({ + readConfig: vi.fn(), +})); + +const { callSmallModel } = await import('./call.js'); +const { readConfig } = await import('../opencode/shared.js'); + +// Minimal catalog fragment used by the catalog-based base URL resolution case. +const CATALOG = { + mistral: { + id: 'mistral', + name: 'Mistral', + api: 'https://api.mistral.ai/v1', + models: { + 'mistral-small-latest': { id: 'mistral-small-latest' }, + }, + }, +}; + +const ok = (content) => ({ + ok: true, + status: 200, + json: async () => ({ + choices: [{ message: { content }, finish_reason: 'stop' }], + }), + text: async () => JSON.stringify({ + choices: [{ message: { content }, finish_reason: 'stop' }], + }), +}); + +const lastCall = (mock) => { + const [url, init] = mock.mock.calls.at(-1); + return { url: String(url), init }; +}; + +// Regression coverage for the small-model dispatch to custom OpenAI-compatible +// providers — credential and endpoint resolution, precedence, and non-leakage. +describe('callSmallModel — custom provider config', () => { + let fetchMock; + let originalFetch; + + beforeEach(() => { + fetchMock = vi.fn(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + readConfig.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('config-supplied credentials (no auth.json entry)', () => { + it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { + options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' }, + }, + }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + const text = await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + expect(text).toBe('hello'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const { url, init } = lastCall(fetchMock); + // Config baseURL is used, never the hardcoded OpenAI endpoint. + expect(url).toBe('https://proxy.example.test/v1/chat/completions'); + expect(url).not.toContain('api.openai.com'); + // Config apiKey becomes the bearer credential. + expect(init.headers.Authorization).toBe('Bearer test-key'); + }); + + it('trims a trailing slash from the configured baseURL', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { options: { apiKey: 'k', baseURL: 'https://proxy.example.test/v1/' } }, + }, + }); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).url).toBe('https://proxy.example.test/v1/chat/completions'); + }); + + it('throws "No OpenCode login found for provider" when neither auth.json nor config apiKey exists', async () => { + readConfig.mockReturnValue({ + provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } }, + }); + + await expect(callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + })).rejects.toThrow('No OpenCode login found for provider "custom"'); + + // The credential gate fires before any network call. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('treats a blank/whitespace apiKey in config as absent', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { options: { apiKey: ' ', baseURL: 'https://proxy.example.test/v1' } }, + }, + }); + + await expect(callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + })).rejects.toThrow('No OpenCode login found for provider "custom"'); + }); + }); + + describe('resolution order when auth.json is also present', () => { + it('uses the auth.json credential with the config baseURL', async () => { + readConfig.mockReturnValue({ + provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } }, + }); + fetchMock.mockResolvedValue(ok('done')); + + const text = await callSmallModel({ + auth: { custom: { type: 'api', key: 'authjson-key' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + expect(text).toBe('done'); + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://proxy.example.test/v1/chat/completions'); + expect(init.headers.Authorization).toBe('Bearer authjson-key'); + }); + + it('prefers the config apiKey over an auth.json credential when both are present (matches OpenCode)', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { options: { apiKey: 'config-key', baseURL: 'https://proxy.example.test/v1' } }, + }, + }); + fetchMock.mockResolvedValue(ok('done')); + + await callSmallModel({ + auth: { custom: { type: 'api', key: 'authjson-key' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + // OpenCode's resolveSDK reads options.apiKey first and only falls back to + // auth.json's key when config has none — so the config key wins and the + // auth.json credential must never be sent. + expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer config-key'); + expect(JSON.stringify(fetchMock.mock.calls[0][1])).not.toContain('authjson-key'); + }); + }); + + describe('openai provider custom baseURL override', () => { + it('respects provider.openai.options.baseURL over the hardcoded OpenAI endpoint', async () => { + readConfig.mockReturnValue({ + provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } }, + }); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-openai' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://gateway.example.test/v1/chat/completions'); + expect(url).not.toContain('api.openai.com'); + expect(init.headers.Authorization).toBe('Bearer sk-openai'); + }); + + it('falls back to https://api.openai.com/v1 when no openai baseURL override is configured', async () => { + readConfig.mockReturnValue({}); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-openai' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).url).toBe('https://api.openai.com/v1/chat/completions'); + }); + + it('still requires a credential: a baseURL alone does not authenticate openai', async () => { + readConfig.mockReturnValue({ + provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } }, + }); + + await expect(callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-4o-mini', + prompt: 'hi', + })).rejects.toThrow('No OpenCode login found for provider "openai"'); + }); + }); + + describe('catalog-based base URL (no config override)', () => { + it('uses the catalog api field when no config baseURL is set', async () => { + readConfig.mockReturnValue({}); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { mistral: { type: 'api', key: 'mistral-key' } }, + catalog: CATALOG, + workingDirectory: '/proj', + providerID: 'mistral', + modelID: 'mistral-small-latest', + prompt: 'hi', + }); + + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://api.mistral.ai/v1/chat/completions'); + expect(init.headers.Authorization).toBe('Bearer mistral-key'); + }); + + it('throws when a non-openai provider has no catalog api and no config baseURL', async () => { + readConfig.mockReturnValue({}); + + await expect(callSmallModel({ + auth: { custom: { type: 'api', key: 'k' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + })).rejects.toThrow('Provider "custom" has no known API base URL'); + }); + }); + + describe('config-supplied key does not leak', () => { + // The config-supplied key must stay in-memory: never copied into catalog + // metadata, the response, or the request body. + it('does not mutate the catalog or echo the key in the request/response', async () => { + const catalog = { custom: { id: 'custom', models: {} } }; + const catalogBefore = JSON.parse(JSON.stringify(catalog)); + readConfig.mockReturnValue({ + provider: { + custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } }, + }, + }); + fetchMock.mockResolvedValue(ok('the answer')); + + const text = await callSmallModel({ + auth: {}, + catalog, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + // Response text is exactly the model output — no credential echoed back. + expect(text).toBe('the answer'); + // Catalog object left untouched (key stays in-memory only). + expect(catalog).toEqual(catalogBefore); + + const { url, init } = lastCall(fetchMock); + // The key rides only in the Authorization header. + expect(url).not.toContain('test-key'); + const body = JSON.parse(init.body); + expect(JSON.stringify(body)).not.toContain('test-key'); + }); + }); + + describe('merged config layers', () => { + it('reads the provider config for the supplied working directory', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } }, + }, + }); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/path/to/project', + providerID: 'custom', + modelID: 'gpt-4o-mini', + prompt: 'hi', + }); + + // readConfig merges global + project-scoped layers for this directory; + // confirm callSmallModel passes the working directory straight through. + expect(readConfig).toHaveBeenCalledWith('/path/to/project'); + }); + }); +}); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 43457c32..92abbad8 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -111,6 +111,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, const text = await callSmallModel({ auth, catalog, + workingDirectory: directory, providerID: resolved.providerID, modelID: resolved.modelID, prompt: clamped.prompt,