feat: small-model utility calls on existing OpenCode providers (#2049)

Adds a server-side "small model" capability: direct, cheap LLM calls that
reuse the user's existing OpenCode provider logins — the mechanism OpenCode
uses internally for titles and summaries but does not expose through the
SDK or plugins. Zero new dependencies; plain fetch with per-provider wire
formats, credentials never leave the server.

Core (packages/web/server/lib/small-model):
- Resolution mirrors OpenCode's session scoping: explicit settings override
  → small_model from the OpenCode config → family scan within the session's
  provider → the session's own model. The global provider scan only serves
  callers without a session context, and background callers forbid it
  entirely (restrictToPreferredProvider), so conversation content never
  reaches a provider the user didn't pick — explicit choices excepted.
- Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot
  (device token as bearer, no exchange), ChatGPT plan via the codex
  Responses API (single-flight OAuth refresh written back to auth.json),
  Anthropic messages, Google generateContent, generic OpenAI-compatible.
- OpenCode's free models (opencode/big-pickle, *-free) are never called
  directly; unauthenticated providers are skipped by design.
- Prompt clamping to the model's catalog context limit; thinking disabled
  where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust
  content parsing with a clear error when a thinking model spends its whol
  budget on reasoning.
- Settings → Sessions gains a Small Model group: use-default checkbox plus
  an override picker limited to authenticated providers, persisted with
  web/desktop/VS Code sanitization parity.

Consumers:
- Session assist: a server-side watcher on the global SSE hub generates a
  short recap and one suggested follow-up after a session idles quietly fo
  a minute, stored on session metadata (openchamber.assist). Freshness is
  keyed to the last assistant message id, so new activity invalidates the
  payload everywhere with no extra writes. The chat shows the recap under
  the last message after five quiet minutes and the suggestion as a
  dismissible chip above the composer (tap fills the input, never sends).
  Gated by a new Chat setting (default on) that is a hard generation
  switch. Language is anchored to the conversation itself, with a
  script-mismatch guard against model/backend language hallucination.
- TTS: a third input mode, summarized — long replies are condensed to
  spoken prose before playback on any TTS engine.
- Git: commit-message and PR generation moved off the active chat session
  onto the small model fed with real diffs and the commit list (bodies
  included), with a session-transport fallback for free-model-only setups.
- Notes: Add to notes distills long selections into 1-3 dense sentences
  preserving exact identifiers, with verbatim fallback on failure.

Fixes along the way:
- The global event watcher now starts unconditionally; it was gated behind
  the desktop-notify env, leaving the server-side event hub dead in
  packaged apps.
- OpenCode re-emits message.updated for old user messages after idle; the
  watcher no longer mistakes those for new activity.
- Session metadata merges from a fresh read right before the PATCH, so
  writes made during the generation window (suggestion dismissals, review
  links) are preserved; the assist runtime stops during graceful shutdown.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 23:19:10 +03:00
committed by GitHub
parent e5b03493da
commit 28f0736d69
61 changed files with 2455 additions and 103 deletions
+27 -5
View File
@@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
@@ -713,6 +714,12 @@ const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSen
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
const sessionAssistRuntime = createSessionAssistRuntime({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getSmallModelService: async () => import('./lib/small-model/index.js'),
});
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -732,6 +739,19 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
},
});
// Session-assist subscribes to the hub directly: it needs the envelope's
// directory to route its own OpenCode calls to the right instance.
console.log('[session-assist] listening for session events');
globalMessageStreamHub.subscribeEvent((event) => {
const raw = event?.payload;
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
if (!payload || typeof payload !== 'object') return;
const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global'
? event.directory
: '';
sessionAssistRuntime.processPayload(payload, directory);
});
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') {
return;
@@ -1014,11 +1034,12 @@ const bootstrapOpenCodeAtStartup = async (...args) => {
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
startHealthMonitoring();
}
if (ENV_DESKTOP_NOTIFY) {
void ensureGlobalWatcherStarted().catch((error) => {
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
});
}
// The global watcher used to start only for desktop notifications; the
// session-assist runtime also rides its event hub, so it now starts
// unconditionally once OpenCode is up.
void ensureGlobalWatcherStarted().catch((error) => {
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
});
};
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args);
@@ -1037,6 +1058,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
},
syncToHmrState,
openCodeWatcherRuntime,
sessionAssistRuntime,
sessionRuntime,
getHealthCheckInterval: () => healthCheckInterval,
clearHealthCheckInterval: (value) => clearInterval(value),
@@ -759,6 +759,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/session-folders') ||
req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
@@ -1,5 +1,6 @@
import { registerFsRoutes } from '../fs/routes.js';
import { registerQuotaRoutes } from '../quota/routes.js';
import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -54,6 +55,14 @@ export const createFeatureRoutesRuntime = (dependencies) => {
return quotaProviders;
};
let smallModelService = null;
const getSmallModelService = async () => {
if (!smallModelService) {
smallModelService = await import('../small-model/index.js');
}
return smallModelService;
};
const registerRoutes = async (app, routeDependencies) => {
const {
crypto,
@@ -226,6 +235,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
});
registerQuotaRoutes(app, { getQuotaProviders });
registerSmallModelRoutes(app, { getSmallModelService });
registerGitHubRoutes(app);
registerGitRoutes(app);
registerMagicPromptRoutes(app, {
@@ -0,0 +1,61 @@
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 8000;
// Shared in-process cache of the models.dev catalog. Used by the
// /api/openchamber/models-metadata route and the small-model resolver so the
// server fetches the catalog once, not per consumer.
let cachedMetadata = null;
let cachedAt = 0;
let inflight = null;
const fetchCatalog = async (url, timeoutMs) => {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
if (!metadata || typeof metadata !== 'object') {
throw new Error('models.dev returned an unexpected payload');
}
return metadata;
};
/**
* Returns the models.dev catalog, serving the in-memory copy while fresh.
* On fetch failure a stale cached copy is returned when available; otherwise
* the error propagates.
*/
export async function getModelsMetadata({
url = MODELS_DEV_API_URL,
ttlMs = DEFAULT_TTL_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const now = Date.now();
if (cachedMetadata && now - cachedAt < ttlMs) {
return { metadata: cachedMetadata, fromCache: true };
}
if (!inflight) {
inflight = fetchCatalog(url, timeoutMs).finally(() => {
inflight = null;
});
}
try {
const metadata = await inflight;
cachedMetadata = metadata;
cachedAt = Date.now();
return { metadata, fromCache: false };
} catch (error) {
if (cachedMetadata) {
return { metadata: cachedMetadata, fromCache: true, stale: true };
}
throw error;
}
}
export { MODELS_DEV_API_URL };
@@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
getCachedZenModels,
} = dependencies;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
@@ -254,48 +251,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(modelsDevApiUrl, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
const { getModelsMetadata } = await import('./models-metadata.js');
const { metadata, fromCache, stale } = await getModelsMetadata({
url: modelsDevApiUrl,
ttlMs: modelsMetadataCacheTtl,
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
});
@@ -245,6 +245,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.sessionAssistEnabled === 'boolean') {
result.sessionAssistEnabled = candidate.sessionAssistEnabled;
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
@@ -374,6 +377,13 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.smallModelUseDefault === 'boolean') {
result.smallModelUseDefault = candidate.smallModelUseDefault;
}
if (typeof candidate.smallModelOverride === 'string') {
const trimmed = candidate.smallModelOverride.trim();
result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultGitIdentityId === 'string') {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
@@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
sessionAssistRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
@@ -41,6 +42,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
sessionAssistRuntime?.stop?.();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();
@@ -0,0 +1,64 @@
# Session Assist
Server-side watcher that generates a short recap of the agent's last reply
and one suggested user follow-up with the small model
(`lib/small-model`), storing both on the session's metadata under
`metadata.openchamber.assist`.
## Flow
1. `createSessionAssistRuntime` is a consumer of the server's global SSE
fan-out (`index.js``onPayload`), riding the same upstream connection as
notifications. Purely event-driven — dormant sessions never generate
anything, there is no backfill and no session scanning.
2. `session.status: idle` arms a 60-second per-session timer; any `busy`/
`retry` status or a user `message.updated` clears it (the "1 minute of
quiet" rule).
3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
take the LAST exchange only — the final assistant reply plus the user
message it answered (assistant `parentID` → user id) — and call
`generateSmallModelText` with the
session's own provider/model taken from the last assistant message — so
the utility call spends the same subscription as the conversation.
`restrictToPreferredProvider` forbids the resolver's global fallback:
conversation content never goes to a provider the user didn't pick for
the session, unless the small model was chosen explicitly (settings
override or opencode config). A resolver 404 is silently skipped.
4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session
metadata together with `forMessageID` (the last assistant message id) and
`generatedAt`. Before writing, the session tail is re-checked (a stale
result is dropped) and the metadata is merged from a fresh session read so
concurrent metadata writes made during generation are preserved.
## Settings gate
`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on)
is a hard generation switch checked at fire time: when off, no small-model
calls run and nothing is written. Existing payloads keep rendering and can
still be dismissed — the switch is about generation, not visibility.
## Freshness contract (no clearing writes)
Clients do not need the payload to be deleted: they render it only while
`assist.forMessageID` still equals the session's last assistant message id
(and the session is idle). Any new message invalidates the payload
everywhere instantly and offline; the next idle cycle overwrites it.
## UI consumers (packages/ui)
- `lib/sessionAssistMetadata.ts` — payload parsing.
- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window
for the recap (single timeout to the boundary, no polling).
- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the
fixed-height reserved gap under the last message (height never changes).
- `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip
near the composer (desktop chips row + above the mobile pill); hidden as
soon as the composer has any content. Tap fills the input, never sends.
## Limitations
- The watcher lives in the web server, so VS Code (extension-only, no web
server) does not generate assists; it still renders payloads produced by a
web/desktop instance of the same OpenCode server via `session.updated`.
- Metadata payloads ride every `session.updated` event — keep the clamps
(`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small.
@@ -0,0 +1,349 @@
// Session assist: after a session goes idle and stays quiet, generate a short
// recap of the agent's last reply plus one suggested user follow-up with the
// small model, and store both on the session's metadata
// (metadata.openchamber.assist). Clients decide visibility from
// assist.forMessageID — a new message makes the payload stale everywhere
// without any extra writes.
//
// Purely event-driven: only sessions that transition busy→idle while the
// server is running ever generate anything. No backfill, no session scans.
import fs from 'fs';
import os from 'os';
import path from 'path';
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',
);
// The Chat setting is a hard generation switch (default on): when off, no
// small-model calls and no metadata writes happen at all. Existing payloads
// stay untouched — clients keep showing them and dismissal still works.
const isSessionAssistEnabled = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return settings?.sessionAssistEnabled !== false;
} catch {
return true;
}
};
const IDLE_QUIET_MS = 60_000;
const TRANSCRIPT_MESSAGE_LIMIT = 12;
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
const RECAP_CHAR_LIMIT = 320;
const SUGGESTION_CHAR_LIMIT = 500;
const FETCH_TIMEOUT_MS = 5_000;
const ASSIST_SYSTEM_PROMPT = [
'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.',
'Shape: {"recap": string, "suggestion": string}',
'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.',
'suggestion: the next message to send in this conversation, addressed TO the agent — a concise instruction or question that moves the work forward, e.g. "Run the tests and fix failures" / "Commit this". Imperative or question form. Never explain, never offer help, never say "you can".',
'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
'Use double quotes for JSON strings, no trailing commas.',
].join('\n');
const extractJsonObject = (value) => {
const text = String(value ?? '').trim();
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
const start = candidate.indexOf('{');
if (start < 0) return null;
for (let end = candidate.length; end > start; end -= 1) {
if (candidate[end - 1] !== '}') continue;
try {
const parsed = JSON.parse(candidate.slice(start, end));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch {
// keep scanning — models wrap JSON in prose sometimes
}
}
return null;
};
const extractSessionStatus = (payload) => {
if (!payload || payload.type !== 'session.status') return null;
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
const type = typeof status.type === 'string'
? status.type.trim()
: (typeof info.type === 'string' ? info.type.trim() : '');
if (!sessionId || !type) return null;
const directory = typeof properties.directory === 'string' && properties.directory
? properties.directory
: (typeof info.directory === 'string' ? info.directory : '');
return { sessionId, type, directory };
};
const extractUserMessage = (payload) => {
if (!payload || payload.type !== 'message.updated') return null;
const info = payload.properties?.info;
if (!info || typeof info !== 'object' || info.role !== 'user') return null;
if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
return {
sessionId: info.sessionID,
createdAt: typeof info.time?.created === 'number' ? info.time.created : 0,
};
};
const messagePartsToText = (message) => {
const parts = Array.isArray(message?.parts) ? message.parts : [];
return parts
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
.filter(Boolean)
.join('\n')
.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
};
export const createSessionAssistRuntime = ({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getSmallModelService,
quietMs = IDLE_QUIET_MS,
}) => {
const timers = new Map();
const inflight = new Set();
let stopped = false;
const clearTimer = (sessionId) => {
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
timers.delete(sessionId);
}
};
const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => {
const base = buildOpenCodeUrl(path, '');
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...getOpenCodeAuthHeaders(),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OpenCode ${method} ${path} failed with ${response.status}`);
}
return response.json().catch(() => null);
};
const fetchRecentMessages = async (sessionId, directory) => {
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
if (directory) params.set('directory', directory);
const response = await fetch(`${base}?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
const messages = await response.json().catch(() => null);
return Array.isArray(messages) ? messages : null;
};
const generateAssist = async (sessionId, directory) => {
if (!isSessionAssistEnabled()) return;
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch((error) => {
console.warn(`[session-assist] session fetch failed: ${error?.message || error}`);
return null;
});
if (!session || typeof session !== 'object') return;
// Sub-agent/task sessions never surface in chat — skip them.
if (typeof session.parentID === 'string' && session.parentID) return;
const messages = await fetchRecentMessages(sessionId, directory);
if (!messages || messages.length === 0) {
console.warn('[session-assist] no messages fetched');
return;
}
let lastAssistant = null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const info = messages[i]?.info;
if (info?.role === 'assistant') {
lastAssistant = messages[i];
break;
}
}
const lastAssistantInfo = lastAssistant?.info;
if (!lastAssistantInfo?.id) return;
// Only the last exchange: the assistant reply plus the user message it
// answered (assistant info.parentID → user info.id). Everything else is
// token waste for a one-line recap and a single suggestion.
const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
: null;
const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
const assistantText = messagePartsToText(lastAssistant);
const transcript = [
userText ? `User:\n${userText}` : '',
assistantText ? `Assistant:\n${assistantText}` : '',
].filter(Boolean).join('\n\n');
if (!transcript) return;
const { generateSmallModelText } = await getSmallModelService();
// Instruct the language by example, not by description — account-side
// personalization (e.g. the ChatGPT backend knowing the user's locale)
// otherwise leaks a different language into the output.
const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
let generated;
try {
generated = await generateSmallModelText({
// Background feature: conversation content must never leave the
// session's own provider unless the user explicitly picked a small
// model (settings override / opencode config).
restrictToPreferredProvider: true,
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`,
system: ASSIST_SYSTEM_PROMPT,
directory,
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
});
} catch (error) {
// No authenticated provider (404) or a transient model failure — this is
// background sugar, never retry loops or logs spam.
if (Number(error?.statusCode) !== 404) {
console.warn('[session-assist] generation failed:', error?.message || error);
}
return;
}
const structured = extractJsonObject(generated?.text);
let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
// Hard guard against language hallucination: if the conversation contains
// no Cyrillic/CJK at all, the output must not either (and drop per-field,
// so one hallucinated field doesn't kill the other).
const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
const inputText = `${userText}\n${assistantText}`;
const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
|| (hasCjk(text) && !hasCjk(inputText));
if (recap && scriptMismatch(recap)) {
console.warn('[session-assist] dropped recap: language mismatch with conversation');
recap = '';
}
if (suggestion && scriptMismatch(suggestion)) {
console.warn('[session-assist] dropped suggestion: language mismatch with conversation');
suggestion = '';
}
if (!recap && !suggestion) return;
// The session may have moved on while we generated — a stale patch would
// flash outdated content, so re-check the tail before writing.
const latest = await fetchRecentMessages(sessionId, directory);
const latestAssistantId = (() => {
if (!latest) return null;
for (let i = latest.length - 1; i >= 0; i -= 1) {
const info = latest[i]?.info;
if (info?.role === 'assistant') return info.id;
if (info?.role === 'user') return null;
}
return null;
})();
if (latestAssistantId !== lastAssistantInfo.id) {
console.log('[session-assist] tail moved on, dropping result');
return;
}
// Merge from a FRESH read: generation takes tens of seconds, and merging
// from the session snapshot fetched before it would clobber any metadata
// written meanwhile (suggestion dismissals, review links, …).
const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
.catch(() => null);
const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object'
? freshSession.metadata
: (session.metadata && typeof session.metadata === 'object' ? session.metadata : {});
const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
? currentMetadata.openchamber
: {};
console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...currentMetadata,
openchamber: {
...currentNamespace,
assist: {
recap,
suggestion,
forMessageID: lastAssistantInfo.id,
generatedAt: Date.now(),
},
},
},
},
});
};
const armTimer = (sessionId, directory) => {
clearTimer(sessionId);
const timer = setTimeout(() => {
timers.delete(sessionId);
if (stopped || inflight.has(sessionId)) return;
inflight.add(sessionId);
generateAssist(sessionId, directory)
.catch((error) => {
console.warn('[session-assist] failed:', error?.message || error);
})
.finally(() => {
inflight.delete(sessionId);
});
}, quietMs);
if (typeof timer?.unref === 'function') timer.unref();
timers.set(sessionId, { timer, armedAt: Date.now() });
};
const processPayload = (payload, directoryHint = '') => {
if (stopped) return;
const status = extractSessionStatus(payload);
if (status) {
if (status.type === 'idle') {
armTimer(status.sessionId, status.directory || directoryHint);
} else {
clearTimer(status.sessionId);
}
return;
}
const userMessage = extractUserMessage(payload);
if (userMessage) {
// OpenCode re-emits message.updated for OLD user messages after the
// session settles (post-completion metadata patches). Only a message
// created after the timer was armed means the user actually moved on.
const armed = timers.get(userMessage.sessionId);
if (armed && userMessage.createdAt >= armed.armedAt) {
clearTimer(userMessage.sessionId);
}
}
};
const stop = () => {
stopped = true;
for (const { timer } of timers.values()) {
clearTimeout(timer);
}
timers.clear();
};
return { processPayload, stop };
};
@@ -0,0 +1,78 @@
# Small Model
Server-side direct LLM calls that reuse the user's existing OpenCode provider
logins (`~/.local/share/opencode/auth.json`). OpenCode uses a "small model"
internally (titles, summaries) but does not expose it through the SDK or
plugins — this module replicates that mechanism as an OpenChamber runtime API.
## Security boundary
Credentials never leave the server process. The client sends only a prompt;
auth resolution, OAuth refresh, and provider dispatch all happen server-side.
Routes live under `/api/*` and are gated by the ui-auth middleware like every
other runtime API.
## Files
- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`.
- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
0. OpenChamber's own settings override (Settings → Sessions → Small Model):
when `smallModelUseDefault` is `false`, `smallModelOverride`
(`provider/model`) outranks everything below. Sanitized in
`settings-helpers.js` (server), `persistence.ts` (client), and
`bridge-settings-runtime.ts` (VS Code).
1. `small_model` from the merged OpenCode config layers (`provider/model`).
2. Family-priority scan (`gemini-flash``gpt-nano``claude-haiku`)
**within the session's provider first** (`preferredProviderID`, like
OpenCode resolves within the current provider), then over the other
providers with a usable auth entry, newest `release_date` first.
3. GitHub Copilot hidden utility models (`gpt-*-nano/mini`) — these never
appear in the catalog, so they participate as the `gpt-nano` family entry
and as a final utility fallback.
4. Last resort: the session's own model (`preferredModelID`) when no small
model resolves anywhere — costlier, but always valid.
- Input clamp: the prompt is truncated to the resolved model's catalog
`limit.context` (minus an output reserve, ~4 chars/token estimate;
conservative default when the model is not in the catalog). Truncation is
reported as `inputTruncated: true` in the response.
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
plugin auth loaders:
- **GitHub Copilot**: OpenAI-compatible `/chat/completions` on
`https://api.githubcopilot.com` (or `copilot-api.<enterprise>`) with the
stored device-OAuth token as the bearer — no token exchange, no expiry.
- **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on
`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`.
- **Google** (`type: api`): `generateContent` with `x-goog-api-key`.
- Everything else: OpenAI-compatible `/chat/completions` against the
provider's models.dev base URL with `Authorization: Bearer <key>`.
- `catalog.js` — models.dev catalog via the shared in-process cache
(`../opencode/models-metadata.js`, also serving
`/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 }`).
## Registration
Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the
module is imported on first request, not at server startup.
## Known limitations
- 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:
a session on an unauthenticated `opencode` provider falls through to the
global scan (or a clean 404 on a vanilla setup with no logins).
- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself
keeps those outside `auth.json` in this generation; only `type: api` keys
work for Anthropic.
- Amazon Bedrock, GitLab, Azure and other credential-chain providers are out
of scope; they need more than a key/token (regions, resource names).
- Responses from the codex backend are collected from the SSE stream; the
endpoint itself is non-streaming by design (small utility calls).
+380
View File
@@ -0,0 +1,380 @@
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
// Direct, non-streaming text generation against the provider APIs, replicating
// how OpenCode authenticates each of them (see the plugin auth loaders in the
// opencode repo). auth.json credentials never leave this process.
const REQUEST_TIMEOUT_MS = 60_000;
// Generous default: thinking models that can't be switched off (DeepSeek,
// Qwen, …) spend part of this budget on reasoning before the actual answer.
const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
const USER_AGENT = 'opencode/1.0 openchamber';
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
const httpError = async (response, provider) => {
const body = await response.text().catch(() => '');
const snippet = body ? `: ${body.slice(0, 300)}` : '';
return new Error(`${provider} request failed with ${response.status}${snippet}`);
};
// ---------------------------------------------------------------------------
// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the
// refreshed token written back to auth.json exactly like OpenCode does.
// ---------------------------------------------------------------------------
let openaiRefreshPromise = null;
const decodeJwtClaims = (token) => {
try {
const payload = token.split('.')[1];
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
};
const extractChatgptAccountId = (accessToken) => {
const claims = decodeJwtClaims(accessToken);
const auth = claims?.['https://api.openai.com/auth'];
const value = auth?.chatgpt_account_id;
return typeof value === 'string' && value ? value : null;
};
const refreshOpenaiOauth = async (entry) => {
if (!openaiRefreshPromise) {
openaiRefreshPromise = (async () => {
const response = await fetch(CODEX_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: entry.refresh,
client_id: CODEX_CLIENT_ID,
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw await httpError(response, 'OpenAI token refresh');
}
const payload = await response.json();
const access = typeof payload?.access_token === 'string' ? payload.access_token : '';
if (!access) {
throw new Error('OpenAI token refresh returned no access token');
}
const refreshed = {
...entry,
type: 'oauth',
access,
refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token
? payload.refresh_token
: entry.refresh,
expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000,
};
const auth = readAuthFile();
auth.openai = refreshed;
writeAuthFile(auth);
return refreshed;
})().finally(() => {
openaiRefreshPromise = null;
});
}
return openaiRefreshPromise;
};
const ensureFreshOpenaiOauth = async (entry) => {
if (entry.access && Number(entry.expires) > Date.now()) {
return entry;
}
if (!entry.refresh) {
throw new Error('OpenAI OAuth entry has no refresh token');
}
return refreshOpenaiOauth(entry);
};
// ---------------------------------------------------------------------------
// Wire formats
// ---------------------------------------------------------------------------
const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => {
const trimmedBase = baseURL.replace(/\/+$/, '');
const response = await fetch(`${trimmedBase}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
},
body: JSON.stringify({
model: modelID,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: prompt },
],
max_tokens: maxOutputTokens,
stream: false,
...(extraBody || {}),
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, providerLabel);
}
const payload = await response.json();
const message = payload?.choices?.[0]?.message;
// Providers disagree on the content shape: plain string, an array of
// typed parts, or (thinking models) an empty content with the budget spent
// on reasoning_content.
let text = '';
if (typeof message?.content === 'string') {
text = message.content;
} else if (Array.isArray(message?.content)) {
text = message.content
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
.join('');
}
if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) {
const finishReason = payload?.choices?.[0]?.finish_reason;
throw new Error(
`${providerLabel} spent the output budget on reasoning and returned no answer`
+ (finishReason ? ` (finish_reason: ${finishReason})` : ''),
);
}
if (!text.trim()) {
throw new Error(`${providerLabel} returned no message content`);
}
return text;
};
const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: modelID,
max_tokens: maxOutputTokens,
...(system ? { system } : {}),
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'Anthropic');
}
const payload = await response.json();
const text = (payload?.content || [])
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
.map((part) => part.text)
.join('');
if (!text) {
throw new Error('Anthropic returned no text content');
}
return text;
};
const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
// thinkingBudget 0 switches Gemini Flash thinking off; Flash is the only
// family the small-model resolver picks for Google.
generationConfig: { maxOutputTokens, thinkingConfig: { thinkingBudget: 0 } },
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'Google');
}
const payload = await response.json();
const text = (payload?.candidates?.[0]?.content?.parts || [])
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
.join('');
if (!text) {
throw new Error('Google returned no text content');
}
return text;
};
// ChatGPT-plan traffic goes to the codex backend, which only speaks the
// streaming Responses API — collect the output_text deltas from the SSE body.
const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => {
const response = await fetch(CODEX_RESPONSES_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${accessToken}`,
...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}),
originator: 'opencode',
'User-Agent': USER_AGENT,
},
body: JSON.stringify({
model: modelID,
...(system ? { instructions: system } : {}),
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: prompt }],
},
],
// The codex backend rejects max_output_tokens (OpenCode forces it to
// undefined for this provider too).
stream: true,
store: false,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw await httpError(response, 'OpenAI (ChatGPT plan)');
}
const raw = await response.text();
let text = '';
let completedText = '';
for (const line of raw.split('\n')) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') {
text += event.delta;
}
if (event?.type === 'response.output_text.done' && typeof event.text === 'string') {
completedText = event.text;
}
if (event?.type === 'response.failed' || event?.type === 'error') {
const message = event?.response?.error?.message || event?.message || 'response failed';
throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`);
}
}
const result = completedText || text;
if (!result) {
throw new Error('OpenAI (ChatGPT plan) returned no text output');
}
return result;
};
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const entry = getAuthEntryForProvider(auth, providerID);
if (!entry) {
throw new Error(`No OpenCode login found for provider "${providerID}"`);
}
if (providerID === 'github-copilot') {
// OpenCode uses the stored device-OAuth token directly as the bearer —
// access === refresh, no exchange, no expiry.
const token = entry.refresh || entry.access || entry.key;
if (!token) {
throw new Error('GitHub Copilot login has no token');
}
const baseURL = entry.enterpriseUrl
? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}`
: 'https://api.githubcopilot.com';
return callOpenaiCompatible({
baseURL,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': USER_AGENT,
'Openai-Intent': 'conversation-edits',
'x-initiator': 'agent',
'X-GitHub-Api-Version': '2026-06-01',
},
modelID,
prompt,
system,
maxOutputTokens: tokens,
providerLabel: 'GitHub Copilot',
});
}
if (providerID === 'openai' && entry.type === 'oauth') {
const fresh = await ensureFreshOpenaiOauth(entry);
return callCodexResponses({
accessToken: fresh.access,
accountId: fresh.accountId || extractChatgptAccountId(fresh.access),
modelID,
prompt,
system,
});
}
const apiKey = entry.type === 'api' ? entry.key
: entry.type === 'wellknown' ? entry.token
: entry.access;
if (!apiKey) {
throw new Error(`OpenCode login for "${providerID}" has no usable credential`);
}
if (providerID === 'anthropic') {
return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
}
if (providerID === 'google') {
return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
}
// Everything else: OpenAI-compatible chat completions against the catalog's
// base URL for that provider (openai itself included).
const provider = getCatalogProvider(catalog, providerID);
const baseURL = providerID === 'openai'
? 'https://api.openai.com/v1'
: typeof provider?.api === 'string' && provider.api
? provider.api
: null;
if (!baseURL) {
throw new Error(`Provider "${providerID}" has no known API base URL`);
}
// Thinking models burn the output budget on reasoning and leave content
// empty — disable thinking where a wire-format switch exists (mirrors
// OpenCode's smallOptions/variants special cases). There is NO universal
// parameter: unknown body fields 400 on some providers, so this stays an
// explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …)
// just get the generous output budget.
const lowerModel = modelID.toLowerCase();
const supportsThinkingToggle = providerID.includes('zai')
|| providerID.includes('zhipu')
|| lowerModel.includes('glm')
|| lowerModel.includes('minimax-m3');
const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined;
return callOpenaiCompatible({
baseURL,
headers: { Authorization: `Bearer ${apiKey}` },
modelID,
prompt,
system,
maxOutputTokens: tokens,
providerLabel: provider?.name || providerID,
extraBody,
});
}
@@ -0,0 +1,13 @@
import { getModelsMetadata } from '../opencode/models-metadata.js';
// The models.dev catalog is shared with the /api/openchamber/models-metadata
// route through one in-process cache — no extra fetches, no cache files.
export async function getModelCatalog() {
const { metadata } = await getModelsMetadata();
return metadata;
}
export function getCatalogProvider(catalog, providerID) {
const entry = catalog?.[providerID];
return entry && typeof entry === 'object' ? entry : null;
}
@@ -0,0 +1,167 @@
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 { callSmallModel } from './call.js';
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 = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
if (!settings || typeof settings !== 'object') return null;
if (settings.smallModelUseDefault !== false) return null;
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
return override || null;
} catch {
return 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;
const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => {
const limit = catalog?.[providerID]?.models?.[modelID]?.limit;
const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS;
const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS);
const maxChars = inputBudgetTokens * 4;
if (prompt.length <= maxChars) {
return { prompt, truncated: false };
}
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, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) {
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 },
);
}
// 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 clamped = clampPromptToModelLimit({
prompt: prompt.trim(),
catalog,
providerID: resolved.providerID,
modelID: resolved.modelID,
});
const text = await callSmallModel({
auth,
catalog,
providerID: resolved.providerID,
modelID: resolved.modelID,
prompt: clamped.prompt,
system: typeof system === 'string' && system.trim() ? system.trim() : undefined,
maxOutputTokens,
});
return {
text: text.trim(),
providerID: resolved.providerID,
modelID: resolved.modelID,
source: resolved.source,
...(clamped.truncated ? { inputTruncated: true } : {}),
};
}
/**
* 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).
*/
export 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');
}
return Array.from(ids);
} catch {
return [];
}
}
/**
* Reports which model would be used, without calling it.
*/
export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) {
const auth = readAuthFile();
const catalog = await getModelCatalog().catch(() => ({}));
const resolved = resolveSmallModel({
auth,
catalog,
settingsSmallModel: readSmallModelSettingsOverride(),
configSmallModel: readConfiguredSmallModel(directory),
preferredProviderID,
preferredModelID,
});
return resolved;
}
@@ -0,0 +1,131 @@
import { getCatalogProvider } from './catalog.js';
// Mirrors OpenCode's getSmallModel fallback chain:
// 1. `small_model` from the merged config layers ("provider/model").
// 2. GitHub Copilot's hidden utility models when Copilot is logged in.
// 3. Family-priority scan of the authenticated providers' catalog models.
const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku'];
const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'];
// The ChatGPT-plan codex backend only accepts a small allowlist of models
// (nano/API-key models are rejected with 400) — this is its cheapest one.
const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini';
const AUTH_PROVIDER_ALIASES = {
'github-copilot': ['github-copilot', 'copilot'],
};
export function getAuthEntryForProvider(auth, providerID) {
const aliases = AUTH_PROVIDER_ALIASES[providerID] || [providerID];
for (const alias of aliases) {
const entry = auth?.[alias];
if (entry && typeof entry === 'object') {
return entry;
}
}
return null;
}
export function isUsableAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return false;
if (entry.type === 'api') return typeof entry.key === 'string' && entry.key.length > 0;
if (entry.type === 'oauth') {
return (typeof entry.access === 'string' && entry.access.length > 0)
|| (typeof entry.refresh === 'string' && entry.refresh.length > 0);
}
if (entry.type === 'wellknown') return typeof entry.token === 'string' && entry.token.length > 0;
return false;
}
export function parseModelRef(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
const slash = trimmed.indexOf('/');
if (slash <= 0 || slash === trimmed.length - 1) return null;
return {
providerID: trimmed.slice(0, slash),
modelID: trimmed.slice(slash + 1),
};
}
const pickByFamily = (models, family) => {
const matches = Object.values(models)
.filter((model) => model && typeof model === 'object' && model.family === family);
if (matches.length === 0) return null;
matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || '')));
return matches[0];
};
// Small-model candidates within ONE provider, by family priority. Copilot and
// ChatGPT-plan OpenAI have fixed small models that never appear in the
// catalog; everyone else is scanned through the catalog families.
const pickWithinProvider = (providerID, auth, catalog, family) => {
if (providerID === 'openai' && auth.openai?.type === 'oauth') {
return family === 'gpt-nano'
? { providerID, modelID: OPENAI_OAUTH_SMALL_MODEL, source: 'codex-small' }
: null;
}
if (providerID === 'github-copilot') {
return family === 'gpt-nano'
? { providerID, modelID: COPILOT_UTILITY_MODELS[0], source: 'copilot-utility' }
: null;
}
const provider = getCatalogProvider(catalog, providerID);
if (!provider || !provider.models || typeof provider.models !== 'object') return null;
const model = pickByFamily(provider.models, family);
return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null;
};
export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) {
// OpenChamber's own setting (Settings → Sessions → Small Model override)
// outranks everything, including the OpenCode config.
const fromSettings = parseModelRef(settingsSmallModel);
if (fromSettings) {
return { ...fromSettings, source: 'settings' };
}
const explicit = parseModelRef(configSmallModel);
if (explicit) {
return { ...explicit, source: 'config' };
}
// Like OpenCode: when the caller has a session context, the utility call
// stays on the session's provider. Scan its families for a small model,
// otherwise run on the session's own model — never silently switch to a
// different provider's subscription.
const preferred = typeof preferredProviderID === 'string' && preferredProviderID
? preferredProviderID
: null;
if (preferred && isUsableAuthEntry(getAuthEntryForProvider(auth, preferred))) {
for (const family of FAMILY_PRIORITY) {
const match = pickWithinProvider(preferred, auth, catalog, family);
if (match) return match;
}
if (typeof preferredModelID === 'string' && preferredModelID) {
return { providerID: preferred, modelID: preferredModelID, source: 'session-model' };
}
}
// No session context (or its provider has no usable login): scan all
// authenticated providers by family priority.
const authedProviders = Object.keys(auth || {}).filter((providerID) =>
providerID !== preferred && isUsableAuthEntry(auth[providerID]));
for (const family of FAMILY_PRIORITY) {
for (const providerID of authedProviders) {
const match = pickWithinProvider(providerID, auth, catalog, family);
if (match) return match;
}
}
// Copilot's utility fallback for legacy auth aliases the loop above missed.
const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot');
if (isUsableAuthEntry(copilotEntry)) {
return {
providerID: 'github-copilot',
modelID: COPILOT_UTILITY_MODELS[0],
source: 'copilot-utility',
};
}
return null;
}
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'bun:test';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry } from './resolve.js';
const catalog = {
google: {
id: 'google',
models: {
'gemini-2.5-flash': { id: 'gemini-2.5-flash', family: 'gemini-flash', release_date: '2025-06-01' },
'gemini-2.0-flash': { id: 'gemini-2.0-flash', family: 'gemini-flash', release_date: '2024-12-01' },
'gemini-2.5-pro': { id: 'gemini-2.5-pro', family: 'gemini-pro', release_date: '2025-06-01' },
},
},
anthropic: {
id: 'anthropic',
models: {
'claude-haiku-4-5': { id: 'claude-haiku-4-5', family: 'claude-haiku', release_date: '2025-10-01' },
'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', family: 'claude-sonnet', release_date: '2025-09-01' },
},
},
};
describe('parseModelRef', () => {
it('splits provider/model on the first slash', () => {
expect(parseModelRef('anthropic/claude-haiku-4-5')).toEqual({
providerID: 'anthropic',
modelID: 'claude-haiku-4-5',
});
});
it('keeps slashes inside the model id', () => {
expect(parseModelRef('openrouter/google/gemini-2.5-flash')).toEqual({
providerID: 'openrouter',
modelID: 'google/gemini-2.5-flash',
});
});
it('rejects values without a provider or model part', () => {
expect(parseModelRef('anthropic/')).toBeNull();
expect(parseModelRef('/model')).toBeNull();
expect(parseModelRef('plain')).toBeNull();
expect(parseModelRef(undefined)).toBeNull();
});
});
describe('isUsableAuthEntry', () => {
it('accepts api keys, oauth tokens, and wellknown tokens', () => {
expect(isUsableAuthEntry({ type: 'api', key: 'sk-x' })).toBe(true);
expect(isUsableAuthEntry({ type: 'oauth', access: 'a', refresh: 'r', expires: 0 })).toBe(true);
expect(isUsableAuthEntry({ type: 'wellknown', key: 'k', token: 't' })).toBe(true);
});
it('rejects empty or malformed entries', () => {
expect(isUsableAuthEntry({ type: 'api', key: '' })).toBe(false);
expect(isUsableAuthEntry({ type: 'oauth' })).toBe(false);
expect(isUsableAuthEntry(null)).toBe(false);
});
});
describe('resolveSmallModel', () => {
it('gives the OpenChamber settings override top priority', () => {
const result = resolveSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-x' } },
catalog,
settingsSmallModel: 'anthropic/claude-haiku-4-5',
configSmallModel: 'openai/gpt-4o-mini',
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'settings' });
});
it('prefers the configured small_model', () => {
const result = resolveSmallModel({
auth: { anthropic: { type: 'api', key: 'sk-x' } },
catalog,
configSmallModel: 'openai/gpt-4o-mini',
});
expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-4o-mini', source: 'config' });
});
it('scans authenticated providers by family priority, newest first', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: 'g-key' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
});
expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
});
it('skips providers without a usable credential', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: '' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
});
it('falls back to Copilot utility models when only Copilot is logged in', () => {
const result = resolveSmallModel({
auth: { 'github-copilot': { type: 'oauth', access: 't', refresh: 't', expires: 0 } },
catalog,
configSmallModel: null,
});
expect(result?.providerID).toBe('github-copilot');
expect(result?.source).toBe('copilot-utility');
});
it('returns null when nothing is authenticated', () => {
expect(resolveSmallModel({ auth: {}, catalog, configSmallModel: null })).toBeNull();
});
it('prefers the session provider over other authenticated providers', () => {
const result = resolveSmallModel({
auth: {
google: { type: 'api', key: 'g-key' },
anthropic: { type: 'api', key: 'sk-x' },
},
catalog,
configSmallModel: null,
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
});
it('ignores a preferred provider without a usable login', () => {
const result = resolveSmallModel({
auth: { google: { type: 'api', key: 'g-key' } },
catalog,
configSmallModel: null,
preferredProviderID: 'anthropic',
});
expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
});
it('never uses a session provider without a login (opencode free models)', () => {
// Vanilla setups default the picker to opencode/big-pickle with no
// opencode token — those free models only work through OpenCode itself
// and must never be called directly, so the session context is ignored.
const result = resolveSmallModel({
auth: { openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 } },
catalog,
configSmallModel: null,
preferredProviderID: 'opencode',
preferredModelID: 'big-pickle',
});
expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-5.4-mini', source: 'codex-small' });
});
it('resolves nothing on a vanilla setup with no logins at all', () => {
const result = resolveSmallModel({
auth: {},
catalog,
configSmallModel: null,
preferredProviderID: 'opencode',
preferredModelID: 'big-pickle',
});
expect(result).toBeNull();
});
it('falls back to the session model instead of scanning other providers', () => {
const result = resolveSmallModel({
auth: {
'opencode-go': { type: 'api', key: 'oc-key' },
openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 },
},
catalog: {
'opencode-go': {
id: 'opencode-go',
models: {
'deepseek-v4-flash': { id: 'deepseek-v4-flash', family: 'deepseek-flash', release_date: '2026-01-01' },
},
},
},
configSmallModel: null,
preferredProviderID: 'opencode-go',
preferredModelID: 'deepseek-v4-flash',
});
expect(result).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', source: 'session-model' });
});
it('falls back to the session model itself when nothing resolves', () => {
const result = resolveSmallModel({
auth: { mistral: { type: 'api', key: 'm-key' } },
catalog,
configSmallModel: null,
preferredProviderID: 'mistral',
preferredModelID: 'mistral-large-latest',
});
expect(result).toEqual({ providerID: 'mistral', modelID: 'mistral-large-latest', source: 'session-model' });
});
});
@@ -0,0 +1,44 @@
export function registerSmallModelRoutes(app, { getSmallModelService }) {
app.get('/api/small-model', async (req, res) => {
try {
const { describeSmallModel, listAuthenticatedProviders } = await getSmallModelService();
const resolved = await describeSmallModel({
directory: typeof req.query.directory === 'string' ? req.query.directory : undefined,
preferredProviderID: typeof req.query.providerID === 'string' ? req.query.providerID : undefined,
preferredModelID: typeof req.query.modelID === 'string' ? req.query.modelID : undefined,
});
res.json({
available: Boolean(resolved),
model: resolved,
authenticatedProviders: listAuthenticatedProviders(),
});
} catch (error) {
console.error('Failed to resolve small model:', error);
res.status(500).json({ error: error.message || 'Failed to resolve small model' });
}
});
app.post('/api/small-model/generate', async (req, res) => {
try {
const { generateSmallModelText } = await getSmallModelService();
const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
const result = await generateSmallModelText({
prompt,
system,
maxOutputTokens,
model,
directory,
preferredProviderID,
preferredModelID,
restrictToPreferredProvider: restrictToPreferredProvider === true,
});
res.json(result);
} catch (error) {
const statusCode = Number(error?.statusCode) || 500;
if (statusCode >= 500) {
console.error('Small model generation failed:', error);
}
res.status(statusCode).json({ error: error.message || 'Small model generation failed' });
}
});
}