diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..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; @@ -674,6 +675,65 @@ 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())), + ); + + /** 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; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"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, + createModelSelection(ProviderInstanceId.make("claudeAgent"), "z-ai/glm-5.3-flash", [ + { id: "effort", value: "low" }, + ]), + ); + }).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; + 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())), + ); + + /** 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; + 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 ed52282c0237..a73c40ab5eda 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, @@ -319,30 +320,78 @@ 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: LegacyCustomModelsProvider, +): 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; +} + +/** 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(([driver, provider]) => { - const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; - return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); - }); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + 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; } + 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, 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..26a6525603d2 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.Finite), + 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: