fix(claude): honor native thinking summary opt-in (#16407) (#16772)

Merged via /merge-batch (2026-10-18). Native Claude OAuth requests now honor the connection's summarizeThinking opt-in for the thinking display (complements #17040, which stops redacting thinking when summarized display is requested).

Integration on top: the new claudeThinkingOptions.ts no longer imports @/lib/providers/requestDefaults — base.ts (which already imports it) resolves summarizeThinking and passes a boolean, so the open-sse → src boundary stays at 774/774. Evidence: 24 thinking/redact/context-management suites 428/428, open-sse typecheck 0, ESLint clean, cognitive complexity unchanged vs tip. Remaining CI reds are the inherited base-red #15306.
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-10-18 16:25:21 -03:00 • committed by GitHub
parent 4e7c7f0ec8
commit d3d86fc5e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 13 deletions

View file

@ -0,0 +1 @@
- Honor the configured thinking-summary display for native Claude OAuth requests while preserving explicit display settings and disabled thinking modes. (#16407)

View file

@ -96,6 +96,7 @@ import {
stripProxyToolPrefix,
} from "./claudeIdentity.ts";
import { withForcedResponsesUpstream } from "./forceResponsesUpstream.ts";
import { finalizeClaudeThinkingOptions } from "./claudeThinkingOptions.ts";
import { detachClaudeCodeRewriteTargets } from "./claudeOauthBodyIsolation.ts";
import {
mergeUpstreamExtraHeaders,
@ -228,11 +229,7 @@ export type CountTokensInput = {
signal?: AbortSignal | null;
};
import {
hasActiveClaudeThinking,
readNestedThinkingBudget,
clampNestedThinkingBudget,
} from "../utils/thinkingBudget.ts";
import { readNestedThinkingBudget, clampNestedThinkingBudget } from "../utils/thinkingBudget.ts";
/**
* Strip the OmniRoute provider prefix from tool model fields (e.g.
@ -1151,14 +1148,12 @@ export class BaseExecutor {
}
}
// Real CLI always pairs context_management with thinking. Mirror
// that invariant so long sessions don't accumulate thinking blocks
// toward the context cap.
if (hasActiveClaudeThinking(tb) && !tb.context_management) {
tb.context_management = {
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
};
}
finalizeClaudeThinkingOptions(
tb,
this.provider,
getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
.summarizeThinking === true
);
const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon";
const psd = activeCredentials?.providerSpecificData as

View file

@ -0,0 +1,20 @@
import { applyClaudeCodeCompatibleThinkingDisplay } from "../services/claudeCodeCompatibleThinkingDisplay.ts";
import { hasActiveClaudeThinking } from "../utils/thinkingBudget.ts";
/** Pair active thinking with context management, then apply the native display opt-in. */
export function finalizeClaudeThinkingOptions(
body: Record<string, unknown>,
provider: string,
summarizeThinking: boolean
): void {
if (!hasActiveClaudeThinking(body)) return;
// Preserve the existing native/CC pairing so long sessions retain thinking context.
if (!body.context_management) {
body.context_management = {
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
};
}
if (provider !== "claude") return;
const configuration = body.thinking as Record<string, unknown>;
body.thinking = applyClaudeCodeCompatibleThinkingDisplay(configuration, { summarizeThinking });
}

View file

@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
class NativeClaudeExecutor extends BaseExecutor {
constructor(provider = "claude") {
super(provider, { baseUrls: ["https://api.anthropic.com/v1/messages"] });
}
needsRefresh() {
return false;
}
async transformRequest(_model: string, body: Record<string, unknown>) {
return structuredClone(body);
}
}
async function capture(
thinking: Record<string, unknown> | undefined,
summarizeThinking?: boolean,
extra: Record<string, unknown> = {},
clientHeaders: Record<string, string> = {},
provider = "claude"
) {
const originalFetch = globalThis.fetch;
let sent: Record<string, unknown> | undefined;
globalThis.fetch = async (_url, init) => {
sent = JSON.parse(String(init?.body));
return Response.json({ content: [{ type: "text", text: "ok" }] });
};
try {
await new NativeClaudeExecutor(provider).execute({
// Manual budgets remain supported on 4.6; Sonnet 5 only accepts adaptive thinking.
model: thinking?.type === "enabled" ? "claude-sonnet-4-6" : "claude-sonnet-5",
body: { messages: [{ role: "user", content: "hello" }], thinking, ...extra },
stream: false,
credentials: {
accessToken: "sk-ant-oat-test-display-16407",
providerSpecificData: { requestDefaults: { summarizeThinking } },
},
clientHeaders,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(sent, "the final upstream payload was captured");
return sent;
}
for (const thinking of [{ type: "adaptive" }, { type: "enabled", budget_tokens: 2048 }]) {
test(`native Claude honors summary opt-in for ${thinking.type}`, async () => {
const sent = await capture(thinking, true);
assert.deepEqual(sent.thinking, { ...thinking, display: "summarized" });
});
}
for (const preference of [undefined, false]) {
test(`native Claude preserves absent display when summary preference is ${preference}`, async () => {
const sent = await capture({ type: "adaptive" }, preference);
assert.deepEqual(sent.thinking, { type: "adaptive" });
});
}
for (const display of ["omitted", "updates"]) {
test(`an explicit ${display} display wins over the connection default`, async () => {
const thinking = { type: "adaptive", display };
assert.deepEqual((await capture(thinking, true)).thinking, thinking);
});
}
for (const type of ["disabled", "between_tools"]) {
test(`${type} never receives an unsupported display field`, async () => {
const sent = await capture({ type }, true);
assert.deepEqual(sent.thinking, { type });
assert.equal(sent.context_management, undefined);
});
}
test("the opt-in does not enable thinking on a generic client", async () => {
assert.equal((await capture(undefined, true)).thinking, undefined);
});
test("an explicit off header still removes thinking", async () => {
const sent = await capture({ type: "adaptive" }, true, {}, { "x-omniroute-thinking": "off" });
assert.equal(sent.thinking, undefined);
});
test("forced tool use still removes thinking", async () => {
const sent = await capture({ type: "adaptive" }, true, {
tools: [{ name: "lookup", description: "Lookup", input_schema: { type: "object" } }],
tool_choice: { type: "tool", name: "lookup" },
});
assert.equal(sent.thinking, undefined);
});
test("another provider does not acquire the native display default", async () => {
const sent = await capture({ type: "adaptive" }, true, {}, {}, "openai");
assert.deepEqual(sent.thinking, { type: "adaptive" });
});
test("active thinking retains its existing context-management pairing without the opt-in", async () => {
const sent = await capture({ type: "adaptive" }, false);
assert.deepEqual(sent.context_management, {
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
});
});
test("explicit context-management policy survives the summary opt-in", async () => {
const contextManagement = { edits: [{ type: "clear_thinking_20251015", keep: 2 }] };
const sent = await capture({ type: "adaptive" }, true, {
context_management: contextManagement,
});
assert.deepEqual(sent.context_management, contextManagement);
assert.deepEqual(sent.thinking, { type: "adaptive", display: "summarized" });
});