Files
openchamber/packages/web/server/lib/small-model/index.js
T
Andrey Meshkov 7e248d4e9b Fix: small model dispatch fails for custom OpenAI-compatible providers (#2134) (#2135)
* fix: add support for custom provider base URLs from config

* feat: read custom provider apiKey from config and use it as primary credential when it exists

* doc: update small-model documentation
2026-07-13 08:33:14 +03:00

169 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,
workingDirectory: directory,
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;
}