项目文件夹

文件
2026-07-13 13:39:12 +08:00

89 行
3.6 KiB
TypeScript

type JsonRecord = Record<string, unknown>;
// Models that reject `thinking.type:"adaptive"` and `output_config.effort` —
// today, all Claude Haiku-tier models (4.5 / 3.5 / dated and aliased). Adaptive
// thinking + the effort knob landed on Sonnet 4.6 / Opus 4.5+ only; sending
// either on Haiku is a hard 400 from the Messages API.
const HAIKU_CONSTRAINT_PATTERN = /haiku/i;
// Default budget when collapsing `thinking.type:"adaptive"` to a manual shape
// on Haiku. Mirrors the upstream 9router decision (`claude.js`, commit
// 401d93bd5) — a conservative ~10K budget keeps reasoning enabled without
// hitting Haiku's output-token cap.
const HAIKU_FALLBACK_THINKING_BUDGET = 10000;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function modelRejectsAdaptiveAndEffort(modelId: string | null | undefined): boolean {
if (typeof modelId !== "string" || modelId.length === 0) return false;
return HAIKU_CONSTRAINT_PATTERN.test(modelId);
}
/**
* Strip Claude-API params that Haiku-tier models reject.
*
* Anthropic restricts two extended-reasoning knobs to Sonnet 4.6 / Opus 4.5+:
* 1. `thinking.type:"adaptive"` — Haiku only accepts manual `"enabled"` (with a
* `budget_tokens`) or `"disabled"`.
* 2. `output_config.effort` — only the larger models steer reasoning via effort.
* Sending either of these to Haiku is a hard `400` from the Messages API.
*
* Multiple OmniRoute paths can still emit those shapes on a Haiku target:
* - native Claude passthrough from newer Claude Code / Cowork clients;
* - the OpenAI→Claude translator when `reasoning_effort` is `max`/`xhigh`
* (see `request/openai-to-claude.ts`) — Haiku isn't `adaptiveThinkingOnly`,
* so it falls into the branch that emits `{type:"adaptive"}` + `effort`;
* - per-model thinking defaults from the request flow / combo routing.
*
* This normalizer is the final, provider-agnostic guard keyed on the resolved
* upstream model. It runs after model substitution in `chatCore.ts`, so it
* covers every routing mode (single-model, combo, fallback).
*
* Returns a NEW object only when it changes the body. No-op for non-Haiku
* models, when the body carries no thinking/output_config, or when neither
* `thinking.type:"adaptive"` nor `output_config.effort` is present — so the
* existing Sonnet/Opus paths and Haiku bodies without these fields are
* unaffected.
*
* Mirrors upstream 9router commit 401d93bd5 (`open-sse/translator/formats/claude.js`).
*/
export function normalizeClaudeHaikuConstraints<T extends Record<string, unknown>>(
body: T,
model: string | null | undefined
): T {
if (!modelRejectsAdaptiveAndEffort(model)) return body;
const record = asRecord(body);
if (!record) return body;
const thinking = asRecord(record.thinking);
const outputConfig = asRecord(record.output_config);
const needsThinkingRewrite = thinking?.type === "adaptive";
const needsEffortStrip = outputConfig != null && outputConfig.effort != null;
if (!needsThinkingRewrite && !needsEffortStrip) return body;
const next: JsonRecord = { ...record };
if (needsThinkingRewrite && thinking) {
next.thinking = {
...thinking,
type: "enabled",
budget_tokens: HAIKU_FALLBACK_THINKING_BUDGET,
};
}
if (needsEffortStrip && outputConfig) {
const nextOutputConfig: JsonRecord = { ...outputConfig };
delete nextOutputConfig.effort;
if (Object.keys(nextOutputConfig).length === 0) {
delete next.output_config;
} else {
next.output_config = nextOutputConfig;
}
}
return next as T;
}