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
|
||||
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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user