From 66af9b52c7f8676cb7c8161afb5ebcf2088f16a6 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Tue, 8 Sep 2026 22:29:23 -0700 Subject: [PATCH] fix(small-model): use selected runtime model endpoint (#3437) --- .../server/lib/small-model/DOCUMENTATION.md | 5 ++-- packages/web/server/lib/small-model/call.js | 13 ++++++--- .../web/server/lib/small-model/call.test.js | 29 +++++++++++++++++++ .../lib/small-model/runtime-providers.js | 22 +++++++++----- .../lib/small-model/runtime-providers.test.js | 8 ++++- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index cb4a2b07..14bb2156 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -117,8 +117,9 @@ other runtime API. - Everything else: OpenAI-compatible `/chat/completions` against the provider's base URL, resolved from (1) `provider..options.baseURL` in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1` - endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the - provider's `api` field from the models.dev catalog. The credential follows + endpoint, (3) the selected model's endpoint OpenCode resolved at runtime, + (4) the provider-level runtime endpoint, or (5) the provider's `api` field + from the models.dev catalog. The credential follows the same shape: config `options.apiKey`, then the runtime credential, then the auth.json entry. `provider..options.headers` is sent with the request and overrides the bearer default, so gateways that authenticate on diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 7703aa74..efb291e3 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -606,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => { } } +const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null; + // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- @@ -650,6 +652,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); const runtimeProvider = await getRuntimeProvider(providerID); + const runtimeModel = getRuntimeModel(runtimeProvider, modelID); // Match OpenCode's resolveSDK precedence: config `provider..options` // wins, then what OpenCode itself resolved at runtime (the only place a // plugin's credential exists), and the auth.json entry last. @@ -759,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI // 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, then to the - // endpoint OpenCode resolved at runtime — which for a plugin provider is the - // only place it exists, and for several of them is a local proxy the plugin - // itself runs. The openai provider also respects + // selected model's endpoint OpenCode resolved at runtime, then to the + // provider-level runtime endpoint. For a plugin provider, the runtime listing + // is the only place those endpoints exist, and several are local proxies the + // plugin itself runs. 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); @@ -771,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI ? providerConfigUrl : providerID === 'openai' ? defaultOpenaiUrl - : runtimeProvider?.baseURL + : runtimeModel?.api?.url + ?? runtimeProvider?.baseURL ?? (typeof provider?.api === 'string' && provider.api ? provider.api : null); diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 31456f0d..fcada4f4 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -565,6 +565,35 @@ describe('callSmallModel — custom provider config', () => { expect(init.headers.Authorization).toBe('Bearer plugin-key'); }); + it('uses the selected runtime model endpoint', async () => { + readConfig.mockReturnValue({}); + getRuntimeProvider.mockResolvedValue({ + id: 'runtime-provider', + apiKey: 'plugin-key', + baseURL: 'https://runtime-provider/v1beta', + models: new Map([ + ['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }], + ['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }], + ]), + anonymousZen: false, + }); + fetchMock.mockResolvedValue(ok('done')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'runtime-provider', + modelID: 'selected-model', + prompt: 'hi', + }); + + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://runtime-provider/v1/chat/completions'); + expect(url).not.toContain('/v1beta'); + expect(JSON.parse(init.body).model).toBe('selected-model'); + }); + it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => { readConfig.mockReturnValue({}); // OpenCode reports an OAuth access token as `options.apiKey` for openai; diff --git a/packages/web/server/lib/small-model/runtime-providers.js b/packages/web/server/lib/small-model/runtime-providers.js index f05006bf..fcfdd925 100644 --- a/packages/web/server/lib/small-model/runtime-providers.js +++ b/packages/web/server/lib/small-model/runtime-providers.js @@ -8,7 +8,8 @@ // // `GET /provider` is where that state becomes visible. It reports, per // provider, the resolved `options.baseURL` and `options.apiKey`, and per model -// the wire adapter (`api.npm`) and endpoint (`api.url`). +// the wire adapter (`api.npm`) and endpoint (`api.url`). The provider-level +// endpoint remains only as a fallback when the selected model has no endpoint. // // What it does NOT report is `options.fetch`. OpenCode strips functions from // the response, and a plugin is free to put its whole protocol in there: @@ -61,8 +62,8 @@ export function resetOpenCodeRuntimeProviders() { /** * The boundary. Everything the `/provider` payload claims is checked here, so * the rest of this module and its callers work with settled values: - * a credential we may use, an endpoint, and whether the provider is the - * anonymous zen case. + * a credential we may use, provider and model endpoints, and whether the + * provider is the anonymous zen case. * * The credential deliberately prefers `options.apiKey` over the `key` field: * for a plugin provider the former is what its auth loader produced and what @@ -82,13 +83,22 @@ function parseProviderListing(payload) { const id = text(record(raw).id); if (!id) continue; const options = record(record(raw).options); - const firstModel = record(Object.values(record(record(raw).models))[0]); + const models = new Map(); + for (const [modelID, rawModel] of Object.entries(record(record(raw).models))) { + const model = record(rawModel); + const api = record(model.api); + const modelURL = endpoint(api.url); + const modelNpm = text(api.npm); + models.set(modelID, { api: { url: modelURL, npm: modelNpm } }); + } + const firstModel = models.values().next().value; const declaredKey = text(options.apiKey); providers.set(id, { id, source: text(record(raw).source), apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)), - baseURL: endpoint(options.baseURL) ?? endpoint(record(firstModel.api).url), + baseURL: endpoint(options.baseURL) ?? firstModel?.api?.url ?? null, + models, // True only for the zen-without-login case: a provider that is present // and usable through OpenCode, but that we must not call ourselves. anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY, @@ -152,5 +162,3 @@ export async function getRuntimeProvider(providerID) { const current = await getRuntimeProviderSnapshot(); return current?.providers.get(providerID) ?? null; } - - diff --git a/packages/web/server/lib/small-model/runtime-providers.test.js b/packages/web/server/lib/small-model/runtime-providers.test.js index a4416511..c56506f4 100644 --- a/packages/web/server/lib/small-model/runtime-providers.test.js +++ b/packages/web/server/lib/small-model/runtime-providers.test.js @@ -14,7 +14,10 @@ const providerPayload = (overrides = {}) => ({ id: 'llmapi', source: 'config', options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' }, - models: { 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } } }, + models: { + 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } }, + 'gpt-5.6-luna': { api: { id: 'gpt-5.6-luna', url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' } }, + }, }, { id: 'opencode', @@ -59,6 +62,9 @@ describe('OpenCode runtime provider snapshot', () => { const provider = await getRuntimeProvider('llmapi'); expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' }); + expect(provider.models.get('gpt-5.6-luna')).toEqual({ + api: { url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' }, + }); expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider'); expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' }); });