From 6a09c63392f8da17eb4929c7d26761455fd9b84d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 00:47:19 +0300 Subject: [PATCH] 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. --- .../sections/openchamber/DefaultsSettings.tsx | 7 +- packages/web/server/index.js | 9 + .../server/lib/small-model/DOCUMENTATION.md | 80 ++++++++- packages/web/server/lib/small-model/call.js | 58 +++++-- .../web/server/lib/small-model/call.test.js | 79 +++++++++ packages/web/server/lib/small-model/index.js | 59 ++++++- .../web/server/lib/small-model/index.test.js | 76 ++++++++- packages/web/server/lib/small-model/routes.js | 2 +- .../lib/small-model/runtime-providers.js | 156 ++++++++++++++++++ .../lib/small-model/runtime-providers.test.js | 112 +++++++++++++ 10 files changed, 606 insertions(+), 32 deletions(-) create mode 100644 packages/web/server/lib/small-model/runtime-providers.js create mode 100644 packages/web/server/lib/small-model/runtime-providers.test.js diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 5d0578cc..72db4ddd 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -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 { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 1cf37004..470c63f1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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) { diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 1776f1e1..1f6ee072 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -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..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 diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index c3a090a1..27e183c8 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -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..options.apiKey` wins, then the auth.json entry. + * `provider..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..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..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`); } diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 154fd986..51097d80 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -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', () => { diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 4118478c..667eb526 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -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, diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js index 65a1e0f4..f345fe0f 100644 --- a/packages/web/server/lib/small-model/index.test.js +++ b/packages/web/server/lib/small-model/index.test.js @@ -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'); }); }); diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index 7bfc2671..461e0992 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -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); diff --git a/packages/web/server/lib/small-model/runtime-providers.js b/packages/web/server/lib/small-model/runtime-providers.js new file mode 100644 index 00000000..f05006bf --- /dev/null +++ b/packages/web/server/lib/small-model/runtime-providers.js @@ -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; +} + + diff --git a/packages/web/server/lib/small-model/runtime-providers.test.js b/packages/web/server/lib/small-model/runtime-providers.test.js new file mode 100644 index 00000000..a4416511 --- /dev/null +++ b/packages/web/server/lib/small-model/runtime-providers.test.js @@ -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(); + }); +});