import fs from 'fs'; import os from 'os'; import path from 'path'; 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 { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js'; import { readMergedSettingsSync } from '../opencode/settings-files.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 ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'settings.json', ); // OpenChamber's own settings: when the user unchecks "use default small model" // their explicit override outranks every other resolution step. const readSmallModelSettingsOverride = () => { const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE }); if (settings.smallModelUseDefault !== false) return null; const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : ''; return override || null; }; // Rough safety clamp so a huge input never blows the model's context window. // Token estimate is ~4 chars/token; when the catalog has no limit for the // model (Copilot/codex utility models are not listed) a conservative default // applies. const DEFAULT_CONTEXT_TOKENS = 64_000; const OUTPUT_RESERVE_TOKENS = 4_000; /** * Input budget in characters, given how much of the context the caller intends * to leave for the answer. The reserve must match the output budget the caller * will actually request, or the two disagree and the model overruns its context. */ export const getModelInputCharBudget = ({ catalog, providerID, modelID, outputReserveTokens }) => { const limit = catalog?.[providerID]?.models?.[modelID]?.limit; const known = Number(limit?.context) > 0; const contextTokens = known ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; const reserve = Number(outputReserveTokens) > 0 ? Number(outputReserveTokens) : OUTPUT_RESERVE_TOKENS; const inputBudgetTokens = Math.max(1_000, contextTokens - reserve); return { maxChars: inputBudgetTokens * 4, contextTokens, contextKnown: known }; }; /** * The output budget to actually request: what the caller asked for, capped by * what the model admits it can emit. Asking for more than `limit.output` is * rejected outright by some providers and silently ignored by others. */ const resolveOutputTokens = ({ catalog, providerID, modelID, maxOutputTokens }) => { const requested = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : 0; if (!requested) return undefined; const limit = Number(catalog?.[providerID]?.models?.[modelID]?.limit?.output); return limit > 0 ? Math.min(requested, limit) : requested; }; // `truncate` keeps the historical behavior for callers whose prompt losing its // tail is survivable (summaries, commit messages). `error` is for callers whose // output would be quietly wrong on a clipped input — they need the failure. const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID, onOverflow, outputReserveTokens }) => { const { maxChars } = getModelInputCharBudget({ catalog, providerID, modelID, outputReserveTokens }); if (prompt.length <= maxChars) { return { prompt, truncated: false }; } if (onOverflow === 'error') { throw Object.assign( new Error(`Input is too large for ${providerID}/${modelID}: ${prompt.length} characters exceeds the ${maxChars} the model's context allows`), { statusCode: 413, code: 'context-too-small', providerID, modelID, requiredChars: prompt.length, availableChars: maxChars }, ); } return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true }; }; const readConfiguredSmallModel = (workingDirectory) => { try { const { mergedConfig } = readConfigLayers(workingDirectory); const value = mergedConfig?.small_model; return typeof value === 'string' ? value : null; } catch { return null; } }; /** * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } const auth = readAuthFile(); const catalog = await getModelCatalog().catch(() => ({})); const explicit = parseModelRef(model); const resolved = explicit ? { ...explicit, source: 'request' } : resolveSmallModel({ auth, catalog, settingsSmallModel: readSmallModelSettingsOverride(), configSmallModel: readConfiguredSmallModel(directory), preferredProviderID, preferredModelID, }); if (!resolved) { throw Object.assign( new Error('No small model available — no authenticated provider has a suitable model'), { statusCode: 404 }, ); } 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 // provider. if (restrictToPreferredProvider && !['settings', 'config', 'request'].includes(resolved.source) && resolved.providerID !== preferredProviderID) { throw Object.assign( new Error('No small model available within the session provider'), { statusCode: 404 }, ); } const outputTokens = resolveOutputTokens({ catalog, providerID: resolved.providerID, modelID: resolved.modelID, maxOutputTokens, }); const clamped = clampPromptToModelLimit({ prompt: prompt.trim(), catalog, providerID: resolved.providerID, modelID: resolved.modelID, onOverflow, outputReserveTokens: outputTokens, }); const text = await callSmallModel({ auth, catalog, workingDirectory: directory, sessionID, providerID: resolved.providerID, modelID: resolved.modelID, prompt: clamped.prompt, system: typeof system === 'string' && system.trim() ? system.trim() : undefined, maxOutputTokens: outputTokens, responseSchema, timeoutMs, signal, }); return { text: text.trim(), providerID: resolved.providerID, modelID: resolved.modelID, source: resolved.source, ...(clamped.truncated ? { inputTruncated: true } : {}), }; } /** * 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 async function listAuthenticatedProviders() { try { const auth = readAuthFile(); const ids = new Set( Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])), ); // 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. * * `inputCharBudget` and `structuredOutput` let callers refuse work before * spending a request: the walkthrough needs both a big enough context and * schema-shaped output, and would rather tell the user to pick another model * than send a doomed prompt. `structuredOutput` is deliberately tri-state — * the catalog omits the field for roughly half of all models (aggregators and * proxies especially), and treating "unknown" as "unsupported" would hide * models that work fine. */ /** * The reserve, resolved against the model that was actually picked. * * A caller that wants "as much answer room as this model allows" cannot state a * number up front — it does not know which model it will get. Passing a * function lets it decide once the limits are known, and keeps the reserve and * the eventual request the same number by construction. */ const resolveReserveTokens = (outputReserveTokens, limits) => ( typeof outputReserveTokens === 'function' ? outputReserveTokens(limits) : outputReserveTokens ); export async function describeSmallModel({ directory, preferredProviderID, preferredModelID, outputReserveTokens, overrideModel } = {}) { const auth = readAuthFile(); const catalog = await getModelCatalog().catch(() => ({})); // A caller with its own model setting (the diff walkthrough) outranks the // small-model chain entirely — it asked for this model on purpose. const explicit = parseModelRef(overrideModel); const resolved = explicit ? { ...explicit, source: 'request' } : resolveSmallModel({ auth, catalog, settingsSmallModel: readSmallModelSettingsOverride(), configSmallModel: readConfiguredSmallModel(directory), preferredProviderID, preferredModelID, }); if (!resolved) return resolved; const entry = catalog?.[resolved.providerID]?.models?.[resolved.modelID]; const outputTokenLimit = Number(entry?.limit?.output) > 0 ? Number(entry.limit.output) : null; // Two passes: the first only to learn the context, which a caller-supplied // reserve function needs before it can answer. const { contextTokens, contextKnown } = getModelInputCharBudget({ catalog, providerID: resolved.providerID, modelID: resolved.modelID, }); const reserveTokens = resolveReserveTokens(outputReserveTokens, { contextTokens, outputTokenLimit }); const { maxChars } = getModelInputCharBudget({ catalog, providerID: resolved.providerID, modelID: resolved.modelID, outputReserveTokens: reserveTokens, }); // 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(await resolveProviderLogin({ auth, workingDirectory: directory, providerID: resolved.providerID, })); return { ...resolved, hasLogin, inputCharBudget: maxChars, contextTokens, contextKnown, // What the caller should ask for, so the request and the reserve above // cannot drift apart. outputTokens: Number(reserveTokens) > 0 ? Number(reserveTokens) : null, structuredOutput: typeof entry?.structured_output === 'boolean' ? entry.structured_output : null, outputTokenLimit, }; }