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