From 8d1e34aec9d17fc0313d8625ec313d5bf8111f01 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:29:56 +0000 Subject: [PATCH 1/6] fix(server): use configured model for text-generation fallback When the default Codex text-generation selection is unusable, fall back to the enabled instance's defaultModelSelection or custom model instead of a hardcoded slug like claude-haiku-4-5. Co-authored-by: maco --- apps/server/src/serverSettings.test.ts | 79 ++++++++++++++++++++++++++ apps/server/src/serverSettings.ts | 34 ++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..9b0b3ded7c05 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -674,6 +674,85 @@ it.layer(NodeServices.layer)("server settings", (it) => { const settings = yield* serverSettings.getSettings; assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.equal(settings.textGenerationModelSelection.model, "claude-haiku-4-5"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("uses the instance default 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, + JSON.stringify({ + providerInstances: { + codex: { driver: "codex", enabled: false, config: {} }, + }, + defaultModelSelection: { + instanceId: "claudeAgent", + model: "z-ai/glm-5.3-flash", + options: [{ id: "effort", value: "low" }], + }, + }), + ); + + const settings = yield* serverSettings.getSettings; + + assert.deepEqual(settings.textGenerationModelSelection, { + instanceId: "claudeAgent", + model: "z-ai/glm-5.3-flash", + options: [{ 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, + JSON.stringify({ + 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, + JSON.stringify({ + 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 ed52282c0237..1a88ac39bbc5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -50,6 +50,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, @@ -325,6 +326,19 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings : fallbackTextGenerationProvider(settings); } +function firstConfiguredCustomModelSlug( + instanceConfig: unknown, + legacyProvider: { readonly customModels?: unknown }, +): string | undefined { + if (instanceConfig !== null && typeof instanceConfig === "object") { + const value = (instanceConfig as { customModels?: unknown }).customModels; + if (Array.isArray(value)) { + return readCustomModelEntries(value)[0]?.slug; + } + } + return readCustomModelEntries(legacyProvider.customModels)[0]?.slug; +} + function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { // Same precedence as isModelSelectionProviderEnabled: an explicit provider // instance wins over the legacy providers map, which decodes to defaults @@ -333,16 +347,30 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); }); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + if (!fallbackEntry) { return settings; } + const fallback = ProviderDriverKind.make(fallbackEntry[0]); + const instanceId = ProviderInstanceId.make(fallback); + // Prefer the instance's configured default or custom model over a hardcoded + // slug (e.g. claude-haiku-4-5) that the backend may not serve. + const defaultSelection = settings.defaultModelSelection; + if (defaultSelection?.instanceId === instanceId) { + return { + ...settings, + textGenerationModelSelection: defaultSelection, + }; + } return { ...settings, textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), + instanceId, model: + firstConfiguredCustomModelSlug( + settings.providerInstances[instanceId]?.config, + fallbackEntry[1], + ) ?? DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_TEXT_GENERATION_MODEL, From d4006c5dd0dd0cc9d3dd8f9b1fede1d289cc41e8 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:38:28 +0000 Subject: [PATCH 2/6] test(server): match settings JSON fixtures for text-generation fallback Use the same inline settings.json strings as the rest of the suite so typecheck does not flag JSON.stringify or unbranded instance ids. Co-authored-by: maco --- apps/server/src/serverSettings.test.ts | 41 ++++++-------------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9b0b3ded7c05..e9d82ec42d18 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -685,25 +685,17 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - JSON.stringify({ - providerInstances: { - codex: { driver: "codex", enabled: false, config: {} }, - }, - defaultModelSelection: { - instanceId: "claudeAgent", - model: "z-ai/glm-5.3-flash", - options: [{ id: "effort", value: "low" }], - }, - }), + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}},"defaultModelSelection":{"instanceId":"claudeAgent","model":"z-ai/glm-5.3-flash","options":[{"id":"effort","value":"low"}]}}', ); const settings = yield* serverSettings.getSettings; - assert.deepEqual(settings.textGenerationModelSelection, { - instanceId: "claudeAgent", - model: "z-ai/glm-5.3-flash", - options: [{ id: "effort", value: "low" }], - }); + assert.deepEqual( + settings.textGenerationModelSelection, + createModelSelection(ProviderInstanceId.make("claudeAgent"), "z-ai/glm-5.3-flash", [ + { id: "effort", value: "low" }, + ]), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -714,15 +706,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - JSON.stringify({ - providerInstances: { - codex: { driver: "codex", enabled: false, config: {} }, - claudeAgent: { - driver: "claudeAgent", - config: { customModels: ["z-ai/glm-5.3-flash"] }, - }, - }, - }), + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}}}', ); const settings = yield* serverSettings.getSettings; @@ -739,14 +723,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - JSON.stringify({ - providerInstances: { - codex: { driver: "codex", enabled: false, config: {} }, - }, - providers: { - claudeAgent: { customModels: ["z-ai/glm-5.3-flash"] }, - }, - }), + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}},"providers":{"claudeAgent":{"customModels":["z-ai/glm-5.3-flash"]}}}', ); const settings = yield* serverSettings.getSettings; From 03066e1cd472801eb912faae0e98e726cee6dead Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:57:46 +0000 Subject: [PATCH 3/6] docs(server): add JSDoc for text-generation fallback helpers CodeRabbit required 80% docstring coverage on functions in this diff. Co-authored-by: maco --- apps/server/src/serverSettings.test.ts | 4 ++++ apps/server/src/serverSettings.ts | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index e9d82ec42d18..a79c05647e51 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -659,6 +659,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + /** Falls back to Claude Haiku when Codex is disabled and nothing else is configured. */ it.effect("skips a disabled provider instance when picking the text generation fallback", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -678,6 +679,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + /** Reuses defaultModelSelection when it belongs to the fallback instance. */ it.effect("uses the instance default model when falling back for text generation", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -699,6 +701,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + /** Uses the fallback instance's customModels list when no matching default is set. */ it.effect("uses a configured custom model when falling back for text generation", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -716,6 +719,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + /** Uses the legacy providers.customModels list when instance config has none. */ it.effect("uses a legacy custom model when falling back for text generation", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1a88ac39bbc5..6ba6a0c6fa5e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -320,15 +320,20 @@ function restoreUsedProviders( }; } +/** Keep the stored text-generation selection when its instance is enabled; otherwise fall back. */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) ? settings : fallbackTextGenerationProvider(settings); } +/** Legacy provider entry that may still list customModels. */ +type LegacyCustomModelsProvider = { readonly customModels?: unknown }; + +/** First custom-model slug from the instance config, else the legacy provider list. */ function firstConfiguredCustomModelSlug( instanceConfig: unknown, - legacyProvider: { readonly customModels?: unknown }, + legacyProvider: LegacyCustomModelsProvider, ): string | undefined { if (instanceConfig !== null && typeof instanceConfig === "object") { const value = (instanceConfig as { customModels?: unknown }).customModels; @@ -339,14 +344,18 @@ function firstConfiguredCustomModelSlug( return readCustomModelEntries(legacyProvider.customModels)[0]?.slug; } +/** Prefer the enabled instance's default or custom model when the stored selection is unusable. */ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { // Same precedence as isModelSelectionProviderEnabled: an explicit provider // instance wins over the legacy providers map, which decodes to defaults // (codex enabled) when the Providers UI has only written providerInstances. - const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { - const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; - return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); - }); + const fallbackEntry = Object.entries(settings.providers).find( + /** True when this driver is the first enabled instance or legacy provider. */ + ([driver, provider]) => { + const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; + return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); + }, + ); if (!fallbackEntry) { return settings; } From 5a5183805437a224750ad096f8321315d6a13d70 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:02:25 +0000 Subject: [PATCH 4/6] docs(server): document text-generation fallback driver check Extract the enabled-instance predicate so the last undocumented callback in the docstring coverage set has a one-line TSDoc. Co-authored-by: maco --- apps/server/src/serverSettings.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6ba6a0c6fa5e..a73c40ab5eda 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -344,18 +344,30 @@ function firstConfiguredCustomModelSlug( return readCustomModelEntries(legacyProvider.customModels)[0]?.slug; } +/** True when this driver is the first enabled instance or legacy provider. */ +function isEnabledTextGenerationFallback( + settings: ServerSettings, + driver: string, + provider: { readonly enabled: boolean; readonly customModels?: unknown }, +): boolean { + const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; + return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); +} + /** Prefer the enabled instance's default or custom model when the stored selection is unusable. */ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { // Same precedence as isModelSelectionProviderEnabled: an explicit provider // instance wins over the legacy providers map, which decodes to defaults // (codex enabled) when the Providers UI has only written providerInstances. - const fallbackEntry = Object.entries(settings.providers).find( - /** True when this driver is the first enabled instance or legacy provider. */ - ([driver, provider]) => { - const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; - return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); - }, - ); + let fallbackEntry: + | [string, { readonly enabled: boolean; readonly customModels?: unknown }] + | undefined; + for (const entry of Object.entries(settings.providers)) { + if (isEnabledTextGenerationFallback(settings, entry[0], entry[1])) { + fallbackEntry = entry; + break; + } + } if (!fallbackEntry) { return settings; } From f732a68ba38371cbd76989ea3fb1bed9dd13cdbc Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:32:09 +0000 Subject: [PATCH 5/6] fix(server): surface Claude stdout API errors over wrapper stderr runClaudeJson preferred non-empty stderr on CLI failure, so credential wrapper notices hid stdout api_error_status and result. Prefer the structured stdout API error when present. --- .../ClaudeTextGeneration.test.ts | 36 +++++++++++++++++ .../textGeneration/ClaudeTextGeneration.ts | 40 +++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8fe5152d3450..27353f85c602 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -579,4 +579,40 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ), ); + + it.effect("surfaces a Claude stdout API error instead of wrapper stderr", () => + withFakeClaudeEnv( + { + exitCode: 1, + stderr: + "Using the OpenRouter credential from the global credential ~/.ori/credentials.json.", + output: JSON.stringify({ + api_error_status: 400, + is_error: true, + result: + "API Error: 400 0 endpoints out of 4 requested are available matching your guardrail restrictions and data policy. Model blocked by guardrail: 4 endpoints excluded", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/claude-api-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }), + ); + + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toContain("API error 400"); + expect(error.detail).toContain("Model blocked by guardrail"); + expect(error.detail).not.toContain("OpenRouter credential"); + }), + ), + ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 357ecd686e46..40a2a278afc3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -69,6 +69,42 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow const decodeClaudeOutput = Schema.decodeEffect( Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), ); +const ClaudeCliApiErrorPayload = Schema.Struct({ + api_error_status: Schema.optionalKey(Schema.Number), + is_error: Schema.optionalKey(Schema.Boolean), + result: Schema.optionalKey(Schema.String), +}); +const decodeClaudeCliApiErrorStdout = Schema.decodeOption( + Schema.fromJsonString( + Schema.Union([ClaudeCliApiErrorPayload, Schema.Array(ClaudeCliApiErrorPayload)]), + ), +); + +/** + * Prefer a structured stdout API error over informational wrapper stderr. + */ +function claudeCliFailureDetail(stdout: string, stderr: string): string { + const stdoutDetail = stdout.trim(); + const stderrDetail = stderr.trim(); + const decoded = decodeClaudeCliApiErrorStdout(stdoutDetail); + if (Option.isSome(decoded)) { + const payloads = Array.isArray(decoded.value) ? decoded.value : [decoded.value]; + for (let i = payloads.length - 1; i >= 0; i--) { + const payload = payloads[i]; + if (payload === undefined) continue; + const status = payload.api_error_status; + const result = payload.result?.trim(); + const hasResult = result !== undefined && result.length > 0; + if (status === undefined && payload.is_error !== true) continue; + if (status !== undefined && hasResult) { + return `API error ${status}: ${result}`; + } + if (hasResult) return result; + if (status !== undefined) return `API error ${status}`; + } + } + return stderrDetail.length > 0 ? stderrDetail : stdoutDetail; +} export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, @@ -250,9 +286,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); if (exitCode !== 0) { - const stderrDetail = stderr.trim(); - const stdoutDetail = stdout.trim(); - const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + const detail = claudeCliFailureDetail(stdout, stderr); return yield* new TextGenerationError({ operation, detail: From 928a13837f34d24e0823998b1d33a7f427d8e820 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:35:20 +0000 Subject: [PATCH 6/6] fix(server): treat Claude api_error_status as a finite number HTTP status codes are finite, so decode api_error_status with Schema.Finite instead of Schema.Number. --- apps/server/src/textGeneration/ClaudeTextGeneration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 40a2a278afc3..26a6525603d2 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -70,7 +70,7 @@ const decodeClaudeOutput = Schema.decodeEffect( Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), ); const ClaudeCliApiErrorPayload = Schema.Struct({ - api_error_status: Schema.optionalKey(Schema.Number), + api_error_status: Schema.optionalKey(Schema.Finite), is_error: Schema.optionalKey(Schema.Boolean), result: Schema.optionalKey(Schema.String), });