fix(ai): drop mismatched item ids when replaying grammar tool calls

Foreign tool call ids are normalized to fc_*, but grammar tools such as
codemode replay as custom_tool_call, which requires ctc_* ids. This broke
switching OpenAI models through Radius after a codemode call
(earendil-works/radius#115).
This commit is contained in:
Armin Ronacher 2026-10-01 20:42:40 +02:00
parent 92fc452c52
commit bc2d8dc1c4
3 changed files with 44 additions and 7 deletions

View file

@ -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

View file

@ -294,14 +294,13 @@ export function convertResponsesMessages<TApi extends Api>(
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;
}

View file

@ -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);