fix(server): forward Small Model override to managed OpenCode config

The Small Model override chosen in Settings never reached the managed
OpenCode process config, so OpenCode's own title/summary generation
kept using its fallback chain instead of the user's explicit choice
and sessions stayed untitled.

Merge main into this branch to pick up the includeWeb/includeMemory
flags added to prepareManagedOpenCodeEnv, and re-apply the Small
Model injection on top of that current env shape in
getManagedOpenCodeEnv (server/index.js).

Closes #2497
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:45:04 +03:00
1275 changed files with 111795 additions and 29714 deletions
@@ -15,6 +15,15 @@ other runtime API.
## Files
- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`.
- `runtime-providers.js` — provider state that exists only inside the running
OpenCode process. A plugin registers its provider from the `config` hook and
supplies the credential from its `auth` loader, so neither reaches
`opencode.json` nor `auth.json`; `GET /provider` is the only place they
become visible. The module caches one snapshot (30s TTL, shared in-flight
request) and answers `null` — never an empty provider list — when OpenCode is
unreachable, so a momentary outage cannot retract providers. It is wired once
from `server/index.js` and reset on OpenCode restart, which reloads plugins
and can move their ports and keys.
- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
0. OpenChamber's own settings override (Settings → Sessions → Small Model):
when `smallModelUseDefault` is `false`, `smallModelOverride`
@@ -94,15 +103,27 @@ other runtime API.
`https://chatgpt.com/backend-api/codex/responses` with
`ChatGPT-Account-Id`; expired tokens are refreshed against
`auth.openai.com` (single-flight) and written back to `auth.json`.
- **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`.
- **Anthropic** (`type: api`): `/messages` with `x-api-key`, against
`provider.anthropic.options.baseURL` when configured (used as-is, matching
`@ai-sdk/anthropic` — no `/v1` is inserted) or `https://api.anthropic.com/v1`
otherwise.
- **Google** (`type: api`): `generateContent` with `x-goog-api-key`; Gemini 3
uses `thinkingLevel` while older Flash models use `thinkingBudget: 0`.
uses `thinkingLevel`, Gemini 2.x uses `thinkingBudget: 0`, and all other
models omit `thinkingConfig` entirely.
- 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, or (3) the provider's `api` field from the models.dev catalog.
Configured API keys honor OpenCode's `{env:NAME}` and `{file:path}`
substitutions; file contents and resolved credentials remain server-side.
endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) 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. Configured API keys honor OpenCode's `{env:NAME}` and
`{file:path}` substitutions; file contents and resolved credentials remain
server-side.
- The runtime credential is refused for providers listed in
`OWN_CREDENTIAL_HANDLING`. Their branches need the stored entry rather than
a bearer token: the clearest case is the ChatGPT-plan `openai` login, whose
runtime `options.apiKey` is an OAuth access token that `api.openai.com`
answers with 401.
- `[small-model:diagnostic]` logs record provider/model, input character
counts, output budget, thinking toggle, HTTP/finish status, and
content/reasoning lengths without logging prompts, response text, or
@@ -110,7 +131,7 @@ other runtime API.
`[session-goal:diagnostic]` structural verdict metadata.
- `catalog.js` — models.dev catalog via the shared in-process cache
(`../opencode/models-metadata.js`, also serving
`/api/openchamber/models-metadata`).
`/api/openchamber/models-metadata`).
- `routes.js` — `GET /api/small-model` (resolution preview) and
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
model?, directory? }` → `{ text, providerID, modelID, source }`).
@@ -126,6 +147,54 @@ other runtime API.
`config-injection.test.js`. External OpenCode servers are unaffected (they
are not launched with this env).
## Which providers the pickers may offer
`listAuthenticatedProviders()` answers one question for the Small Model and
Changes Walkthrough pickers alike: which providers can this module actually
call. One rule decides it, applied the same way to every provider — **a
credential we are allowed to use, and an endpoint to send it to.** The
auth.json scan as before, plus the credential and endpoint OpenCode resolved
for a plugin provider.
**opencode zen is excluded without a real login.** When the user has no zen
credential, OpenCode substitutes the sentinel `options.apiKey = "public"` and
trims its catalog to the free models. Those run on OpenCode's own subsidised
infrastructure and are meant to be reached through OpenCode, so the sentinel is
never accepted as a credential — see `ZEN_ANONYMOUS_API_KEY`.
### Why there is no capability probe
A plugin may implement its whole integration inside `options.fetch` —
rewriting the path, signing the request, translating the payload — and OpenCode
cannot serialise a function. Such a provider advertises an ordinary base URL
that answers nothing we know how to ask, and no reported field distinguishes it
from a plain one.
Asking the endpoint (`GET /models`) does identify that case correctly. It was
measured against all 166 providers carrying an `api` URL in the models.dev
catalog, and it also denies six of them — `cloudflare-workers-ai`,
`infomaniak`, `iflowcn`, `inference`, `kuae-cloud-coding-plan`,
`thinkingmachines` — which work fine and simply have no `/models` route. At a
3.6% false-negative rate on providers known to work, the probe removes more
working models from the picker than broken ones, and a provider that silently
vanishes explains nothing while one that fails on use says why.
So availability stops at credential and endpoint, and the protocol verdict is
left to the call. A provider whose protocol lives in a plugin's `fetch` stays
selectable and fails when used — which is what it did before this resolution
existed.
Claude Code is refused unconditionally. A plugin can publish an
OpenAI-compatible endpoint for it, but that endpoint is a façade over the
Claude Agent SDK, which spawns the Claude Code CLI per request and spends the
user's Claude subscription rate limit. Paying that for a session title or a
summary is the wrong trade, so an available endpoint does not lift the
refusal — the cost is the reason, not the transport.
The result is served as `authenticatedProviders` on `GET /api/small-model`.
The field name predates the runtime resolution; it now means "callable", which
is a superset of "has an auth.json entry".
## Registration
Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the
@@ -136,9 +205,12 @@ module is imported on first request, not at server startup.
- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a
token only through OpenCode's own server — direct calls are rejected, and
piggybacking on their subsidized infra is out of bounds by design. Every
resolution step therefore requires a usable auth entry for the provider:
resolution step therefore requires a credential we are allowed to use:
a session on an unauthenticated `opencode` provider falls through to the
global scan (or a clean 404 on a vanilla setup with no logins).
global scan (or a clean 404 on a vanilla setup with no logins). The runtime
snapshot does not weaken this — OpenCode reports the sentinel
`apiKey: "public"` for that state, and this module refuses to read it as a
credential.
- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself
keeps those outside `auth.json` in this generation; only `type: api` keys
+59 -23
View File
@@ -5,6 +5,7 @@ import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { readConfig, readConfigLayers } from '../opencode/shared.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
import { getRuntimeProvider } from './runtime-providers.js';
// Direct, non-streaming text generation against the provider APIs, replicating
// how OpenCode authenticates each of them (see the plugin auth loaders in the
@@ -339,8 +340,11 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo
return text;
};
const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({
url: 'https://api.anthropic.com/v1/messages',
const callAnthropic = async ({ apiKey, baseURL, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({
// Matches @ai-sdk/anthropic: baseURL is the full API prefix (commonly
// already ending in /v1), so it gets /messages appended as-is rather than
// having /v1/messages appended, which would double up a configured /v1.
url: `${(baseURL || 'https://api.anthropic.com/v1').replace(/\/+$/, '')}/messages`,
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
@@ -402,9 +406,10 @@ const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => {
const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3')
? { thinkingLevel: modelID.toLowerCase().includes('flash') ? 'minimal' : 'low' }
: { thinkingBudget: 0 };
const lowerModelID = modelID.toLowerCase();
const thinkingConfig = lowerModelID.startsWith('gemini-3')
? { thinkingLevel: lowerModelID.includes('flash') ? 'minimal' : 'low' }
: lowerModelID.startsWith('gemini-2') ? { thinkingBudget: 0 } : null;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -414,13 +419,11 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, re
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
...(system && { systemInstruction: { parts: [{ text: system }] } }),
generationConfig: {
maxOutputTokens,
thinkingConfig,
...(responseSchema
? { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }
: {}),
...(thinkingConfig && { thinkingConfig }),
...(responseSchema && { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }),
},
}),
signal: requestSignal(timeoutMs, signal),
@@ -566,23 +569,52 @@ const readProviderConfig = (workingDirectory, providerID) => {
// Dispatch
// ---------------------------------------------------------------------------
/**
* Providers reached through a dedicated wire format below: a token exchange,
* an OAuth refresh, or a non-bearer header. OpenCode's runtime
* `options.apiKey` is not the value those branches need — the ChatGPT-plan
* `openai` login is the clearest case, where the runtime key is an OAuth
* access token that api.openai.com answers with 401 — so the runtime
* credential never stands in for them, and the runtime listing skips them
* because the auth.json scan already covers them.
*/
export const DEDICATED_WIRE_FORMAT_PROVIDERS = new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']);
/**
* The runtime credential shaped as an auth entry, or `null` when the provider
* owns its credential handling or OpenCode reports nothing usable.
*/
const runtimeCredential = (providerID, runtime) => (
!DEDICATED_WIRE_FORMAT_PROVIDERS.has(providerID) && runtime?.apiKey
? { type: 'api', key: runtime.apiKey }
: null
);
/**
* Same credential resolution the request path uses: config
* `provider.<id>.options.apiKey` wins, then the auth.json entry.
* `provider.<id>.options.apiKey` wins, then the runtime credential OpenCode
* resolved for a plugin provider, then the auth.json entry.
* Callers that need to refuse before spending a request (walkthrough readiness)
* must use this rather than inventing a second rule.
*/
export function resolveProviderLogin({ auth, workingDirectory, providerID }) {
export async function resolveProviderLogin({ auth, workingDirectory, providerID }) {
const providerConfig = readProviderConfig(workingDirectory, providerID);
return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null;
return providerConfig?.auth
|| runtimeCredential(providerID, await getRuntimeProvider(providerID))
|| getAuthEntryForProvider(auth, providerID)
|| null;
}
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const providerConfig = readProviderConfig(workingDirectory, providerID);
// Match OpenCode's resolveSDK precedence:
// config provider.<id>.options.apiKey wins; the auth.json entry is only a fallback.
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
const runtimeProvider = await getRuntimeProvider(providerID);
// 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.
const entry = providerConfig?.auth
|| runtimeCredential(providerID, runtimeProvider)
|| getAuthEntryForProvider(auth, providerID);
if (!entry) {
// Structured so the walkthrough (and any other caller) can show a blocker
// instead of a raw 500 banner with this developer-oriented sentence.
@@ -676,7 +708,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
}
if (providerID === 'anthropic') {
return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
return callAnthropic({ apiKey, baseURL: providerConfig?.baseURL, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
}
if (providerID === 'google') {
return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
@@ -685,9 +717,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
// Everything else: OpenAI-compatible chat completions against the catalog's
// 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.
// 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
// provider.openai.options.baseURL — OpenCode itself uses the same config for
// all providers including openai.
const provider = getCatalogProvider(catalog, providerID);
const providerConfigUrl = providerConfig?.baseURL;
const defaultOpenaiUrl = 'https://api.openai.com/v1';
@@ -695,9 +730,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
? providerConfigUrl
: providerID === 'openai'
? defaultOpenaiUrl
: typeof provider?.api === 'string' && provider.api
? provider.api
: null;
: runtimeProvider?.baseURL
?? (typeof provider?.api === 'string' && provider.api
? provider.api
: null);
if (!baseURL) {
throw new Error(`Provider "${providerID}" has no known API base URL`);
}
@@ -12,8 +12,11 @@ vi.mock('../opencode/shared.js', () => ({
readConfigLayers: vi.fn(),
}));
vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) }));
const { callSmallModel } = await import('./call.js');
const { readConfig, readConfigLayers } = await import('../opencode/shared.js');
const { getRuntimeProvider } = await import('./runtime-providers.js');
// Minimal catalog fragment used by the catalog-based base URL resolution case.
const CATALOG = {
@@ -55,6 +58,9 @@ describe('callSmallModel — custom provider config', () => {
globalThis.fetch = fetchMock;
readConfig.mockReset();
readConfigLayers.mockReset();
// Default: OpenCode knows nothing, so resolution stays file-based.
getRuntimeProvider.mockReset();
getRuntimeProvider.mockResolvedValue(null);
});
afterEach(() => {
@@ -310,6 +316,69 @@ describe('callSmallModel — custom provider config', () => {
});
});
describe('anthropic provider custom baseURL override', () => {
const anthropicOk = (text) => ({
ok: true,
status: 200,
json: async () => ({ content: [{ type: 'text', text }] }),
});
it('respects provider.anthropic.options.baseURL over the hardcoded Anthropic endpoint', async () => {
readConfig.mockReturnValue({
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456/v1' } } },
});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'dummy' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
const { url, init } = lastCall(fetchMock);
expect(url).toBe('http://127.0.0.1:3456/v1/messages');
expect(url).not.toContain('api.anthropic.com');
expect(init.headers['x-api-key']).toBe('dummy');
});
it('uses a bare-host baseURL as-is without inserting /v1, matching @ai-sdk/anthropic', async () => {
readConfig.mockReturnValue({
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456' } } },
});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'dummy' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('http://127.0.0.1:3456/messages');
});
it('falls back to https://api.anthropic.com when no anthropic baseURL override is configured', async () => {
readConfig.mockReturnValue({});
fetchMock.mockResolvedValue(anthropicOk('ok'));
await callSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('https://api.anthropic.com/v1/messages');
});
});
describe('catalog-based base URL (no config override)', () => {
it('uses the catalog api field when no config baseURL is set', async () => {
readConfig.mockReturnValue({});
@@ -341,6 +410,79 @@ describe('callSmallModel — custom provider config', () => {
prompt: 'hi',
})).rejects.toThrow('Provider "custom" has no known API base URL');
});
// A plugin registers its provider inside the running OpenCode process, so
// neither the config nor auth.json knows anything about it. This is the
// case that used to fail with "has no known API base URL" (#2666).
it('uses the endpoint and credential OpenCode resolved for a plugin provider', async () => {
readConfig.mockReturnValue({});
getRuntimeProvider.mockResolvedValue({
id: 'llmapi',
apiKey: 'plugin-key',
baseURL: 'https://api.llmapi.ai/v1',
anonymousZen: false,
});
const fetchMock = vi.fn(async () => ok('done'));
vi.stubGlobal('fetch', fetchMock);
await callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'llmapi',
modelID: 'claude-opus-4-8',
prompt: 'hi',
});
const { url, init } = lastCall(fetchMock);
expect(url).toBe('https://api.llmapi.ai/v1/chat/completions');
expect(init.headers.Authorization).toBe('Bearer plugin-key');
});
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;
// api.openai.com answers it with 401, so it must not stand in for the
// codex path.
getRuntimeProvider.mockResolvedValue({
id: 'openai',
apiKey: 'oauth-access-token',
baseURL: null,
anonymousZen: false,
});
await expect(callSmallModel({
auth: {},
catalog: {},
workingDirectory: '/proj',
providerID: 'openai',
modelID: 'gpt-5.4-mini',
prompt: 'hi',
})).rejects.toMatchObject({ code: 'no-provider-login' });
});
it('prefers an explicit config baseURL over the runtime endpoint', async () => {
readConfig.mockReturnValue({ provider: { custom: { options: { baseURL: 'https://configured.example/v1' } } } });
getRuntimeProvider.mockResolvedValue({
id: 'custom',
apiKey: 'runtime-key',
baseURL: 'https://runtime.example/v1',
anonymousZen: false,
});
const fetchMock = vi.fn(async () => ok('done'));
vi.stubGlobal('fetch', fetchMock);
await callSmallModel({
auth: { custom: { type: 'api', key: 'auth-key' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'custom',
modelID: 'm',
prompt: 'hi',
});
expect(lastCall(fetchMock).url).toBe('https://configured.example/v1/chat/completions');
});
});
describe('config-supplied key does not leak', () => {
@@ -457,6 +599,22 @@ describe('callSmallModel — Google thinking configuration', () => {
const body = JSON.parse(lastCall(fetchMock).init.body);
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 });
});
it('omits thinkingConfig for other Google/Gemini models', async () => {
fetchMock.mockResolvedValue(googleResponse('generated commit'));
await callSmallModel({
auth: { google: { type: 'api', key: 'google-key' } },
catalog: {},
workingDirectory: '/proj',
providerID: 'google',
modelID: 'gemini-1.5-flash',
prompt: 'generate',
});
const body = JSON.parse(lastCall(fetchMock).init.body);
expect(body.generationConfig.thinkingConfig).toBeUndefined();
});
});
describe('callSmallModel — GitHub Copilot endpoint routing', () => {
+57 -6
View File
@@ -5,7 +5,16 @@ import { readAuthFile } from '../opencode/auth.js';
import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
import { callSmallModel, resolveProviderLogin } from './call.js';
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
// Never a small model, whatever the transport looks like. A plugin can publish
// an OpenAI-compatible endpoint for Claude Code, but it is a façade over the
// Claude Agent SDK, which spawns the Claude Code CLI per request and spends
// the user's Claude subscription rate limit. Paying that for a session title
// or a summary is the wrong trade, so the refusal is unconditional rather than
// conditional on an endpoint existing.
const CLAUDE_CODE_PROVIDER = 'claude-code';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -120,6 +129,13 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
);
}
if (resolved.providerID === CLAUDE_CODE_PROVIDER) {
throw Object.assign(
new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
{ statusCode: 422, code: 'small-model-provider-unsupported' },
);
}
// Callers with a session context can forbid silently switching providers:
// an explicit user choice (settings override, opencode config, request
// model) is always allowed, anything else must stay on the session's
@@ -173,11 +189,12 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
}
/**
* Provider ids with a usable OpenCode login — the set the small model can
* actually call. Used by the settings override picker to hide providers that
* would only ever fail (e.g. opencode free models without a token).
* Provider ids the small model can actually call — an auth.json login, or a
* credential and endpoint the running OpenCode resolved for a plugin. Used by
* the Small Model and Changes Walkthrough pickers to hide providers that would
* only ever fail (e.g. opencode free models without a token).
*/
export function listAuthenticatedProviders() {
export async function listAuthenticatedProviders() {
try {
const auth = readAuthFile();
const ids = new Set(
@@ -188,12 +205,46 @@ export function listAuthenticatedProviders() {
if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
ids.add('github-copilot');
}
// Kept separate so a runtime lookup that goes wrong costs the providers it
// would have added, never the logins already established from disk.
try {
for (const providerID of await listRuntimeCallableProviders()) ids.add(providerID);
} catch {
// The auth.json set below stands on its own.
}
ids.delete(CLAUDE_CODE_PROVIDER);
return Array.from(ids);
} catch {
return [];
}
}
/**
* Providers that only the running OpenCode knows about — plugin-registered
* ones, and any whose endpoint is resolved at startup.
*
* The test is the same one applied to an auth.json login: a credential we may
* use and somewhere to send it. Whether the endpoint answers the protocol we
* speak is not knowable from any field OpenCode reports, and guessing it wrong
* removes a working model from the picker with nothing to explain it.
*/
async function listRuntimeCallableProviders() {
const snapshot = await getRuntimeProviderSnapshot();
if (!snapshot) return [];
const ids = [];
for (const id of snapshot.connected) {
const provider = snapshot.providers.get(id);
// No credential we may use — including the zen sentinel, whose free models
// belong to OpenCode's own server.
if (!provider?.apiKey || !provider.baseURL) continue;
// Reached through a dedicated wire format and already covered by the
// auth.json scan above.
if (DEDICATED_WIRE_FORMAT_PROVIDERS.has(id)) continue;
ids.push(id);
}
return ids;
}
/**
* Reports which model would be used, without calling it.
*
@@ -254,7 +305,7 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe
// Settings/config/request overrides can name a provider with no usable login.
// Report that here so readiness can refuse before the user pays for a 401.
const hasLogin = Boolean(resolveProviderLogin({
const hasLogin = Boolean(await resolveProviderLogin({
auth,
workingDirectory: directory,
providerID: resolved.providerID,
@@ -19,19 +19,119 @@ vi.mock('./catalog.js', () => ({
getCatalogProvider: vi.fn(),
}));
vi.mock('./call.js', () => ({
DEDICATED_WIRE_FORMAT_PROVIDERS: new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']),
callSmallModel: vi.fn(),
resolveProviderLogin: vi.fn(({ auth, providerID }) => {
resolveProviderLogin: vi.fn(async ({ auth, providerID }) => {
const entry = auth?.[providerID];
return entry && typeof entry === 'object' ? entry : null;
}),
}));
vi.mock('./runtime-providers.js', () => ({
getRuntimeProviderSnapshot: vi.fn(async () => null),
}));
const { generateSmallModelText, describeSmallModel } = await import('./index.js');
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
const { readAuthFile } = await import('../opencode/auth.js');
const { getRuntimeProviderSnapshot } = await import('./runtime-providers.js');
const { readConfigLayers } = await import('../opencode/shared.js');
const { getModelCatalog } = await import('./catalog.js');
const { callSmallModel } = await import('./call.js');
describe('unsupported small-model providers', () => {
beforeEach(() => {
readAuthFile.mockReturnValue({
'claude-code': {
type: 'oauth',
access: 'claude-cli-managed',
refresh: 'claude-cli-managed',
},
});
readConfigLayers.mockReturnValue({ mergedConfig: {} });
getModelCatalog.mockResolvedValue({});
callSmallModel.mockReset();
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
it('rejects Claude Code with an actionable error before transport dispatch', async () => {
await expect(generateSmallModelText({
prompt: 'summarize this',
model: 'claude-code/haiku',
})).rejects.toMatchObject({
statusCode: 422,
code: 'small-model-provider-unsupported',
});
expect(callSmallModel).not.toHaveBeenCalled();
});
it('does not offer Claude Code in the Small Model picker', async () => {
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
});
// A plugin can publish an OpenAI-compatible endpoint for Claude Code, but it
// is a façade over the Claude Agent SDK: every call spawns the CLI and
// spends the user's Claude subscription. The refusal is about that cost, so
// an available endpoint must not lift it.
it('still refuses Claude Code when a plugin publishes an HTTP endpoint for it', async () => {
getRuntimeProviderSnapshot.mockResolvedValue({
providers: new Map([['claude-code', { id: 'claude-code', apiKey: 'plugin-key', baseURL: 'http://127.0.0.1:60668/v1', anonymousZen: false }]]),
connected: new Set(['claude-code']),
});
await expect(generateSmallModelText({
prompt: 'summarize this',
model: 'claude-code/haiku',
})).rejects.toMatchObject({ code: 'small-model-provider-unsupported' });
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
});
describe('provider availability for the model pickers', () => {
beforeEach(() => {
readAuthFile.mockReturnValue({ openai: { type: 'api', key: 'sk-test' } });
readConfigLayers.mockReturnValue({ mergedConfig: {} });
getModelCatalog.mockResolvedValue({});
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
const snapshot = (providers, connected) => ({
providers: new Map(providers.map((provider) => [provider.id, provider])),
connected: new Set(connected ?? providers.map((provider) => provider.id)),
});
it('offers a plugin provider that OpenCode resolved at runtime', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'llmapi', apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1', anonymousZen: false },
]));
expect(await listAuthenticatedProviders()).toEqual(expect.arrayContaining(['openai', 'llmapi']));
});
it('hides a provider with no endpoint to send a request to', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'endpointless', apiKey: 'plugin-key', baseURL: null, anonymousZen: false },
]));
expect(await listAuthenticatedProviders()).not.toContain('endpointless');
});
it('never offers opencode zen without a real login', async () => {
// The zen sentinel is not a credential, so the snapshot carries no apiKey.
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
{ id: 'opencode', apiKey: null, baseURL: 'https://opencode.ai/zen/v1', anonymousZen: true },
]));
expect(await listAuthenticatedProviders()).not.toContain('opencode');
});
it('keeps the auth.json providers when OpenCode cannot be reached', async () => {
getRuntimeProviderSnapshot.mockResolvedValue(null);
expect(await listAuthenticatedProviders()).toContain('openai');
});
});
// 8k context leaves 4k input tokens after the output reserve → 16k chars.
const CATALOG = {
anthropic: {
@@ -10,7 +10,7 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
res.json({
available: Boolean(resolved),
model: resolved,
authenticatedProviders: listAuthenticatedProviders(),
authenticatedProviders: await listAuthenticatedProviders(),
});
} catch (error) {
console.error('Failed to resolve small model:', error);
@@ -39,7 +39,9 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
console.error('Small model generation failed:', error);
}
res.status(statusCode).json({
error: error.message || 'Small model generation failed',
error: statusCode === 404
? (error.message || 'No small model is available')
: 'The selected Small Model could not complete this action. Choose another model in Settings → Sessions → Small Model and try again.',
...(error?.code ? { code: error.code } : {}),
});
}
@@ -0,0 +1,156 @@
// Provider state that exists only inside the running OpenCode process.
//
// A plugin registers its provider from the `config` hook and supplies the
// credential from its `auth` loader, both at startup. Neither ends up in
// `opencode.json` or `auth.json`, so a server that only reads files sees
// nothing — which is why plugin-backed models used to fail here with
// "has no known API base URL" while working fine in chat (#2666).
//
// `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`).
//
// 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:
// rewriting the path, signing the request, translating the payload. Such a
// provider advertises a perfectly ordinary base URL that answers nothing we
// know how to ask, and no field distinguishes the two.
//
// Asking the endpoint (`GET /models`) looked like the way to tell them apart,
// and it does answer correctly for that case — but measured against the 166
// providers in the models.dev catalog it also denies six that work fine and
// simply have no `/models` route. A provider that vanishes from the picker
// explains nothing; one that fails on use says why. So this module reports
// what it knows and leaves the verdict to the call itself.
const SNAPSHOT_TTL_MS = 30_000;
const SNAPSHOT_TIMEOUT_MS = 5_000;
// opencode zen hands out this sentinel instead of a key when the user has no
// zen login, and trims its catalog to the free models. Those run on OpenCode's
// own subsidised infrastructure and are meant to be reached through OpenCode,
// not by us. Treating the sentinel as a credential would do exactly that, so
// it is never accepted as one.
export const ZEN_ANONYMOUS_API_KEY = 'public';
let connection = null;
let snapshot = null;
let snapshotAt = 0;
let inflight = null;
/**
* Wires this module to the running OpenCode instance. Called once at server
* startup; pass `null` to detach. Until it is wired every lookup answers
* "nothing known", which leaves the file-based resolution unchanged.
*/
export function configureOpenCodeRuntimeProviders(next) {
connection = next ?? null;
resetOpenCodeRuntimeProviders();
}
/**
* Drops every cached answer. OpenCode restarts reload plugins, which can
* change ports, keys and the provider list itself.
*/
export function resetOpenCodeRuntimeProviders() {
snapshot = null;
snapshotAt = 0;
inflight = null;
}
/**
* 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.
*
* The credential deliberately prefers `options.apiKey` over the `key` field:
* for a plugin provider the former is what its auth loader produced and what
* OpenCode itself sends, while `key` only carries env/auth.json values this
* server can already read from disk.
*/
function parseProviderListing(payload) {
const providers = new Map();
const connected = new Set();
if (!payload || typeof payload !== 'object') return { providers, connected };
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
const record = (value) => (value && typeof value === 'object' ? value : {});
const endpoint = (value) => text(value)?.replace(/\/+$/, '') ?? null;
for (const raw of Array.isArray(payload.all) ? payload.all : []) {
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 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),
// 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,
});
}
// Providers OpenCode considers usable right now. A provider can be present
// in `all` (it is in the catalog) without any credential behind it.
for (const raw of Array.isArray(payload.connected) ? payload.connected : []) {
const id = text(raw);
if (id) connected.add(id);
}
return { providers, connected };
}
const fetchSnapshot = async () => {
const response = await fetch(connection.buildOpenCodeUrl('/provider', ''), {
headers: { Accept: 'application/json', ...connection.getOpenCodeAuthHeaders() },
signal: AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OpenCode provider listing failed with ${response.status}`);
}
return parseProviderListing(await response.json());
};
/**
* The current runtime provider snapshot, or `null` when OpenCode cannot be
* reached.
*
* `null` means "unknown", never "no providers": callers must fall back to
* their file-based resolution rather than treat an unreachable OpenCode as an
* empty provider list.
*/
export async function getRuntimeProviderSnapshot() {
if (!connection) return null;
if (snapshot && Date.now() - snapshotAt < SNAPSHOT_TTL_MS) return snapshot;
if (!inflight) {
inflight = fetchSnapshot().finally(() => {
inflight = null;
});
}
try {
snapshot = await inflight;
snapshotAt = Date.now();
return snapshot;
} catch {
// Keep serving the previous snapshot when there is one: a momentarily
// unreachable OpenCode should not retract providers that were resolving a
// second ago.
return snapshot;
}
}
/**
* Runtime credential and endpoint for one provider, or `null` when OpenCode
* knows nothing about it.
*/
export async function getRuntimeProvider(providerID) {
const current = await getRuntimeProviderSnapshot();
return current?.providers.get(providerID) ?? null;
}
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ZEN_ANONYMOUS_API_KEY,
configureOpenCodeRuntimeProviders,
getRuntimeProvider,
getRuntimeProviderSnapshot,
resetOpenCodeRuntimeProviders,
} from './runtime-providers.js';
const providerPayload = (overrides = {}) => ({
all: [
{
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' } } },
},
{
id: 'opencode',
source: 'custom',
options: { apiKey: ZEN_ANONYMOUS_API_KEY },
models: { 'free-model': { api: { id: 'free-model', url: 'https://opencode.ai/zen/v1', npm: '@ai-sdk/openai-compatible' } } },
},
{
id: 'zai-coding-plan',
source: 'api',
key: 'auth-json-key',
options: {},
models: { 'glm-5': { api: { id: 'glm-5', url: 'https://api.z.ai/api/coding/paas/v4', npm: '@ai-sdk/openai-compatible' } } },
},
],
connected: ['llmapi', 'opencode', 'zai-coding-plan'],
...overrides,
});
describe('OpenCode runtime provider snapshot', () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(async () => new Response(JSON.stringify(providerPayload()), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
configureOpenCodeRuntimeProviders({
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
});
});
afterEach(() => {
configureOpenCodeRuntimeProviders(null);
resetOpenCodeRuntimeProviders();
vi.unstubAllGlobals();
});
it('reports the credential and endpoint a plugin registered at runtime', async () => {
const provider = await getRuntimeProvider('llmapi');
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
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' });
});
it('refuses the zen sentinel as a credential', async () => {
const provider = await getRuntimeProvider('opencode');
expect(provider.apiKey).toBeNull();
expect(provider.anonymousZen).toBe(true);
// The endpoint is still reported; only the credential is withheld.
expect(provider.baseURL).toBe('https://opencode.ai/zen/v1');
});
it('falls back to the model endpoint when the provider carries no baseURL', async () => {
expect((await getRuntimeProvider('zai-coding-plan')).baseURL).toBe('https://api.z.ai/api/coding/paas/v4');
});
it('serves one snapshot to concurrent callers instead of refetching', async () => {
await Promise.all([getRuntimeProvider('llmapi'), getRuntimeProvider('opencode'), getRuntimeProvider('zai-coding-plan')]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('answers "unknown" rather than "no providers" when OpenCode is unreachable', async () => {
resetOpenCodeRuntimeProviders();
fetchMock.mockRejectedValue(new Error('connection refused'));
expect(await getRuntimeProviderSnapshot()).toBeNull();
});
it('keeps the previous snapshot when a later refresh fails', async () => {
await getRuntimeProviderSnapshot();
fetchMock.mockRejectedValue(new Error('connection refused'));
// Past the snapshot TTL, so the next read genuinely attempts a refresh.
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 60_000);
const refreshed = await getRuntimeProviderSnapshot();
vi.useRealTimers();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshed.providers.has('llmapi')).toBe(true);
});
it('stays on file-based resolution until it is configured', async () => {
configureOpenCodeRuntimeProviders(null);
expect(await getRuntimeProvider('llmapi')).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});