OmniRoute/tests/unit/claude-classifier-compat.test.ts
Dmitry Galenko f3d302c67a
fix(reasoning): honor explicit opt-outs and Claude classifier defaults (#15533)
Onda A 2026-10-07. Combined board e1b721a1 validated this PR together with the rest of the wave, then this squash lands the contributor head unchanged.

- typecheck:core passed
- complexity 2993 (baseline 3218)
- cognitive complexity 1366 (baseline 1437)
- changelog integrity passed
- file-size passed after the authorized ceiling bump; that bump ships in the wave follow-up, not in this squash
- focused tests: 318 passed in the batch run. The Devin anthropic price assertion already mismatches CLAUDE_SONNET_5_PRICING on origin/release/v3.8.52, and this wave does not change that constant. The combo-metadata miss did not reproduce when that file ran alone.

fix(reasoning): honor explicit opt-outs and Claude classifier defaults

Thanks @D4rk4.

⚠️ base-red inherited: #15306
2026-10-07 14:46:39 -03:00

457 lines
17 KiB
TypeScript

/**
* TDD test for the Claude Code auto-mode classifier compat mode (opt-in, default off).
*
* Claude Code's `--permission-mode auto` sends an internal `/v1/messages` security-classifier
* request and requires the response to START with the literal token `<block>no</block>` (ALLOW)
* or `<block>yes</block>` (BLOCK) — anything else is unparseable and Claude Code fails closed
* with "Auto mode could not evaluate this action and is blocking it for safety".
*
* When a combo/fallback route sends the classifier call to a cheap model that returns 200 with
* empty content, the well-formed-but-empty Claude message OmniRoute produces still fails that
* parser. With `claudeClassifierCompat` set to "auto" (or "always"), handleChatCore detects the
* classifier request and short-circuits with a synthetic ALLOW response — WITHOUT ever calling
* the upstream provider. Default is "off": nothing changes unless an operator explicitly opts in.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude-classifier-compat-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
applyClaudeClassifierReasoningDefault,
} = await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const originalFetch = globalThis.fetch;
function noopLog() {
return { debug() {}, info() {}, warn() {}, error() {} };
}
async function invokeUpstreamClassifier({
headers = {},
resolvedThinkingEffort,
}: {
headers?: Record<string, string>;
resolvedThinkingEffort?: string;
} = {}) {
let outbound: Record<string, unknown> | null = null;
globalThis.fetch = (async (_url, init) => {
outbound = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(
JSON.stringify({
id: "chatcmpl-classifier",
object: "chat.completion",
model: "sonnet-classifier-test",
choices: [
{
index: 0,
message: { role: "assistant", content: "<block>yes</block>" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
const body = {
...structuredClone(CLASSIFIER_BODY),
model: "sonnet-classifier-test",
max_tokens: 2112,
};
try {
const result = await handleChatCore({
body,
modelInfo: {
provider: "openai-compatible-classifier-test",
model: "sonnet-classifier-test",
extendedContext: false,
resolvedThinkingEffort,
},
credentials: {
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://engine.example.test/v1",
apiType: "chat",
reasoningControl: "chat-template",
},
},
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(body),
headers: new Headers({ accept: "application/json", ...headers }),
},
userAgent: "unit-test",
});
await waitForCallLogSaves(5000);
return { outbound, result };
} finally {
globalThis.fetch = originalFetch;
}
}
// Shape of the classifier request Claude Code's `--permission-mode auto` sends internally:
// a Claude Messages request carrying the security-monitor system prompt AND `</block>` as a
// stop sequence — the two independent signals the compat detector relies on.
const CLASSIFIER_BODY = {
model: "claude-3-5-haiku-20241022",
stream: false,
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action and respond with <block>yes</block> or <block>no</block>.",
},
],
stop_sequences: ["</block>"],
messages: [
{
role: "user",
content: [{ type: "text", text: "<transcript>WebFetch https://example.com</transcript>" }],
},
],
max_tokens: 8,
};
// Newer Claude Code builds send a "severity classifier" variant of the same internal
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
const SEVERITY_CLASSIFIER_BODY = {
...CLASSIFIER_BODY,
stop_sequences: ["</severity>"],
};
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
// ─── Settings default is opt-in (off) ────────────────────────────────────────
// Runs FIRST, before any updateSettings() write, so it reads the pristine DB.
// (DATA_DIR freezes at the first DB open, so a later "fresh dir" swap would still
// read this same DB — hence assert the default up front.)
test("settings default: claudeClassifierCompat is 'off' (opt-in)", async () => {
const { getSettings } = await import("../../src/lib/db/settings.ts");
const settings = await getSettings();
assert.equal(settings.claudeClassifierCompat, "off", "claudeClassifierCompat defaults to off");
});
// ─── Pure detector: shouldDefaultAllowClassifier ─────────────────────────────
test("detector: off never short-circuits (pass-through preserved by default)", () => {
assert.equal(shouldDefaultAllowClassifier(FORMATS.CLAUDE, CLASSIFIER_BODY, "off"), false);
assert.equal(shouldDefaultAllowClassifier(FORMATS.CLAUDE, CLASSIFIER_BODY, undefined), false);
});
test("detector: auto fires on the security-monitor system-prompt marker", () => {
const body = {
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents." }],
stop_sequences: [],
};
assert.equal(shouldDefaultAllowClassifier(FORMATS.CLAUDE, body, "auto"), true);
});
test("detector: auto does NOT fire on the </block> stop_sequence token alone (#8189 — over-broad trigger fix)", () => {
const body = { system: [{ type: "text", text: "unrelated" }], stop_sequences: ["</block>"] };
assert.equal(
shouldDefaultAllowClassifier(FORMATS.CLAUDE, body, "auto"),
false,
"stop_sequences=['</block>'] alone must not short-circuit without the security-monitor marker"
);
});
test("detector: auto does NOT fire on a regular Claude request (no marker, no </block>)", () => {
const body = {
system: [{ type: "text", text: "You are a helpful coding assistant." }],
stop_sequences: [],
messages: [{ role: "user", content: "hello" }],
};
assert.equal(shouldDefaultAllowClassifier(FORMATS.CLAUDE, body, "auto"), false);
});
test("detector: never fires for non-Claude source formats even in always mode", () => {
assert.equal(shouldDefaultAllowClassifier(FORMATS.OPENAI, CLASSIFIER_BODY, "always"), false);
});
test("detector: always does NOT fire for normal chat without classifier marker (#9276)", () => {
const plain = { system: [{ type: "text", text: "hi" }], stop_sequences: [] };
assert.equal(
shouldDefaultAllowClassifier(FORMATS.CLAUDE, plain, "always"),
false,
"always must NOT short-circuit a normal chat (no security-monitor marker)"
);
});
test("detector: always fires when classifier marker is present", () => {
const classifier = {
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
},
],
stop_sequences: ["</block>"],
};
assert.equal(
shouldDefaultAllowClassifier(FORMATS.CLAUDE, classifier, "always"),
true,
"always must short-circuit when the classifier marker is present"
);
});
test("classifier requests without reasoning default to disabled native thinking on both stages", () => {
assert.equal(
typeof applyClaudeClassifierReasoningDefault,
"function",
"classifier reasoning default is not implemented"
);
for (const [maxTokens, stopSequences] of [
[2112, ["</block>"]],
[10240, undefined],
] as const) {
const body: Record<string, unknown> = {
...structuredClone(CLASSIFIER_BODY),
model: "sonnet-classifier-test",
max_tokens: maxTokens,
};
if (stopSequences === undefined) delete body.stop_sequences;
else body.stop_sequences = [...stopSequences];
const normalized = applyClaudeClassifierReasoningDefault!(FORMATS.CLAUDE, body);
assert.deepEqual(normalized?.thinking, { type: "disabled" });
assert.equal(normalized?.max_tokens, maxTokens);
assert.deepEqual(normalized?.stop_sequences, stopSequences);
assert.equal(body.thinking, undefined, "normalization must not mutate the client body");
}
});
test("ordinary requests and explicit classifier reasoning controls stay unchanged", () => {
assert.equal(
typeof applyClaudeClassifierReasoningDefault,
"function",
"classifier reasoning default is not implemented"
);
const ordinary = {
system: "You are a coding assistant.",
messages: [{ role: "user", content: "hi" }],
};
assert.equal(applyClaudeClassifierReasoningDefault!(FORMATS.CLAUDE, ordinary), ordinary);
assert.equal(
applyClaudeClassifierReasoningDefault!(FORMATS.OPENAI, CLASSIFIER_BODY),
CLASSIFIER_BODY
);
for (const explicit of [
{ thinking: { type: "enabled", budget_tokens: 1024 } },
{ thinking: { type: "disabled" } },
{ reasoning: { effort: "high" } },
{ reasoning_effort: "high" },
{ output_config: { effort: "high" } },
{ chat_template_kwargs: { enable_thinking: true, custom_flag: "kept" } },
{
_omnirouteReasoningRule: {
id: "force-high",
effortMode: "force",
targetEffort: "high",
},
},
]) {
const body = { ...structuredClone(CLASSIFIER_BODY), ...explicit };
assert.equal(applyClaudeClassifierReasoningDefault!(FORMATS.CLAUDE, body), body);
}
const explicitContexts = [
...["auto", "low", "medium", "high", "xhigh", "max", "off"].map((value) => [
`effort header ${value}`,
{ headers: new Headers({ "X-OmniRoute-Effort": value.toUpperCase() }) },
]),
...["adaptive", "off"].map((value) => [
`thinking header ${value}`,
{ headers: { "x-OMNIROUTE-thinking": value.toUpperCase() } },
]),
["resolved effort", { resolvedThinkingEffort: "high" }],
] as Array<
[string, { headers?: Headers | Record<string, string>; resolvedThinkingEffort?: string }]
>;
for (const [name, context] of explicitContexts) {
const body = structuredClone(CLASSIFIER_BODY);
assert.equal(applyClaudeClassifierReasoningDefault!(FORMATS.CLAUDE, body, context), body, name);
}
});
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
});
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
});
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
assert.equal(detectClassifierFormat({}), "block");
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
});
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
type: string;
role: string;
stop_reason: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
assert.equal(payload.role, "assistant");
assert.equal(payload.stop_reason, "end_turn");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.ok(
text.startsWith("<block>no</block>"),
`expected synthetic text to start with <block>no</block>, got: ${text}`
);
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
});
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
content: Array<{ type: string; text?: string }>;
};
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(text, "<severity>0</severity>");
});
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
test("handler: compat=off preserves upstream BLOCK while disabling native thinking", async () => {
await updateSettings({ claudeClassifierCompat: "off" });
const { outbound, result } = await invokeUpstreamClassifier();
assert.deepEqual(outbound?.chat_template_kwargs, {
thinking: false,
enable_thinking: false,
});
assert.equal(outbound?.reasoning_effort, undefined);
assert.equal(outbound?.max_tokens, 2112);
const payload = await (result as { response: Response }).response.json();
assert.equal(payload.content[0].text, "<block>yes</block>");
});
test("handler: explicit classifier effort header and resolved effort remain authoritative", async () => {
await updateSettings({ claudeClassifierCompat: "off" });
const header = await invokeUpstreamClassifier({
headers: { "x-omniroute-effort": "auto" },
});
assert.equal(header.outbound?.reasoning_effort, "low");
assert.equal(header.outbound?.chat_template_kwargs, undefined);
const resolved = await invokeUpstreamClassifier({ resolvedThinkingEffort: "high" });
assert.equal(resolved.outbound?.reasoning_effort, "high");
assert.equal(resolved.outbound?.chat_template_kwargs, undefined);
});
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.ok(
text.startsWith("<block>no</block>"),
`expected classifier response to start with <block>no</block>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(
text,
"<severity>0</severity>",
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});