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
57 changes: 47 additions & 10 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,13 +901,23 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk
* Runs on every forward request; with intact pairs it returns the original reference.
*/
/**
* Backfill `queries` on a replayed single-query `web_search_call`.
* Repair a replayed `web_search_call` action that is missing either key.
*
* `webSearchAction()` in the bridge now emits both keys, but that only helps items
* created after the fix. A conversation that already recorded
* `{type:"search", query:"..."}` replays that stored item on every subsequent turn, and
* DeepSeek's native Responses parser requires `queries` — so upgrading alone leaves
* those threads permanently 400ing with `missing field 'queries'` (#930).
* `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored
* item on every subsequent turn. DeepSeek's native Responses parser requires `queries`
* (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves
* those threads permanently 400ing in one direction or the other. The repair runs both
* ways.
*
* Input items carry a loose schema, so a stored `queries` is not necessarily an array of
* strings. A partly- or wholly-malformed array is left alone rather than used as a source
* for the singular field: writing `query: 123` would satisfy the presence check and still
* fail the validator this repair exists to satisfy, and deriving `query` from
* `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay.
* An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search,
* keeping an existing `query` when the item has one.
*
* Runs on every Responses request, on both `input` items and the `action` nested inside
* them. Returns the original reference when nothing needs repair, so the common path
Expand All @@ -920,9 +930,35 @@ function backfillWebSearchQueries(body: unknown): unknown {
if (!isPlainObject(item) || item.type !== "web_search_call") return item;
const action = item.action;
if (!isPlainObject(action) || action.type !== "search") return item;
if (typeof action.query !== "string" || Array.isArray(action.queries)) return item;
changed = true;
return { ...item, action: { ...action, queries: [action.query] } };
// Repair whichever side is missing so both strict parsers pass:
// DeepSeek native Responses requires `queries`; Console Go requires `query`.
const rep: Record<string, unknown> = { ...action };
let itemChanged = false;
const hasQuery = typeof action.query === "string";
const queries = Array.isArray(action.queries) ? action.queries : undefined;
if (queries !== undefined && queries.length === 0) {
// An empty array satisfies neither validator. Canonicalize to the empty-search
// shape the bridge emits, keeping an existing query rather than discarding it.
const query = hasQuery ? action.query as string : "";
rep.query = query;
rep.queries = [query];
itemChanged = true;
} else if (!hasQuery && queries !== undefined) {
// A plural array is only a usable source for the singular field when EVERY member
// is a string: deriving `query` from a partly-malformed array would satisfy Console
// Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are
// left untouched — coercing or dropping members would invent semantics the stored
// item never had.
if (queries.every(entry => typeof entry === "string")) {
rep.query = queries[0]; // multi-query item recorded before the fix
itemChanged = true;
}
} else if (hasQuery && queries === undefined) {
rep.queries = [action.query]; // single-query item recorded before the fix
itemChanged = true;
}
if (itemChanged) changed = true;
return itemChanged ? { ...item, action: rep } : item;
});
return changed ? { ...body, input } : body;
}
Expand Down Expand Up @@ -2097,9 +2133,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = repairOversizedReplayCallIds(outBody);
}
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
// Repair stored history from before the bridge emitted both keys: a conversation
// that already recorded a single-query web_search_call replays it every turn, and
// a strict parser rejects the whole request over it (#930).
// Repair stored history from before the bridge emitted both keys, in either
// direction: a conversation that already recorded a web_search_call replays it
// every turn, and a strict parser rejects the whole request over the missing key —
// `queries` for DeepSeek (#930), `query` for Console Go (#3071).
outBody = backfillWebSearchQueries(outBody);
if (!isCanonicalOpenAiForwardProvider(provider)) {
outBody = promoteClientLoadedTools(outBody);
Expand Down
30 changes: 15 additions & 15 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,27 +146,27 @@ export { adapterFailureFromMessage } from "./lib/errors";
/**
* Build the native `WebSearchAction::Search` payload from the queries that ran.
*
* Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular
* `query`. Empty → `{ query: "", queries: [""] }`.
* Every action carries BOTH keys: `{ query, queries }`, where `query` is the first
* member. Empty → `{ query: "", queries: [""] }`.
*
* The asymmetry is load-bearing in both directions. codex-rs prefers a non-empty `query`
* for the cell label and renders "<first> ..." only when `query` is ABSENT and
* `queries.len() > 1`, so adding `query` to a batch would collapse the plural ellipsis.
* Meanwhile DeepSeek's native Responses parser makes `queries` a required field, so a
* replayed one-term `web_search_call` — carried in the history of every subsequent turn
* — fails deserialization with `missing field 'queries'` and 400s the rest of the
* conversation (#930). Carrying both keys in the single case satisfies the strict parser
* without changing what codex-rs displays.
* Carrying both is load-bearing in both directions. DeepSeek's native Responses parser
* makes `queries` a required field, and Console Go's upstream validator makes `query` a
* required field — so a replayed `web_search_call` carried in the history of every
* subsequent turn fails deserialization with `missing field 'queries'` (#930) or 400s
* with `missing required field 'query'` unless both keys are present. Carrying both keys
* in every case satisfies both strict parsers; the trade-off is that a multi-query batch
* loses the "<first> ..." ellipsis in codex-rs and shows the first query as the label.
*
* That trade is deliberate: a cosmetic label against a conversation that 400s on every
* subsequent turn. Do not restore the old batch-omits-`query` shape to win the ellipsis
* back — it reopens #3071.
*
* This fixes items created from here on. History recorded before it is repaired at the
* replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
*/
function webSearchAction(queries: string[]): Record<string, unknown> {
if (queries.length <= 1) {
const query = queries[0] ?? "";
return { type: "search", query, queries: [query] };
}
return { type: "search", queries };
const first = queries[0] ?? "";
return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] };
}

interface OutputItem {
Expand Down
8 changes: 4 additions & 4 deletions tests/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ describe("Responses bridge web_search_call native item", () => {
});
});

test("a batched (plural) search emits action.search.queries without a singular query", () => {
test("a batched (plural) search carries both query and queries for Console Go (#3071)", () => {
const json = buildResponseJSON([
{ type: "web_search_call_begin", id: "ws_3" },
{ type: "web_search_call_end", id: "ws_3", queries: ["rust async", "tokio runtime"] },
Expand All @@ -1085,9 +1085,9 @@ describe("Responses bridge web_search_call native item", () => {

const output = json.output as Record<string, unknown>[];
const action = (output[0] as Record<string, unknown>).action as Record<string, unknown>;
// Native renders "<first> ..." only when `query` is absent and queries.len() > 1.
expect(action).toEqual({ type: "search", queries: ["rust async", "tokio runtime"] });
expect(action.query).toBeUndefined();
// Console Go's upstream validator requires singular `query` on the search action,
// and DeepSeek native Responses requires `queries` — so a batch carries both now.
expect(action).toEqual({ type: "search", query: "rust async", queries: ["rust async", "tokio runtime"] });
});

test("a single-query search also carries queries so strict parsers accept the replay (#930)", () => {
Expand Down
67 changes: 60 additions & 7 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,11 +1295,11 @@ describe("OpenAI Responses passthrough sanitization", () => {
expect(input[0]).not.toHaveProperty("id");
});

test("backfills queries on a replayed single-query web_search_call (#930)", () => {
test("backfills web_search_call actions in either missing direction (#930, #3071)", () => {
// The bridge fix only helps items created after it. A conversation that already
// recorded {type:"search", query:"..."} replays that stored item every turn, and
// DeepSeek's parser rejects the whole request over it — so upgrading alone would
// leave those threads permanently broken.
// recorded a legacy web_search_call replays that stored item every turn. DeepSeek's
// parser rejects an action without `queries` (#930) and Console Go rejects one
// without `query` (#3071) — so upgrading alone would leave those threads broken.
const adapter = createResponsesPassthroughAdapter(provider);
const request = adapter.buildRequest({
modelId: "provider-model",
Expand All @@ -1319,13 +1319,66 @@ describe("OpenAI Responses passthrough sanitization", () => {

// Repaired: singular query gains the array the strict parser requires.
expect(input[0].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] });
// Untouched: a batch already satisfies the parser, and adding `query` would collapse
// the native plural rendering.
expect(input[1].action).toEqual({ type: "search", queries: ["a", "b"] });
// Repaired: multi-query batch gains the singular `query` Console Go requires.
expect(input[1].action).toEqual({ type: "search", query: "a", queries: ["a", "b"] });
// Untouched: not a search action.
expect(input[2].action).toEqual({ type: "open_page", url: "https://example.test" });
});

test("does not forge a singular query from a malformed or empty queries array (#3071)", () => {
// Input items use a loose schema, so a stored `queries` need not be an array of
// strings. Copying a non-string first member would satisfy the presence check and
// still fail the Console Go validator this repair exists to satisfy — a repair that
// reports success and produces an invalid shape is worse than no repair.
const adapter = createResponsesPassthroughAdapter(provider);
const request = adapter.buildRequest({
modelId: "provider-model",
context: { messages: [] },
stream: true,
options: {},
_rawBody: {
model: "provider-model",
input: [
{ type: "web_search_call", id: "ws_num", status: "completed", action: { type: "search", queries: [42] } },
{ type: "web_search_call", id: "ws_obj", status: "completed", action: { type: "search", queries: [{ q: "x" }] } },
{ type: "web_search_call", id: "ws_empty", status: "completed", action: { type: "search", queries: [] } },
],
},
}, meta);
const input = (JSON.parse(request.body) as { input: Array<{ action: Record<string, unknown> }> }).input;

// Left alone: a non-string first member is not a query.
expect(input[0].action).toEqual({ type: "search", queries: [42] });
expect(input[1].action).toEqual({ type: "search", queries: [{ q: "x" }] });
// Canonicalized: an empty array satisfies neither validator, so it becomes the same
// empty-search shape the bridge emits rather than being passed through.
expect(input[2].action).toEqual({ type: "search", query: "", queries: [""] });
});

test("repairs a partly-malformed or already-queried empty action (#3071)", () => {
const adapter = createResponsesPassthroughAdapter(provider);
const request = adapter.buildRequest({
modelId: "provider-model",
context: { messages: [] },
stream: true,
options: {},
_rawBody: {
model: "provider-model",
input: [
{ type: "web_search_call", id: "ws_mixed", status: "completed", action: { type: "search", queries: ["a", 42] } },
{ type: "web_search_call", id: "ws_qempty", status: "completed", action: { type: "search", query: "legacy", queries: [] } },
],
},
}, meta);
const input = (JSON.parse(request.body) as { input: Array<{ action: Record<string, unknown> }> }).input;

// Left alone: deriving `query: "a"` would satisfy Console Go and leave DeepSeek to
// reject the same replay over the non-string second member.
expect(input[0].action).toEqual({ type: "search", queries: ["a", 42] });
// Canonicalized without discarding the query the item already carried.
expect(input[1].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] });
});

test("strips invalid type-specific ids from serialized input items", () => {
const adapter = createResponsesPassthroughAdapter(provider);
const encryptedContent = "opaque-openai-encrypted-content";
Expand Down
Loading