feat(small-model): resolve plugin-registered providers from the running OpenCode

Plugin providers are registered from a plugin's `config` hook and credentialed
from its `auth` loader, both inside the running OpenCode process. Nothing about
them reaches `opencode.json` or `auth.json`, so resolution that only reads files
could not see them: selecting such a model failed with "has no known API base
URL" while the same model worked in chat (#2666).

`GET /provider` is where that state is visible. A new `runtime-providers`
module keeps one cached snapshot of it and reports, per provider, the
credential and endpoint OpenCode itself resolved. Credential resolution becomes
config -> runtime -> auth.json, and endpoint resolution config -> openai default
-> runtime -> models.dev catalog.

Providers with a dedicated wire format (Copilot, ChatGPT-plan OpenAI, Anthropic,
Google) are excluded from the runtime credential: for them OpenCode reports an
OAuth access token that their real transport does not accept.

opencode zen is excluded when the user has no zen login. OpenCode then reports
the sentinel `apiKey: "public"` and trims its catalog to free models that run on
its own infrastructure; the sentinel is never read as a credential.

Claude Code stays refused for background actions even when a plugin publishes an
OpenAI-compatible endpoint for it, because that endpoint is a facade over the
Claude Agent SDK and spawns the CLI per request.

No capability probe. Asking `GET /models` does identify a plugin whose protocol
lives in its own `fetch`, but measured across the 166 providers with an `api`
URL in the models.dev catalog it also denies six that work and simply have no
`/models` route. A provider that vanishes from the picker explains nothing,
while one that fails on use says why, so availability stops at credential and
endpoint.

The same list drives the Small Model and Changes Walkthrough pickers.

Validated against a real OpenCode with four plugin providers loaded: offered
providers went from 3 to 7, zen and Claude Code stayed out, and a generation
through a plugin-backed model that previously failed now returns.
This commit is contained in:
Bohdan Triapitsyn
2026-08-20 00:47:34 +03:00
parent 52ebe51122
commit 6a09c63392
10 changed files with 606 additions and 32 deletions
@@ -275,8 +275,11 @@ export const DefaultsSettings: React.FC = () => {
[walkthroughModelOverride]
);
React.useEffect(() => {
// Both pickers filter by the same authenticated-provider list, and the
// walkthrough picker is always visible, so this is always worth fetching.
// Both pickers offer the same providers — the walkthrough runs through the
// small model — and the walkthrough picker is always visible, so this is
// always worth fetching. The server answers with the providers it has a
// credential and an endpoint for, including plugin-registered ones that
// exist only inside the running OpenCode.
let cancelled = false;
(async () => {
try {
+9
View File
@@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js';
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from './lib/small-model/runtime-providers.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
@@ -664,6 +665,11 @@ const buildOpenCodeUrl = (...args) => openCodeNetworkRuntime.buildOpenCodeUrl(..
const ensureOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.ensureOpenCodeApiPrefix(...args);
const scheduleOpenCodeApiDetection = (...args) => openCodeNetworkRuntime.scheduleOpenCodeApiDetection(...args);
// Plugin-registered providers exist only inside the running OpenCode process.
// Small-model callers resolve them through this connection; without it they
// stay on the file-based resolution and plugin models remain unreachable.
configureOpenCodeRuntimeProviders({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
);
@@ -1162,6 +1168,9 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
// process (#2638). The runtime is created later by the startup pipeline;
// by the time any restart runs, it is assigned.
onOpenCodeRestarted: () => {
// A restart reloads plugins: provider ports, credentials and the provider
// list itself can all differ from what was cached.
resetOpenCodeRuntimeProviders();
try {
messageStreamRuntime?.rebindUpstream();
} catch (error) {
@@ -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`
@@ -100,9 +109,17 @@ 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, 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,11 +127,59 @@ 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 }`).
## 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
@@ -125,9 +190,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
+46 -12
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
@@ -566,23 +567,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.
@@ -685,9 +715,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 +728,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(() => {
@@ -341,6 +347,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', () => {
+51 -8
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,7 +129,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
);
}
if (resolved.providerID === 'claude-code') {
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' },
@@ -180,28 +189,62 @@ 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(
Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
);
ids.delete('claude-code');
// The catalog id is github-copilot while legacy auth entries may sit
// under the copilot alias.
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.
*
@@ -262,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,15 +19,20 @@ 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, 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');
@@ -44,6 +49,7 @@ describe('unsupported small-model providers', () => {
readConfigLayers.mockReturnValue({ mergedConfig: {} });
getModelCatalog.mockResolvedValue({});
callSmallModel.mockReset();
getRuntimeProviderSnapshot.mockResolvedValue(null);
});
it('rejects Claude Code with an actionable error before transport dispatch', async () => {
@@ -57,8 +63,72 @@ describe('unsupported small-model providers', () => {
expect(callSmallModel).not.toHaveBeenCalled();
});
it('does not offer Claude Code in the Small Model picker', () => {
expect(listAuthenticatedProviders()).not.toContain('claude-code');
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');
});
});
@@ -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);
@@ -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();
});
});