Skip to content
Merged
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
11 changes: 9 additions & 2 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint";
import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
import { decodeServerSentEvents } from "../lib/sse-decoder";
import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget";
import { modelRecordValue } from "../reasoning-effort";
import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort";

/** Map a user content part to an Anthropic content block (text or image source). */
function toAnthropicContentPart(p: OcxContentPart): unknown {
Expand Down Expand Up @@ -521,7 +521,14 @@ function adaptiveEffort(effort: string): string {

function defaultReasoningEffort(provider: OcxProviderConfig, modelId: string): string | undefined {
const value = modelRecordValue(provider.modelDefaultReasoningEfforts, modelId);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
// `__omit__` means "send no reasoning field", not "an effort literally named
// __omit__". Without this the sentinel reached the wire as
// `output_config.effort: "__omit__"` on adaptive models, and enabled budget
// thinking on the rest — the opposite of what it asks for (#2432).
if (!trimmed || isReasoningEffortOmitted(trimmed)) return undefined;
return trimmed;
}

function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage | undefined {
Expand Down
40 changes: 40 additions & 0 deletions tests/anthropic-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,43 @@ describe("Claude Desktop classifier round trip (#545)", () => {
expect(body.stop_sequences).toEqual(["</block>"]);
});
});

describe("provider default reasoning effort (#2494)", () => {
const withDefault = (model: string, effort: string) => ({
...(provider as unknown as Record<string, unknown>),
modelDefaultReasoningEfforts: { [model]: effort },
} as unknown as OcxProviderConfig);

test("a configured default applies when the caller omits reasoning", async () => {
const model = "anthropic/claude-sonnet-4.5";
const b = await bodyOf(parsed(undefined, {}, model), withDefault(model, "high"));
expect((b.thinking as { type?: string } | undefined)?.type).toBe("enabled");
expect((b.thinking as { budget_tokens?: number }).budget_tokens).toBe(16384);
});

test("an explicit caller effort still wins over the configured default", async () => {
const model = "anthropic/claude-sonnet-4.5";
const b = await bodyOf(parsed("none", {}, model), withDefault(model, "high"));
expect(b.thinking).toBeUndefined();
});

// The sentinel means "send no reasoning field". Treating it as an effort put
// output_config.effort: "__omit__" on the wire for adaptive models and turned
// budget thinking ON for the rest — the opposite of the request.
test("the __omit__ sentinel never becomes an effort value", async () => {
const adaptive = "anthropic/claude-fable-5";
const a = await bodyOf(parsed(undefined, {}, adaptive), withDefault(adaptive, "__omit__"));
expect(a.output_config).toBeUndefined();
expect(a.thinking).toBeUndefined();

const budget = "anthropic/claude-sonnet-4.5";
const b = await bodyOf(parsed(undefined, {}, budget), withDefault(budget, "__omit__"));
expect(b.thinking).toBeUndefined();
});

test("a blank default is ignored rather than treated as an effort", async () => {
const model = "anthropic/claude-sonnet-4.5";
const b = await bodyOf(parsed(undefined, {}, model), withDefault(model, " "));
expect(b.thinking).toBeUndefined();
});
});
Loading