mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* chore(config): ignore additional agent workflow command files Add newly introduced agent workflow and Claude command files to .gitignore so proprietary automation assets are not committed. * feat(deepseek-web): fix auth to use userToken + WASM PoW solver Rewrite deepseek-web executor from broken cookie auth to userToken Bearer flow (like Chat2API). Replace pure JS Keccak PoW with WASM solver (5.8s → 86ms). Add 14 models, validation, and dashboard UX. * fix(deepseek-web): update target_path to use challenge property * refactor(deepseek-web): streamline token handling and implement cache eviction * fix(deepseek-web): fix SSE parser, prompt format, and error handling - Handle all 3 DeepSeek SSE stream formats: initial fragments, APPEND operations, and bare string tokens (fixes truncated responses) - Simplify prompt builder to send system + last user message only (DeepSeek web API is single-turn, full history caused marker leakage) - Check json.code before token extraction (fixes "did not return access token: Authorization" on code 40003 with HTTP 200) - Clear session cache alongside token cache on auth errors - Add dev origin for remote testing Co-authored-by: Cursor <cursoragent@cursor.com> * chore: ignore memory-bank and cursor agent rules from tracking Co-authored-by: Cursor <cursoragent@cursor.com> * feat: enhance documentation and configuration for Fumadocs integration - Added Fumadocs MDX support in the Next.js configuration. - Updated transpile packages to include fumadocs-ui and fumadocs-core. - Implemented a comprehensive set of redirects for documentation paths to improve navigation. - Removed the generate-docs-index script as it is no longer needed. - Updated various documentation titles for consistency and clarity. - Enhanced global styles to incorporate Fumadocs UI themes and styles. * refactor(docs): cleanup fumadocs PR — revert deepseek, add i18n fallback, restore LanguageSelector - Revert unrelated deepseek-web.ts changes (should be separate PR) - Add .source/ to .gitignore (Fumadocs generated files) - Remove contributor IP from allowedDevOrigins - Add i18n runtime fallback: reads NEXT_LOCALE cookie, loads translated .md from docs/i18n/<locale>/docs/ (preserves existing translation pipeline) - Restore LanguageSelector in Fumadocs layout nav - Restore SEO metadata (title template, description, robots) * fix(codex): use allowlist to strip non-Responses-API fields in non-passthrough path (#2608) (#2615) Integrated into release/v3.8.3 — fix(codex): allowlist-based sanitization for gpt-5.5 Responses API * fix(deepseek-web): fix SSE parser, prompt format, error handling, and cache keys (#2616) Integrated into release/v3.8.3 — fix(deepseek-web): SSE parser (APPEND + bare tokens), prompt builder, error handling, session cache cleanup * chore(config): ignore additional agent workflow command files Add newly introduced agent workflow and Claude command files to .gitignore so proprietary automation assets are not committed. * feat(docs): migrate /docs to Fumadocs MDX with nested routes (#2614) Integrated into release/v3.8.3 — Fumadocs MDX migration with nested routes, search API, and 50+ URL redirects * fix(catalog): skip static PROVIDER_MODELS when synced models exist (#2625) Integrated into release/v3.8.3 * fix(qoder): Cosy auth fallback for PAT tokens + vision support for qwen3-vl-plus (#2629) Integrated into release/v3.8.3 * fix(cli): register tsx loader and add opencode config subcommand (#2631) Integrated into release/v3.8.3 * feat(dashboard): add search and filters to /dashboard/api-manager (#2628) Integrated into release/v3.8.3 * fix(claude): improve Pi and OpenCode compatibility (#2621) Integrated into release/v3.8.3 * fix: restore semantic passthrough system-role-only extraction instead of full normalization (#2620) Integrated into release/v3.8.3 * fix(kiro): stabilize conversationId across prompt compression (#2630) Integrated into release/v3.8.3 * fix(deepseek-web): SSE thinking/search routing and session lifecycle (#2624) Integrated into release/v3.8.3 — DeepSeek Web SSE thinking/search routing overhaul * feat(dashboard): free-tier grouping with symbolic link in /providers (#2632) Integrated into release/v3.8.3 * fix: close implementation gaps — t3-chat-web, stream_options, combo_strategy, batch config (#2634) Integrated into release/v3.8.3 * feat(dashboard): risk notice modal for sensitive providers (#2633) Integrated into release/v3.8.3 * fix(reasoning): extend reasoning_content injection to Kimi K2 and other replay models (#2639) Integrated into release/v3.8.3 * fix(cli): Linux autostart via systemd user service (fixes #2627) (#2635) Integrated into release/v3.8.3 * Refactor/providers free tier (#2640) Integrated into release/v3.8.3 * fix(tests): remove duplicate assertion in schema coercion & fix(cli): ignore system vars in env check * fix(combo): preserve omniModel tag in streaming output for round-trip context pinning (#2646) Integrated into release/v3.8.3 * feat(dashboard): media providers pages + Web Fetch category (#2645) Integrated into release/v3.8.3 * Feature provider adapta org com tutorial de conexão em modal (#2643) Integrated into release/v3.8.3 * fix(rtk): skip content-based filter matching for non-shell tool results (#2642) Integrated into release/v3.8.3 * fix(translator): enable Claude extended thinking for Copilot Responses-API requests (#2647) Integrated into release/v3.8.3 * feat(dashboard): add search and filters to /dashboard/api-manager (#2641) Integrated into release/v3.8.3 * feat(dashboard): risk notice modal for sensitive providers (#2638) Integrated into release/v3.8.3 * feat(dashboard): mini-playground inline (Phase 4) (#2648) Integrated into release/v3.8.3 * fix(settings): fix Require Login modal Cancel button text and dismissal (#2649) Integrated into release/v3.8.3 * feat(combos): universal context handoff for cross-model conversation continuity (#2653) Integrated into release/v3.8.3 * chore(release): bump to v3.8.3 — changelog, docs, version sync * feat(i18n): complete zh-CN translations for 1220 missing keys (#2655) Integrated into release/v3.8.3 * chore(release): include electron package changes in v3.8.3 * docs(changelog): integrate PR #2655 into v3.8.3 * feat(i18n): translate 377 additional zh-CN entries (81 new keys + 296 same-as-en) (#2659) Integrated into release/v3.8.3 * feat(dashboard): add Cmd+K / Ctrl+K command palette for sidebar navigation (#2656) Integrated into release/v3.8.3 * docs: update changelog for PR integrations under v3.8.3 * feat(cli): integrate native updates, autostart and headless CLI mode (#2662) Integrated into release/v3.8.3 * fix(proxy): save dashboard custom proxies in registry (#2661) Integrated into release/v3.8.3 * feat(dashboard): chat-first test slide-over (Option A) (#2660) Integrated into release/v3.8.3 * docs: update changelog with Batch 2 PR merges for v3.8.3 * fix: add xhigh+max to effortLevel schema; add opencode-plugin publish job (#2666) Integrated into release/v3.8.3 * docs: update changelog with Batch 3 PR #2666 merge for v3.8.3 * feat(quota+providers): card-grid layout, provider group headers, Codex race fix (#2667) Integrated into release/v3.8.3 * feat(dashboard): real-time live WebSocket monitoring (#2668) Integrated into release/v3.8.3 * feat(copilot): AI assistant with CodeGraph + CLI + knowledge base (#2669) Integrated into release/v3.8.3 * feat(pipeline): pre-request middleware hooks (#2670) Integrated into release/v3.8.3 * feat(resilience): credential health check + adaptive circuit breaker (#2671) Integrated into release/v3.8.3 * feat(playground): combo routing visual simulator (#2672) Integrated into release/v3.8.3 * feat(auth): API key groups with model-level permissions (#2673) Integrated into release/v3.8.3 * feat(pwa): enhanced manifest + push notification support (#2674) Integrated into release/v3.8.3 * feat(proxy): serverless relay endpoints with rate limiting (#2675) Integrated into release/v3.8.3 * docs(changelog): update changelog for PRs 2667-2675 & fix: resolve typescript compile-time errors * fix(db): remove transactions from migrations Remove explicit transaction wrappers from recent migrations and correct the API key groups migration metadata. Also fix codegraph path resolution for ESM environments and refresh generated fumadocs source output. --------- Co-authored-by: Ömer Vehbe <ovehbe@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mr. Meowgi <mr@meowgi.dev> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com> Co-authored-by: amogus22877769 <y.lev357@gmail.com> Co-authored-by: Halil Tezcan KARABULUT <info@hlltzcnkb.com> Co-authored-by: Tentoxa <53821604+Tentoxa@users.noreply.github.com> Co-authored-by: HALDRO <121296348+HALDRO@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: janeza2 <49841619+janeza2@users.noreply.github.com> Co-authored-by: df4p <38404+df4p@users.noreply.github.com> Co-authored-by: ivan-mezentsev <ivan@mezentsev.me> Co-authored-by: Chewji <126886556+Chewji9875@users.noreply.github.com> Co-authored-by: L-aros <107354918+L-aros@users.noreply.github.com> Co-authored-by: M.M <mr.maatoug@gmail.com> Co-authored-by: Benson K B <bensonkbmca@gmail.com> Co-authored-by: terence71-glitch <mcdowellterence71@gmail.com>
1201 lines
37 KiB
TypeScript
1201 lines
37 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";
|
|
import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts";
|
|
import {
|
|
fixToolPairs,
|
|
fixToolAdjacency,
|
|
stripTrailingAssistantOrphanToolUse,
|
|
} from "./contextManager.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.146";
|
|
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.146 (external, sdk-cli)";
|
|
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0";
|
|
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
|
|
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
|
|
const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary";
|
|
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"];
|
|
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;
|
|
preserveClaudeMessages?: 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,
|
|
preserveClaudeMessages = false,
|
|
}: BuildRequestOptions) {
|
|
const normalized = normalizedBody || {};
|
|
const preparedClaudeBody = claudeBody
|
|
? preserveClaudeMessages
|
|
? prepareClaudeCodeCompatibleSemanticBody(claudeBody)
|
|
: prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
|
|
: null;
|
|
const normalizedMessages = Array.isArray(normalized.messages)
|
|
? (normalized.messages as MessageLike[])
|
|
: [];
|
|
const extractedClaudeBody =
|
|
!preparedClaudeBody && sourceBody
|
|
? extractClaudeBodyFromSource(sourceBody, preserveCacheControl)
|
|
: null;
|
|
const effectiveClaudeBody = preparedClaudeBody || extractedClaudeBody;
|
|
const messages = effectiveClaudeBody
|
|
? preserveClaudeMessages && preparedClaudeBody
|
|
? cloneClaudeCodeCompatibleMessagesFromClaude(
|
|
effectiveClaudeBody.messages as MessageLike[],
|
|
preserveCacheControl
|
|
)
|
|
: buildClaudeCodeCompatibleMessagesFromClaude(
|
|
effectiveClaudeBody.messages as MessageLike[],
|
|
preserveCacheControl
|
|
)
|
|
: buildClaudeCodeCompatibleMessages(normalizedMessages);
|
|
const system = buildClaudeCodeCompatibleSystemBlocks({
|
|
messages: preserveClaudeMessages ? [] : normalizedMessages,
|
|
systemBlocks: effectiveClaudeBody?.system as Record<string, unknown>[] | undefined,
|
|
preserveCacheControl,
|
|
});
|
|
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 5b: Config-driven system transforms (issue #2260, v2)
|
|
// Normalizes system blocks to classifier-correct structure regardless of
|
|
// source client (OpenCode, Cline, Cursor, Continue, Open WebUI, raw API).
|
|
// Routed via the generic per-provider DSL so the same pipeline shape covers
|
|
// the CC bridge, the native `claude` path, and any other configured
|
|
// provider. Idempotent on re-run.
|
|
{
|
|
const transformResult = applySystemTransformPipeline(
|
|
PROVIDER_CC_BRIDGE,
|
|
body as Parameters<typeof applySystemTransformPipeline>[1]
|
|
);
|
|
if (transformResult.appliedOpKinds.length > 0) {
|
|
console.log(`[SystemTransforms] cc-bridge: ${transformResult.appliedOpKinds.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
// Step 5c: Guard against orphan tool_use / tool_result blocks.
|
|
// Anthropic rejects requests where a tool_use has no matching tool_result
|
|
// in the next user message (e.g. `messages.N: tool_use ids were found
|
|
// without tool_result blocks immediately after: toolu_...`). Clients can
|
|
// ship truncated histories mid-tool-call; fixToolPairs strips orphans
|
|
// (preserving final-message tool_use for in-flight rounds), then
|
|
// stripTrailingAssistantOrphanToolUse catches the case where the request
|
|
// body itself ends on an unmatched assistant(tool_use) — invalid for an
|
|
// upstream-send turn since the body must end on a user message.
|
|
// Both are idempotent on clean histories.
|
|
{
|
|
const b = body as Record<string, unknown>;
|
|
if (Array.isArray(b.messages)) {
|
|
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
|
|
const adjacent = fixToolAdjacency(fixed);
|
|
// fixToolAdjacency can leave orphan tool_result blocks behind when it
|
|
// strips a tool_use whose tool_result wasn't in the next message.
|
|
// Re-pair to drop those orphans (discussion #2410).
|
|
const cleaned = fixToolPairs(adjacent);
|
|
b.messages = stripTrailingAssistantOrphanToolUse(cleaned);
|
|
}
|
|
}
|
|
|
|
// Step 6: Obfuscation (optional, per-provider setting)
|
|
if (enableObfuscation) {
|
|
obfuscateInBody(body);
|
|
}
|
|
|
|
// Step 7: Serialize with CCH placeholder (strip internal sentinel fields)
|
|
delete (body as Record<string, unknown>)["_claudeCodeRequiresLowercaseToolNames"];
|
|
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";
|
|
// Preferred (v2): generic per-provider DSL.
|
|
export {
|
|
applySystemTransformPipeline,
|
|
setSystemTransformsConfig,
|
|
getSystemTransformsConfig,
|
|
resetSystemTransformsConfig,
|
|
DEFAULT_SYSTEM_TRANSFORMS_CONFIG,
|
|
DEFAULT_CLAUDE_PIPELINE,
|
|
DEFAULT_CC_BRIDGE_PROVIDER_PIPELINE,
|
|
DEFAULT_OBFUSCATE_WORDS,
|
|
OPENWEBUI_PARAGRAPH_ANCHORS,
|
|
OPENWEBUI_IDENTITY_PREFIXES,
|
|
PROVIDER_CLAUDE,
|
|
PROVIDER_CC_BRIDGE,
|
|
} from "./systemTransforms.ts";
|
|
export type { SystemTransformsConfig, ProviderTransformsConfig } from "./systemTransforms.ts";
|
|
|
|
// Legacy (deprecated, kept for transitional API consumers).
|
|
// The base executor is still used internally by systemTransforms.ts;
|
|
// these exports let downstream code reference the building blocks directly
|
|
// while we migrate UI + settings to the v2 shape.
|
|
export {
|
|
applyCcBridgeTransformPipeline,
|
|
buildBillingHeaderValue,
|
|
setCcBridgeTransformsConfig,
|
|
getCcBridgeTransformsConfig,
|
|
resetCcBridgeTransformsConfig,
|
|
DEFAULT_CC_BRIDGE_PIPELINE,
|
|
DEFAULT_PARAGRAPH_REMOVAL_ANCHORS,
|
|
DEFAULT_IDENTITY_PREFIXES,
|
|
DEFAULT_TEXT_REPLACEMENTS,
|
|
CLAUDE_AGENT_SDK_IDENTITY,
|
|
} from "./ccBridgeTransforms.ts";
|
|
export type { TransformOp, CcBridgeTransformsConfig } from "./ccBridgeTransforms.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>> }> = [];
|
|
let previousAssistantHadToolUse = false;
|
|
|
|
for (const message of converted) {
|
|
const hasToolUse = message.content.some((block) => block.type === "tool_use");
|
|
const hasToolResult = message.content.some((block) => block.type === "tool_result");
|
|
const last = merged[merged.length - 1];
|
|
const shouldKeepSeparate =
|
|
hasToolUse ||
|
|
hasToolResult ||
|
|
previousAssistantHadToolUse ||
|
|
last?.content?.some((block) => block.type === "tool_use") ||
|
|
last?.content?.some((block) => block.type === "tool_result");
|
|
|
|
if (last && last.role === message.role && !shouldKeepSeparate) {
|
|
last.content.push(...message.content);
|
|
} else {
|
|
merged.push({ role: message.role, content: [...message.content] });
|
|
}
|
|
|
|
previousAssistantHadToolUse = message.role === "assistant" && hasToolUse;
|
|
}
|
|
|
|
while (merged.length > 0) {
|
|
const last = merged[merged.length - 1];
|
|
const hasToolUse = last.content.some((block) => block.type === "tool_use");
|
|
if (last.role !== "assistant" || hasToolUse) {
|
|
break;
|
|
}
|
|
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 cloneClaudeCodeCompatibleMessagesFromClaude(
|
|
messages: MessageLike[] | undefined,
|
|
preserveCacheControl: boolean
|
|
) {
|
|
const cloned = Array.isArray(messages)
|
|
? messages
|
|
.map((message) => cloneValue(message) as MessageLike)
|
|
.filter((message) => {
|
|
const role = String(message?.role || "").toLowerCase();
|
|
return role !== "system" && role !== "developer";
|
|
})
|
|
: [];
|
|
|
|
if (!preserveCacheControl) {
|
|
for (const message of cloned) {
|
|
if (Array.isArray(message.content)) {
|
|
stripCacheControlFromContentBlocks(message.content as Array<Record<string, unknown>>);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cloned;
|
|
}
|
|
|
|
function buildClaudeCodeCompatibleSystemBlocks({
|
|
messages,
|
|
systemBlocks,
|
|
preserveCacheControl,
|
|
}: {
|
|
messages: MessageLike[] | undefined;
|
|
systemBlocks?: Array<Record<string, unknown>> | undefined;
|
|
preserveCacheControl: 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;
|
|
});
|
|
|
|
const hasDefaultSystemBlock = containsDefaultSystemSkeleton(preparedCustomSystemBlocks);
|
|
|
|
if (hasDefaultSystemBlock) return preparedCustomSystemBlocks;
|
|
|
|
return [
|
|
...CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS.map((block) => ({ ...block })),
|
|
...preparedCustomSystemBlocks,
|
|
];
|
|
}
|
|
|
|
function containsDefaultSystemSkeleton(blocks: Array<Record<string, unknown>>) {
|
|
const skeleton = CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS;
|
|
if (skeleton.length === 0) return true;
|
|
if (blocks.length < skeleton.length) return false;
|
|
|
|
return blocks.some((_, startIndex) =>
|
|
skeleton.every((defaultBlock, offset) => {
|
|
const candidateBlock = blocks[startIndex + offset];
|
|
if (!candidateBlock) return false;
|
|
|
|
return Object.entries(defaultBlock).every(([key, value]) => candidateBlock[key] === value);
|
|
})
|
|
);
|
|
}
|
|
|
|
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 prepareClaudeCodeCompatibleSemanticBody(claudeBody: Record<string, unknown>) {
|
|
const rawMessages = Array.isArray(claudeBody.messages)
|
|
? (claudeBody.messages as MessageLike[])
|
|
: [];
|
|
|
|
const systemBlocks = normalizeClaudeSystemInput(claudeBody.system);
|
|
const systemFromMessages = extractCustomSystemBlocks(rawMessages);
|
|
const mergedSystem = [...systemBlocks, ...systemFromMessages];
|
|
|
|
const normalizedMessages = rawMessages.filter((message) => {
|
|
const role = String(message?.role || "").toLowerCase();
|
|
return role !== "system" && role !== "developer";
|
|
});
|
|
|
|
const prepared: Record<string, unknown> = {
|
|
system: mergedSystem,
|
|
messages: normalizedMessages,
|
|
tools: normalizeClaudeToolInput(claudeBody.tools),
|
|
thinking: (readRecord(cloneValue(claudeBody.thinking)) || null) as Record<
|
|
string,
|
|
unknown
|
|
> | null,
|
|
};
|
|
|
|
const metadata = readRecord(cloneValue(claudeBody.metadata));
|
|
if (metadata) prepared.metadata = metadata;
|
|
|
|
const outputConfig = readRecord(cloneValue(claudeBody.output_config));
|
|
if (outputConfig) prepared.output_config = outputConfig;
|
|
|
|
return prepared;
|
|
}
|
|
|
|
function extractClaudeBodyFromSource(
|
|
sourceBody: Record<string, unknown>,
|
|
preserveCacheControl: boolean
|
|
): Record<string, unknown> | null {
|
|
const rawMessages = Array.isArray(sourceBody.messages)
|
|
? (sourceBody.messages as MessageLike[])
|
|
: [];
|
|
const hasSystemRoleMessages = rawMessages.some((message) => {
|
|
const role = String(message?.role || "").toLowerCase();
|
|
return role === "system" || role === "developer";
|
|
});
|
|
const hasClaudeSystem =
|
|
typeof sourceBody.system === "string" ||
|
|
(Array.isArray(sourceBody.system) && sourceBody.system.length > 0);
|
|
|
|
if (!hasClaudeSystem && !hasSystemRoleMessages) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedMessages = rawMessages.filter((message) => {
|
|
const role = String(message?.role || "").toLowerCase();
|
|
return role !== "system" && role !== "developer";
|
|
});
|
|
|
|
return prepareClaudeCodeCompatibleBody(
|
|
{
|
|
...sourceBody,
|
|
...(hasClaudeSystem ? {} : { system: extractCustomSystemBlocks(rawMessages) }),
|
|
messages: normalizedMessages,
|
|
},
|
|
preserveCacheControl
|
|
);
|
|
}
|
|
|
|
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 applyClaudeCodeCompatibleThinkingDisplay(thinking, normalizedBody);
|
|
}
|
|
|
|
return applyClaudeCodeCompatibleThinkingDisplay(
|
|
{
|
|
type: "adaptive",
|
|
},
|
|
normalizedBody
|
|
);
|
|
}
|
|
|
|
function applyClaudeCodeCompatibleThinkingDisplay(
|
|
thinking: Record<string, unknown>,
|
|
normalizedBody?: Record<string, unknown> | null
|
|
) {
|
|
if (
|
|
normalizedBody?.[COPILOT_REASONING_SUMMARY_MARKER] !== "summarized" ||
|
|
thinking.type === "disabled"
|
|
) {
|
|
return thinking;
|
|
}
|
|
return {
|
|
...thinking,
|
|
display: "summarized",
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (key === "__proto__" || key === "constructor" || key === "prototype") return null;
|
|
if (!Object.prototype.hasOwnProperty.call(current, key)) return null;
|
|
current = Reflect.get(current as object, key);
|
|
}
|
|
return toNonEmptyString(current);
|
|
}
|