From 3062701c24ac6b6bde3d238fab8689e48ce2396b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:19:23 -0700 Subject: [PATCH] fix(server): commit and title generation use your custom model when no text model is set (#19386) Co-authored-by: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 38 ++++++++++++++++++++++++++ apps/server/src/serverSettings.ts | 1 + packages/shared/src/projectSettings.ts | 5 +++- packages/shared/src/serverSettings.ts | 20 ++++++++++++-- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 26ae1dbc4ee0e7b0aa7d30247b379279e82b0cd0..83936691e785620748cc302d162a8039598039c4 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -813,6 +813,44 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + // A backend behind a custom model may refuse the built-in default (#12651). + it.effect("falls back to the thread default when it is a custom model on that instance", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const claude = ProviderInstanceId.make("claudeAgent"); + const providerInstances = { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + [claude]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: ["z-ai/glm-5.3-flash"] }, + }, + }; + + const custom = yield* serverSettings.updateSettings({ + providerInstances, + defaultModelSelection: { + instanceId: claude, + model: "z-ai/glm-5.3-flash", + options: [{ id: "thinking", value: false }], + }, + }); + assert.deepEqual(custom.textGenerationModelSelection, { + instanceId: claude, + model: "z-ai/glm-5.3-flash", + options: [{ id: "thinking", value: false }], + }); + + const builtIn = yield* serverSettings.updateSettings({ + defaultModelSelection: { instanceId: claude, model: "claude-fable-5-1" }, + }); + assert.equal(builtIn.textGenerationModelSelection.model, "claude-haiku-5-5"); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 44e645858c49121dacfb711f9da3eab6690762e0..2661d585bfa81344fe6e859d2adc49f461d17ba5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -484,6 +484,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting resolveProviderInstanceEnabled( settings.providerInstances[instanceId] ?? { driver, config: {} }, ), + { defaultModelSelection: settings.defaultModelSelection }, ); return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; } diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 7bc976ad0adc80c8eeb6c907f7787205f50e83fb..0d3f7ae4b03b87774f270f4c82203c1090507014 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -199,7 +199,10 @@ function resolveProjectOverrides( const fallback = fallbackTextGenerationModelSelection( settings.providerInstances, (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), - { includeConfiguredInstances: true }, + { + includeConfiguredInstances: true, + defaultModelSelection: effective.defaultModelSelection as ModelSelection | null, + }, ); if (fallback !== null) effective.textGenerationModelSelection = fallback; } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index acfca1a8a08ff8c6e22f54373fe2e80cfdfd88b5..77662e6a9308afe79a6948a5d2a9166ef4c57ff8 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; -import { createModelSelection } from "./model.ts"; +import { createModelSelection, readCustomModelEntries } from "./model.ts"; import { getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, @@ -95,6 +95,11 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ * `includeConfiguredInstances` is set, configured instances of those * drivers. `null` when none is. * + * The model is the driver's cheap default, unless `defaultModelSelection` + * (the thread default) is one of that instance's custom models. A custom + * model usually means a backend that may refuse the built-in default, and + * the thread default, with its options, is known to work there. + * * `includeConfiguredInstances` is for the project-scoped caller only: a * project that disabled every built-in instance should still be able to * fall back to one it configured itself. The machine-level caller leaves it @@ -104,7 +109,10 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ export function fallbackTextGenerationModelSelection( providerInstances: ServerSettings["providerInstances"], isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, - options?: { readonly includeConfiguredInstances?: boolean }, + options?: { + readonly includeConfiguredInstances?: boolean; + readonly defaultModelSelection?: ModelSelection | null; + }, ): ModelSelection | null { const candidates = [ ...TEXT_GENERATION_FALLBACK_DRIVERS.map( @@ -121,6 +129,14 @@ export function fallbackTextGenerationModelSelection( const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); if (match === undefined) return null; const [instanceId, driver] = match; + const threadDefault = options?.defaultModelSelection; + const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; + if ( + threadDefault?.instanceId === instanceId && + readCustomModelEntries(config?.customModels).some((entry) => entry.slug === threadDefault.model) + ) { + return threadDefault; + } return { instanceId, model: -- 2.54.0