diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b3265d801..cefb1f32a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,10 @@ - Changed OAuth browser pages to use the color Pi logo. +### Fixed + +- Fixed OpenAI Responses requests failing with `Expected an ID that begins with 'ctc'` when replaying grammar tool calls, such as `codemode`, from another provider or a gateway like Radius. + ## [0.99.2] - 2026-09-30 ### Added diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 54abbdc8f..59c9b5b42 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -294,14 +294,13 @@ export function convertResponsesMessages( let itemId: string | undefined = itemIdRaw; // For different-model messages, set id to undefined to avoid pairing validation. - // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items. + // OpenAI tracks which item IDs were paired with rs_xxx reasoning items. // By omitting the id, we avoid triggering that validation (like cross-provider does). - // When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as - // ctc_* custom-tool ids because function_call item ids must be fc_*. - if ( - (isDifferentModel && itemId?.startsWith("fc_")) || - (customInputProperty === undefined && !itemId?.startsWith("fc_")) - ) { + // Also drop ids that do not match the replayed item type: function_call ids must be fc_* + // and custom_tool_call ids must be ctc_*. Foreign tool call ids are normalized to fc_*, and + // a call can switch between the two types when grammar tool support differs. + const itemIdPrefix = customInputProperty === undefined ? "fc_" : "ctc_"; + if (isDifferentModel || !itemId?.startsWith(itemIdPrefix)) { itemId = undefined; } diff --git a/packages/ai/test/constrained-sampling.test.ts b/packages/ai/test/constrained-sampling.test.ts index b3ce0ad6a..8eb9ea8da 100644 --- a/packages/ai/test/constrained-sampling.test.ts +++ b/packages/ai/test/constrained-sampling.test.ts @@ -256,6 +256,40 @@ describe("constrained tool sampling", () => { }); }); + // earendil-works/radius#115: a gateway forwards another model's history as a foreign provider. + it("drops foreign item ids when replaying grammar calls as custom Responses items", () => { + const context = normalizeContext({ + messages: [ + { + role: "assistant", + api: "pi-messages", + provider: "radius", + model: "gpt-other", + content: [{ type: "toolCall", id: "call_1|ctc_1", name: "sample_tool", arguments: { payload: "abc" } }], + usage: makeUsage(), + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "call_1|ctc_1", + toolName: "sample_tool", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: Date.now(), + }, + ], + }); + + const messages = convertResponsesMessages(makeModel(), context, new Set(["openai"]), { + grammarToolInputProperties: new Map([["sample_tool", "payload"]]), + }); + + const call = messages.find((item) => "type" in item && item.type === "custom_tool_call"); + expect(call).toMatchObject({ type: "custom_tool_call", call_id: "call_1", input: "abc" }); + expect(call && "id" in call ? call.id : undefined).toBeUndefined(); + }); + it("keeps grammar input JSON deltas append-only", () => { const buffer = { input: "", started: false, closed: false }; const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"', false);