From e3e962dbda3c7b2e5f35aa6b70211e576938e3d0 Mon Sep 17 00:00:00 2001 From: Mourad Maatoug Date: Fri, 15 May 2026 14:04:34 +0200 Subject: [PATCH 01/13] feat(cc-bridge): config-driven system block transforms (closes #2260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a config-driven DSL pipeline for normalizing the system blocks sent to Anthropic via the Claude Code bridge. Removes third-party agent CLI fingerprints (OpenCode, Cline, Cursor, Continue) and prepends the SDK identity + signed billing header so the request looks classifier-correct regardless of which client originally sent it. Why a DSL: future fingerprint defenses ship as config rows, not new code. Mode presets (basic/aggressive/custom), reorder, add, delete, reset — all hot-reloaded through the existing runtimeSettings registry. Files - open-sse/services/ccBridgeTransforms.ts (~440 LOC; 8 op-kinds, executor, buildBillingHeaderValue ported from ex-machina cch.ts, singleton config matching cliFingerprints pattern, T4-200 fixture-matching defaults) - tests/unit/cc-bridge-transforms.test.ts (30 tests; per-op, idempotency, ordering, verbatim-OpenCode regression) - open-sse/services/claudeCodeCompatible.ts (step 5b in buildAndSign pipeline + re-exports for downstream) - open-sse/executors/base.ts (maintainer comment: native OAuth path is intentionally not routed through the DSL; it has its own billing prepend at lines 744-773) - src/lib/config/runtimeSettings.ts (RuntimeReloadSection + snapshot + default + normalize + apply + change-detection) - src/shared/validation/settingsSchemas.ts (zod discriminatedUnion over the 8 op-kinds, max 50 ops per pipeline) - src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx (Settings UI Card: enabled toggle, ordered pipeline list with reorder buttons, add-op dropdown, reset-to-defaults) - src/i18n/messages/en.json (10 new strings under settings.ccBridgeTransforms.*) Defaults match the empirical T4=200 layout: [billing, identity, sanitized, extras]. Verified against prod call logs (1c1946a2 = plugin-200, de8ab5bd = raw-429) and four-variant live A/B test (T1=429 baseline, T2=429 phrase-only, T3=429 sanitizer-only, T4=200 with billing+identity). Test result: 30/30 ccBridgeTransforms + 6/6 baseline claude-code-compatible-helpers green. Scope: CC bridge surface only (open-sse/services/claudeCodeCompatible.ts). Native claude OAuth path is NOT routed through the DSL — it has its own billing prepend mechanism that already works. Refs: https://github.com/diegosouzapw/OmniRoute/issues/2260 --- open-sse/executors/base.ts | 9 + open-sse/services/ccBridgeTransforms.ts | 577 ++++++++++++++++++ open-sse/services/claudeCodeCompatible.ts | 19 + .../settings/components/RoutingTab.tsx | 279 +++++++++ src/i18n/messages/en.json | 9 + src/lib/config/runtimeSettings.ts | 32 +- src/shared/validation/settingsSchemas.ts | 57 ++ tests/unit/cc-bridge-transforms.test.ts | 432 +++++++++++++ 8 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 open-sse/services/ccBridgeTransforms.ts create mode 100644 tests/unit/cc-bridge-transforms.test.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index e9750e2dc5..c25de9aa1c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -619,6 +619,15 @@ export class BaseExecutor { remapToolNamesInRequest(tb); obfuscateInBody(tb); + // NOTE (issue #2260): This is the native `claude` provider OAuth path. + // It is intentionally NOT routed through applyCcBridgeTransformPipeline. + // The native OAuth path already prepends its own billing line + sentinel + // (see lines ~744-773 below, dayStamp-based, cc_entrypoint=cli, cch=00000 + // placeholder, signed at body level). The CC bridge transforms DSL is + // wired into buildAndSignClaudeCodeRequest (claudeCodeCompatible.ts step 5b) + // which is the anthropic-compatible-cc-* relay path — a different, + // separately classified surface. Do not double-prepend here. + // Real CLI never sets cache_control on tools. if (Array.isArray(tb.tools)) { for (const t of tb.tools as Array>) { diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts new file mode 100644 index 0000000000..62a149da23 --- /dev/null +++ b/open-sse/services/ccBridgeTransforms.ts @@ -0,0 +1,577 @@ +/** + * CC Bridge Transforms — config-driven request body normalization for the + * Claude Code Compatible (`anthropic-compatible-cc-*`) bridge. + * + * Goal: ensure the final request body OmniRoute sends to Anthropic's + * `/v1/messages?beta=true` endpoint has classifier-correct structure + * regardless of which client (OpenCode, Cline, Cursor, Continue, raw API + * consumer) supplied the prompt. + * + * Approach: an ordered pipeline of declarative `TransformOp` entries that + * mutate the request body in place. Each op is idempotent; the executor is + * pure (no I/O); new defenses can be added through Settings UI by appending + * a new op — no new TypeScript needed. + * + * Reference implementation: ex-machina/opencode-anthropic-auth `transform.ts` + * and `cch.ts`. Ported with the same defaults (paragraph anchors, identity + * prefixes, text replacements, billing header algorithm) but generalised + * behind a discriminated-union DSL so future fingerprints are configurable. + * + * Related: OmniRoute issue #2260. + */ +import { createHash } from "node:crypto"; + +// ──────────────────────────────────────────────────────────────────────────── +// DSL types +// ──────────────────────────────────────────────────────────────────────────── + +export type TransformOp = + | DropParagraphIfContainsOp + | DropParagraphIfStartsWithOp + | ReplaceTextOp + | ReplaceRegexOp + | DropBlockIfContainsOp + | PrependSystemBlockOp + | AppendSystemBlockOp + | InjectBillingHeaderOp; + +export interface DropParagraphIfContainsOp { + kind: "drop_paragraph_if_contains"; + needles: string[]; + caseSensitive?: boolean; +} + +export interface DropParagraphIfStartsWithOp { + kind: "drop_paragraph_if_starts_with"; + prefixes: string[]; + caseSensitive?: boolean; +} + +export interface ReplaceTextOp { + kind: "replace_text"; + match: string; + replacement: string; + allOccurrences?: boolean; +} + +export interface ReplaceRegexOp { + kind: "replace_regex"; + pattern: string; + flags?: string; + replacement: string; +} + +export interface DropBlockIfContainsOp { + kind: "drop_block_if_contains"; + needles: string[]; +} + +export interface PrependSystemBlockOp { + kind: "prepend_system_block"; + text: string; + /** Skip if any earlier block already starts with this prefix. */ + idempotencyKey?: string; +} + +export interface AppendSystemBlockOp { + kind: "append_system_block"; + text: string; + idempotencyKey?: string; +} + +export interface InjectBillingHeaderOp { + kind: "inject_billing_header"; + /** Anthropic billing entrypoint label (e.g. `sdk-cli`, `cli`). */ + entrypoint: string; + /** + * Version suffix algorithm: + * - ex-machina: sha256(SALT + chars-at-positions + version).slice(0,3) + * - omniroute-daystamp: sha256(YYYY-MM-DD + version).slice(0,3) + */ + versionFormat: "ex-machina" | "omniroute-daystamp"; + /** + * CCH attestation algorithm: + * - sha256-first-user: sha256(firstUserMessageText).slice(0,5) (ex-machina) + * - xxhash64-body: defer to body-level signRequestBody; emit "00000" here + * - static-zero: emit "00000" (relay endpoints don't validate) + */ + cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; + /** Override the embedded `cc_version=` value. Defaults to `2.1.137`. */ + version?: string; +} + +export interface CcBridgeTransformsConfig { + enabled: boolean; + pipeline: TransformOp[]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Ported constants (ex-machina/constants.ts) +// ──────────────────────────────────────────────────────────────────────────── + +/** Stable salt used by ex-machina/cch.ts for the version-suffix hash. */ +export const CCH_SALT = "59cf53e54c78"; +/** Character positions sampled from the first user message text. */ +export const CCH_POSITIONS = [4, 7, 20] as const; +/** Default `cc_version=` value embedded in the billing header. */ +export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.137"; +/** Identity sentinel prepended for Claude Agent SDK callers. */ +export const CLAUDE_AGENT_SDK_IDENTITY = + "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +/** Paragraph anchors from ex-machina (URLs identifying third-party agents). */ +export const DEFAULT_PARAGRAPH_REMOVAL_ANCHORS = [ + "github.com/anomalyco/opencode", + "opencode.ai/docs", + "github.com/cline/cline", + "github.com/getcursor/cursor", + "continue.dev", +]; +/** Identity paragraph prefixes that signal a third-party agent. */ +export const DEFAULT_IDENTITY_PREFIXES = ["You are OpenCode"]; +/** Text replacements (last entry is the v1.7.5 phrase-shape filter fix). */ +export const DEFAULT_TEXT_REPLACEMENTS: Array<{ match: string; replacement: string }> = [ + { match: "if OpenCode honestly", replacement: "if the assistant honestly" }, + { + match: "Here is some useful information about the environment you are running in:", + replacement: "Environment context you are running in:", + }, +]; + +/** + * Default pipeline shipped with the PR — matches the T4-200 fixture layout + * proven against the live OmniRoute deployment (call log + * f0c2fedb-b27a-4f1d-9ee6-0c88646a6d42). + * + * Layout after pipeline (system blocks): + * [0] x-anthropic-billing-header: cc_version=…; cc_entrypoint=sdk-cli; cch=… + * [1] You are a Claude agent, built on Anthropic's Claude Agent SDK. + * [2..] sanitized caller-supplied system blocks + */ +export const DEFAULT_CC_BRIDGE_PIPELINE: TransformOp[] = [ + // Sanitize caller-supplied system blocks first so dropped paragraphs do not + // accidentally contain a stale billing header from a previous pass. + { + kind: "drop_paragraph_if_contains", + needles: [...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: [...DEFAULT_IDENTITY_PREFIXES], + }, + ...DEFAULT_TEXT_REPLACEMENTS.map((r) => ({ + kind: "replace_text", + match: r.match, + replacement: r.replacement, + allOccurrences: true, + })), + // Then prepend the SDK identity (becomes block[1] after billing prepend). + { + kind: "prepend_system_block", + text: CLAUDE_AGENT_SDK_IDENTITY, + idempotencyKey: "claude-agent-sdk-identity", + }, + // Billing header always lands at block[0] — matches T4-200 fixture layout. + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, +]; + +export const DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG: CcBridgeTransformsConfig = { + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Billing header value +// ──────────────────────────────────────────────────────────────────────────── + +interface SystemBlock { + type: string; + text?: string; + [key: string]: unknown; +} + +interface MessageContentBlock { + type?: string; + text?: string; + [key: string]: unknown; +} + +interface Message { + role?: string; + content?: string | MessageContentBlock[]; + [key: string]: unknown; +} + +/** + * Pull the textual content of the first user message in the request. + * Returns "" when no user message has text content. + */ +export function extractFirstUserMessageText(messages: Message[]): string { + if (!Array.isArray(messages)) return ""; + + for (const msg of messages) { + if (msg?.role !== "user") continue; + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block?.type === "text" && typeof block.text === "string") { + return block.text; + } + } + } + } + return ""; +} + +function sha256Hex(input: string): string { + return createHash("sha256").update(input, "utf8").digest("hex"); +} + +function pickCharsAtPositions(text: string, positions: readonly number[]): string { + return positions.map((p) => (typeof text[p] === "string" ? text[p] : "\0")).join(""); +} + +/** + * Compute the `cc_version` suffix per the ex-machina algorithm. + * + * `sha256(CCH_SALT + chars-at-CCH_POSITIONS(firstUserMessage) + version).slice(0, 3)` + */ +export function computeExMachinaVersionSuffix(firstUserText: string, version: string): string { + const picks = pickCharsAtPositions(firstUserText, CCH_POSITIONS); + return sha256Hex(`${CCH_SALT}${picks}${version}`).slice(0, 3); +} + +/** + * Compute the `cc_version` suffix per the OmniRoute native-OAuth algorithm: + * sha256(YYYY-MM-DD + version).slice(0,3). Stable per UTC day. + */ +export function computeDaystampVersionSuffix(version: string, now: Date = new Date()): string { + const dayStamp = now.toISOString().slice(0, 10); + return sha256Hex(`${dayStamp}${version}`).slice(0, 3); +} + +/** + * Compute the `cch=` attestation value per ex-machina algorithm: + * sha256(firstUserMessage).slice(0,5). + */ +export function computeCchSha256FirstUser(firstUserText: string): string { + return sha256Hex(firstUserText).slice(0, 5); +} + +interface BuildBillingHeaderOptions { + entrypoint: string; + versionFormat: "ex-machina" | "omniroute-daystamp"; + cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; + version?: string; + now?: Date; +} + +/** + * Build the `x-anthropic-billing-header: …` string injected as system block[0]. + * + * `xxhash64-body` and `static-zero` both emit `cch=00000` here because the + * actual body-level CCH attestation is computed later by + * `claudeCodeCCH.signRequestBody()` and replaces a 00000 placeholder in the + * serialized JSON. ex-machina's `sha256-first-user` value lives in the + * header itself. + */ +export function buildBillingHeaderValue( + messages: Message[], + options: BuildBillingHeaderOptions +): string { + const version = options.version || DEFAULT_CLAUDE_CODE_VERSION; + const firstUserText = extractFirstUserMessageText(messages); + + const suffix = + options.versionFormat === "omniroute-daystamp" + ? computeDaystampVersionSuffix(version, options.now) + : computeExMachinaVersionSuffix(firstUserText, version); + + let cch: string; + switch (options.cchAlgo) { + case "sha256-first-user": + cch = computeCchSha256FirstUser(firstUserText); + break; + case "xxhash64-body": + case "static-zero": + default: + cch = "00000"; + break; + } + + return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=${options.entrypoint}; cch=${cch};`; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Body shape helpers +// ──────────────────────────────────────────────────────────────────────────── + +interface RequestBody { + system?: string | SystemBlock[]; + messages?: Message[]; + [key: string]: unknown; +} + +function normalizeSystemToBlocks(system: unknown): SystemBlock[] { + if (system === null || system === undefined) return []; + if (typeof system === "string") { + return system.length > 0 ? [{ type: "text", text: system }] : []; + } + if (Array.isArray(system)) { + return system + .filter((b): b is SystemBlock => !!b && typeof b === "object") + .map((b) => ({ ...b })); + } + if (typeof system === "object") { + const block = system as SystemBlock; + return block && typeof block.text === "string" ? [{ ...block }] : []; + } + return []; +} + +function isTextBlock(block: SystemBlock): block is SystemBlock & { text: string } { + return block.type === "text" && typeof block.text === "string"; +} + +function containsString(haystack: string, needle: string, caseSensitive: boolean): boolean { + if (caseSensitive) return haystack.includes(needle); + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +function startsWithString(haystack: string, prefix: string, caseSensitive: boolean): boolean { + if (caseSensitive) return haystack.startsWith(prefix); + return haystack.toLowerCase().startsWith(prefix.toLowerCase()); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Op executors +// ──────────────────────────────────────────────────────────────────────────── + +function applyDropParagraphIfContains( + blocks: SystemBlock[], + op: DropParagraphIfContainsOp +): SystemBlock[] { + const caseSensitive = op.caseSensitive !== false; + const needles = op.needles || []; + if (needles.length === 0) return blocks; + + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + const paragraphs = block.text.split(/\n\n+/); + const filtered = paragraphs.filter( + (p) => !needles.some((n) => containsString(p, n, caseSensitive)) + ); + return { ...block, text: filtered.join("\n\n") }; + }); +} + +function applyDropParagraphIfStartsWith( + blocks: SystemBlock[], + op: DropParagraphIfStartsWithOp +): SystemBlock[] { + const caseSensitive = op.caseSensitive !== false; + const prefixes = op.prefixes || []; + if (prefixes.length === 0) return blocks; + + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + const paragraphs = block.text.split(/\n\n+/); + const filtered = paragraphs.filter( + (p) => !prefixes.some((prefix) => startsWithString(p.trimStart(), prefix, caseSensitive)) + ); + return { ...block, text: filtered.join("\n\n") }; + }); +} + +function applyReplaceText(blocks: SystemBlock[], op: ReplaceTextOp): SystemBlock[] { + if (!op.match) return blocks; + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + if (!block.text.includes(op.match)) return block; + let next = block.text; + if (op.allOccurrences) { + next = next.split(op.match).join(op.replacement); + } else { + next = next.replace(op.match, op.replacement); + } + return { ...block, text: next }; + }); +} + +function applyReplaceRegex(blocks: SystemBlock[], op: ReplaceRegexOp): SystemBlock[] { + if (!op.pattern) return blocks; + let regex: RegExp; + try { + regex = new RegExp(op.pattern, op.flags ?? "u"); + } catch { + return blocks; + } + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + return { ...block, text: block.text.replace(regex, op.replacement) }; + }); +} + +function applyDropBlockIfContains(blocks: SystemBlock[], op: DropBlockIfContainsOp): SystemBlock[] { + const needles = op.needles || []; + if (needles.length === 0) return blocks; + return blocks.filter((block) => { + if (!isTextBlock(block)) return true; + return !needles.some((n) => block.text.includes(n)); + }); +} + +function applyPrependSystemBlock(blocks: SystemBlock[], op: PrependSystemBlockOp): SystemBlock[] { + if (!op.text) return blocks; + if (op.idempotencyKey || op.text) { + const firstText = blocks.find(isTextBlock)?.text ?? ""; + if (firstText === op.text || firstText.startsWith(op.text)) { + return blocks; + } + } + return [{ type: "text", text: op.text }, ...blocks]; +} + +function applyAppendSystemBlock(blocks: SystemBlock[], op: AppendSystemBlockOp): SystemBlock[] { + if (!op.text) return blocks; + const last = [...blocks].reverse().find(isTextBlock)?.text ?? ""; + if (last === op.text) return blocks; + return [...blocks, { type: "text", text: op.text }]; +} + +function applyInjectBillingHeader( + body: RequestBody, + blocks: SystemBlock[], + op: InjectBillingHeaderOp +): SystemBlock[] { + // No user message → no billing header (ex-machina parity, transform.ts:340). + const messages = Array.isArray(body.messages) ? body.messages : []; + const hasUser = messages.some((m) => m?.role === "user"); + if (!hasUser) return blocks; + + const headerValue = buildBillingHeaderValue(messages, { + entrypoint: op.entrypoint, + versionFormat: op.versionFormat, + cchAlgo: op.cchAlgo, + version: op.version, + }); + + // Idempotency: replace any existing billing header block (ex-machina + native + // OAuth path both rebuild on retry; see executors/base.ts issue #1712). + const headerPrefix = "x-anthropic-billing-header:"; + const filtered = blocks.filter((b) => !(isTextBlock(b) && b.text.startsWith(headerPrefix))); + return [{ type: "text", text: headerValue }, ...filtered]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Pipeline executor +// ──────────────────────────────────────────────────────────────────────────── + +export interface ApplyPipelineResult { + body: RequestBody; + appliedOpKinds: string[]; +} + +/** + * Run the configured transform pipeline against a request body. + * + * The body is mutated in place (its `system` field is replaced); returned for + * chaining. `appliedOpKinds` lists the ops that ran (omitting no-ops when + * config is disabled). When `config.enabled === false`, the body is returned + * unchanged and `appliedOpKinds` is empty. + */ +export function applyCcBridgeTransformPipeline( + body: RequestBody, + config: CcBridgeTransformsConfig = getCcBridgeTransformsConfig() +): ApplyPipelineResult { + if (!body || typeof body !== "object") { + return { body, appliedOpKinds: [] }; + } + if (!config.enabled || !Array.isArray(config.pipeline) || config.pipeline.length === 0) { + return { body, appliedOpKinds: [] }; + } + + let blocks = normalizeSystemToBlocks(body.system); + const appliedOpKinds: string[] = []; + + for (const op of config.pipeline) { + switch (op.kind) { + case "drop_paragraph_if_contains": + blocks = applyDropParagraphIfContains(blocks, op); + break; + case "drop_paragraph_if_starts_with": + blocks = applyDropParagraphIfStartsWith(blocks, op); + break; + case "replace_text": + blocks = applyReplaceText(blocks, op); + break; + case "replace_regex": + blocks = applyReplaceRegex(blocks, op); + break; + case "drop_block_if_contains": + blocks = applyDropBlockIfContains(blocks, op); + break; + case "prepend_system_block": + blocks = applyPrependSystemBlock(blocks, op); + break; + case "append_system_block": + blocks = applyAppendSystemBlock(blocks, op); + break; + case "inject_billing_header": + blocks = applyInjectBillingHeader(body, blocks, op); + break; + default: { + // Unknown op kind — skip silently to keep forward compatibility. + continue; + } + } + appliedOpKinds.push(op.kind); + } + + // Drop empty text blocks left behind by paragraph removal (matches + // ex-machina sanitizeSystemText trim semantics). + blocks = blocks.filter((b) => !isTextBlock(b) || b.text.length > 0); + + body.system = blocks; + return { body, appliedOpKinds }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Runtime singleton (mirrors cliFingerprints `_cliCompatProviders` pattern). +// ──────────────────────────────────────────────────────────────────────────── + +let _runtimeConfig: CcBridgeTransformsConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; + +/** + * Replace the active CC bridge transforms config. Called from + * `runtimeSettings.applyCcBridgeTransformsSection()` when the Settings UI + * saves a new pipeline. + */ +export function setCcBridgeTransformsConfig(config: CcBridgeTransformsConfig | null): void { + if (!config) { + _runtimeConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; + return; + } + _runtimeConfig = { + enabled: config.enabled !== false, + pipeline: Array.isArray(config.pipeline) ? config.pipeline : DEFAULT_CC_BRIDGE_PIPELINE, + }; +} + +/** + * Read the currently active config (defaults to DEFAULT_CC_BRIDGE_PIPELINE). + */ +export function getCcBridgeTransformsConfig(): CcBridgeTransformsConfig { + return _runtimeConfig; +} + +/** + * Reset to defaults — exposed for tests and the Settings UI "Reset" button. + */ +export function resetCcBridgeTransformsConfig(): void { + _runtimeConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c8439ef118..93a6cacb6c 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -12,6 +12,7 @@ import { enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; +import { applyCcBridgeTransformPipeline } from "./ccBridgeTransforms.ts"; /** * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept @@ -332,6 +333,11 @@ export async function buildAndSignClaudeCodeRequest( // Step 5: Cache control enforceCacheControlLimit(body); + // Step 5b: Config-driven CC bridge transforms (issue #2260) + // Normalizes system blocks to classifier-correct structure regardless of source + // client (OpenCode, Cline, Cursor, Continue, raw API). Idempotent on re-run. + applyCcBridgeTransformPipeline(body as Parameters[0]); + // Step 6: Obfuscation (optional, per-provider setting) if (enableObfuscation) { obfuscateInBody(body); @@ -362,6 +368,19 @@ export { disableThinkingIfToolChoiceForced, enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; +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 | null, diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 82f5110939..9630be847d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -10,6 +10,109 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; +// Mirror of DEFAULT_CC_BRIDGE_PIPELINE from open-sse/services/ccBridgeTransforms.ts. +// Kept client-side so the UI can render + reset to defaults without a server roundtrip. +// Server is the source of truth — if pipeline is empty/missing on PATCH, server falls back to its own defaults. +const DEFAULT_CC_BRIDGE_PIPELINE_CLIENT = [ + { + kind: "drop_paragraph_if_contains", + needles: [ + "github.com/anomalyco/opencode", + "opencode.ai/docs", + "github.com/cline/cline", + "github.com/getcursor/cursor", + "continue.dev", + ], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: ["You are OpenCode"], + }, + { + kind: "replace_text", + match: "if OpenCode honestly", + replacement: "if the assistant honestly", + }, + { + kind: "replace_text", + match: "Here is some useful information about the environment you are running in:", + replacement: "Environment context you are running in:", + }, + { + kind: "prepend_system_block", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + idempotencyKey: "claude-agent-sdk-identity", + }, + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, +]; + +const TRANSFORM_OP_KINDS = [ + "drop_paragraph_if_contains", + "drop_paragraph_if_starts_with", + "replace_text", + "replace_regex", + "drop_block_if_contains", + "prepend_system_block", + "append_system_block", + "inject_billing_header", +] as const; + +function summarizeTransformOp(op: any): string { + switch (op?.kind) { + case "drop_paragraph_if_contains": + return `drop paragraphs containing: ${(op.needles || []).slice(0, 3).join(", ")}${(op.needles || []).length > 3 ? "…" : ""}`; + case "drop_paragraph_if_starts_with": + return `drop paragraphs starting with: ${(op.prefixes || []).slice(0, 3).join(", ")}${(op.prefixes || []).length > 3 ? "…" : ""}`; + case "replace_text": + return `replace "${(op.match || "").slice(0, 40)}${(op.match || "").length > 40 ? "…" : ""}" → "${(op.replacement || "").slice(0, 40)}${(op.replacement || "").length > 40 ? "…" : ""}"`; + case "replace_regex": + return `regex /${op.pattern}/${op.flags || ""} → "${(op.replacement || "").slice(0, 40)}"`; + case "drop_block_if_contains": + return `drop blocks containing: ${(op.needles || []).slice(0, 3).join(", ")}`; + case "prepend_system_block": + return `prepend block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`; + case "append_system_block": + return `append block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`; + case "inject_billing_header": + return `inject billing header (entrypoint=${op.entrypoint}, version=${op.versionFormat}, cch=${op.cchAlgo})`; + default: + return JSON.stringify(op); + } +} + +function makeBlankOp(kind: string): any { + switch (kind) { + case "drop_paragraph_if_contains": + return { kind, needles: [""] }; + case "drop_paragraph_if_starts_with": + return { kind, prefixes: [""] }; + case "replace_text": + return { kind, match: "", replacement: "" }; + case "replace_regex": + return { kind, pattern: "", flags: "g", replacement: "" }; + case "drop_block_if_contains": + return { kind, needles: [""] }; + case "prepend_system_block": + return { kind, text: "" }; + case "append_system_block": + return { kind, text: "" }; + case "inject_billing_header": + return { + kind, + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }; + default: + return { kind }; + } +} + export default function RoutingTab() { const [settings, setSettings] = useState({ alwaysPreserveClientCache: "auto", @@ -17,7 +120,9 @@ export default function RoutingTab() { cliCompatProviders: [], autoRoutingEnabled: true, autoRoutingDefaultVariant: "lkgp", + ccBridgeTransforms: { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }, }); + const [addOpKind, setAddOpKind] = useState("drop_paragraph_if_contains"); const [loading, setLoading] = useState(true); const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" }); @@ -59,6 +164,43 @@ export default function RoutingTab() { ); const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]); + const ccBridgeTransforms = useMemo(() => { + const raw = settings.ccBridgeTransforms; + if (raw && typeof raw === "object" && Array.isArray(raw.pipeline)) { + return { enabled: raw.enabled !== false, pipeline: raw.pipeline }; + } + return { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }; + }, [settings.ccBridgeTransforms]); + + const updateCcBridgeTransforms = (next: { enabled: boolean; pipeline: any[] }) => { + updateSetting({ ccBridgeTransforms: next }); + }; + + const moveCcBridgeOp = (index: number, direction: -1 | 1) => { + const pipeline = [...ccBridgeTransforms.pipeline]; + const target = index + direction; + if (target < 0 || target >= pipeline.length) return; + [pipeline[index], pipeline[target]] = [pipeline[target], pipeline[index]]; + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const deleteCcBridgeOp = (index: number) => { + const pipeline = ccBridgeTransforms.pipeline.filter((_, i) => i !== index); + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const addCcBridgeOp = () => { + const pipeline = [...ccBridgeTransforms.pipeline, makeBlankOp(addOpKind)]; + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const resetCcBridgePipeline = () => { + updateCcBridgeTransforms({ + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT.map((op) => ({ ...op })), + }); + }; + const toggleCliCompatProvider = (providerId: string, enabled: boolean) => { const normalizedProviderId = normalizeCliCompatProviderId(providerId); const nextProviders = new Set(cliCompatProviders); @@ -331,6 +473,143 @@ export default function RoutingTab() { + +
+
+
+ +
+
+

{t("ccBridgeTransforms")}

+

{t("ccBridgeTransformsDesc")}

+

+ {t("ccBridgeTransformsEnabled", { count: ccBridgeTransforms.pipeline.length })} +

+
+
+
+ +
+
+ + {ccBridgeTransforms.pipeline.length === 0 ? ( +

{t("ccBridgeTransformsEmpty")}

+ ) : ( +
    + {ccBridgeTransforms.pipeline.map((op: any, index: number) => ( +
  1. + + {index + 1} + +
    +
    {op?.kind}
    +
    + {summarizeTransformOp(op)} +
    +
    +
    + + + +
    +
  2. + ))} +
+ )} + +
+ + + +
+ +

+ Pipeline applies in order at step 5b of the CC bridge request build (after cache-control, + before ZWJ obfuscation). All ops are idempotent on re-run. Op editing beyond + reorder/delete requires PATCH via /api/settings — full per-op form is coming in a + follow-up; for now, edit the JSON directly via the API to fine-tune individual ops. +

+
+
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7955339c41..e3568cbebf 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3386,6 +3386,15 @@ "disableFingerprintTitle": "Disable fingerprint for {provider}", "forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.", "forcedFingerprintBadge": "Required", + "ccBridgeTransforms": "CC Bridge System Transforms", + "ccBridgeTransformsDesc": "Config-driven pipeline that normalizes the system prompt sent to Anthropic via the CC bridge. Removes third-party client fingerprints (OpenCode, Cline, Cursor, Continue), prepends the SDK identity, and injects the billing header — so Anthropic's classifier sees a classifier-correct request regardless of which client sent it. Issue #2260.", + "ccBridgeTransformsEnabled": "{count} transform(s) active in pipeline", + "ccBridgeTransformsAddOp": "Add transform", + "ccBridgeTransformsReset": "Reset to defaults", + "ccBridgeTransformsEmpty": "Pipeline empty — requests pass through untouched.", + "ccBridgeTransformsMoveUp": "Move up", + "ccBridgeTransformsMoveDown": "Move down", + "ccBridgeTransformsDelete": "Delete", "routingStrategy": "Routing Strategy", "routingAdvancedGuideTitle": "Advanced routing guidance", "routingAdvancedGuideHint1": "Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience.", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index 9df6d5985f..d0f9fbf56f 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -12,7 +12,8 @@ export type RuntimeReloadSection = | "healthCheckLogs" | "thoughtSignature" | "modelsDevSync" - | "corsOrigins"; + | "corsOrigins" + | "ccBridgeTransforms"; export interface RuntimeReloadChange { section: RuntimeReloadSection; @@ -31,6 +32,7 @@ interface RuntimeSettingsSnapshot { modelsDevSyncEnabled: boolean; modelsDevSyncInterval: number | null; corsOrigins: string; + ccBridgeTransforms: unknown; } const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { @@ -45,6 +47,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { modelsDevSyncEnabled: false, modelsDevSyncInterval: null, corsOrigins: "", + ccBridgeTransforms: null, }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -180,6 +183,7 @@ export function buildRuntimeSettingsSnapshot( modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true, modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval), corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "", + ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"), }; } @@ -257,6 +261,24 @@ async function applyCorsOriginsSection(corsOrigins: string) { setRuntimeAllowedOrigins(corsOrigins); } +async function applyCcBridgeTransformsSection(ccBridgeTransforms: unknown) { + const { setCcBridgeTransformsConfig, resetCcBridgeTransformsConfig } = + await import("@omniroute/open-sse/services/ccBridgeTransforms.ts"); + + if ( + ccBridgeTransforms === null || + ccBridgeTransforms === undefined || + typeof ccBridgeTransforms !== "object" + ) { + resetCcBridgeTransformsConfig(); + return; + } + + setCcBridgeTransformsConfig( + ccBridgeTransforms as Parameters[0] + ); +} + async function applyModelsDevSyncSection( previousSnapshot: RuntimeSettingsSnapshot, currentSnapshot: RuntimeSettingsSnapshot, @@ -392,6 +414,14 @@ export async function applyRuntimeSettings( markChanged("corsOrigins"); } + if ( + force || + hasChanged(currentSnapshot.ccBridgeTransforms, previousSnapshot.ccBridgeTransforms) + ) { + await applyCcBridgeTransformsSection(currentSnapshot.ccBridgeTransforms); + markChanged("ccBridgeTransforms"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 7a5b1b3411..8ed8804eba 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -63,6 +63,63 @@ export const updateSettingsSchema = z.object({ wsAuth: z.boolean().optional(), // CLI Fingerprint compatibility (per-provider) cliCompatProviders: z.array(z.string().max(100)).optional(), + // CC bridge transforms (issue #2260): config-driven pipeline that normalizes + // system blocks at the Claude Code bridge so any client (OpenCode, Cline, + // Cursor, Continue, raw API) ends up with classifier-correct structure. + ccBridgeTransforms: z + .object({ + enabled: z.boolean(), + pipeline: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("drop_paragraph_if_contains"), + needles: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("drop_paragraph_if_starts_with"), + prefixes: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_text"), + match: z.string().min(1).max(500), + replacement: z.string().max(500), + allOccurrences: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_regex"), + pattern: z.string().min(1).max(500), + flags: z.string().max(10).optional(), + replacement: z.string().max(500), + }), + z.object({ + kind: z.literal("drop_block_if_contains"), + needles: z.array(z.string().max(500)).max(50), + }), + z.object({ + kind: z.literal("prepend_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("append_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("inject_billing_header"), + entrypoint: z.string().min(1).max(50), + versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), + cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), + version: z.string().max(50).optional(), + }), + ]) + ) + .max(50), + }) + .optional(), // Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4") stripModelPrefix: z.boolean().optional(), // Cache control preservation mode diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts new file mode 100644 index 0000000000..11666d0bc5 --- /dev/null +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -0,0 +1,432 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + DEFAULT_CC_BRIDGE_PIPELINE, + DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG, + DEFAULT_CLAUDE_CODE_VERSION, + CLAUDE_AGENT_SDK_IDENTITY, + DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, + DEFAULT_IDENTITY_PREFIXES, + DEFAULT_TEXT_REPLACEMENTS, + applyCcBridgeTransformPipeline, + buildBillingHeaderValue, + computeCchSha256FirstUser, + computeExMachinaVersionSuffix, + computeDaystampVersionSuffix, + extractFirstUserMessageText, + setCcBridgeTransformsConfig, + getCcBridgeTransformsConfig, + resetCcBridgeTransformsConfig, +} = await import("../../open-sse/services/ccBridgeTransforms.ts"); + +type TransformOp = Parameters[1] extends infer C + ? C extends { pipeline: infer P } + ? P extends Array + ? T + : never + : never + : never; + +function bodyWithSystem(systemBlocks: Array<{ type: string; text: string }>, userText = "hi") { + return { + model: "claude-opus-4-7", + messages: [{ role: "user", content: [{ type: "text", text: userText }] }], + system: systemBlocks, + }; +} + +function runPipeline(body: any, ops: any[]) { + return applyCcBridgeTransformPipeline(body, { enabled: true, pipeline: ops }); +} + +test.beforeEach(() => { + resetCcBridgeTransformsConfig(); +}); + +// ── Defaults sanity ───────────────────────────────────────────────────────── + +test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1] in output", () => { + const result = runPipeline( + bodyWithSystem([{ type: "text", text: "body" }], "user prompt"), + DEFAULT_CC_BRIDGE_PIPELINE + ); + const blocks = result.body.system as any[]; + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header:")); + assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); +}); + +test("DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG is enabled", () => { + assert.equal(DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG.enabled, true); +}); + +test("DEFAULT_PARAGRAPH_REMOVAL_ANCHORS includes ex-machina v1.7.5 anchors", () => { + assert.ok(DEFAULT_PARAGRAPH_REMOVAL_ANCHORS.includes("github.com/anomalyco/opencode")); + assert.ok(DEFAULT_PARAGRAPH_REMOVAL_ANCHORS.includes("opencode.ai/docs")); +}); + +test("DEFAULT_IDENTITY_PREFIXES strips OpenCode identity", () => { + assert.ok(DEFAULT_IDENTITY_PREFIXES.includes("You are OpenCode")); +}); + +test("DEFAULT_TEXT_REPLACEMENTS includes v1.7.5 phrase fix", () => { + const phrase = "Here is some useful information about the environment you are running in:"; + const rule = DEFAULT_TEXT_REPLACEMENTS.find((r) => r.match === phrase); + assert.ok(rule, "expected phrase replacement"); + assert.equal(rule!.replacement, "Environment context you are running in:"); +}); + +// ── Op: drop_paragraph_if_contains ───────────────────────────────────────── + +test("drop_paragraph_if_contains removes matching paragraphs only", () => { + const text = [ + "Intro paragraph here.", + "See github.com/anomalyco/opencode for details.", + "Final paragraph survives.", + ].join("\n\n"); + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(out.includes("Intro paragraph")); + assert.ok(out.includes("Final paragraph")); + assert.ok(!out.includes("anomalyco")); +}); + +test("drop_paragraph_if_contains with empty needles is a no-op", () => { + const text = "Stays put."; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_contains", needles: [] }, + ]); + assert.equal((result.body.system as any[])[0].text, text); +}); + +// ── Op: drop_paragraph_if_starts_with ────────────────────────────────────── + +test("drop_paragraph_if_starts_with drops identity-prefixed paragraphs", () => { + const text = ["You are OpenCode, a helper.", "Real content."].join("\n\n"); + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_starts_with", prefixes: ["You are OpenCode"] }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(!out.includes("You are OpenCode")); + assert.ok(out.includes("Real content")); +}); + +// ── Op: replace_text ─────────────────────────────────────────────────────── + +test("replace_text replaces v1.7.5 phrase exactly once by default", () => { + const phrase = "Here is some useful information about the environment you are running in:"; + const text = `Prefix. ${phrase} body.`; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { + kind: "replace_text", + match: phrase, + replacement: "Environment context you are running in:", + }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(out.includes("Environment context you are running in:")); + assert.ok(!out.includes("Here is some useful information")); +}); + +test("replace_text allOccurrences=true replaces every hit", () => { + const text = "X X X"; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_text", match: "X", replacement: "Y", allOccurrences: true }, + ]); + assert.equal((result.body.system as any[])[0].text, "Y Y Y"); +}); + +// ── Op: replace_regex ────────────────────────────────────────────────────── + +test("replace_regex handles flags and patterns", () => { + const text = "foo bar foo BAR"; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_regex", pattern: "foo", flags: "gi", replacement: "baz" }, + ]); + assert.equal((result.body.system as any[])[0].text, "baz bar baz BAR"); +}); + +test("replace_regex invalid pattern is a no-op", () => { + const text = "stays."; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_regex", pattern: "[invalid(", replacement: "X" }, + ]); + assert.equal((result.body.system as any[])[0].text, text); +}); + +// ── Op: drop_block_if_contains ───────────────────────────────────────────── + +test("drop_block_if_contains drops entire matching blocks", () => { + const body = bodyWithSystem([ + { type: "text", text: "block A keep" }, + { type: "text", text: "block B drop github.com/anomalyco/opencode" }, + { type: "text", text: "block C keep" }, + ]); + const result = runPipeline(body, [ + { kind: "drop_block_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[0].text, "block A keep"); + assert.equal(blocks[1].text, "block C keep"); +}); + +// ── Op: prepend_system_block ─────────────────────────────────────────────── + +test("prepend_system_block adds identity at position [0]", () => { + const result = runPipeline(bodyWithSystem([{ type: "text", text: "body" }]), [ + { kind: "prepend_system_block", text: CLAUDE_AGENT_SDK_IDENTITY }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, CLAUDE_AGENT_SDK_IDENTITY); + assert.equal(blocks[1].text, "body"); +}); + +test("prepend_system_block is idempotent when first block already matches", () => { + const body = bodyWithSystem([ + { type: "text", text: CLAUDE_AGENT_SDK_IDENTITY }, + { type: "text", text: "body" }, + ]); + const result = runPipeline(body, [ + { kind: "prepend_system_block", text: CLAUDE_AGENT_SDK_IDENTITY }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[0].text, CLAUDE_AGENT_SDK_IDENTITY); +}); + +// ── Op: append_system_block ──────────────────────────────────────────────── + +test("append_system_block adds to end and stays idempotent", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }]); + const op = { kind: "append_system_block", text: "trailer" } as const; + let result = runPipeline(body, [op]); + result = runPipeline(result.body, [op]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[1].text, "trailer"); +}); + +// ── Op: inject_billing_header ────────────────────────────────────────────── + +test("inject_billing_header builds ex-machina sdk-cli header at [0]", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }], "user prompt here"); + const result = runPipeline(body, [ + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].type, "text"); + assert.match( + blocks[0].text, + /^x-anthropic-billing-header: cc_version=\d+\.\d+\.\d+\.[0-9a-f]{3}; cc_entrypoint=sdk-cli; cch=[0-9a-f]{5};$/ + ); +}); + +test("inject_billing_header is idempotent — replaces existing header in place", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }], "p1"); + const op = { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + } as const; + const first = runPipeline(body, [op]); + const second = runPipeline(first.body, [op]); + const blocks = second.body.system as any[]; + const headerCount = blocks.filter( + (b: any) => typeof b.text === "string" && b.text.startsWith("x-anthropic-billing-header:") + ).length; + assert.equal(headerCount, 1); +}); + +test("inject_billing_header skips when no user message present", () => { + const result = applyCcBridgeTransformPipeline( + { messages: [], system: [{ type: "text", text: "body" }] } as any, + { + enabled: true, + pipeline: [ + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, + ], + } + ); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, "body"); +}); + +test("inject_billing_header xxhash64-body uses 00000 placeholder", () => { + const result = runPipeline(bodyWithSystem([{ type: "text", text: "body" }], "user"), [ + { + kind: "inject_billing_header", + entrypoint: "cli", + versionFormat: "omniroute-daystamp", + cchAlgo: "xxhash64-body", + }, + ]); + const blocks = result.body.system as any[]; + assert.match(blocks[0].text, /cch=00000;$/); + assert.match(blocks[0].text, /cc_entrypoint=cli;/); +}); + +// ── Algorithm primitives ─────────────────────────────────────────────────── + +test("extractFirstUserMessageText handles string and block content", () => { + assert.equal(extractFirstUserMessageText([{ role: "user", content: "hello" }] as any), "hello"); + assert.equal( + extractFirstUserMessageText([ + { role: "system", content: "ignore" } as any, + { role: "user", content: [{ type: "text", text: "world" }] as any }, + ]), + "world" + ); + assert.equal(extractFirstUserMessageText([] as any), ""); +}); + +test("computeCchSha256FirstUser yields 5-hex digest", () => { + const hex = computeCchSha256FirstUser("hello"); + assert.match(hex, /^[0-9a-f]{5}$/); +}); + +test("computeExMachinaVersionSuffix yields 3-hex digest", () => { + const hex = computeExMachinaVersionSuffix( + "the quick brown fox jumps", + DEFAULT_CLAUDE_CODE_VERSION + ); + assert.match(hex, /^[0-9a-f]{3}$/); +}); + +test("computeDaystampVersionSuffix yields 3-hex digest", () => { + const hex = computeDaystampVersionSuffix( + DEFAULT_CLAUDE_CODE_VERSION, + new Date("2026-05-15T00:00:00Z") + ); + assert.match(hex, /^[0-9a-f]{3}$/); +}); + +test("buildBillingHeaderValue produces the expected ex-machina format", () => { + const value = buildBillingHeaderValue([{ role: "user", content: "hello" }] as any, { + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }); + assert.match( + value, + /^x-anthropic-billing-header: cc_version=2\.1\.137\.[0-9a-f]{3}; cc_entrypoint=sdk-cli; cch=[0-9a-f]{5};$/ + ); +}); + +// ── Disabled config short-circuits ───────────────────────────────────────── + +test("applyCcBridgeTransformPipeline does nothing when config.enabled=false", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }]); + const result = applyCcBridgeTransformPipeline(body, { + enabled: false, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, + }); + assert.equal(result.appliedOpKinds.length, 0); + assert.equal((result.body.system as any[])[0].text, "body"); +}); + +// ── Singleton config getters/setters ─────────────────────────────────────── + +test("setCcBridgeTransformsConfig swaps the runtime config; reset restores defaults", () => { + setCcBridgeTransformsConfig({ enabled: false, pipeline: [] }); + assert.equal(getCcBridgeTransformsConfig().enabled, false); + resetCcBridgeTransformsConfig(); + assert.equal(getCcBridgeTransformsConfig().enabled, true); +}); + +// ── Pipeline ordering ────────────────────────────────────────────────────── + +test("pipeline ordering is preserved — replace runs before drop", () => { + const body = bodyWithSystem([{ type: "text", text: "X" }]); + const result = runPipeline(body, [ + { kind: "replace_text", match: "X", replacement: "github.com/anomalyco/opencode" }, + { kind: "drop_paragraph_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 0); +}); + +// ── End-to-end: T4-200 fixture layout ────────────────────────────────────── + +test("DEFAULT_CC_BRIDGE_PIPELINE produces T4-200 fixture shape on verbatim OpenCode prompt", () => { + // Verbatim phrase from issue #2260 — the v1.7.5 trigger. + const fingerprintPhrase = + "Here is some useful information about the environment you are running in:"; + const openCodePrompt = [ + "You are OpenCode, a coding assistant.", + "See github.com/anomalyco/opencode for details.", + fingerprintPhrase, + "Working directory: /home/dev", + "If OpenCode honestly cannot find the answer, say so.", + ].join("\n\n"); + + const body = { + model: "claude-opus-4-7", + messages: [{ role: "user", content: [{ type: "text", text: "Say OK." }] }], + system: [ + { type: "text", text: openCodePrompt }, + { type: "text", text: "Memory protocol block." }, + { type: "text", text: "Browser MCP block." }, + ], + }; + + const result = applyCcBridgeTransformPipeline(body as any, { + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, + }); + const blocks = result.body.system as any[]; + + // Layout: [billing][identity][sanitized][memory][browser] + assert.equal(blocks.length, 5, "expected 5 system blocks"); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header:")); + assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); + + const sanitized = blocks[2].text as string; + assert.ok(!sanitized.includes("You are OpenCode"), "identity paragraph should be dropped"); + assert.ok( + !sanitized.includes("github.com/anomalyco/opencode"), + "anchor paragraph should be dropped" + ); + assert.ok(!sanitized.includes(fingerprintPhrase), "v1.7.5 phrase should be replaced"); + assert.ok( + sanitized.includes("Environment context you are running in:"), + "replacement phrase should be present" + ); + assert.ok( + sanitized.includes("Working directory: /home/dev"), + "non-fingerprint content preserved" + ); + + assert.equal(blocks[3].text, "Memory protocol block."); + assert.equal(blocks[4].text, "Browser MCP block."); + + // Pipeline reports every op ran. + assert.equal(result.appliedOpKinds.length, DEFAULT_CC_BRIDGE_PIPELINE.length); +}); + +// ── String system field normalization ────────────────────────────────────── + +test("string system field is normalized to a single text block before transforms", () => { + const body = { + model: "claude-opus-4-7", + messages: [{ role: "user", content: "hi" }], + system: "raw string system", + }; + const result = runPipeline(body as any, [{ kind: "prepend_system_block", text: "PREFIX" }]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, "PREFIX"); + assert.equal(blocks[1].text, "raw string system"); +}); From 634b4fe0ce0a64dd216f27abab4c094c60c240b0 Mon Sep 17 00:00:00 2001 From: Mourad Maatoug Date: Fri, 15 May 2026 17:36:21 +0200 Subject: [PATCH 02/13] feat(system-transforms): generic per-provider DSL closing OpenWebUI bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 of the CC bridge body transforms (issue #2260). Generalizes the single-provider `ccBridgeTransforms` config (commit e3e962db) into a per-provider registry keyed by OmniRoute provider id, and wires the native `claude` OAuth path into the same DSL so raw `claude/` requests no longer bypass the sanitization layer. Reference: comment 4459544580 reported a 429 on raw `claude/` from Open WebUI; CC-bridge-only v1 didn't help that path. v2 closes three gaps named in the comment: 1. `openwebui` / `open-webui` added to the default obfuscation word list (new `obfuscate_words` op kind, configurable). 2. Native `claude` provider path now runs the per-provider pipeline after its existing billing+sentinel prepend (executors/base.ts). Its default pipeline is cosmetic only (Open WebUI paragraph anchors + identity-prefix drop + ZWJ obfuscation); it deliberately omits `inject_billing_header` so it never collides with the native prepend. 3. Open WebUI paragraph anchors (github.com/open-webui/open-webui, openwebui.com, docs.openwebui.com) and identity prefix ("You are Open WebUI") added to both `claude` and CC bridge default pipelines. API surface: - New module: open-sse/services/systemTransforms.ts * `SystemTransformsConfig { providers: Record }` * `TransformOp` extends the base CC bridge op set with `obfuscate_words { words[], targets[] }`. * `applySystemTransformPipeline(providerId, body, config?)` routes to the right per-provider pipeline (with CC bridge prefix match so `anthropic-compatible-cc-*` all share one config). * `setSystemTransformsConfig` accepts both legacy single-provider shape (migrates into providers[anthropic-compatible-cc]) and the new per-provider shape (merges with defaults for unset providers). - Native claude wedge: executors/base.ts now calls `applySystemTransformPipeline(PROVIDER_CLAUDE, tb)` after the existing billing+sentinel prepend (line ~789). - CC bridge step 5b: claudeCodeCompatible.ts step 5b switched from `applyCcBridgeTransformPipeline` (single-config) to `applySystemTransformPipeline(PROVIDER_CC_BRIDGE, body)`. The underlying ccBridgeTransforms.ts module remains the base executor that systemTransforms.ts delegates to for the shared op kinds — no rename to keep the diff reviewable, and all 30 base-op tests stay green. - Settings: * Zod schema gains `systemTransforms.providers[*]` with full discriminated union over the 9 op kinds (including obfuscate_words). * Legacy `ccBridgeTransforms` zod field kept for back-compat read; runtime now feeds it through `setSystemTransformsConfig` migration shim so persisted Phase-2 data keeps working. v2 `systemTransforms` wins on conflict (applied last). * Runtime settings registry gains `systemTransforms` reload section next to legacy `ccBridgeTransforms`. - UI rewrite (RoutingTab.tsx): the two standalone cards (CLI Fingerprint + CC Bridge Transforms) collapse into one "Provider Upstream Compatibility" card. Per-provider tiles render the pipeline summary + JSON editor with client-side shape validation + Apply / Reset. Copy clarifies that no local Claude Code binary is required (the lock badge on the Claude tile means the fingerprint is force-applied for OAuth account safety, not that the user must install the CLI). Tests: - tests/unit/system-transforms.test.ts (22 new tests covering defaults, per-op semantics, ordering, per-provider routing, opt-in pass-through, Open WebUI fixture, migration shim, idempotency). - tests/unit/cc-bridge-transforms.test.ts (30 base-op tests unchanged, still green). - tests/unit/claude-code-compatible-{helpers,request}.test.ts and 8 related claude/cc/executor test files all green (140 tests). Closes #2260. --- open-sse/executors/base.ts | 10 + open-sse/services/claudeCodeCompatible.ts | 37 +- open-sse/services/systemTransforms.ts | 472 +++++++++++ .../settings/components/RoutingTab.tsx | 731 +++++++++++------- src/lib/config/runtimeSettings.ts | 45 +- src/shared/validation/settingsSchemas.ts | 70 ++ tests/unit/system-transforms.test.ts | 374 +++++++++ 7 files changed, 1430 insertions(+), 309 deletions(-) create mode 100644 open-sse/services/systemTransforms.ts create mode 100644 tests/unit/system-transforms.test.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index c25de9aa1c..721bfde73a 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -13,6 +13,7 @@ import { import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; +import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts"; import { randomUUID } from "node:crypto"; import { CLAUDE_CODE_VERSION, @@ -781,6 +782,15 @@ export class BaseExecutor { sysBlocks.unshift({ type: "text", text: billingLine }, { type: "text", text: SENTINEL }); tb.system = sysBlocks; + // Run the configurable system-transforms pipeline for the native + // `claude` provider (issue #2260 / comment 4459544580). The default + // claude pipeline runs cosmetic ops only (Open WebUI paragraph + // anchors, identity-prefix paragraph drop, ZWJ obfuscation of + // sensitive words). It deliberately does NOT include + // `inject_billing_header` — billing + sentinel are already + // prepended above. Users can extend the pipeline via Settings UI. + applySystemTransformPipeline(PROVIDER_CLAUDE, tb); + if (!tb.metadata || typeof tb.metadata !== "object") tb.metadata = {}; (tb.metadata as Record).user_id = buildUserIdJson({ deviceId, diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 93a6cacb6c..ae2d779973 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -12,7 +12,7 @@ import { enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; -import { applyCcBridgeTransformPipeline } from "./ccBridgeTransforms.ts"; +import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts"; /** * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept @@ -333,10 +333,16 @@ export async function buildAndSignClaudeCodeRequest( // Step 5: Cache control enforceCacheControlLimit(body); - // Step 5b: Config-driven CC bridge transforms (issue #2260) - // Normalizes system blocks to classifier-correct structure regardless of source - // client (OpenCode, Cline, Cursor, Continue, raw API). Idempotent on re-run. - applyCcBridgeTransformPipeline(body as Parameters[0]); + // 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. + applySystemTransformPipeline( + PROVIDER_CC_BRIDGE, + body as Parameters[1] + ); // Step 6: Obfuscation (optional, per-provider setting) if (enableObfuscation) { @@ -368,6 +374,27 @@ export { 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, diff --git a/open-sse/services/systemTransforms.ts b/open-sse/services/systemTransforms.ts new file mode 100644 index 0000000000..50009b4273 --- /dev/null +++ b/open-sse/services/systemTransforms.ts @@ -0,0 +1,472 @@ +/** + * System Transforms — generic, per-provider, config-driven body normalization. + * + * Generalises the CC-bridge-only transforms (`ccBridgeTransforms.ts`) into + * a per-provider registry so the same pipeline DSL can run against ANY + * provider's request body: native `claude` OAuth, `anthropic-compatible-cc-*` + * bridge, `gemini`, `codex`, `openai`, etc. + * + * Each provider has its own `{ enabled, pipeline }`. Defaults shipped: + * - `claude`: obfuscate_words ON (adds Open WebUI words on top of native + * ZWJ pass). Billing+sentinel handled by native code (executors/base.ts + * :753-782); DSL on this path does NOT inject billing. + * - `anthropic-compatible-cc-*`: full T4-200 pipeline (anchors + identity + * prefixes + replacements + prepend identity + inject_billing_header). + * - all other providers: `{ enabled: false, pipeline: [] }`. + * + * Adds a new op kind `obfuscate_words` (ZWJ insertion driven by configurable + * word list, replaces the hardcoded `claudeCodeObfuscation.ts` defaults for + * users who opt-in). + * + * Migration: legacy `ccBridgeTransforms` config (single-pipeline shape) is + * accepted and normalized into `systemTransforms.providers["anthropic-compatible-cc-*"]`. + * + * Reference: OmniRoute issue #2260 + comment 4459544580 (Open WebUI bypass). + */ +import { obfuscateSensitiveWords } from "./claudeCodeObfuscation.ts"; +import { + applyCcBridgeTransformPipeline, + CLAUDE_AGENT_SDK_IDENTITY, + DEFAULT_CC_BRIDGE_PIPELINE, + DEFAULT_IDENTITY_PREFIXES, + DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, + DEFAULT_TEXT_REPLACEMENTS, +} from "./ccBridgeTransforms.ts"; +import type { + CcBridgeTransformsConfig, + TransformOp as BaseTransformOp, +} from "./ccBridgeTransforms.ts"; + +// Re-export base DSL types so external callers depend only on systemTransforms. +export { + CLAUDE_AGENT_SDK_IDENTITY, + DEFAULT_CC_BRIDGE_PIPELINE, + DEFAULT_IDENTITY_PREFIXES, + DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, + DEFAULT_TEXT_REPLACEMENTS, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// DSL — extends base TransformOp with the new `obfuscate_words` op kind. +// ──────────────────────────────────────────────────────────────────────────── + +export interface ObfuscateWordsOp { + kind: "obfuscate_words"; + /** Words to obfuscate via zero-width joiner insertion. Case-insensitive. */ + words: string[]; + /** Where to apply obfuscation. Defaults to ["system", "messages", "tools"]. */ + targets?: Array<"system" | "messages" | "tools">; +} + +export type TransformOp = BaseTransformOp | ObfuscateWordsOp; + +export interface ProviderTransformsConfig { + enabled: boolean; + pipeline: TransformOp[]; +} + +export interface SystemTransformsConfig { + providers: Record; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Default word lists (legacy hardcoded + OpenWebUI additions). +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Default obfuscation word list — current hardcoded set from + * `claudeCodeObfuscation.ts` PLUS Open WebUI additions per issue #2260 + * comment 4459544580. + */ +export const DEFAULT_OBFUSCATE_WORDS = [ + // legacy hardcoded set + "opencode", + "open-code", + "cline", + "roo-cline", + "roo_cline", + "cursor", + "windsurf", + "aider", + "continue.dev", + "copilot", + "avante", + "codecompanion", + // Open WebUI additions + "openwebui", + "open-webui", +]; + +/** + * Open WebUI paragraph anchors — URLs identifying Open WebUI deployments. + * Added on top of `DEFAULT_PARAGRAPH_REMOVAL_ANCHORS` for the CC bridge + * default pipeline. + */ +export const OPENWEBUI_PARAGRAPH_ANCHORS = [ + "github.com/open-webui/open-webui", + "openwebui.com", + "docs.openwebui.com", +]; + +/** Open WebUI identity paragraph prefixes. */ +export const OPENWEBUI_IDENTITY_PREFIXES = ["You are Open WebUI"]; + +// ──────────────────────────────────────────────────────────────────────────── +// Per-provider defaults. +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Provider key for the native Claude OAuth path (`executors/base.ts`). + * Billing+sentinel is already prepended by native code; DSL on this path + * runs cosmetic ops only (no `inject_billing_header`). + */ +export const PROVIDER_CLAUDE = "claude"; + +/** + * Provider key prefix for the Claude-Code-compatible bridge. + * `claudeCodeCompatible.ts` invokes the pipeline via + * `applyCcBridgeTransformPipeline` (kept for backward compatibility). + */ +export const PROVIDER_CC_BRIDGE = "anthropic-compatible-cc"; + +/** + * Default pipeline for the native `claude` provider path. + * + * Cosmetic ops only — native executor already injects billing+sentinel. + * Obfuscates sensitive words (Open WebUI etc.) and drops Open WebUI + * paragraph anchors. Does NOT inject billing header (would conflict + * with native code at executors/base.ts:753-782). + */ +export const DEFAULT_CLAUDE_PIPELINE: TransformOp[] = [ + // Open WebUI paragraph anchors (Gap 2 from comment 4459544580). + { + kind: "drop_paragraph_if_contains", + needles: [...OPENWEBUI_PARAGRAPH_ANCHORS], + }, + // Open WebUI identity prefixes. + { + kind: "drop_paragraph_if_starts_with", + prefixes: [...OPENWEBUI_IDENTITY_PREFIXES], + }, + // ZWJ obfuscation of sensitive words (closes Gap 1 + Gap 3). + { + kind: "obfuscate_words", + words: [...DEFAULT_OBFUSCATE_WORDS], + targets: ["system", "messages", "tools"], + }, +]; + +/** + * Default pipeline for the CC bridge provider. + * + * Wraps the existing `DEFAULT_CC_BRIDGE_PIPELINE` (T4-200 proven layout) + * and layers Open WebUI defenses on top: extra paragraph anchors + ZWJ + * obfuscation of Open WebUI words. + */ +export const DEFAULT_CC_BRIDGE_PROVIDER_PIPELINE: TransformOp[] = [ + // Extra Open WebUI anchors (the base pipeline only carries OpenCode/Cline/ + // Cursor/Continue anchors). + { + kind: "drop_paragraph_if_contains", + needles: [...OPENWEBUI_PARAGRAPH_ANCHORS], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: [...OPENWEBUI_IDENTITY_PREFIXES], + }, + // ZWJ obfuscate Open WebUI words across system+messages+tools. + { + kind: "obfuscate_words", + words: ["openwebui", "open-webui"], + targets: ["system", "messages", "tools"], + }, + // Base CC bridge pipeline (anchors + identity prefixes + replacements + + // prepend SDK identity + inject billing header). + ...DEFAULT_CC_BRIDGE_PIPELINE, +]; + +export const DEFAULT_SYSTEM_TRANSFORMS_CONFIG: SystemTransformsConfig = { + providers: { + [PROVIDER_CLAUDE]: { + enabled: true, + pipeline: DEFAULT_CLAUDE_PIPELINE, + }, + [PROVIDER_CC_BRIDGE]: { + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PROVIDER_PIPELINE, + }, + }, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Body shape helpers (mirrors ccBridgeTransforms.ts internals). +// ──────────────────────────────────────────────────────────────────────────── + +interface RequestBody { + system?: unknown; + messages?: unknown; + tools?: unknown; + [key: string]: unknown; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Op: obfuscate_words (the only op kind beyond the base set). +// ──────────────────────────────────────────────────────────────────────────── + +function setWordsForOp(words: string[]): void { + // Reuse the existing obfuscation engine — but it reads from its own module + // singleton. We swap the words for this op invocation; obfuscateSensitiveWords + // uses the module-level `sensitiveWords` array via setSensitiveWords. + // To keep this stateless across concurrent requests, we instead call + // obfuscateSensitiveWordsCustom (defined below) which takes the words list + // explicitly. + // — unused (intentional, see obfuscateWithList below). + void words; +} +void setWordsForOp; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const ZWJ = "\u200d"; + +function obfuscateWord(word: string): string { + if (word.length <= 1) return word; + return word[0] + ZWJ + word.slice(1); +} + +/** + * Stateless variant of `obfuscateSensitiveWords` — uses the supplied word + * list instead of the module-level singleton, so concurrent requests with + * different op configs do not race. + */ +function obfuscateWithList(text: string, words: string[]): string { + if (!text || words.length === 0) return text; + let result = text; + for (const word of words) { + if (!word) continue; + const regex = new RegExp(escapeRegex(word), "gi"); + result = result.replace(regex, (match) => obfuscateWord(match)); + } + return result; +} + +function applyObfuscateWords(body: RequestBody, op: ObfuscateWordsOp): void { + const words = op.words || []; + if (words.length === 0) return; + const targets = + op.targets && op.targets.length > 0 ? op.targets : ["system", "messages", "tools"]; + + if (targets.includes("system")) { + if (typeof body.system === "string") { + body.system = obfuscateWithList(body.system, words); + } else if (Array.isArray(body.system)) { + for (const block of body.system as Array>) { + if (typeof block.text === "string") { + block.text = obfuscateWithList(block.text, words); + } + } + } + } + + if (targets.includes("messages") && Array.isArray(body.messages)) { + for (const msg of body.messages as Array>) { + const content = msg.content; + if (typeof content === "string") { + msg.content = obfuscateWithList(content, words); + } else if (Array.isArray(content)) { + for (const block of content as Array>) { + if (typeof block.text === "string") { + block.text = obfuscateWithList(block.text, words); + } + } + } + } + } + + if (targets.includes("tools") && Array.isArray(body.tools)) { + for (const tool of body.tools as Array>) { + if (typeof tool.description === "string") { + tool.description = obfuscateWithList(tool.description, words); + } + const fn = tool.function as Record | undefined; + if (fn && typeof fn.description === "string") { + fn.description = obfuscateWithList(fn.description, words); + } + } + } +} + +// Silence unused-import warning on obfuscateSensitiveWords — re-export for +// callers that want the global-singleton variant. +export { obfuscateSensitiveWords }; + +// ──────────────────────────────────────────────────────────────────────────── +// Pipeline executor (delegates base ops to applyCcBridgeTransformPipeline). +// ──────────────────────────────────────────────────────────────────────────── + +export interface ApplyPipelineResult { + body: RequestBody; + appliedOpKinds: string[]; +} + +function isObfuscateWordsOp(op: TransformOp): op is ObfuscateWordsOp { + return op.kind === "obfuscate_words"; +} + +/** + * Apply a pipeline of generic transforms to a request body. + * + * Strategy: + * 1. Split the pipeline into runs of `obfuscate_words` ops vs base ops. + * 2. Base-op runs delegate to `applyCcBridgeTransformPipeline` (preserves + * the well-tested base executor, including ordering semantics). + * 3. `obfuscate_words` ops mutate the body in place between base-op runs. + * + * This preserves declared order (e.g. drop paragraphs first → obfuscate + * what survives) while reusing the existing executor. + */ +export function applyTransformPipeline( + body: RequestBody, + pipeline: TransformOp[] +): ApplyPipelineResult { + if (!body || typeof body !== "object") return { body, appliedOpKinds: [] }; + if (!Array.isArray(pipeline) || pipeline.length === 0) { + return { body, appliedOpKinds: [] }; + } + + const appliedOpKinds: string[] = []; + let baseRun: BaseTransformOp[] = []; + + const flushBaseRun = () => { + if (baseRun.length === 0) return; + const config: CcBridgeTransformsConfig = { enabled: true, pipeline: baseRun }; + const result = applyCcBridgeTransformPipeline(body, config); + appliedOpKinds.push(...result.appliedOpKinds); + baseRun = []; + }; + + for (const op of pipeline) { + if (isObfuscateWordsOp(op)) { + flushBaseRun(); + applyObfuscateWords(body, op); + appliedOpKinds.push(op.kind); + } else { + baseRun.push(op); + } + } + flushBaseRun(); + + return { body, appliedOpKinds }; +} + +/** + * Apply the configured per-provider pipeline to `body`. No-op when the + * provider is unconfigured or disabled. + * + * `providerId` matches OmniRoute's provider key (`claude`, + * `anthropic-compatible-cc-…`, `gemini`, etc.). For CC bridge providers, + * the bridge-prefix match falls back to the `PROVIDER_CC_BRIDGE` key so + * a single config entry covers every cc/* variant. + */ +export function applySystemTransformPipeline( + providerId: string, + body: RequestBody, + config: SystemTransformsConfig = getSystemTransformsConfig() +): ApplyPipelineResult { + if (!body || typeof body !== "object") return { body, appliedOpKinds: [] }; + if (!config || !config.providers) return { body, appliedOpKinds: [] }; + + const providerConfig = resolveProviderConfig(providerId, config); + if (!providerConfig || !providerConfig.enabled) { + return { body, appliedOpKinds: [] }; + } + + return applyTransformPipeline(body, providerConfig.pipeline); +} + +function resolveProviderConfig( + providerId: string, + config: SystemTransformsConfig +): ProviderTransformsConfig | undefined { + if (!providerId) return undefined; + const exact = config.providers[providerId]; + if (exact) return exact; + + // CC bridge providers (anthropic-compatible-cc-*) all share one config. + if (providerId.startsWith(`${PROVIDER_CC_BRIDGE}-`) || providerId === PROVIDER_CC_BRIDGE) { + return config.providers[PROVIDER_CC_BRIDGE]; + } + return undefined; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Runtime singleton. +// ──────────────────────────────────────────────────────────────────────────── + +let _systemTransformsConfig: SystemTransformsConfig = DEFAULT_SYSTEM_TRANSFORMS_CONFIG; + +/** + * Replace the active system-transforms config. Called from + * `runtimeSettings.applySystemTransformsSection()` on Settings UI save. + * + * Accepts a legacy `CcBridgeTransformsConfig` shape (single-provider) and + * migrates it into `providers[PROVIDER_CC_BRIDGE]`. + */ +export function setSystemTransformsConfig(input: unknown): void { + if (!input || typeof input !== "object") { + _systemTransformsConfig = DEFAULT_SYSTEM_TRANSFORMS_CONFIG; + return; + } + const candidate = input as Record; + + // Legacy shape: { enabled, pipeline } → migrate to per-provider map. + if ("pipeline" in candidate && Array.isArray(candidate.pipeline)) { + _systemTransformsConfig = { + providers: { + ...DEFAULT_SYSTEM_TRANSFORMS_CONFIG.providers, + [PROVIDER_CC_BRIDGE]: { + enabled: candidate.enabled !== false, + pipeline: candidate.pipeline as TransformOp[], + }, + }, + }; + return; + } + + // Per-provider shape: { providers: Record } + if ("providers" in candidate && candidate.providers && typeof candidate.providers === "object") { + const next: SystemTransformsConfig = { providers: {} }; + const providers = candidate.providers as Record; + for (const [providerId, providerEntry] of Object.entries(providers)) { + if (!providerEntry || typeof providerEntry !== "object") continue; + const entry = providerEntry as Record; + next.providers[providerId] = { + enabled: entry.enabled !== false, + pipeline: Array.isArray(entry.pipeline) ? (entry.pipeline as TransformOp[]) : [], + }; + } + // Merge defaults for any unset provider — keeps ZWJ on by default even + // when the user only configured one provider explicitly. + for (const [providerId, providerDefault] of Object.entries( + DEFAULT_SYSTEM_TRANSFORMS_CONFIG.providers + )) { + if (!next.providers[providerId]) { + next.providers[providerId] = providerDefault; + } + } + _systemTransformsConfig = next; + return; + } + + _systemTransformsConfig = DEFAULT_SYSTEM_TRANSFORMS_CONFIG; +} + +export function getSystemTransformsConfig(): SystemTransformsConfig { + return _systemTransformsConfig; +} + +export function resetSystemTransformsConfig(): void { + _systemTransformsConfig = DEFAULT_SYSTEM_TRANSFORMS_CONFIG; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 9630be847d..fb75173834 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -10,57 +10,136 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; -// Mirror of DEFAULT_CC_BRIDGE_PIPELINE from open-sse/services/ccBridgeTransforms.ts. -// Kept client-side so the UI can render + reset to defaults without a server roundtrip. -// Server is the source of truth — if pipeline is empty/missing on PATCH, server falls back to its own defaults. -const DEFAULT_CC_BRIDGE_PIPELINE_CLIENT = [ - { - kind: "drop_paragraph_if_contains", - needles: [ - "github.com/anomalyco/opencode", - "opencode.ai/docs", - "github.com/cline/cline", - "github.com/getcursor/cursor", - "continue.dev", - ], - }, - { - kind: "drop_paragraph_if_starts_with", - prefixes: ["You are OpenCode"], - }, - { - kind: "replace_text", - match: "if OpenCode honestly", - replacement: "if the assistant honestly", - }, - { - kind: "replace_text", - match: "Here is some useful information about the environment you are running in:", - replacement: "Environment context you are running in:", - }, - { - kind: "prepend_system_block", - text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", - idempotencyKey: "claude-agent-sdk-identity", - }, - { - kind: "inject_billing_header", - entrypoint: "sdk-cli", - versionFormat: "ex-machina", - cchAlgo: "sha256-first-user", - }, +// Provider keys (mirror of open-sse/services/systemTransforms.ts). +const PROVIDER_CLAUDE = "claude"; +const PROVIDER_CC_BRIDGE = "anthropic-compatible-cc"; + +const OPENWEBUI_PARAGRAPH_ANCHORS = [ + "github.com/open-webui/open-webui", + "openwebui.com", + "docs.openwebui.com", ]; -const TRANSFORM_OP_KINDS = [ - "drop_paragraph_if_contains", - "drop_paragraph_if_starts_with", - "replace_text", - "replace_regex", - "drop_block_if_contains", - "prepend_system_block", - "append_system_block", - "inject_billing_header", -] as const; +const DEFAULT_OBFUSCATE_WORDS = [ + "opencode", + "open-code", + "cline", + "roo-cline", + "roo_cline", + "cursor", + "windsurf", + "aider", + "continue.dev", + "copilot", + "avante", + "codecompanion", + "openwebui", + "open-webui", +]; + +// Mirror of DEFAULT_SYSTEM_TRANSFORMS_CONFIG from open-sse/services/systemTransforms.ts. +// Kept client-side so the UI can render + reset to defaults without a server roundtrip. +// Server remains the source of truth — UI just lets the user inspect, edit, and reset. +const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = { + providers: { + [PROVIDER_CLAUDE]: { + enabled: true, + pipeline: [ + { + kind: "drop_paragraph_if_contains", + needles: [...OPENWEBUI_PARAGRAPH_ANCHORS], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: ["You are Open WebUI"], + }, + { + kind: "obfuscate_words", + words: [...DEFAULT_OBFUSCATE_WORDS], + targets: ["system", "messages", "tools"], + }, + ], + }, + [PROVIDER_CC_BRIDGE]: { + enabled: true, + pipeline: [ + { + kind: "drop_paragraph_if_contains", + needles: [...OPENWEBUI_PARAGRAPH_ANCHORS], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: ["You are Open WebUI"], + }, + { + kind: "obfuscate_words", + words: ["openwebui", "open-webui"], + targets: ["system", "messages", "tools"], + }, + { + kind: "drop_paragraph_if_contains", + needles: [ + "github.com/anomalyco/opencode", + "opencode.ai/docs", + "github.com/cline/cline", + "github.com/getcursor/cursor", + "continue.dev", + ], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: ["You are OpenCode"], + }, + { + kind: "replace_text", + match: "if OpenCode honestly", + replacement: "if the assistant honestly", + allOccurrences: true, + }, + { + kind: "replace_text", + match: "Here is some useful information about the environment you are running in:", + replacement: "Environment context you are running in:", + allOccurrences: true, + }, + { + kind: "prepend_system_block", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + idempotencyKey: "claude-agent-sdk-identity", + }, + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, + ], + }, + }, +} as const; + +// Provider display metadata for the per-provider tiles. +// Mirrors the names from CLI_COMPAT_PROVIDER_DISPLAY where applicable so the +// header-fingerprint toggle row stays consistent with the rest of the card. +const PROVIDER_TILE_DISPLAY: Record< + string, + { name: string; description: string; icon: string; tone: string } +> = { + [PROVIDER_CLAUDE]: { + name: "Claude (OAuth)", + description: + "Native Claude provider — handles OAuth-issued tokens. Header fingerprint is force-applied for account safety; transforms are cosmetic-only (no billing/identity prepend — native code already does that).", + icon: "anthropic", + tone: "indigo", + }, + [PROVIDER_CC_BRIDGE]: { + name: "Claude-Code Bridge (anthropic-compatible-cc-*)", + description: + "Relay endpoints that accept Anthropic-shaped requests on behalf of API-key callers. The full transform pipeline ships here (paragraph anchors + identity prefixes + text replacements + SDK identity + billing header) — that's the T4-200 layout proven on the live OmniRoute deployment.", + icon: "hub", + tone: "purple", + }, +}; function summarizeTransformOp(op: any): string { switch (op?.kind) { @@ -80,37 +159,42 @@ function summarizeTransformOp(op: any): string { return `append block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`; case "inject_billing_header": return `inject billing header (entrypoint=${op.entrypoint}, version=${op.versionFormat}, cch=${op.cchAlgo})`; + case "obfuscate_words": + return `obfuscate ${(op.words || []).length} word(s) via ZWJ in ${(op.targets || ["system", "messages", "tools"]).join("+")}`; default: return JSON.stringify(op); } } -function makeBlankOp(kind: string): any { - switch (kind) { - case "drop_paragraph_if_contains": - return { kind, needles: [""] }; - case "drop_paragraph_if_starts_with": - return { kind, prefixes: [""] }; - case "replace_text": - return { kind, match: "", replacement: "" }; - case "replace_regex": - return { kind, pattern: "", flags: "g", replacement: "" }; - case "drop_block_if_contains": - return { kind, needles: [""] }; - case "prepend_system_block": - return { kind, text: "" }; - case "append_system_block": - return { kind, text: "" }; - case "inject_billing_header": - return { - kind, - entrypoint: "sdk-cli", - versionFormat: "ex-machina", - cchAlgo: "sha256-first-user", - }; - default: - return { kind }; +// Client-side validator — light shape check before we PATCH; the server +// re-validates with the full zod schema in settingsSchemas.ts. +function validateProviderTransformsConfig(value: unknown): string | null { + if (!value || typeof value !== "object") return "Config must be a JSON object"; + const cfg = value as { enabled?: unknown; pipeline?: unknown }; + if (typeof cfg.enabled !== "boolean") return "`enabled` must be true or false"; + if (!Array.isArray(cfg.pipeline)) return "`pipeline` must be an array of ops"; + if (cfg.pipeline.length > 50) return "Pipeline cannot exceed 50 ops"; + for (let i = 0; i < cfg.pipeline.length; i++) { + const op = cfg.pipeline[i] as { kind?: unknown }; + if (!op || typeof op !== "object" || typeof op.kind !== "string") { + return `Op #${i + 1}: missing or invalid \`kind\``; + } + const validKinds = [ + "drop_paragraph_if_contains", + "drop_paragraph_if_starts_with", + "replace_text", + "replace_regex", + "drop_block_if_contains", + "prepend_system_block", + "append_system_block", + "inject_billing_header", + "obfuscate_words", + ]; + if (!validKinds.includes(op.kind)) { + return `Op #${i + 1}: unknown kind "${op.kind}"`; + } } + return null; } export default function RoutingTab() { @@ -120,9 +204,14 @@ export default function RoutingTab() { cliCompatProviders: [], autoRoutingEnabled: true, autoRoutingDefaultVariant: "lkgp", - ccBridgeTransforms: { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }, + systemTransforms: DEFAULT_SYSTEM_TRANSFORMS_CLIENT, }); - const [addOpKind, setAddOpKind] = useState("drop_paragraph_if_contains"); + // Per-provider JSON draft + error state for the system-transforms editor. + // Map keyed by provider id; values track the textarea content + last + // validation error string (null when valid). Synced from settings via + // effect so server-side values flow into the editor. + const [jsonDrafts, setJsonDrafts] = useState>({}); + const [jsonErrors, setJsonErrors] = useState>({}); const [loading, setLoading] = useState(true); const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" }); @@ -164,40 +253,86 @@ export default function RoutingTab() { ); const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]); - const ccBridgeTransforms = useMemo(() => { - const raw = settings.ccBridgeTransforms; - if (raw && typeof raw === "object" && Array.isArray(raw.pipeline)) { - return { enabled: raw.enabled !== false, pipeline: raw.pipeline }; + // Normalize the server snapshot into a per-provider map. Legacy v1 + // `ccBridgeTransforms` payloads from Phase 2 are migrated client-side + // into providers[PROVIDER_CC_BRIDGE] so the editor never breaks. + const systemTransforms = useMemo(() => { + const raw = settings.systemTransforms; + if (raw && typeof raw === "object" && raw.providers && typeof raw.providers === "object") { + return raw as { providers: Record }; } - return { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }; - }, [settings.ccBridgeTransforms]); + // Legacy migration shim: { enabled, pipeline } → providers[CC_BRIDGE]. + const legacy = settings.ccBridgeTransforms; + if (legacy && typeof legacy === "object" && Array.isArray(legacy.pipeline)) { + return { + providers: { + ...DEFAULT_SYSTEM_TRANSFORMS_CLIENT.providers, + [PROVIDER_CC_BRIDGE]: { + enabled: legacy.enabled !== false, + pipeline: legacy.pipeline, + }, + }, + }; + } + return DEFAULT_SYSTEM_TRANSFORMS_CLIENT; + }, [settings.systemTransforms, settings.ccBridgeTransforms]); - const updateCcBridgeTransforms = (next: { enabled: boolean; pipeline: any[] }) => { - updateSetting({ ccBridgeTransforms: next }); + // Sync JSON drafts from settings whenever the server snapshot changes. + useEffect(() => { + const nextDrafts: Record = {}; + for (const [providerId, providerCfg] of Object.entries(systemTransforms.providers)) { + nextDrafts[providerId] = JSON.stringify(providerCfg, null, 2); + } + setJsonDrafts(nextDrafts); + setJsonErrors({}); + }, [systemTransforms]); + + const updateProviderTransforms = ( + providerId: string, + next: { enabled: boolean; pipeline: any[] } + ) => { + const merged = { + providers: { + ...systemTransforms.providers, + [providerId]: next, + }, + }; + updateSetting({ systemTransforms: merged }); }; - const moveCcBridgeOp = (index: number, direction: -1 | 1) => { - const pipeline = [...ccBridgeTransforms.pipeline]; - const target = index + direction; - if (target < 0 || target >= pipeline.length) return; - [pipeline[index], pipeline[target]] = [pipeline[target], pipeline[index]]; - updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + const toggleProviderEnabled = (providerId: string, enabled: boolean) => { + const current = systemTransforms.providers[providerId] ?? { enabled: false, pipeline: [] }; + updateProviderTransforms(providerId, { enabled, pipeline: current.pipeline }); }; - const deleteCcBridgeOp = (index: number) => { - const pipeline = ccBridgeTransforms.pipeline.filter((_, i) => i !== index); - updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + const applyProviderJson = (providerId: string) => { + const raw = jsonDrafts[providerId] ?? ""; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + setJsonErrors((prev) => ({ + ...prev, + [providerId]: `Invalid JSON: ${(err as Error).message}`, + })); + return; + } + const validationError = validateProviderTransformsConfig(parsed); + if (validationError) { + setJsonErrors((prev) => ({ ...prev, [providerId]: validationError })); + return; + } + setJsonErrors((prev) => ({ ...prev, [providerId]: null })); + updateProviderTransforms(providerId, parsed as { enabled: boolean; pipeline: any[] }); }; - const addCcBridgeOp = () => { - const pipeline = [...ccBridgeTransforms.pipeline, makeBlankOp(addOpKind)]; - updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); - }; - - const resetCcBridgePipeline = () => { - updateCcBridgeTransforms({ - enabled: true, - pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT.map((op) => ({ ...op })), + const resetProviderTransforms = (providerId: string) => { + const def = (DEFAULT_SYSTEM_TRANSFORMS_CLIENT.providers as Record)[providerId]; + if (!def) return; + setJsonErrors((prev) => ({ ...prev, [providerId]: null })); + updateProviderTransforms(providerId, { + enabled: def.enabled, + pipeline: def.pipeline.map((op: any) => ({ ...op })), }); }; @@ -397,217 +532,225 @@ export default function RoutingTab() { -
-
+
+
-

{t("cliFingerprint")}

-

{t("cliFingerprintDesc")}

+

Provider Upstream Compatibility

+

+ Configure per-provider request normalization. The header-fingerprint row matches + upstream CLI binary signatures (reordering headers/body shapes) and the + system-transform pipeline rewrites system blocks so Anthropic-side classifiers (or + equivalent guards on other providers) see a classifier-correct request regardless of + which client sent it. Issue #2260 + comment 4459544580. +

- {t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })} + No local CLI binary is required — OmniRoute + builds the upstream-compatible request server-side. The "Managed" badge on + the Claude tile means the fingerprint is force-applied for OAuth account safety, not + that the user must install the Claude Code CLI.

-
- {CLI_COMPAT_TOGGLE_IDS.map((providerId) => { - const normalizedProviderId = normalizeCliCompatProviderId(providerId); - const providerDisplay = CLI_COMPAT_PROVIDER_DISPLAY[providerId]; - // Claude OAuth force-applies the fingerprint regardless of this toggle - // (base.ts: shouldFingerprint), so render the tile as locked-on. - const forced = providerId === "claude"; - const checked = forced || cliCompatProviderSet.has(normalizedProviderId); - const label = providerDisplay?.name || providerId; - const description = providerDisplay?.description || providerId; - const titleText = forced - ? t("forcedFingerprintTitle", { provider: label }) - : checked - ? t("disableFingerprintTitle", { provider: label }) - : t("enableFingerprintTitle", { provider: label }); +
+

Header fingerprint (per provider)

+

+ {t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })} +

+
+ {CLI_COMPAT_TOGGLE_IDS.map((providerId) => { + const normalizedProviderId = normalizeCliCompatProviderId(providerId); + const providerDisplay = CLI_COMPAT_PROVIDER_DISPLAY[providerId]; + // Claude OAuth force-applies the fingerprint regardless of this toggle + // (base.ts: shouldFingerprint) — that's an account-safety constraint, + // not a local-binary requirement. + const forced = providerId === "claude"; + const checked = forced || cliCompatProviderSet.has(normalizedProviderId); + const label = providerDisplay?.name || providerId; + const description = providerDisplay?.description || providerId; + const titleText = forced + ? t("forcedFingerprintTitle", { provider: label }) + : checked + ? t("disableFingerprintTitle", { provider: label }) + : t("enableFingerprintTitle", { provider: label }); - return ( - - ); - })} -
- - - -
-
-
- -
-
-

{t("ccBridgeTransforms")}

-

{t("ccBridgeTransformsDesc")}

-

- {t("ccBridgeTransformsEnabled", { count: ccBridgeTransforms.pipeline.length })} -

-
-
-
- + + + + {label} + + {forced ? ( + + Managed + + ) : null} + + {description} + + + ); + })}
- {ccBridgeTransforms.pipeline.length === 0 ? ( -

{t("ccBridgeTransformsEmpty")}

- ) : ( -
    - {ccBridgeTransforms.pipeline.map((op: any, index: number) => ( -
  1. - - {index + 1} - -
    -
    {op?.kind}
    -
    - {summarizeTransformOp(op)} +
    +

    System-block transform pipeline

    +

    + Per-provider DSL. Each provider has its own ordered pipeline of ops + (drop_paragraph_if_contains, replace_text, obfuscate_words, inject_billing_header, …). + Edit the JSON below and click Apply JSON — the server validates against the + full zod schema before persisting. Click Reset to restore the shipped defaults + for that provider. +

    + +
    + {Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => { + const display = PROVIDER_TILE_DISPLAY[providerId] ?? { + name: providerId, + description: + "Custom provider — pipeline only runs when an OmniRoute request targets this provider key.", + icon: "extension", + tone: "purple", + }; + const draft = jsonDrafts[providerId] ?? JSON.stringify(providerCfg, null, 2); + const errorMsg = jsonErrors[providerId] ?? null; + const opCount = Array.isArray(providerCfg.pipeline) ? providerCfg.pipeline.length : 0; + const hasDefault = Boolean( + (DEFAULT_SYSTEM_TRANSFORMS_CLIENT.providers as Record)[providerId] + ); + + return ( +
    +
    +
    +
    + + {providerId} + + {display.name} +
    +

    {display.description}

    +

    + {opCount} op{opCount === 1 ? "" : "s"} +

    +
    + +
    + + {opCount > 0 && ( +
      + {(providerCfg.pipeline as any[]).map((op, index) => ( +
    1. + + {index + 1} + +
      +
      {op?.kind}
      +
      + {summarizeTransformOp(op)} +
      +
      +
    2. + ))} +
    + )} + + +