fix(small-model): use selected runtime model endpoint (#3437)
This commit is contained in:
@@ -117,8 +117,9 @@ other runtime API.
|
|||||||
- Everything else: OpenAI-compatible `/chat/completions` against the
|
- Everything else: OpenAI-compatible `/chat/completions` against the
|
||||||
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
||||||
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
||||||
endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
|
endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
|
||||||
provider's `api` field from the models.dev catalog. The credential follows
|
(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 same shape: config `options.apiKey`, then the runtime credential, then
|
||||||
the auth.json entry. `provider.<id>.options.headers` is sent with the
|
the auth.json entry. `provider.<id>.options.headers` is sent with the
|
||||||
request and overrides the bearer default, so gateways that authenticate on
|
request and overrides the bearer default, so gateways that authenticate on
|
||||||
|
|||||||
@@ -606,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dispatch
|
// 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 tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
||||||
const runtimeProvider = await getRuntimeProvider(providerID);
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
||||||
|
const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
|
||||||
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
||||||
// wins, then what OpenCode itself resolved at runtime (the only place a
|
// wins, then what OpenCode itself resolved at runtime (the only place a
|
||||||
// plugin's credential exists), and the auth.json entry last.
|
// 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
|
// 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),
|
// 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
|
// 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
|
// selected model's endpoint OpenCode resolved at runtime, then to the
|
||||||
// only place it exists, and for several of them is a local proxy the plugin
|
// provider-level runtime endpoint. For a plugin provider, the runtime listing
|
||||||
// itself runs. The openai provider also respects
|
// 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
|
// provider.openai.options.baseURL — OpenCode itself uses the same config for
|
||||||
// all providers including openai.
|
// all providers including openai.
|
||||||
const provider = getCatalogProvider(catalog, providerID);
|
const provider = getCatalogProvider(catalog, providerID);
|
||||||
@@ -771,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
|||||||
? providerConfigUrl
|
? providerConfigUrl
|
||||||
: providerID === 'openai'
|
: providerID === 'openai'
|
||||||
? defaultOpenaiUrl
|
? defaultOpenaiUrl
|
||||||
: runtimeProvider?.baseURL
|
: runtimeModel?.api?.url
|
||||||
|
?? runtimeProvider?.baseURL
|
||||||
?? (typeof provider?.api === 'string' && provider.api
|
?? (typeof provider?.api === 'string' && provider.api
|
||||||
? provider.api
|
? provider.api
|
||||||
: null);
|
: null);
|
||||||
|
|||||||
@@ -565,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
|
|||||||
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
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 () => {
|
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
||||||
readConfig.mockReturnValue({});
|
readConfig.mockReturnValue({});
|
||||||
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
// 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
|
// `GET /provider` is where that state becomes visible. It reports, per
|
||||||
// provider, the resolved `options.baseURL` and `options.apiKey`, and per model
|
// 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
|
// 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:
|
// 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 boundary. Everything the `/provider` payload claims is checked here, so
|
||||||
* the rest of this module and its callers work with settled values:
|
* 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
|
* a credential we may use, provider and model endpoints, and whether the
|
||||||
* anonymous zen case.
|
* provider is the anonymous zen case.
|
||||||
*
|
*
|
||||||
* The credential deliberately prefers `options.apiKey` over the `key` field:
|
* The credential deliberately prefers `options.apiKey` over the `key` field:
|
||||||
* for a plugin provider the former is what its auth loader produced and what
|
* 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);
|
const id = text(record(raw).id);
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const options = record(record(raw).options);
|
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);
|
const declaredKey = text(options.apiKey);
|
||||||
providers.set(id, {
|
providers.set(id, {
|
||||||
id,
|
id,
|
||||||
source: text(record(raw).source),
|
source: text(record(raw).source),
|
||||||
apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
|
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
|
// True only for the zen-without-login case: a provider that is present
|
||||||
// and usable through OpenCode, but that we must not call ourselves.
|
// and usable through OpenCode, but that we must not call ourselves.
|
||||||
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
|
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
|
||||||
@@ -152,5 +162,3 @@ export async function getRuntimeProvider(providerID) {
|
|||||||
const current = await getRuntimeProviderSnapshot();
|
const current = await getRuntimeProviderSnapshot();
|
||||||
return current?.providers.get(providerID) ?? null;
|
return current?.providers.get(providerID) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const providerPayload = (overrides = {}) => ({
|
|||||||
id: 'llmapi',
|
id: 'llmapi',
|
||||||
source: 'config',
|
source: 'config',
|
||||||
options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
|
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',
|
id: 'opencode',
|
||||||
@@ -59,6 +62,9 @@ describe('OpenCode runtime provider snapshot', () => {
|
|||||||
const provider = await getRuntimeProvider('llmapi');
|
const provider = await getRuntimeProvider('llmapi');
|
||||||
|
|
||||||
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
|
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][0]).toBe('http://127.0.0.1:4096/provider');
|
||||||
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
|
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user