From 8a850732613ea3027320995e3060d0b9f56609f7 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:46:30 +0300 Subject: [PATCH] fix(server): forward Small Model override to managed OpenCode config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenChamber's Settings → Chat → Small Model override only fed OpenChamber's own /api/small-model/generate utility service; it never reached the managed OpenCode server, whose internal title/summary generation reads small_model from its config. With the override injected into OPENCODE_CONFIG_CONTENT at managed-process launch, session title generation uses the user's explicit model instead of falling back (or failing to resolve) — fixing sessions that stayed untitled even with a Small Model configured. Only an explicit override (smallModelUseDefault === false with a non-empty smallModelOverride) is injected; "use default" leaves the config untouched so OpenCode's own resolution chain stays authoritative. Malformed user config is left unmodified. External OpenCode servers are unaffected (they are not launched with this env). Fixes #2497 --- packages/web/server/index.js | 19 ++-- .../server/lib/small-model/DOCUMENTATION.md | 11 +++ .../lib/small-model/config-injection.js | 51 +++++++++++ .../lib/small-model/config-injection.test.js | 90 +++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/web/server/lib/small-model/config-injection.js create mode 100644 packages/web/server/lib/small-model/config-injection.test.js diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 1c386b3c..f08f1089 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -75,6 +75,7 @@ import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; +import { applySmallModelOverrideToOpenCodeConfig } from './lib/small-model/config-injection.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; @@ -1084,11 +1085,19 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ const managedEnv = settings?.agentControlToolEnabled === false ? {} : await (agentToolRuntime?.prepareManagedOpenCodeEnv() || {}); - if (settings?.optimizeSystemPrompt !== true) return managedEnv; - - const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; - const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent); - return { ...managedEnv, ...systemPromptEnv }; + const env = settings?.optimizeSystemPrompt === true + ? { ...managedEnv, ...(await systemPromptRuntime.prepareManagedOpenCodeEnv(managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT)) } + : managedEnv; + // Apply the explicit Small Model override to the managed OpenCode config + // so OpenCode's own title/summary generation uses the user's chosen model. + const configContent = env.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + const withSmallModel = applySmallModelOverrideToOpenCodeConfig({ + configContent, + smallModelUseDefault: settings?.smallModelUseDefault, + smallModelOverride: settings?.smallModelOverride, + }); + if (withSmallModel === configContent) return env; + return { ...env, OPENCODE_CONFIG_CONTENT: withSmallModel }; }, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 1776f1e1..9709fa37 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -114,6 +114,17 @@ other runtime API. - `routes.js` — `GET /api/small-model` (resolution preview) and `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, model?, directory? }` → `{ text, providerID, modelID, source }`). +- `config-injection.js` — applies the Settings → Chat → Small Model override + to the config injected into the **managed OpenCode process** + (`OPENCODE_CONFIG_CONTENT`), so OpenCode's own internal `small_model` + consumers — session title and summary generation — use the user's explicit + choice instead of OpenCode's fallback chain. Only an explicit override + (`smallModelUseDefault === false` with a non-empty `smallModelOverride`) is + injected; "use default" leaves the config untouched so OpenCode's own + resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in + `server/index.js`; the pure helper is unit-tested in + `config-injection.test.js`. External OpenCode servers are unaffected (they + are not launched with this env). ## Registration diff --git a/packages/web/server/lib/small-model/config-injection.js b/packages/web/server/lib/small-model/config-injection.js new file mode 100644 index 00000000..3738ec8e --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.js @@ -0,0 +1,51 @@ +/** + * Applies the user's explicit Small Model override (Settings → Chat → Small + * Model) to the configuration injected into the managed OpenCode process. + * + * OpenCode's own session-title and summary generation reads `small_model` + * from its config layers. Previously the OpenChamber settings override only + * fed OpenChamber's own `/api/small-model/generate` utility service, so a + * configured Small Model never reached OpenCode's title generation and + * sessions kept their fallback/untitled state. Injecting the override as + * `small_model` in the managed `OPENCODE_CONFIG_CONTENT` closes that gap for + * the managed server. + * + * Only an explicit override applies (`smallModelUseDefault === false` with a + * non-empty `smallModelOverride`). "Use default" leaves the config untouched, + * so OpenCode's own resolution chain (config `small_model`, then its family + * scan) stays authoritative — this mirrors the precedence documented in + * `packages/web/server/lib/small-model/DOCUMENTATION.md`. + * + * Malformed user config is left untouched rather than rewritten: OpenCode's + * own loader is the right place to surface it, and silently rewriting it + * would hide the error. + */ +export const applySmallModelOverrideToOpenCodeConfig = ({ + configContent, + smallModelUseDefault, + smallModelOverride, +}) => { + if (smallModelUseDefault !== false) { + return configContent; + } + const override = typeof smallModelOverride === 'string' ? smallModelOverride.trim() : ''; + if (!override) { + return configContent; + } + + const current = (() => { + if (typeof configContent !== 'string' || configContent.trim().length === 0) { + return {}; + } + try { + return JSON.parse(configContent); + } catch { + return null; + } + })(); + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + return configContent; + } + + return JSON.stringify({ ...current, small_model: override }); +}; diff --git a/packages/web/server/lib/small-model/config-injection.test.js b/packages/web/server/lib/small-model/config-injection.test.js new file mode 100644 index 00000000..22342038 --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { applySmallModelOverrideToOpenCodeConfig } from './config-injection.js'; + +describe('applySmallModelOverrideToOpenCodeConfig', () => { + it('leaves config unchanged when use-default is not explicitly disabled', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: true, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: undefined, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + }); + + it('leaves config unchanged when the override is empty or whitespace', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: ' ', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: undefined, + }), + ).toBe(config); + }); + + it('injects small_model into an empty config', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: undefined, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'anthropic/claude-haiku-4-5' }); + }); + + it('injects small_model while preserving existing config keys and plugins', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"model":"anthropic/claude-sonnet-4-5","plugin":["file:///tool.js"]}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + plugin: ['file:///tool.js'], + small_model: 'google/gemini-2.5-flash', + }); + }); + + it('replaces an existing small_model with the override', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"small_model":"anthropic/claude-haiku-4-5"}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'google/gemini-2.5-flash' }); + }); + + it('leaves malformed config untouched instead of rewriting it', () => { + const config = '{not-valid-json'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + const arrayConfig = '["not","an","object"]'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: arrayConfig, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(arrayConfig); + }); +});