From b595062a0b2d2c5d9fab95c17da74fa1572298f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 08:35:46 +0000 Subject: [PATCH 1/2] fix(server): text-generation fallback uses a configured custom model One-shot commit, PR, branch, and title generation sent claude-haiku-4-5 whenever the stored provider was disabled, even when that instance's custom model was the slug the backend would accept. The product text-generation slug still applies when no custom model is configured, and a built-in thread default does not replace it. Co-authored-by: maco --- apps/server/src/serverSettings.test.ts | 85 +++++++++++++++++++++++++ apps/server/src/serverSettings.ts | 86 +++++++++++++++++++++++--- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 502ae3430e27..e11e62637c31 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ModelSelection, ProjectId, ProjectScript, @@ -688,6 +689,90 @@ it.layer(NodeServices.layer)("server settings", (it) => { const settings = yield* serverSettings.getSettings; assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.equal( + settings.textGenerationModelSelection.model, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect( + "keeps the product text-generation model when the thread default is a built-in slug", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A built-in chat model must not replace the cheap text-generation slug. + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}},"defaultModelSelection":{"instanceId":"claudeAgent","model":"claude-opus-4-6","options":[{"id":"effort","value":"high"}]}}', + ); + + const settings = yield* serverSettings.getSettings; + const productModel = + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")]; + + assert.isDefined(productModel); + assert.deepEqual( + settings.textGenerationModelSelection, + createModelSelection(ProviderInstanceId.make("claudeAgent"), productModel), + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("uses a custom thread default when falling back for text generation", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}},"defaultModelSelection":{"instanceId":"claudeAgent","model":"z-ai/glm-5.3-flash","options":[{"id":"effort","value":"low"}]}}', + ); + + const settings = yield* serverSettings.getSettings; + + assert.deepEqual( + settings.textGenerationModelSelection, + createModelSelection(ProviderInstanceId.make("claudeAgent"), "z-ai/glm-5.3-flash", [ + { id: "effort", value: "low" }, + ]), + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("uses a configured custom model when falling back for text generation", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}}}', + ); + + const settings = yield* serverSettings.getSettings; + + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.equal(settings.textGenerationModelSelection.model, "z-ai/glm-5.3-flash"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("uses a legacy custom model when falling back for text generation", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}},"providers":{"claudeAgent":{"customModels":["z-ai/glm-5.3-flash"]}}}', + ); + + const settings = yield* serverSettings.getSettings; + + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.equal(settings.textGenerationModelSelection.model, "z-ai/glm-5.3-flash"); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6949a66d981b..cf3a0c48123b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -49,6 +49,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; +import { createModelSelection, readCustomModelEntries } from "@t3tools/shared/model"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { @@ -338,6 +339,79 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings : fallbackTextGenerationProvider(settings); } +/** + * Custom-model slugs configured on this instance. + * + * An explicit `providerInstances[id].config.customModels` array wins, including + * an empty one. Otherwise the legacy `providers[driver].customModels` list is + * used for the default instance only, matching the Settings UI. + */ +function configuredCustomModelSlugs( + settings: ServerSettings, + driver: ProviderDriverKind, + instanceId: ProviderInstanceId, +): ReadonlyArray { + const instanceConfig = settings.providerInstances[instanceId]?.config; + if ( + instanceConfig !== null && + typeof instanceConfig === "object" && + "customModels" in instanceConfig + ) { + const value = (instanceConfig as { customModels?: unknown }).customModels; + if (Array.isArray(value)) { + return readCustomModelEntries(value).map((entry) => entry.slug); + } + } + if (instanceId !== ProviderInstanceId.make(driver)) { + return []; + } + const legacy = Object.entries(settings.providers).find(([key]) => key === driver)?.[1]; + return readCustomModelEntries(legacy?.customModels).map((entry) => entry.slug); +} + +/** + * Model for one-shot text generation when the stored selection's instance is + * unusable. Commit, PR, branch, and title generation all read this value. + * + * The product slug (`DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER`, Claude's + * `claude-haiku-4-5`) stays the fallback unless this instance configured a + * custom model that slug is not. A built-in `defaultModelSelection` does not + * replace it: title, branch, and commit generation keep the cheap product + * default for users who only picked a chat model. + * + * On that broken fallback, a `defaultModelSelection` whose model is one of the + * instance's custom models wins, options included. Otherwise the first custom + * slug is used. The product slug remains the last resort. + */ +function fallbackTextGenerationModelSelection( + settings: ServerSettings, + driver: ProviderDriverKind, + instanceId: ProviderInstanceId, +): ModelSelection { + const productModel = + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL; + const productSelection = createModelSelection(instanceId, productModel); + const customModels = configuredCustomModelSlugs(settings, driver, instanceId); + if (customModels.length === 0 || customModels.includes(productModel)) { + return productSelection; + } + + const threadDefault = settings.defaultModelSelection; + if (threadDefault?.instanceId === instanceId) { + if (!customModels.includes(threadDefault.model)) { + return productSelection; + } + return createModelSelection(instanceId, threadDefault.model, threadDefault.options); + } + + const customModel = customModels[0]; + return customModel === undefined + ? productSelection + : createModelSelection(instanceId, customModel); +} + function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { // Same precedence as isModelSelectionProviderEnabled: an explicit provider // instance wins over the legacy providers map, which decodes to defaults @@ -353,13 +427,11 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting return { ...settings, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_TEXT_GENERATION_MODEL, - } satisfies ModelSelection, + textGenerationModelSelection: fallbackTextGenerationModelSelection( + settings, + fallback, + ProviderInstanceId.make(fallback), + ), }; } From 148eaf0e94b8638c6176836712bc30f9c975c189 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 08:37:34 +0000 Subject: [PATCH 2/2] docs(server): clarify text-generation fallback model policy The product slug stays in place for built-in thread defaults. A custom model replaces it only when that slug is not one of the instance's configured models. Co-authored-by: maco --- apps/server/src/serverSettings.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index cf3a0c48123b..6343ecd4f1e2 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -374,14 +374,16 @@ function configuredCustomModelSlugs( * unusable. Commit, PR, branch, and title generation all read this value. * * The product slug (`DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER`, Claude's - * `claude-haiku-4-5`) stays the fallback unless this instance configured a - * custom model that slug is not. A built-in `defaultModelSelection` does not - * replace it: title, branch, and commit generation keep the cheap product - * default for users who only picked a chat model. + * `claude-haiku-4-5`) stays the fallback when this instance has no custom + * model, when that slug is itself configured, or when `defaultModelSelection` + * for this instance is a built-in chat model. Title, branch, and commit + * generation keep the cheap product default for users who only picked a chat + * model. * - * On that broken fallback, a `defaultModelSelection` whose model is one of the - * instance's custom models wins, options included. Otherwise the first custom - * slug is used. The product slug remains the last resort. + * The hardcoded slug is replaced only on the broken fallback: the instance + * has custom models that do not include the product slug, and it did not pin + * a built-in thread default. A matching custom `defaultModelSelection` wins, + * options included; otherwise the first custom slug is used. */ function fallbackTextGenerationModelSelection( settings: ServerSettings,