3432dfd280
Build Electron Desktop App / Validate version (push) Failing after 35s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
* test: resolve typescript strictness complaints in unit tests * Update Claude Code obfuscation to version 2.1.114 (#1403) * fix(cloud-code): scope thinking stripping to executor boundaries (#1401) * fix(cloud-code): scope thinking stripping to executors * fix(cloud-code): guard antigravity normalized body * Update Claude Code obfuscation to version 2.1.114 - Update Claude Code version from 2.1.87 to 2.1.114 - Update X-Stainless-Package-Version from 0.80.0 to 0.81.0 - Add new beta flags: redact-thinking-2026-02-12, advisor-tool-2026-03-01, advanced-tool-use-2025-11-20 - Add missing headers: anthropic-version, anthropic-dangerous-direct-browser-access, x-app, X-Stainless-Timeout - Add all X-Stainless-* headers (Arch, Lang, OS, Runtime, Runtime-Version, Retry-Count) - Fix accept-encoding header: identity -> gzip, deflate, br, zstd - Add connection: keep-alive header - Update tool name mapping: add lsp, apply_patch, websearch These changes ensure that requests from OpenCode through Omniroute are indistinguishable from genuine Claude Code 2.1.114 requests, allowing proper authentication with Anthropic's API without triggering extra credits errors. * fix: resolve CodeQL password hash alert and TruffleHog CI failure --------- Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> * fix(claude-code): scope obfuscation to cli clients and fix tests * docs(workflows): enforce PR merge instead of manual close * docs(changelog): update 3.6.9 notes with missing PR 1403 and fixes * docs(workflows): update generate-release to use full changelog for PR body * fix(tsc): silence baseUrl deprecation warnings for TS 5.5+ * fix(chatcore): apply proactive compression before provider translation (#1406) Integrated into release/v3.6.9 * docs(changelog): add PR 1406 * Makes text visible in dark-mode (#1409) Integrated into release/v3.6.9 * docs(changelog): add PR 1409 * chore: save local work * chore(release): sync version references to 3.6.9 * fix(codex): prevent proactive token refresh consumption and strip background parameter * ci: shard long-running suites and relax timeouts * ci: allow manual CI dispatch for release branches * feat(skills): provider-aware marketplace UX, scored AUTO injection, and memory pipeline hardening (#1411) * fix/400 for GeminiCLI(add "ref" in GEMINI_UNSUPPORTED_SCHEMA_KEYS) * feat(cc-compatible): align request shape with Claude CLI * fix(cc-compatible): add Claude CLI system skeleton for OpenAI input * preserve reasoning when translating chat to responses (#1414) Integrated into release/v3.6.9 * fix(skills): optimize AUTO scoring and include Responses input context (#1418) Integrated into release/v3.6.9 * chore: fix TS errors and update review-prs workflow * fix(api): stop sending unsupported Gemini and Codex parameters Prevent Gemini request translation from injecting default thoughtSignature values that the upstream API strictly validates and rejects. Only preserve real signatures resolved from prior upstream responses, and strip additionalProperties from Gemini function schemas to avoid 400 "Unknown name" errors. Also remove fallback-injected session_id and conversation_id fields before sending Codex requests, and restore compatibility with the legacy OUTBOUND_SSRF_GUARD_ENABLED flag when determining whether private provider URLs are allowed. Updates the Gemini translator and regression tests for issue #1410 and related 400 error cases. * fix(core): stabilization fixes for token refresh, usage translation, and testing - Update Codex token refresh detection logic - Mark provider connections invalid on unrecoverable refresh error - Fix Claude usage translation under-reporting cached tokens - Update test expectations - Update CHANGELOG.md for v3.6.9 * fix(auth): reload fresh token state and unify expiry persistence Refresh checks now re-read the latest stored provider connection before attempting rotation so they do not use stale refresh tokens captured by an earlier sweep. Token updates also persist both expiresAt and tokenExpiresAt across the health check, usage-limit refresh path, and SSE refresh flow. This keeps known token expiry metadata in sync and avoids interval-based refreshes for connections whose tokens are still valid well into the future. * fix: resolve SSRF environment static evaluation bug (#1427) Fix import aliases and strict TS typings for tests and ACP agents. * test: resolve remaining strict type errors in test files * test: fix provider service assertion for anthropic-compatible header * fix(codex): respect openaiStoreEnabled setting during native passthrough (#1432) * fix(codex): fix token refresh unrecoverable detection for expired tokens * fix(ci): restore release v3.6.9 build and flaky tests * fix(cc-compatible): trim default OpenAI system skeleton (#1433) Integrated into release/v3.6.9 * fix: prevent masked API keys from being written to CLI tool configs (#1435) * feat: mark Qwen provider as deprecated and add deprecation warning to CLI tool (#1437) * docs(changelog): comprehensive v3.6.9 update with all 59 commits since v3.6.8 * test(ci): align qwen guide settings assertions * fix(security): resolve CodeQL alert 163 for incomplete URL sanitization in Qwen CLI settings --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <74762779+nikolay-popov-ideogram@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tim Massey <tim-massey@users.noreply.github.com> Co-authored-by: Paijo <oyi77@users.noreply.github.com> Co-authored-by: dail45 <dail45@yandex.ru> Co-authored-by: R.D. <rogerproself@gmail.com>
970 lines
29 KiB
TypeScript
970 lines
29 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts";
|
|
import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts";
|
|
import { supportsXHighEffort } from "../config/providerModels.ts";
|
|
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
|
|
import { signRequestBody } from "./claudeCodeCCH.ts";
|
|
import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts";
|
|
import {
|
|
enforceThinkingTemperature,
|
|
disableThinkingIfToolChoiceForced,
|
|
enforceCacheControlLimit,
|
|
} from "./claudeCodeConstraints.ts";
|
|
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
|
|
|
|
/**
|
|
* `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept
|
|
* traffic which looks like the official Claude Code client, often because those
|
|
* gateways resell the same models at materially lower prices than the direct API.
|
|
*
|
|
* This bridge is intentionally compatibility-first while still preserving as
|
|
* much Claude-native structure as possible. Third-party relays are sensitive to
|
|
* wire-image details, so we only synthesize the minimum required defaults when
|
|
* the caller did not already provide Claude-shaped fields.
|
|
*/
|
|
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
|
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
|
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
|
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 64000;
|
|
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = ANTHROPIC_VERSION_HEADER;
|
|
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [
|
|
"claude-code-20250219",
|
|
"interleaved-thinking-2025-05-14",
|
|
"effort-2025-11-24",
|
|
].join(",");
|
|
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.113";
|
|
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.113 (external, sdk-cli)";
|
|
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0";
|
|
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
|
|
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
|
|
const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [
|
|
{
|
|
type: "text",
|
|
text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
|
|
},
|
|
];
|
|
const CONTEXT_1M_SUPPORTED_MODELS = [
|
|
"claude-opus-4-7",
|
|
"claude-opus-4-6",
|
|
"claude-sonnet-4-6",
|
|
"claude-sonnet-4-5",
|
|
"claude-sonnet-4",
|
|
];
|
|
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds(
|
|
process.env
|
|
);
|
|
|
|
type HeaderLike =
|
|
| Headers
|
|
| Record<string, string | undefined>
|
|
| { get?: (name: string) => string | null }
|
|
| null
|
|
| undefined;
|
|
|
|
type MessageLike = {
|
|
role?: string;
|
|
content?: unknown;
|
|
};
|
|
|
|
type BuildRequestOptions = {
|
|
sourceBody?: Record<string, unknown> | null;
|
|
normalizedBody?: Record<string, unknown> | null;
|
|
claudeBody?: Record<string, unknown> | null;
|
|
model: string;
|
|
stream?: boolean;
|
|
cwd?: string;
|
|
now?: Date;
|
|
sessionId?: string | null;
|
|
preserveCacheControl?: boolean;
|
|
};
|
|
|
|
function supportsClaudeXHighEffort(model: string | null | undefined): boolean {
|
|
return typeof model === "string" && supportsXHighEffort("claude", model);
|
|
}
|
|
|
|
export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean {
|
|
return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
|
}
|
|
|
|
export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined): string {
|
|
const normalized = String(baseUrl || "")
|
|
.trim()
|
|
.replace(/\/$/, "");
|
|
if (!normalized) return "";
|
|
return normalized.split("?")[0].replace(/\/messages$/i, "");
|
|
}
|
|
|
|
export function stripClaudeCodeCompatibleEndpointSuffix(
|
|
baseUrl: string | null | undefined
|
|
): string {
|
|
const normalized = String(baseUrl || "")
|
|
.trim()
|
|
.replace(/\/$/, "");
|
|
if (!normalized) return "";
|
|
return normalized.split("?")[0].replace(/\/(?:v\d+\/)?messages$/i, "");
|
|
}
|
|
|
|
function joinNormalizedBaseUrlAndPath(baseUrl: string, path: string): string {
|
|
const normalizedBase = String(baseUrl || "").replace(/\/$/, "");
|
|
const normalizedPath = String(path || "").startsWith("/")
|
|
? String(path)
|
|
: `/${String(path || "")}`;
|
|
const versionMatch = normalizedBase.match(/(\/v\d+)$/i);
|
|
if (
|
|
versionMatch &&
|
|
normalizedPath.toLowerCase().startsWith(`${versionMatch[1].toLowerCase()}/`)
|
|
) {
|
|
return `${normalizedBase}${normalizedPath.slice(versionMatch[1].length)}`;
|
|
}
|
|
return `${normalizedBase}${normalizedPath}`;
|
|
}
|
|
|
|
export function joinBaseUrlAndPath(baseUrl: string, path: string): string {
|
|
return joinNormalizedBaseUrlAndPath(stripAnthropicMessagesSuffix(baseUrl), path);
|
|
}
|
|
|
|
export function joinClaudeCodeCompatibleUrl(baseUrl: string, path: string): string {
|
|
return joinNormalizedBaseUrlAndPath(stripClaudeCodeCompatibleEndpointSuffix(baseUrl), path);
|
|
}
|
|
|
|
export function appendAnthropicBetaHeader(
|
|
headers: Record<string, string>,
|
|
betaHeader: string
|
|
): void {
|
|
const existingKey = Object.keys(headers).find((key) => key.toLowerCase() === "anthropic-beta");
|
|
if (!existingKey) {
|
|
headers["anthropic-beta"] = betaHeader;
|
|
return;
|
|
}
|
|
|
|
const existingValues = String(headers[existingKey] || "")
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!existingValues.includes(betaHeader)) {
|
|
headers[existingKey] = [...existingValues, betaHeader].join(",");
|
|
}
|
|
}
|
|
|
|
export function modelSupportsContext1mBeta(model: string | null | undefined): boolean {
|
|
const normalizedModel = String(model || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/-\d{8}$/, "");
|
|
|
|
return CONTEXT_1M_SUPPORTED_MODELS.some(
|
|
(supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`)
|
|
);
|
|
}
|
|
|
|
export function buildClaudeCodeCompatibleHeaders(
|
|
apiKey: string,
|
|
stream = false,
|
|
sessionId?: string | null
|
|
): Record<string, string> {
|
|
void stream;
|
|
// These headers intentionally mirror Claude Code's wire image closely.
|
|
// For CC-compatible relays, passing the upstream's client-gating checks is
|
|
// more important than forwarding arbitrary caller-specific header shapes.
|
|
return {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"anthropic-version": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
|
|
"anthropic-beta": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA,
|
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
"x-app": "cli",
|
|
"User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT,
|
|
"X-Stainless-Retry-Count": "0",
|
|
"X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS),
|
|
"X-Stainless-Lang": "js",
|
|
"X-Stainless-Package-Version": CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION,
|
|
"X-Stainless-OS": "MacOS",
|
|
"X-Stainless-Arch": "arm64",
|
|
"X-Stainless-Runtime": "node",
|
|
"X-Stainless-Runtime-Version": CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION,
|
|
"accept-encoding": "gzip, deflate, br, zstd",
|
|
...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}),
|
|
};
|
|
}
|
|
|
|
export function buildClaudeCodeCompatibleValidationPayload(model = "claude-sonnet-4-6") {
|
|
const sessionId = randomUUID();
|
|
return buildClaudeCodeCompatibleRequest({
|
|
sourceBody: { max_tokens: 1 },
|
|
normalizedBody: {
|
|
messages: [{ role: "user", content: "ok" }],
|
|
max_tokens: 1,
|
|
},
|
|
model,
|
|
stream: true,
|
|
sessionId,
|
|
cwd: process.cwd(),
|
|
now: new Date(),
|
|
});
|
|
}
|
|
|
|
export function resolveClaudeCodeCompatibleSessionId(headers?: HeaderLike): string {
|
|
const raw =
|
|
getHeader(headers, "x-claude-code-session-id") ||
|
|
getHeader(headers, "x-session-id") ||
|
|
getHeader(headers, "x_session_id") ||
|
|
getHeader(headers, "x-omniroute-session") ||
|
|
null;
|
|
|
|
return (raw && raw.trim()) || randomUUID();
|
|
}
|
|
|
|
export function buildClaudeCodeCompatibleRequest({
|
|
sourceBody,
|
|
normalizedBody,
|
|
claudeBody,
|
|
model,
|
|
stream = false,
|
|
cwd = process.cwd(),
|
|
sessionId,
|
|
preserveCacheControl = false,
|
|
}: BuildRequestOptions) {
|
|
const normalized = normalizedBody || {};
|
|
const preparedClaudeBody = claudeBody
|
|
? prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
|
|
: null;
|
|
const messages = preparedClaudeBody
|
|
? buildClaudeCodeCompatibleMessagesFromClaude(
|
|
preparedClaudeBody.messages as MessageLike[],
|
|
preserveCacheControl
|
|
)
|
|
: Array.isArray(normalized.messages)
|
|
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
|
|
: [];
|
|
const system = buildClaudeCodeCompatibleSystemBlocks({
|
|
messages: normalized.messages as MessageLike[],
|
|
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | undefined,
|
|
preserveCacheControl,
|
|
injectDefaultSkeleton: !preparedClaudeBody,
|
|
});
|
|
const resolvedSessionId = sessionId || randomUUID();
|
|
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
|
|
const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody);
|
|
const tools = preparedClaudeBody?.tools
|
|
? buildClaudeCodeCompatibleToolsFromClaude(
|
|
preparedClaudeBody.tools as Record<string, unknown>[],
|
|
preserveCacheControl
|
|
)
|
|
: buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
|
|
const toolChoice =
|
|
tools.length > 0
|
|
? buildClaudeCodeCompatibleToolChoice(
|
|
normalizedBody?.["tool_choice"] ?? sourceBody?.["tool_choice"]
|
|
)
|
|
: undefined;
|
|
const metadata = resolveClaudeCodeCompatibleMetadata({
|
|
claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
cwd,
|
|
sessionId: resolvedSessionId,
|
|
});
|
|
const thinking = resolveClaudeCodeCompatibleThinking({
|
|
claudeBody: preparedClaudeBody ?? claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
});
|
|
const outputConfig = resolveClaudeCodeCompatibleOutputConfig({
|
|
claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
model,
|
|
effort,
|
|
});
|
|
|
|
return {
|
|
model,
|
|
messages,
|
|
system,
|
|
tools,
|
|
metadata,
|
|
max_tokens: maxTokens,
|
|
thinking,
|
|
output_config: outputConfig,
|
|
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
|
...(stream ? { stream: true } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Full Claude Code request processing pipeline.
|
|
*
|
|
* Applies all mechanisms that real Claude Code uses:
|
|
* 1. Build base request (system prompt, billing header, messages, tools)
|
|
* 2. Remap tool names to TitleCase
|
|
* 3. Enforce thinking temperature constraint (temp=1)
|
|
* 4. Disable thinking when tool_choice forces a specific tool
|
|
* 5. Enforce 4-block cache_control limit when markers are already present
|
|
* 6. Obfuscate sensitive words in user messages
|
|
* 7. Serialize with CCH placeholder
|
|
* 8. Sign body with xxHash64 CCH attestation
|
|
*
|
|
* Returns { bodyString, headers } ready to send upstream.
|
|
*/
|
|
export async function buildAndSignClaudeCodeRequest(
|
|
options: BuildRequestOptions & { apiKey: string; enableObfuscation?: boolean }
|
|
): Promise<{ bodyString: string; headers: Record<string, string> }> {
|
|
const { apiKey, enableObfuscation = false, ...buildOptions } = options;
|
|
|
|
// Step 1: Build base request
|
|
const body = buildClaudeCodeCompatibleRequest(buildOptions);
|
|
|
|
// Step 2: Remap tool names
|
|
remapToolNamesInRequest(body);
|
|
|
|
// Step 3-4: Thinking constraints
|
|
enforceThinkingTemperature(body);
|
|
disableThinkingIfToolChoiceForced(body);
|
|
|
|
// Step 5: Cache control
|
|
enforceCacheControlLimit(body);
|
|
|
|
// Step 6: Obfuscation (optional, per-provider setting)
|
|
if (enableObfuscation) {
|
|
obfuscateInBody(body);
|
|
}
|
|
|
|
// Step 7: Serialize with CCH placeholder
|
|
const serialized = JSON.stringify(body);
|
|
|
|
// Step 8: Sign with xxHash64
|
|
const bodyString = await signRequestBody(serialized);
|
|
|
|
// Build headers
|
|
const sessionId = options.sessionId || resolveClaudeCodeCompatibleSessionId();
|
|
const headers = buildClaudeCodeCompatibleHeaders(apiKey, options.stream ?? false, sessionId);
|
|
|
|
return { bodyString, headers };
|
|
}
|
|
|
|
/**
|
|
* Re-export for consumers that need to post-process SSE response chunks.
|
|
*/
|
|
export { remapToolNamesInResponse } from "./claudeCodeToolRemapper.ts";
|
|
export { signRequestBody } from "./claudeCodeCCH.ts";
|
|
export { computeFingerprint } from "./claudeCodeFingerprint.ts";
|
|
export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts";
|
|
export {
|
|
enforceThinkingTemperature,
|
|
disableThinkingIfToolChoiceForced,
|
|
enforceCacheControlLimit,
|
|
} from "./claudeCodeConstraints.ts";
|
|
|
|
export function resolveClaudeCodeCompatibleEffort(
|
|
sourceBody?: Record<string, unknown> | null,
|
|
normalizedBody?: Record<string, unknown> | null,
|
|
model?: string | null
|
|
): "low" | "medium" | "high" | "xhigh" {
|
|
const raw =
|
|
readNestedString(sourceBody, ["output_config", "effort"]) ||
|
|
readNestedString(sourceBody, ["reasoning", "effort"]) ||
|
|
toNonEmptyString(sourceBody?.["reasoning_effort"]) ||
|
|
readNestedString(normalizedBody, ["output_config", "effort"]) ||
|
|
readNestedString(normalizedBody, ["reasoning", "effort"]) ||
|
|
toNonEmptyString(normalizedBody?.["reasoning_effort"]) ||
|
|
"";
|
|
|
|
const normalizedEffort = raw.toLowerCase();
|
|
|
|
if (!normalizedEffort) {
|
|
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
|
|
}
|
|
if (normalizedEffort === "low") return "low";
|
|
if (normalizedEffort === "medium") return "medium";
|
|
if (normalizedEffort === "high") return "high";
|
|
if (normalizedEffort === "none" || normalizedEffort === "disabled") return "low";
|
|
if (normalizedEffort === "xhigh") {
|
|
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
|
|
}
|
|
if (normalizedEffort === "max") {
|
|
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
|
|
}
|
|
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
|
|
}
|
|
|
|
export function resolveClaudeCodeCompatibleMaxTokens(
|
|
sourceBody?: Record<string, unknown> | null,
|
|
normalizedBody?: Record<string, unknown> | null
|
|
): number {
|
|
const candidates = [
|
|
sourceBody?.["max_tokens"],
|
|
sourceBody?.["max_completion_tokens"],
|
|
sourceBody?.["max_output_tokens"],
|
|
normalizedBody?.["max_tokens"],
|
|
normalizedBody?.["max_completion_tokens"],
|
|
normalizedBody?.["max_output_tokens"],
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value > 0) {
|
|
return Math.floor(value);
|
|
}
|
|
}
|
|
|
|
return CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS;
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
|
|
const converted = messages
|
|
.map((message) => convertClaudeCodeCompatibleMessage(message))
|
|
.filter(
|
|
(
|
|
message
|
|
): message is {
|
|
role: "user" | "assistant";
|
|
content: Array<{ type: string; text: string }>;
|
|
} => !!message && message.content.length > 0
|
|
);
|
|
|
|
const merged: Array<{
|
|
role: "user" | "assistant";
|
|
content: Array<{ type: string; text: string }>;
|
|
}> = [];
|
|
|
|
for (const message of converted) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && last.role === message.role) {
|
|
last.content.push(...message.content);
|
|
continue;
|
|
}
|
|
merged.push({ role: message.role, content: [...message.content] });
|
|
}
|
|
|
|
// CC-compatible sites we tested reject assistant-prefill shaped requests even
|
|
// when Anthropic would normally allow them. Keep assistant/model history, but
|
|
// drop trailing assistant turns so the upstream request ends on a user turn.
|
|
while (merged.length > 0 && merged[merged.length - 1].role === "assistant") {
|
|
merged.pop();
|
|
}
|
|
|
|
if (merged.length === 0) {
|
|
const fallbackText = converted
|
|
.flatMap((message) => message.content)
|
|
.map((block) => toNonEmptyString(block.text))
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
.trim();
|
|
|
|
if (fallbackText) {
|
|
return [
|
|
{
|
|
role: "user" as const,
|
|
content: [{ type: "text", text: fallbackText }],
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleMessagesFromClaude(
|
|
messages: MessageLike[] | undefined,
|
|
preserveCacheControl: boolean
|
|
) {
|
|
const converted = Array.isArray(messages)
|
|
? messages
|
|
.map((message) => convertClaudeCodeCompatibleClaudeMessage(message, preserveCacheControl))
|
|
.filter(
|
|
(
|
|
message
|
|
): message is { role: "user" | "assistant"; content: Array<Record<string, unknown>> } =>
|
|
!!message && message.content.length > 0
|
|
)
|
|
: [];
|
|
|
|
const merged: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }> = [];
|
|
|
|
for (const message of converted) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && last.role === message.role) {
|
|
last.content.push(...message.content);
|
|
continue;
|
|
}
|
|
merged.push({ role: message.role, content: [...message.content] });
|
|
}
|
|
|
|
while (merged.length > 0 && merged[merged.length - 1].role === "assistant") {
|
|
merged.pop();
|
|
}
|
|
|
|
if (!preserveCacheControl) {
|
|
for (const message of merged) {
|
|
stripCacheControlFromContentBlocks(message.content);
|
|
}
|
|
}
|
|
|
|
if (merged.length === 0) {
|
|
const fallbackText = converted
|
|
.flatMap((message) => message.content)
|
|
.map((block) => contentToText(block))
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
.trim();
|
|
if (fallbackText) {
|
|
return [
|
|
{
|
|
role: "user" as const,
|
|
content: [{ type: "text", text: fallbackText }],
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleSystemBlocks({
|
|
messages,
|
|
systemBlocks,
|
|
preserveCacheControl,
|
|
injectDefaultSkeleton,
|
|
}: {
|
|
messages: MessageLike[] | undefined;
|
|
systemBlocks?: Array<Record<string, unknown>> | undefined;
|
|
preserveCacheControl: boolean;
|
|
injectDefaultSkeleton: boolean;
|
|
}) {
|
|
const customSystemBlocks =
|
|
Array.isArray(systemBlocks) && systemBlocks.length > 0
|
|
? systemBlocks.map((block) => ({ ...block }))
|
|
: extractCustomSystemBlocks(messages);
|
|
|
|
const preparedCustomSystemBlocks = customSystemBlocks.map((systemBlock) => {
|
|
const preparedBlock = { ...systemBlock } as Record<string, unknown>;
|
|
if (!preserveCacheControl) {
|
|
delete preparedBlock["cache_control"];
|
|
}
|
|
return preparedBlock;
|
|
});
|
|
|
|
if (!injectDefaultSkeleton) {
|
|
return preparedCustomSystemBlocks;
|
|
}
|
|
|
|
return [
|
|
...CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS.map((block) => ({ ...block })),
|
|
...preparedCustomSystemBlocks,
|
|
];
|
|
}
|
|
|
|
function convertClaudeCodeCompatibleMessage(message: MessageLike | null | undefined) {
|
|
const rawRole = String(message?.role || "").toLowerCase();
|
|
const role =
|
|
rawRole === "user"
|
|
? "user"
|
|
: rawRole === "assistant" || rawRole === "model"
|
|
? "assistant"
|
|
: null;
|
|
|
|
if (!role) return null;
|
|
|
|
const text = contentToText(message?.content);
|
|
if (!text) return null;
|
|
|
|
return {
|
|
role,
|
|
content: [{ type: "text", text }],
|
|
};
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleTools(
|
|
normalizedBody?: Record<string, unknown> | null,
|
|
sourceBody?: Record<string, unknown> | null
|
|
) {
|
|
const rawTools = Array.isArray(normalizedBody?.["tools"])
|
|
? normalizedBody?.["tools"]
|
|
: Array.isArray(sourceBody?.["tools"])
|
|
? sourceBody?.["tools"]
|
|
: [];
|
|
|
|
return rawTools
|
|
.map((tool) => convertClaudeCodeCompatibleTool(tool))
|
|
.filter((tool): tool is Record<string, unknown> => !!tool)
|
|
.map((tool) => ({ ...tool }));
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleToolsFromClaude(
|
|
tools: Record<string, unknown>[] | undefined,
|
|
preserveCacheControl: boolean
|
|
) {
|
|
if (!Array.isArray(tools)) return [];
|
|
|
|
return tools.map((tool) => {
|
|
const preparedTool = { ...tool };
|
|
if (!preserveCacheControl) {
|
|
delete preparedTool.cache_control;
|
|
}
|
|
return preparedTool;
|
|
});
|
|
}
|
|
|
|
function convertClaudeCodeCompatibleTool(tool: unknown) {
|
|
const rawTool = readRecord(tool);
|
|
if (!rawTool) return null;
|
|
|
|
const toolData = rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool;
|
|
|
|
const name = toNonEmptyString(toolData.name);
|
|
if (!name) return null;
|
|
|
|
const rawSchema = readRecord(toolData.parameters) ||
|
|
readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] };
|
|
const inputSchema =
|
|
rawSchema.type === "object" && !readRecord(rawSchema.properties)
|
|
? { ...rawSchema, properties: {} }
|
|
: rawSchema;
|
|
|
|
const converted: Record<string, unknown> = {
|
|
name,
|
|
description: toNonEmptyString(toolData.description) || "",
|
|
input_schema: inputSchema,
|
|
};
|
|
|
|
if (typeof toolData.defer_loading === "boolean") {
|
|
converted.defer_loading = toolData.defer_loading;
|
|
}
|
|
|
|
return converted;
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleToolChoice(choice: unknown) {
|
|
if (!choice) return null;
|
|
|
|
if (typeof choice === "string") {
|
|
if (choice === "required") return { type: "any" };
|
|
return null;
|
|
}
|
|
|
|
const rawChoice = readRecord(choice);
|
|
if (!rawChoice) return null;
|
|
|
|
if (rawChoice.type === "tool") {
|
|
const name = toNonEmptyString(rawChoice.name);
|
|
return name ? { type: "tool", name } : null;
|
|
}
|
|
|
|
if (rawChoice.type === "function") {
|
|
const functionName =
|
|
toNonEmptyString(readRecord(rawChoice.function)?.name) || toNonEmptyString(rawChoice.name);
|
|
return functionName ? { type: "tool", name: functionName } : null;
|
|
}
|
|
|
|
if (rawChoice.type === "required" || rawChoice.type === "any") {
|
|
return { type: "any" };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function prepareClaudeCodeCompatibleBody(
|
|
claudeBody: Record<string, unknown>,
|
|
preserveCacheControl: boolean
|
|
) {
|
|
void preserveCacheControl;
|
|
const prepared = prepareClaudeRequest(
|
|
{
|
|
system: normalizeClaudeSystemInput(claudeBody.system),
|
|
messages: normalizeClaudeMessageInput(claudeBody.messages) as Array<{
|
|
role?: string;
|
|
content?: string | Array<Record<string, unknown>>;
|
|
}>,
|
|
tools: normalizeClaudeToolInput(claudeBody.tools),
|
|
thinking: (readRecord(claudeBody.thinking) || null) as Record<string, unknown> | null,
|
|
},
|
|
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
|
true
|
|
);
|
|
|
|
return readRecord(prepared);
|
|
}
|
|
|
|
function normalizeClaudeSystemInput(system: unknown) {
|
|
if (typeof system === "string") {
|
|
const text = system.trim();
|
|
return text ? [{ type: "text", text }] : [];
|
|
}
|
|
|
|
if (!Array.isArray(system)) return [];
|
|
return system
|
|
.map((block) => normalizeClaudeContentBlock(block))
|
|
.filter((block): block is Record<string, unknown> => !!block);
|
|
}
|
|
|
|
function normalizeClaudeMessageInput(messages: unknown) {
|
|
if (!Array.isArray(messages)) return [];
|
|
return messages
|
|
.map((message) => {
|
|
const record = readRecord(message);
|
|
if (!record) return null;
|
|
|
|
return {
|
|
...record,
|
|
content: normalizeClaudeContentInput(record.content),
|
|
};
|
|
})
|
|
.filter((message): message is Record<string, unknown> & { content: unknown } => !!message);
|
|
}
|
|
|
|
function normalizeClaudeToolInput(tools: unknown) {
|
|
if (!Array.isArray(tools)) return [];
|
|
return tools
|
|
.map((tool) => readRecord(cloneValue(tool)))
|
|
.filter((tool): tool is Record<string, unknown> => !!tool);
|
|
}
|
|
|
|
function normalizeClaudeContentInput(content: unknown) {
|
|
const blocks = normalizeClaudeContentBlocks(content);
|
|
return blocks.length > 0 ? blocks : content;
|
|
}
|
|
|
|
function normalizeClaudeContentBlocks(content: unknown) {
|
|
if (typeof content === "string") {
|
|
const text = content.trim();
|
|
return text ? [{ type: "text", text }] : [];
|
|
}
|
|
|
|
if (!Array.isArray(content)) {
|
|
const block = normalizeClaudeContentBlock(content);
|
|
return block ? [block] : [];
|
|
}
|
|
|
|
return content
|
|
.map((block) => normalizeClaudeContentBlock(block))
|
|
.filter((block): block is Record<string, unknown> => !!block);
|
|
}
|
|
|
|
function normalizeClaudeContentBlock(block: unknown) {
|
|
const record = readRecord(cloneValue(block));
|
|
if (!record) return null;
|
|
|
|
if (
|
|
record.type === "text" ||
|
|
(typeof record.type !== "string" && typeof record.text === "string")
|
|
) {
|
|
const text = toNonEmptyString(record.text);
|
|
if (!text) return null;
|
|
return {
|
|
...record,
|
|
type: "text",
|
|
text,
|
|
};
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
function convertClaudeCodeCompatibleClaudeMessage(
|
|
message: MessageLike | null | undefined,
|
|
preserveCacheControl: boolean
|
|
) {
|
|
const rawRole = String(message?.role || "").toLowerCase();
|
|
const role = rawRole === "user" ? "user" : rawRole === "assistant" ? "assistant" : null;
|
|
|
|
if (!role) return null;
|
|
|
|
const content = normalizeClaudeContentBlocks(message?.content).map((block) => {
|
|
if (preserveCacheControl) return block;
|
|
const { cache_control, ...rest } = block;
|
|
return rest;
|
|
});
|
|
if (content.length === 0) return null;
|
|
|
|
return {
|
|
role,
|
|
content,
|
|
};
|
|
}
|
|
|
|
function extractCustomSystemBlocks(messages: MessageLike[] | undefined) {
|
|
if (!Array.isArray(messages)) return [];
|
|
|
|
return messages
|
|
.filter((message) => {
|
|
const role = String(message?.role || "").toLowerCase();
|
|
return role === "system" || role === "developer";
|
|
})
|
|
.map((message) => contentToText(message?.content))
|
|
.filter(Boolean)
|
|
.map((text) => ({
|
|
type: "text",
|
|
text,
|
|
}));
|
|
}
|
|
|
|
function stripCacheControlFromContentBlocks(content: Array<Record<string, unknown>>) {
|
|
for (const block of content) {
|
|
delete block.cache_control;
|
|
}
|
|
}
|
|
|
|
function resolveClaudeCodeCompatibleMetadata({
|
|
claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
cwd,
|
|
sessionId,
|
|
}: {
|
|
claudeBody?: Record<string, unknown> | null;
|
|
sourceBody?: Record<string, unknown> | null;
|
|
normalizedBody?: Record<string, unknown> | null;
|
|
cwd: string;
|
|
sessionId: string;
|
|
}) {
|
|
const metadata =
|
|
readRecord(cloneValue(claudeBody?.metadata)) ||
|
|
readRecord(cloneValue(sourceBody?.metadata)) ||
|
|
readRecord(cloneValue(normalizedBody?.metadata)) ||
|
|
{};
|
|
|
|
if (!toNonEmptyString(metadata.user_id)) {
|
|
metadata.user_id = JSON.stringify({
|
|
device_id: createHash("sha256")
|
|
.update(String(cwd || ""))
|
|
.digest("hex"),
|
|
account_uuid: "",
|
|
session_id: sessionId,
|
|
});
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
function resolveClaudeCodeCompatibleThinking({
|
|
claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
}: {
|
|
claudeBody?: Record<string, unknown> | null;
|
|
sourceBody?: Record<string, unknown> | null;
|
|
normalizedBody?: Record<string, unknown> | null;
|
|
}) {
|
|
const thinking =
|
|
readRecord(cloneValue(claudeBody?.thinking)) ||
|
|
readRecord(cloneValue(sourceBody?.thinking)) ||
|
|
readRecord(cloneValue(normalizedBody?.thinking));
|
|
|
|
if (thinking) {
|
|
return thinking;
|
|
}
|
|
|
|
return {
|
|
type: "adaptive",
|
|
};
|
|
}
|
|
|
|
function resolveClaudeCodeCompatibleOutputConfig({
|
|
claudeBody,
|
|
sourceBody,
|
|
normalizedBody,
|
|
model,
|
|
effort,
|
|
}: {
|
|
claudeBody?: Record<string, unknown> | null;
|
|
sourceBody?: Record<string, unknown> | null;
|
|
normalizedBody?: Record<string, unknown> | null;
|
|
model?: string | null;
|
|
effort: "low" | "medium" | "high" | "xhigh";
|
|
}) {
|
|
const outputConfig =
|
|
readRecord(cloneValue(claudeBody?.output_config)) ||
|
|
readRecord(cloneValue(sourceBody?.output_config)) ||
|
|
readRecord(cloneValue(normalizedBody?.output_config)) ||
|
|
{};
|
|
|
|
return {
|
|
...outputConfig,
|
|
effort: resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model) || effort,
|
|
};
|
|
}
|
|
|
|
function cloneValue<T>(value: T): T {
|
|
if (typeof structuredClone === "function") {
|
|
return structuredClone(value);
|
|
}
|
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
}
|
|
|
|
function contentToText(content: unknown): string {
|
|
if (typeof content === "string") {
|
|
return content.trim();
|
|
}
|
|
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((part) => {
|
|
if (!part || typeof part !== "object") return "";
|
|
const record = part as Record<string, unknown>;
|
|
if (record.type === "text" && typeof record.text === "string") {
|
|
return record.text.trim();
|
|
}
|
|
if (typeof record.text === "string") {
|
|
return record.text.trim();
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
if (content && typeof content === "object") {
|
|
const record = content as Record<string, unknown>;
|
|
if (typeof record.text === "string") return record.text.trim();
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getHeader(headers: HeaderLike, name: string): string | null {
|
|
if (!headers) return null;
|
|
|
|
if (typeof (headers as Headers).get === "function") {
|
|
return (headers as Headers).get(name);
|
|
}
|
|
|
|
const record = headers as Record<string, string | undefined>;
|
|
const target = name.toLowerCase();
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (key.toLowerCase() === target) {
|
|
return value ?? null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function readNestedString(
|
|
source: Record<string, unknown> | null | undefined,
|
|
path: string[]
|
|
): string | null {
|
|
let current: unknown = source;
|
|
for (const key of path) {
|
|
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
return null;
|
|
}
|
|
current = (current as Record<string, unknown>)[key];
|
|
}
|
|
return toNonEmptyString(current);
|
|
}
|