Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
45 changes: 44 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -165,7 +166,9 @@ import {
} from "../../provider/Drivers/ClaudeSkillDispatch.ts";
import {
claudeLaunchArgDirectories,
customClaudeApiEndpointHost,
discoverClaudeSkills,
readClaudeSettingsEnv,
} from "../../provider/Drivers/ClaudeSkills.ts";
import {
compileClaudeModelSelection,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -4446,6 +4468,7 @@ interface ClaudeLiveQueryContext {
receivedInit: boolean;
// From system/init; feature-detects control requests such as MCP Apps.
capabilities: ReadonlySet<string>;
claudeCodeVersion: string | undefined;
permissionMode: PermissionMode;
stopping: boolean;
readonly subagentsFromEarlierProcesses: ReadonlySet<ActiveClaudeSubagent>;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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" &&
Expand Down
13 changes: 6 additions & 7 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const directories: string[] = [];
Expand Down
Loading