diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1add2822a01e9..3791d87abc171 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6587,9 +6587,42 @@ describe("ClaudeAdapterV2 background wake turns", () => { expected: "Claude stopped: the prompt exceeds the model's context window. Run /compact or start a new thread.", }, + ...[ + { + name: "a model version error rewritten by a custom endpoint", + baseUrl: "https://gateway.example.test/v1", + installed: "2.1.322", + expected: + "The custom API endpoint gateway.example.test reported Claude Code 2.1.280, but this session runs Claude Code 2.1.322. Running claude update will not fix this; the endpoint or a proxy behind it rewrote the version.", + }, + { name: "a model version error without a custom endpoint", installed: "2.1.322" }, + { + name: "a model version error from a Claude Code still below the required version", + baseUrl: "https://gateway.example.test/v1", + installed: "2.1.285", + }, + ].map((row) => { + const result = + "API Error: 400 Claude Code 2.1.280 does not support this model; version 2.1.290 or newer is required. Run 'claude update', or update the Claude desktop app, then try again."; + return { + ...row, + isError: true, + result, + errors: undefined, + apiErrorStatus: undefined, + terminalReason: undefined, + environment: { + CLAUDE_CONFIG_DIR: "/synthetic/no-claude-config", + ...(row.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: row.baseUrl }), + }, + expected: row.expected ?? result, + }; + }), ])("reports terminal API error text for $name", (input) => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions( + "environment" in input ? { environment: input.environment } : undefined, + ); const now = yield* DateTime.now; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6602,6 +6635,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); const apiErrorStatus = input.apiErrorStatus ?? 400; + if ("installed" in input) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000698", + session_id: WAKE_NATIVE_SESSION, + claude_code_version: input.installed, + }), + ); + } yield* Queue.offer( harness.sdkMessages, makeResultFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7b63b7efc0b65..20e5e6abc77f2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -84,6 +84,7 @@ import { import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, @@ -165,7 +166,9 @@ import { } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { claudeLaunchArgDirectories, + customClaudeApiEndpointHost, discoverClaudeSkills, + readClaudeSettingsEnv, } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection, @@ -4106,6 +4109,25 @@ function gatewayCooldownResetAt( : undefined; } +const CLAUDE_UNSUPPORTED_VERSION_ERROR = + /^API Error: 400 Claude Code (\d+\.\d+\.\d+) does not support this model; version (\d+\.\d+\.\d+) or newer is required\./; + +/** The version a gateway sent in place of the session's own, when the session itself meets the requirement. */ +function claudeRewrittenVersion( + message: SDKResultMessage, + sessionVersion: string | undefined, +): string | undefined { + if (message.subtype !== "success" || message.terminal_reason !== "api_error") return undefined; + const [, reported, required] = CLAUDE_UNSUPPORTED_VERSION_ERROR.exec(message.result.trim()) ?? []; + if (reported === undefined || required === undefined || sessionVersion === undefined) { + return undefined; + } + return compareSemverVersions(reported, sessionVersion) < 0 && + compareSemverVersions(sessionVersion, required) >= 0 + ? reported + : undefined; +} + function providerFailureFromResult( message: SDKResultMessage, receivedAt: DateTime.Utc, @@ -4446,6 +4468,7 @@ interface ClaudeLiveQueryContext { receivedInit: boolean; // From system/init; feature-detects control requests such as MCP Apps. capabilities: ReadonlySet; + claudeCodeVersion: string | undefined; permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; @@ -10083,12 +10106,30 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // The CLI also labels 403 authentication_failed; show its text because login cannot grant access. const permissionDenied = message.subtype === "success" && message.api_error_status === 403; + const rewrittenVersion = claudeRewrittenVersion(message, liveQuery.claudeCodeVersion); + const endpointHost = + rewrittenVersion === undefined + ? undefined + : customClaudeApiEndpointHost( + (yield* readClaudeSettingsEnv( + adapterOptions.settings, + context.input.runtimePolicy.cwd ?? undefined, + adapterOptions.environment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + )).ANTHROPIC_BASE_URL ?? adapterOptions.environment.ANTHROPIC_BASE_URL, + ); + const rewrittenVersionError = + endpointHost === undefined + ? undefined + : `The custom API endpoint ${endpointHost} reported Claude Code ${rewrittenVersion}, but this session runs Claude Code ${liveQuery.claudeCodeVersion}. Running claude update will not fix this; the endpoint or a proxy behind it rewrote the version.`; const failureHint = permissionDenied ? message.result : (context.authenticationFailureMessage ?? (usageLimited ? "Claude usage limit reached. Send the message again once the limit resets." - : undefined)); + : rewrittenVersionError)); // Usage-limited /compact reports success; fail it so queued messages wait for the limit to clear. const limitedCompaction = usageLimited && message.subtype === "success" && message.local_command === "compact" @@ -11077,6 +11118,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( openedPermissionMode: queryOptions.permissionMode, receivedInit: false, capabilities: new Set(), + claudeCodeVersion: undefined, permissionMode: queryOptions.permissionMode, stopping: false, handedOffSubagents: new Set(), @@ -11117,6 +11159,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.permissionMode ?? context.openedPermissionMode; context.receivedInit = true; context.capabilities = new Set(message.capabilities ?? []); + context.claudeCodeVersion = message.claude_code_version; } if ( message.type === "system" && diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 72221ff3187b4..47eaf7aa14864 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -45,7 +45,11 @@ import { hasClaudeManagedMcpConfig, makeClaudeEnvironment, } from "./Drivers/ClaudeHome.ts"; -import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts"; +import { + customClaudeApiEndpointHost, + discoverClaudeSkills, + readClaudeSettingsEnv, +} from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { @@ -671,12 +675,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const baseUrl = (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; - const endpoint = URL.parse(baseUrl?.trim() ?? ""); - const customApiEndpoint = - endpoint !== null && - (endpoint.protocol === "http:" || endpoint.protocol === "https:") && - endpoint.hostname !== "" && - endpoint.hostname !== "api.anthropic.com"; + const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { const path = yield* Path.Path; const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment)) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 0d62109cce828..ada8a39e1ed5b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -472,6 +472,17 @@ export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function return settingsEnv; }); +/** The host of an `ANTHROPIC_BASE_URL` that is not Anthropic's own API. */ +export function customClaudeApiEndpointHost(baseUrl: string | undefined): string | undefined { + const endpoint = URL.parse(baseUrl?.trim() ?? ""); + return endpoint !== null && + (endpoint.protocol === "http:" || endpoint.protocol === "https:") && + endpoint.hostname !== "" && + endpoint.hostname !== "api.anthropic.com" + ? endpoint.hostname + : undefined; +} + // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { const directories: string[] = [];