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(); + }); +});