mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 23:22:09 +03:00
fix(sse): default summary + strip malformed id on kept Responses input items (#11110)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.
Defaults summary and strips malformed ids on kept Responses input items — both 400s observed against live muse-spark traffic; the flipped legacy assertion is documented contract propagation. Thank you @maxmad64bis!
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { REGISTRY } from "../config/providerRegistry.ts";
|
||||
import type { ReasoningTransport } from "../config/providerRegistry.ts";
|
||||
import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -279,13 +280,27 @@ function sanitizeResponsesInput(
|
||||
if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasOpaque && typeof next.id === "string") delete next.id;
|
||||
// `id` is only worth keeping on an opaque item with a valid string value —
|
||||
// non-opaque items don't replay their id, and a malformed value (e.g. `null`,
|
||||
// observed on opencode/zen) must not survive either way (#11108).
|
||||
if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id;
|
||||
// Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque
|
||||
// reasoning items instead of sending an empty array. Replaying that shape
|
||||
// verbatim trips strict Responses-API validators that require the field
|
||||
// to be present on every `input[]` item of type `reasoning` (#11108).
|
||||
// Plaintext-only items intentionally have no `summary` key and must stay
|
||||
// untouched.
|
||||
if (hasOpaque && next.summary === undefined) next.summary = [];
|
||||
filtered.push(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cloned = { ...record };
|
||||
if (typeof cloned.id === "string") delete cloned.id;
|
||||
// Strip `id` whenever present, valid or not: these items don't need a
|
||||
// replayed server id, and a malformed one (e.g. `null`, same opencode/zen
|
||||
// omission pattern as the reasoning branch above) must not survive either
|
||||
// (#11108).
|
||||
if (cloned.id !== undefined) delete cloned.id;
|
||||
filtered.push(cloned);
|
||||
}
|
||||
return filtered;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SanitizeResponsesInputOptions = {
|
||||
dropInternalAssistantMessages?: boolean;
|
||||
@@ -40,7 +42,12 @@ function sanitizeFunctionName(name: string): string {
|
||||
}
|
||||
|
||||
function sanitizeInputItemId(record: JsonRecord): JsonRecord {
|
||||
if (typeof record.id !== "string") return record;
|
||||
if (record.id === undefined) return record;
|
||||
if (!isValidResponsesItemId(record.id)) {
|
||||
const next = { ...record };
|
||||
delete next.id;
|
||||
return next;
|
||||
}
|
||||
|
||||
const type = typeof record.type === "string" ? record.type : "";
|
||||
const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type];
|
||||
|
||||
7
open-sse/services/responsesItemId.ts
Normal file
7
open-sse/services/responsesItemId.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a
|
||||
// Responses-API `input[]` item's `id` field when it isn't a valid string before
|
||||
// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never
|
||||
// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108).
|
||||
export function isValidResponsesItemId(id: unknown): id is string {
|
||||
return typeof id === "string";
|
||||
}
|
||||
145
tests/unit/reasoning-input-policy-summary-11108.test.ts
Normal file
145
tests/unit/reasoning-input-policy-summary-11108.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { applyReasoningInputPolicy } =
|
||||
await import("../../open-sse/services/reasoningInputPolicy.ts");
|
||||
|
||||
test("#11108 applyReasoningInputPolicy defaults summary on a kept opaque reasoning item", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_example",
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input.length, 1);
|
||||
assert.deepEqual(input[0].summary, []);
|
||||
});
|
||||
|
||||
test("#11108 applyReasoningInputPolicy preserves an existing summary on a kept reasoning item", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_example",
|
||||
encrypted_content: "opaque-blob",
|
||||
summary: [{ type: "summary_text", text: "Planning." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.deepEqual(input[0].summary, [{ type: "summary_text", text: "Planning." }]);
|
||||
});
|
||||
|
||||
test("#11108 applyReasoningInputPolicy defaults summary on an opaque item surviving incompatible-drop", () => {
|
||||
// Mixed item (plaintext + opaque) on an opaque-only transport is incompatible;
|
||||
// dropIncompatibleResponsesReasoning() strips the plaintext content but keeps
|
||||
// the opaque item alive — it must still get a default `summary`.
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_mixed",
|
||||
content: [{ type: "reasoning_text", text: "inspect first" }],
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "codex",
|
||||
onIncompatibleReasoning: "drop",
|
||||
});
|
||||
|
||||
assert.equal(result.incompatibleReasoning, false);
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input.length, 1);
|
||||
assert.equal(input[0].content, undefined);
|
||||
assert.equal(input[0].encrypted_content, "opaque-blob");
|
||||
assert.deepEqual(input[0].summary, []);
|
||||
});
|
||||
|
||||
test("#11108 applyReasoningInputPolicy strips a non-string id on a kept opaque reasoning item", () => {
|
||||
// Same gap class as the summary fix above: opencode/zen also omits `id`
|
||||
// entirely (surfaced by the client as `id: null`) on opaque-only reasoning
|
||||
// items instead of a `rs_...` string. Replaying that shape verbatim trips
|
||||
// strict Responses-API validators with "Expected 'id' to be a string."
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: null,
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input.length, 1);
|
||||
assert.equal("id" in input[0], false);
|
||||
});
|
||||
|
||||
test("#11108 applyReasoningInputPolicy strips a non-string id on a non-reasoning item (function_call)", () => {
|
||||
// Same gap class, generic branch: any non-"reasoning" input item (function_call,
|
||||
// message, ...) only stripped `id` when it was already a valid string, so a
|
||||
// malformed `id` (e.g. `null`, mirroring the opencode/zen omission pattern)
|
||||
// on a function_call item survived replay untouched.
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: null,
|
||||
call_id: "call_abc",
|
||||
name: "bash",
|
||||
arguments: "{}",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", { provider: "opencode" });
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input.length, 1);
|
||||
assert.equal("id" in input[0], false);
|
||||
assert.equal(input[0].call_id, "call_abc");
|
||||
});
|
||||
|
||||
test("#11108 applyReasoningInputPolicy preserves a valid string id on a kept opaque reasoning item", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_example",
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input[0].id, "rs_example");
|
||||
});
|
||||
@@ -73,6 +73,23 @@ test("keeps valid server reasoning item ids", () => {
|
||||
assert.equal(result[0].id, "rs_123");
|
||||
});
|
||||
|
||||
test("strips a non-string reasoning item id instead of passing it through (#11108)", () => {
|
||||
// Same gap class fixed in reasoningInputPolicy.ts: some upstreams (e.g.
|
||||
// opencode/zen) send `id: null` instead of omitting it. The previous
|
||||
// `typeof record.id !== "string"` guard returned the record unchanged in
|
||||
// that case, letting a malformed id reach a strict Responses-API upstream.
|
||||
const items = [
|
||||
{
|
||||
id: null,
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "cached reasoning" }],
|
||||
},
|
||||
];
|
||||
const result = sanitizeResponsesInputItems(items) as Array<Record<string, unknown>>;
|
||||
assert.equal("id" in result[0], false);
|
||||
assert.equal(result[0].type, "reasoning");
|
||||
});
|
||||
|
||||
test("normalizes user image_url content parts to input_image", () => {
|
||||
const items = [
|
||||
{
|
||||
|
||||
@@ -274,7 +274,11 @@ test("explicit custom target opt-in remains an opaque transport override", () =>
|
||||
const result = applyReasoningInputPolicy(body, "responses", { preserveEncryptedReasoning: true });
|
||||
|
||||
assert.equal(result.incompatibleReasoning, false);
|
||||
assert.deepEqual(body.input, [{ type: "reasoning", encrypted_content: "encrypted-blob" }]);
|
||||
// #11108: a kept opaque item defaults `summary` when the source omitted it —
|
||||
// some upstreams reject `input[]` reasoning items missing the field entirely.
|
||||
assert.deepEqual(body.input, [
|
||||
{ type: "reasoning", encrypted_content: "encrypted-blob", summary: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserved opaque reasoning remains redacted from log copies", () => {
|
||||
|
||||
Reference in New Issue
Block a user