Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,14 @@ ALLOW_API_KEY_REVEAL=false
# Default: false
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false

# Set to false to stop OmniRoute adding the redact-thinking-2026-02-12 Anthropic beta
# on its own (thinking text is then returned instead of empty signature-only blocks).
# Requests with thinking.display "summarized" already skip it; client-negotiated or
# CC-compatible requestDefaults.redactThinking opt-ins keep it.
# Used by: open-sse/executors/base/redactThinkingBeta.ts
# Default: true
# OMNIROUTE_REDACT_THINKING=true

# Legacy resolveBareModelToConnectionDefault behaviour (#4825): replace ANY bare
# model name (not only "auto"/empty) with the selected connection's defaultModel.
# Off by default: a concrete bare name such as claude-haiku-4-5 is kept.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(claude):** Claude requests that ask for `thinking.display: "summarized"` (e.g. `@ai-sdk/anthropic` / OpenCode) now get readable thinking text. OmniRoute no longer adds the `redact-thinking-2026-02-12` beta on its own for those requests (OAuth shape-derived betas and the static API-key set), which made Anthropic return empty, signature-only thinking blocks. `OMNIROUTE_REDACT_THINKING=false` disables the OmniRoute-added redact beta entirely; client-negotiated redact betas and CC-compatible `requestDefaults.redactThinking` opt-ins are unchanged.
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
| `OMNIROUTE_REDACT_THINKING` | `true` | `open-sse/executors/base/redactThinkingBeta.ts` | Set `false` to stop OmniRoute adding the `redact-thinking-2026-02-12` Anthropic beta itself (empty signature-only thinking blocks). Requests with `thinking.display: "summarized"` skip it regardless; a client-negotiated redact beta or a CC-compatible `requestDefaults.redactThinking` opt-in keeps it. |
| `BARE_MODEL_DEFAULT_OVERRIDE_LEGACY` | `false` | `open-sse/services/model.ts` | Legacy `resolveBareModelToConnectionDefault` behaviour (#4825): replace any bare model name with the selected connection's `defaultModel`. Off by default, only `auto` or an empty model is replaced; logs one warning per pair when on. |
| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. |
| `DISABLE_CONTEXT_WINDOW_CHECKS` | `false` | `open-sse/handlers/chatCore.ts` | Dangerous opt-in that skips OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits; prompt compression and the model's own output-token cap remain active. Effective precedence is Feature Flags DB override > environment variable > default; no restart is required. |
Expand Down
5 changes: 5 additions & 0 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export type { ProviderCredentials, StrictValidationDispatch } from "./base/valid
import { applyReasoningEffortRecovery } from "./base/reasoningEffortRecovery.ts";
import { applyFieldDowngradeRecovery } from "./base/fieldDowngradeRecovery.ts";
import { applyAdvisorUndecryptableRecovery } from "./base/advisorRecovery.ts";
import { stripRedactThinkingBetaForVisibleThinking } from "./base/redactThinkingBeta.ts";

function parseSerializedBody(bodyString: string): unknown {
try {
Expand Down Expand Up @@ -1403,6 +1404,10 @@ export class BaseExecutor {
);
}

// Explicit `thinking.display: "summarized"` must not be blanked by redact-thinking.
stripRedactThinkingBetaForVisibleThinking(headers, transformedBody, clientHeaders, {
connectionOptedIn: ccRequestDefaults.redactThinking === true,
});
stripInternalBodyFields(transformedBody);
let bodyString = JSON.stringify(transformedBody);

Expand Down
85 changes: 85 additions & 0 deletions open-sse/executors/base/redactThinkingBeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Visible-thinking guard for the `redact-thinking-*` Anthropic beta.
//
// OmniRoute adds `redact-thinking-2026-02-12` for opaque Claude clients and for
// clients that negotiated `interleaved-thinking-2025-05-14` (selectBetaFlags),
// and the static API-key beta set includes it too. With that beta active,
// Anthropic returns thinking blocks with an empty `thinking` string (signature
// only), which silently overrides a client that explicitly asked for readable
// thinking via `thinking.display: "summarized"`.
//
// Rule: the redact beta is dropped when the request body asks for summarized
// thinking, or when the operator sets OMNIROUTE_REDACT_THINKING=false — unless
// the client itself negotiated a redact-thinking beta in its own anthropic-beta
// (or a CC-compatible connection explicitly opted in via requestDefaults).

export const REDACT_THINKING_BETA_PREFIX = "redact-thinking-";

type EnvLike = Record<string, string | undefined>;

function isRedactThinkingToken(token: string): boolean {
return token.trim().toLowerCase().startsWith(REDACT_THINKING_BETA_PREFIX);
}

export function isRedactThinkingDisabledByEnv(env: EnvLike = process.env): boolean {
const raw = env.OMNIROUTE_REDACT_THINKING;
if (typeof raw !== "string") return false;
return ["false", "0", "off", "no"].includes(raw.trim().toLowerCase());
}

export function requestsSummarizedThinking(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const thinking = (body as Record<string, unknown>).thinking;
if (!thinking || typeof thinking !== "object") return false;
const display = (thinking as Record<string, unknown>).display;
return typeof display === "string" && display.trim().toLowerCase() === "summarized";
}

export function clientNegotiatedRedactThinking(clientBeta: string | null | undefined): boolean {
if (typeof clientBeta !== "string") return false;
return clientBeta.split(",").some(isRedactThinkingToken);
}

/** True when OmniRoute must not add/keep a redact-thinking beta on its own. */
export function shouldSuppressRedactThinking(
body: unknown,
clientBeta?: string | null,
env: EnvLike = process.env
): boolean {
if (clientNegotiatedRedactThinking(clientBeta)) return false;
return requestsSummarizedThinking(body) || isRedactThinkingDisabledByEnv(env);
}

/**
* Remove `redact-thinking-*` tokens from the outbound anthropic-beta header
* (any casing) when shouldSuppressRedactThinking() holds and the connection did
* not explicitly opt into redact-thinking. Returns true when the header changed.
*/
export function stripRedactThinkingBetaForVisibleThinking(
headers: Record<string, string>,
body: unknown,
clientHeaders?: Record<string, string> | null,
options: { connectionOptedIn?: boolean; env?: EnvLike } = {}
): boolean {
if (options.connectionOptedIn === true) return false;
const env = options.env ?? process.env;
const clientBeta = clientHeaders
? (Object.entries(clientHeaders).find(([key]) => key.toLowerCase() === "anthropic-beta")?.[1] ??
null)
: null;
if (!shouldSuppressRedactThinking(body, clientBeta, env)) return false;

let changed = false;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() !== "anthropic-beta" || typeof headers[key] !== "string") continue;
const tokens = headers[key]
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const kept = tokens.filter((value) => !isRedactThinkingToken(value));
if (kept.length !== tokens.length) {
headers[key] = kept.join(",");
changed = true;
}
}
return changed;
}
12 changes: 7 additions & 5 deletions open-sse/executors/claudeIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import { createHash, randomBytes, randomUUID } from "node:crypto";

import { shouldSuppressRedactThinking } from "./base/redactThinkingBeta.ts";

import {
CLAUDE_CODE_CLIENT_VERSION,
CLAUDE_CODE_SDK_PACKAGE_VERSION,
Expand Down Expand Up @@ -439,11 +441,11 @@ export function selectBetaFlags(
// interleaved-thinking semantics that conflict with a tool_choice-forced turn,
// producing malformed opus tool_use streams when the client never asked for it.
if (allowThinking) {
flags.push(
"interleaved-thinking-2025-05-14",
"redact-thinking-2026-02-12",
"thinking-token-count-2026-05-13"
);
flags.push("interleaved-thinking-2025-05-14");
// A client that explicitly asks for summarized (readable) thinking must not
// have it blanked by a redact beta it never negotiated.
if (!shouldSuppressRedactThinking(b, clientBeta)) flags.push("redact-thinking-2026-02-12");
flags.push("thinking-token-count-2026-05-13");
}
flags.push("context-management-2025-06-27", "prompt-caching-scope-2026-01-05");
if (hasStructuredOutput || isFullAgent) flags.push("advisor-tool-2026-03-01");
Expand Down
107 changes: 107 additions & 0 deletions tests/unit/claude-5x-thinking-passthrough.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// End-to-end request shaping for Claude 5.x adaptive thinking through handleChatCore:
// an Anthropic-format client (e.g. @ai-sdk/anthropic / OpenCode) sending
// `thinking: {type:"adaptive", display:"summarized"}` must reach the upstream unchanged
// for 5.x models (Haiku 5.x included — it is adaptive-only), while Haiku 4.5 keeps the
// manual `enabled` + budget shape it requires.

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude5-thinking-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { isAdaptiveThinkingOnly } = await import("../../src/shared/constants/modelSpecs.ts");
const { normalizeClaudeAdaptiveThinking } =
await import("../../open-sse/services/claudeAdaptiveThinking.ts");
const originalFetch = globalThis.fetch;

test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

const noopLog = { debug() {}, info() {}, warn() {}, error() {} };
const SSE = [
"event: message_start",
'data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}',
"",
"event: message_stop",
'data: {"type":"message_stop"}',
"",
].join("\n");

async function upstreamThinking(
provider: string,
model: string,
credentials: Record<string, unknown>,
thinking: Record<string, unknown>
): Promise<Record<string, unknown> | undefined> {
let sent: Record<string, unknown> | undefined;
globalThis.fetch = (async (url: unknown, init: { body?: unknown } = {}) => {
if (String(url).includes("/v1/messages")) sent = JSON.parse(String(init.body ?? "{}"));
return new Response(SSE, { status: 200, headers: { "Content-Type": "text/event-stream" } });
}) as typeof globalThis.fetch;
const body = {
model,
max_tokens: 32000,
stream: true,
thinking,
system: [{ type: "text", text: "You are a coding agent." }],
tools: [{ name: "read", description: "read", input_schema: { type: "object" } }],
messages: [{ role: "user", content: "hi" }],
};
const headers = new Headers({
"anthropic-beta": "interleaved-thinking-2025-05-14",
"user-agent": "ai-sdk/anthropic",
});
const result = await handleChatCore({
body: structuredClone(body),
modelInfo: { provider, model, extendedContext: false },
credentials: { ...credentials, providerSpecificData: {} },
log: noopLog,
clientRawRequest: { endpoint: "/v1/messages", body: structuredClone(body), headers },
userAgent: "ai-sdk/anthropic",
} as never);
await (result as { response: Response }).response.text();
assert.ok(sent, "expected an upstream /v1/messages request");
return sent.thinking as Record<string, unknown> | undefined;
}

const ADAPTIVE = { type: "adaptive", display: "summarized" };
const OAUTH = { accessToken: "sk-ant-oat-test-token" };
const API_KEY = { apiKey: "sk-ant-api-test" };

for (const [provider, creds] of [
["claude", OAUTH],
["anthropic", API_KEY],
] as const) {
for (const model of ["claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"]) {
test(`${provider}/${model}: adaptive + display summarized passes through unchanged`, async () => {
assert.deepEqual(await upstreamThinking(provider, model, creds, ADAPTIVE), ADAPTIVE);
});
}

test(`${provider}/claude-haiku-4-5: manual enabled + budget is kept`, async () => {
const manual = { type: "enabled", budget_tokens: 2000, display: "summarized" };
assert.deepEqual(
await upstreamThinking(provider, "claude-haiku-4-5-20251001", creds, manual),
manual
);
});
}

test("Claude Haiku 5.x is adaptive-only: a manual budget collapses to adaptive", () => {
assert.equal(isAdaptiveThinkingOnly("claude-haiku-5-5"), true);
assert.equal(isAdaptiveThinkingOnly("claude-haiku-4-5-20251001"), false);
const out = normalizeClaudeAdaptiveThinking(
{ thinking: { type: "enabled", budget_tokens: 2000, display: "summarized" } },
"claude-haiku-5-5"
);
assert.deepEqual(out.thinking, { type: "adaptive", display: "summarized" });
});
Loading