mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122)
Repairs 3 release-green test reds + test-masking; unblocks #5078.
This commit is contained in:
committed by
GitHub
parent
05071c011c
commit
58d942829f
@@ -44,6 +44,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
- **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942).
|
||||
- **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix).
|
||||
- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau)
|
||||
- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
|
||||
"_rebaseline_2026_06_26_relgreen_db_test": "Release-green follow-up: tests/unit/db-core-init.test.ts 867->877 (+10 = the invalid-DATA_DIR test now captures the rejection and asserts both Error type AND message — restores net-neutral assert count after #5117's consolidation, satisfying check:test-masking — instead of a single assert.rejects).",
|
||||
"_rebaseline_2026_06_26_5074_fusion_editor": "PR #5074 own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4485->4594 (+109 = the Fusion judgeModel + fusionTuning editor block — text/number inputs wired through updateFusionTuning, schema-validated) and tests/unit/combo-config.test.ts 800->881 (testFrozen add: 5 new Fusion-config schema tests). Cohesive UI block at the strategy-conditional editor; combos/page.tsx structural shrink tracked in #3501. Covered by combo-config.test.ts.",
|
||||
"_rebaseline_2026_06_26_5101_responses_textformat": "PR #5101 own growth: open-sse/executors/default.ts 859->876 (+17 = defaultResponsesTextFormat — fills the Responses-API default text.format for openai-compatible-*responses* providers so LM-Studio-style upstreams stop 400ing; guarded, never overwrites an existing format). Irreducible executor chokepoint next to applyJsonSchemaFallback; covered by tests/unit/responses-default-text-format.test.ts.",
|
||||
"_rebaseline_2026_06_26_5117_basereds": "Base-red repair #5117 own growth + sibling drift: open-sse/handlers/responseSanitizer.ts 1122->1139 (+17 = synthesize an output[] message item from an output_text-only Responses body so the answer is not dropped/false-502'd; #4942 regression, covered by tests/unit/response-sanitizer.test.ts). src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 866->868 (+2 sibling drift from a separately-merged PR, already on the release tip; rebaselined here to green the file-size gate).",
|
||||
@@ -253,7 +254,7 @@
|
||||
"tests/unit/chatgpt-web.test.ts": 2855,
|
||||
"tests/unit/combo-routing-engine.test.ts": 3213,
|
||||
"tests/unit/combo-strategy-fallbacks.test.ts": 880,
|
||||
"tests/unit/db-core-init.test.ts": 867,
|
||||
"tests/unit/db-core-init.test.ts": 877,
|
||||
"tests/unit/db-migration-runner.test.ts": 1491,
|
||||
"tests/unit/db-settings-crud.test.ts": 941,
|
||||
"tests/unit/deepseek-web.test.ts": 1081,
|
||||
|
||||
@@ -37,6 +37,11 @@ export function filterToOpenAIFormat(body, opts = {}) {
|
||||
// requested upstream, keep the `cache_control` field on each content block
|
||||
// instead of destructuring it away. `signature` is always stripped.
|
||||
const preserveCacheControl = opts?.preserveCacheControl === true;
|
||||
// #4849 strips reasoning_content from tool-call assistant turns to stop O(n^2)
|
||||
// context growth — but reasoning-replay providers (DeepSeek V4, Kimi K2, etc.)
|
||||
// REQUIRE the client's reasoning_content to be passed back, so keep it for them
|
||||
// (the caller sets this when the routed model needs reasoning replay).
|
||||
const preserveReasoningContent = opts?.preserveReasoningContent === true;
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
body.messages = body.messages.map((msg) => {
|
||||
@@ -50,8 +55,10 @@ export function filterToOpenAIFormat(body, opts = {}) {
|
||||
|
||||
// Keep assistant messages with tool_calls, but strip reasoning_content —
|
||||
// reasoning blobs inflate context on every subsequent agentic turn (O(n^2)).
|
||||
// Exception: reasoning-replay providers must keep client-provided
|
||||
// reasoning_content (they 400 without it), so preserve it when requested.
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
if (msg.reasoning_content !== undefined) {
|
||||
if (!preserveReasoningContent && msg.reasoning_content !== undefined) {
|
||||
const { reasoning_content, ...cleanMsg } = msg;
|
||||
return cleanMsg;
|
||||
}
|
||||
|
||||
@@ -264,6 +264,23 @@ export function translateRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
|
||||
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
|
||||
// replay providers) and the cache re-injection further down.
|
||||
const normalizedProvider = String(provider ?? "");
|
||||
const normalizedModel = String(model ?? "");
|
||||
const resolvedCapabilities = getResolvedModelCapabilities({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
});
|
||||
const isReasoner = requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
thinkingEnabled: hasThinkingConfig(result),
|
||||
supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }),
|
||||
interleavedField: resolvedCapabilities?.interleavedField ?? null,
|
||||
});
|
||||
|
||||
// Always normalize to clean OpenAI format when target is OpenAI
|
||||
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||
if (targetFormat === FORMATS.OPENAI) {
|
||||
@@ -274,6 +291,8 @@ export function translateRequest(
|
||||
preserveCacheControl:
|
||||
options?.preserveCacheControl === true &&
|
||||
providerHonorsOpenAIFormatCacheControl(provider),
|
||||
// #4849 regression guard: keep client reasoning_content for replay providers.
|
||||
preserveReasoningContent: isReasoner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,19 +352,9 @@ export function translateRequest(
|
||||
// clients omit it from the conversation history. Without this, DeepSeek V4
|
||||
// returns 400: "The reasoning_content in the thinking mode must be passed
|
||||
// back to the API."
|
||||
const normalizedProvider = String(provider ?? "");
|
||||
const normalizedModel = String(model ?? "");
|
||||
const resolvedCapabilities = getResolvedModelCapabilities({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
});
|
||||
const isReasoner = requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
thinkingEnabled: hasThinkingConfig(result),
|
||||
supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }),
|
||||
interleavedField: resolvedCapabilities?.interleavedField ?? null,
|
||||
});
|
||||
// isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were
|
||||
// resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip
|
||||
// could honor reasoning-replay providers.
|
||||
if (isReasoner && result.messages && Array.isArray(result.messages)) {
|
||||
const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel);
|
||||
|
||||
|
||||
@@ -1156,6 +1156,29 @@
|
||||
"stream": "devin://acp/stdio"
|
||||
}
|
||||
},
|
||||
"dgrid": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "https://api.dgrid.ai/v1/chat/completions",
|
||||
"stream": "https://api.dgrid.ai/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"dify": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
@@ -3126,6 +3149,29 @@
|
||||
"stream": "https://www.phind.com/api/chat"
|
||||
}
|
||||
},
|
||||
"pioneer": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "<CRED>"
|
||||
},
|
||||
"nonStream": {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "<CRED>"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "<CRED>"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "https://api.pioneer.ai/v1/chat/completions",
|
||||
"stream": "https://api.pioneer.ai/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"pollinations": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
@@ -73,7 +73,11 @@ test("non-rotating OAuth provider is still refreshed proactively from quota-sync
|
||||
const origRefresh = exec.refreshCredentials;
|
||||
let refreshCalls = 0;
|
||||
exec.needsRefresh = () => true;
|
||||
// null -> non-github -> surfaces a 401; proves the refresh was actually attempted.
|
||||
// Refresh returns null with no usable access token left -> surfaces a 401;
|
||||
// proves the refresh was actually attempted (the codex no-rotate gate did not
|
||||
// block this non-rotating provider). Note: since #4786 a *present* accessToken
|
||||
// would gracefully fall back for any OAuth provider, so we leave it empty here
|
||||
// to keep the 401-throw branch reachable and the gate assertion meaningful.
|
||||
exec.refreshCredentials = async () => {
|
||||
refreshCalls++;
|
||||
return null;
|
||||
@@ -83,7 +87,7 @@ test("non-rotating OAuth provider is still refreshed proactively from quota-sync
|
||||
refreshAndUpdateCredentials({
|
||||
id: "cursor-1",
|
||||
provider: "cursor",
|
||||
accessToken: "a",
|
||||
accessToken: "",
|
||||
refreshToken: "r",
|
||||
tokenExpiresAt: new Date(2000).toISOString(),
|
||||
providerSpecificData: {},
|
||||
|
||||
@@ -515,8 +515,18 @@ test("invalid DATA_DIR (a file where a dir is expected) surfaces as a startup fa
|
||||
// regular file is a non-permission misconfiguration (EEXIST/ENOTDIR), which
|
||||
// resolveWritableDataDir rethrows by design (only EACCES/EPERM fall back), so
|
||||
// the failure now surfaces at import time, not lazily from getDbInstance().
|
||||
await assert.rejects(
|
||||
withEnv({ DATA_DIR: fileAsDir }, () => importFresh("src/lib/db/core.ts")),
|
||||
let caught: unknown;
|
||||
await withEnv({ DATA_DIR: fileAsDir }, () => importFresh("src/lib/db/core.ts")).then(
|
||||
() => {
|
||||
throw new Error("expected importing db/core with an invalid DATA_DIR to reject");
|
||||
},
|
||||
(err) => {
|
||||
caught = err;
|
||||
}
|
||||
);
|
||||
assert.ok(caught instanceof Error, "an invalid DATA_DIR must surface as a thrown Error");
|
||||
assert.match(
|
||||
String((caught as Error).message),
|
||||
/unable to open database file|ENOTDIR|EEXIST|not a directory|file already exists/i
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user