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.
168 lines
5.7 KiB
JavaScript
168 lines
5.7 KiB
JavaScript
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;
|
|
}
|