Files
openchamber/packages/web/server/lib/small-model/call.js
T
Bohdan Triapitsyn 28f0736d69 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.
2026-07-05 23:19:10 +03:00

381 lines
13 KiB
JavaScript

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,
});
}