fix(server): forward Small Model override to managed OpenCode config (#2687)
fix(server): forward Small Model override to managed OpenCode config
This commit is contained in:
@@ -76,6 +76,7 @@ import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from
|
|||||||
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
||||||
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
|
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
|
||||||
import { createSessionGoalRuntime } from './lib/session-goal/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 { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js';
|
||||||
import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js';
|
import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js';
|
||||||
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
|
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
|
||||||
@@ -1204,11 +1205,25 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
|||||||
const managedEnv = includeControl || includeWeb || includeMemory
|
const managedEnv = includeControl || includeWeb || includeMemory
|
||||||
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
|
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
|
||||||
: {};
|
: {};
|
||||||
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
|
const envWithSystemPrompt = settings?.optimizeSystemPrompt === true
|
||||||
|
? {
|
||||||
|
...managedEnv,
|
||||||
|
...(await systemPromptRuntime.prepareManagedOpenCodeEnv(
|
||||||
|
managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
: managedEnv;
|
||||||
|
|
||||||
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
// Apply the explicit Small Model override to the managed OpenCode config
|
||||||
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
|
// so OpenCode's own title/summary generation uses the user's chosen model.
|
||||||
return { ...managedEnv, ...systemPromptEnv };
|
const configContent = envWithSystemPrompt.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
||||||
|
const withSmallModel = applySmallModelOverrideToOpenCodeConfig({
|
||||||
|
configContent,
|
||||||
|
smallModelUseDefault: settings?.smallModelUseDefault,
|
||||||
|
smallModelOverride: settings?.smallModelOverride,
|
||||||
|
});
|
||||||
|
if (withSmallModel === configContent) return envWithSystemPrompt;
|
||||||
|
return { ...envWithSystemPrompt, OPENCODE_CONFIG_CONTENT: withSmallModel };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ other runtime API.
|
|||||||
- `routes.js` — `GET /api/small-model` (resolution preview) and
|
- `routes.js` — `GET /api/small-model` (resolution preview) and
|
||||||
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
|
`POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
|
||||||
model?, directory? }` → `{ text, providerID, modelID, source }`).
|
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).
|
||||||
|
|
||||||
## Which providers the pickers may offer
|
## Which providers the pickers may offer
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user