mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
feat(cc-bridge): config-driven system block transforms (closes #2260)
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
This commit is contained in:
@@ -619,6 +619,15 @@ export class BaseExecutor {
|
|||||||
remapToolNamesInRequest(tb);
|
remapToolNamesInRequest(tb);
|
||||||
obfuscateInBody(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.
|
// Real CLI never sets cache_control on tools.
|
||||||
if (Array.isArray(tb.tools)) {
|
if (Array.isArray(tb.tools)) {
|
||||||
for (const t of tb.tools as Array<Record<string, unknown>>) {
|
for (const t of tb.tools as Array<Record<string, unknown>>) {
|
||||||
|
|||||||
577
open-sse/services/ccBridgeTransforms.ts
Normal file
577
open-sse/services/ccBridgeTransforms.ts
Normal file
@@ -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<ReplaceTextOp>((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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
enforceCacheControlLimit,
|
enforceCacheControlLimit,
|
||||||
} from "./claudeCodeConstraints.ts";
|
} from "./claudeCodeConstraints.ts";
|
||||||
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
|
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
|
||||||
|
import { applyCcBridgeTransformPipeline } from "./ccBridgeTransforms.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept
|
* `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept
|
||||||
@@ -332,6 +333,11 @@ export async function buildAndSignClaudeCodeRequest(
|
|||||||
// Step 5: Cache control
|
// Step 5: Cache control
|
||||||
enforceCacheControlLimit(body);
|
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<typeof applyCcBridgeTransformPipeline>[0]);
|
||||||
|
|
||||||
// Step 6: Obfuscation (optional, per-provider setting)
|
// Step 6: Obfuscation (optional, per-provider setting)
|
||||||
if (enableObfuscation) {
|
if (enableObfuscation) {
|
||||||
obfuscateInBody(body);
|
obfuscateInBody(body);
|
||||||
@@ -362,6 +368,19 @@ export {
|
|||||||
disableThinkingIfToolChoiceForced,
|
disableThinkingIfToolChoiceForced,
|
||||||
enforceCacheControlLimit,
|
enforceCacheControlLimit,
|
||||||
} from "./claudeCodeConstraints.ts";
|
} 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(
|
export function resolveClaudeCodeCompatibleEffort(
|
||||||
sourceBody?: Record<string, unknown> | null,
|
sourceBody?: Record<string, unknown> | null,
|
||||||
|
|||||||
@@ -10,6 +10,109 @@ import {
|
|||||||
normalizeCliCompatProviderId,
|
normalizeCliCompatProviderId,
|
||||||
} from "@/shared/constants/cliCompatProviders";
|
} 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() {
|
export default function RoutingTab() {
|
||||||
const [settings, setSettings] = useState<any>({
|
const [settings, setSettings] = useState<any>({
|
||||||
alwaysPreserveClientCache: "auto",
|
alwaysPreserveClientCache: "auto",
|
||||||
@@ -17,7 +120,9 @@ export default function RoutingTab() {
|
|||||||
cliCompatProviders: [],
|
cliCompatProviders: [],
|
||||||
autoRoutingEnabled: true,
|
autoRoutingEnabled: true,
|
||||||
autoRoutingDefaultVariant: "lkgp",
|
autoRoutingDefaultVariant: "lkgp",
|
||||||
|
ccBridgeTransforms: { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT },
|
||||||
});
|
});
|
||||||
|
const [addOpKind, setAddOpKind] = useState<string>("drop_paragraph_if_contains");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
||||||
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
|
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
|
||||||
@@ -59,6 +164,43 @@ export default function RoutingTab() {
|
|||||||
);
|
);
|
||||||
const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]);
|
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 toggleCliCompatProvider = (providerId: string, enabled: boolean) => {
|
||||||
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
|
const normalizedProviderId = normalizeCliCompatProviderId(providerId);
|
||||||
const nextProviders = new Set(cliCompatProviders);
|
const nextProviders = new Set(cliCompatProviders);
|
||||||
@@ -331,6 +473,143 @@ export default function RoutingTab() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 h-fit">
|
||||||
|
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||||
|
tune
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{t("ccBridgeTransforms")}</h3>
|
||||||
|
<p className="text-sm text-text-muted mt-1">{t("ccBridgeTransformsDesc")}</p>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{t("ccBridgeTransformsEnabled", { count: ccBridgeTransforms.pipeline.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-1">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={ccBridgeTransforms.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateCcBridgeTransforms({
|
||||||
|
enabled: e.target.checked,
|
||||||
|
pipeline: ccBridgeTransforms.pipeline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ccBridgeTransforms.pipeline.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted italic mb-3">{t("ccBridgeTransformsEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
<ol className="flex flex-col gap-2 mb-3">
|
||||||
|
{ccBridgeTransforms.pipeline.map((op: any, index: number) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 rounded-lg border border-border/50 bg-surface/30 p-3"
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-xs font-semibold text-purple-400">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-mono text-purple-300">{op?.kind}</div>
|
||||||
|
<div className="mt-1 text-xs text-text-muted break-words">
|
||||||
|
{summarizeTransformOp(op)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveCcBridgeOp(index, -1)}
|
||||||
|
disabled={loading || index === 0}
|
||||||
|
title={t("ccBridgeTransformsMoveUp")}
|
||||||
|
aria-label={t("ccBridgeTransformsMoveUp")}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||||
|
arrow_upward
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveCcBridgeOp(index, 1)}
|
||||||
|
disabled={loading || index === ccBridgeTransforms.pipeline.length - 1}
|
||||||
|
title={t("ccBridgeTransformsMoveDown")}
|
||||||
|
aria-label={t("ccBridgeTransformsMoveDown")}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||||
|
arrow_downward
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteCcBridgeOp(index)}
|
||||||
|
disabled={loading}
|
||||||
|
title={t("ccBridgeTransformsDelete")}
|
||||||
|
aria-label={t("ccBridgeTransformsDelete")}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||||
|
delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={addOpKind}
|
||||||
|
onChange={(e) => setAddOpKind(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-lg border border-border/50 bg-surface px-3 py-1.5 text-xs text-text"
|
||||||
|
>
|
||||||
|
{TRANSFORM_OP_KINDS.map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{kind}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
onClick={addCcBridgeOp}
|
||||||
|
disabled={loading}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="add"
|
||||||
|
>
|
||||||
|
{t("ccBridgeTransformsAddOp")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={resetCcBridgePipeline}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="restart_alt"
|
||||||
|
>
|
||||||
|
{t("ccBridgeTransformsReset")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-[11px] text-text-muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
||||||
|
|||||||
@@ -3386,6 +3386,15 @@
|
|||||||
"disableFingerprintTitle": "Disable fingerprint for {provider}",
|
"disableFingerprintTitle": "Disable fingerprint for {provider}",
|
||||||
"forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.",
|
"forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.",
|
||||||
"forcedFingerprintBadge": "Required",
|
"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",
|
"routingStrategy": "Routing Strategy",
|
||||||
"routingAdvancedGuideTitle": "Advanced routing guidance",
|
"routingAdvancedGuideTitle": "Advanced routing guidance",
|
||||||
"routingAdvancedGuideHint1": "Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience.",
|
"routingAdvancedGuideHint1": "Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience.",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export type RuntimeReloadSection =
|
|||||||
| "healthCheckLogs"
|
| "healthCheckLogs"
|
||||||
| "thoughtSignature"
|
| "thoughtSignature"
|
||||||
| "modelsDevSync"
|
| "modelsDevSync"
|
||||||
| "corsOrigins";
|
| "corsOrigins"
|
||||||
|
| "ccBridgeTransforms";
|
||||||
|
|
||||||
export interface RuntimeReloadChange {
|
export interface RuntimeReloadChange {
|
||||||
section: RuntimeReloadSection;
|
section: RuntimeReloadSection;
|
||||||
@@ -31,6 +32,7 @@ interface RuntimeSettingsSnapshot {
|
|||||||
modelsDevSyncEnabled: boolean;
|
modelsDevSyncEnabled: boolean;
|
||||||
modelsDevSyncInterval: number | null;
|
modelsDevSyncInterval: number | null;
|
||||||
corsOrigins: string;
|
corsOrigins: string;
|
||||||
|
ccBridgeTransforms: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
||||||
@@ -45,6 +47,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
|||||||
modelsDevSyncEnabled: false,
|
modelsDevSyncEnabled: false,
|
||||||
modelsDevSyncInterval: null,
|
modelsDevSyncInterval: null,
|
||||||
corsOrigins: "",
|
corsOrigins: "",
|
||||||
|
ccBridgeTransforms: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
|
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
|
||||||
@@ -180,6 +183,7 @@ export function buildRuntimeSettingsSnapshot(
|
|||||||
modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true,
|
modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true,
|
||||||
modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval),
|
modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval),
|
||||||
corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "",
|
corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "",
|
||||||
|
ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +261,24 @@ async function applyCorsOriginsSection(corsOrigins: string) {
|
|||||||
setRuntimeAllowedOrigins(corsOrigins);
|
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<typeof setCcBridgeTransformsConfig>[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function applyModelsDevSyncSection(
|
async function applyModelsDevSyncSection(
|
||||||
previousSnapshot: RuntimeSettingsSnapshot,
|
previousSnapshot: RuntimeSettingsSnapshot,
|
||||||
currentSnapshot: RuntimeSettingsSnapshot,
|
currentSnapshot: RuntimeSettingsSnapshot,
|
||||||
@@ -392,6 +414,14 @@ export async function applyRuntimeSettings(
|
|||||||
markChanged("corsOrigins");
|
markChanged("corsOrigins");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
force ||
|
||||||
|
hasChanged(currentSnapshot.ccBridgeTransforms, previousSnapshot.ccBridgeTransforms)
|
||||||
|
) {
|
||||||
|
await applyCcBridgeTransformsSection(currentSnapshot.ccBridgeTransforms);
|
||||||
|
markChanged("ccBridgeTransforms");
|
||||||
|
}
|
||||||
|
|
||||||
lastAppliedSnapshot = currentSnapshot;
|
lastAppliedSnapshot = currentSnapshot;
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,63 @@ export const updateSettingsSchema = z.object({
|
|||||||
wsAuth: z.boolean().optional(),
|
wsAuth: z.boolean().optional(),
|
||||||
// CLI Fingerprint compatibility (per-provider)
|
// CLI Fingerprint compatibility (per-provider)
|
||||||
cliCompatProviders: z.array(z.string().max(100)).optional(),
|
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")
|
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
|
||||||
stripModelPrefix: z.boolean().optional(),
|
stripModelPrefix: z.boolean().optional(),
|
||||||
// Cache control preservation mode
|
// Cache control preservation mode
|
||||||
|
|||||||
432
tests/unit/cc-bridge-transforms.test.ts
Normal file
432
tests/unit/cc-bridge-transforms.test.ts
Normal file
@@ -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<typeof applyCcBridgeTransformPipeline>[1] extends infer C
|
||||||
|
? C extends { pipeline: infer P }
|
||||||
|
? P extends Array<infer T>
|
||||||
|
? 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user