fix(small-model): use selected runtime model endpoint (#3437)

This commit is contained in:
Matt Cowger
2026-09-09 08:29:23 +03:00
committed by GitHub
parent 458dcf78c0
commit 66af9b52c7
5 changed files with 63 additions and 14 deletions
@@ -117,8 +117,9 @@ other runtime API.
- Everything else: OpenAI-compatible `/chat/completions` against the
provider's base URL, resolved from (1) `provider.<id>.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.<id>.options.headers` is sent with the
request and overrides the bearer default, so gateways that authenticate on
+9 -4
View File
@@ -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.<id>.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);
@@ -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;
@@ -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;
}
@@ -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' });
});