Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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())),
);

Expand Down
63 changes: 56 additions & 7 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/textGeneration/ClaudeTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
),
);
});
40 changes: 37 additions & 3 deletions apps/server/src/textGeneration/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down