Skip to content

Commit 92199ac

Browse files
authored
fix(agents): unblock gpt-5.3-codex API-key routing and replay (#31083)
* fix(agents): unblock gpt-5.3-codex API-key replay path * fix(agents): scope OpenAI replay ID rewrites per turn * test: fix nodes-tool mock typing and reformat telegram accounts
1 parent e1bf959 commit 92199ac

9 files changed

Lines changed: 347 additions & 26 deletions

src/agents/model-fallback.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ async function expectSkippedUnavailableProvider(params: {
174174
}
175175

176176
describe("runWithModelFallback", () => {
177-
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
177+
it("keeps openai gpt-5.3 codex on the openai provider before running", async () => {
178178
const cfg = makeCfg();
179179
const run = vi.fn().mockResolvedValueOnce("ok");
180180

@@ -187,7 +187,7 @@ describe("runWithModelFallback", () => {
187187

188188
expect(result.result).toBe("ok");
189189
expect(run).toHaveBeenCalledTimes(1);
190-
expect(run).toHaveBeenCalledWith("openai-codex", "gpt-5.3-codex");
190+
expect(run).toHaveBeenCalledWith("openai", "gpt-5.3-codex");
191191
});
192192

193193
it("falls back on unrecognized errors when candidates remain", async () => {

src/agents/model-selection.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,17 @@ describe("model-selection", () => {
7070
});
7171
});
7272

73-
it("normalizes openai gpt-5.3 codex refs to openai-codex provider", () => {
73+
it("keeps openai gpt-5.3 codex refs on the openai provider", () => {
7474
expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({
75-
provider: "openai-codex",
75+
provider: "openai",
7676
model: "gpt-5.3-codex",
7777
});
7878
expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({
79-
provider: "openai-codex",
79+
provider: "openai",
8080
model: "gpt-5.3-codex",
8181
});
8282
expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({
83-
provider: "openai-codex",
83+
provider: "openai",
8484
model: "gpt-5.3-codex-codex",
8585
});
8686
});

src/agents/model-selection.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
2727
"sonnet-4.6": "claude-sonnet-4-6",
2828
"sonnet-4.5": "claude-sonnet-4-5",
2929
};
30-
const OPENAI_CODEX_OAUTH_MODEL_PREFIXES = ["gpt-5.3-codex"] as const;
3130

3231
function normalizeAliasKey(value: string): string {
3332
return value.trim().toLowerCase();
@@ -133,25 +132,9 @@ function normalizeProviderModelId(provider: string, model: string): string {
133132
return model;
134133
}
135134

136-
function shouldUseOpenAICodexProvider(provider: string, model: string): boolean {
137-
if (provider !== "openai") {
138-
return false;
139-
}
140-
const normalized = model.trim().toLowerCase();
141-
if (!normalized) {
142-
return false;
143-
}
144-
return OPENAI_CODEX_OAUTH_MODEL_PREFIXES.some(
145-
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`),
146-
);
147-
}
148-
149135
export function normalizeModelRef(provider: string, model: string): ModelRef {
150136
const normalizedProvider = normalizeProviderId(provider);
151137
const normalizedModel = normalizeProviderModelId(normalizedProvider, model.trim());
152-
if (shouldUseOpenAICodexProvider(normalizedProvider, normalizedModel)) {
153-
return { provider: "openai-codex", model: normalizedModel };
154-
}
155138
return { provider: normalizedProvider, model: normalizedModel };
156139
}
157140

src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
downgradeOpenAIFunctionCallReasoningPairs,
34
downgradeOpenAIReasoningBlocks,
45
isMessagingToolDuplicate,
56
normalizeTextForComparison,
@@ -318,6 +319,125 @@ describe("downgradeOpenAIReasoningBlocks", () => {
318319
});
319320
});
320321

322+
describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
323+
it("strips fc ids when reasoning cannot be replayed", () => {
324+
const input = [
325+
{
326+
role: "assistant",
327+
content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
328+
},
329+
{
330+
role: "toolResult",
331+
toolCallId: "call_123|fc_123",
332+
toolName: "read",
333+
content: [{ type: "text", text: "ok" }],
334+
},
335+
];
336+
337+
// oxlint-disable-next-line typescript/no-explicit-any
338+
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
339+
{
340+
role: "assistant",
341+
content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
342+
},
343+
{
344+
role: "toolResult",
345+
toolCallId: "call_123",
346+
toolName: "read",
347+
content: [{ type: "text", text: "ok" }],
348+
},
349+
]);
350+
});
351+
352+
it("keeps fc ids when replayable reasoning is present", () => {
353+
const input = [
354+
{
355+
role: "assistant",
356+
content: [
357+
{
358+
type: "thinking",
359+
thinking: "internal",
360+
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
361+
},
362+
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
363+
],
364+
},
365+
{
366+
role: "toolResult",
367+
toolCallId: "call_123|fc_123",
368+
toolName: "read",
369+
content: [{ type: "text", text: "ok" }],
370+
},
371+
];
372+
373+
// oxlint-disable-next-line typescript/no-explicit-any
374+
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual(input);
375+
});
376+
377+
it("only rewrites tool results paired to the downgraded assistant turn", () => {
378+
const input = [
379+
{
380+
role: "assistant",
381+
content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
382+
},
383+
{
384+
role: "toolResult",
385+
toolCallId: "call_123|fc_123",
386+
toolName: "read",
387+
content: [{ type: "text", text: "turn1" }],
388+
},
389+
{
390+
role: "assistant",
391+
content: [
392+
{
393+
type: "thinking",
394+
thinking: "internal",
395+
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
396+
},
397+
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
398+
],
399+
},
400+
{
401+
role: "toolResult",
402+
toolCallId: "call_123|fc_123",
403+
toolName: "read",
404+
content: [{ type: "text", text: "turn2" }],
405+
},
406+
];
407+
408+
// oxlint-disable-next-line typescript/no-explicit-any
409+
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
410+
{
411+
role: "assistant",
412+
content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
413+
},
414+
{
415+
role: "toolResult",
416+
toolCallId: "call_123",
417+
toolName: "read",
418+
content: [{ type: "text", text: "turn1" }],
419+
},
420+
{
421+
role: "assistant",
422+
content: [
423+
{
424+
type: "thinking",
425+
thinking: "internal",
426+
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
427+
},
428+
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
429+
],
430+
},
431+
{
432+
role: "toolResult",
433+
toolCallId: "call_123|fc_123",
434+
toolName: "read",
435+
content: [{ type: "text", text: "turn2" }],
436+
},
437+
]);
438+
});
439+
});
440+
321441
describe("normalizeTextForComparison", () => {
322442
it.each([
323443
{ input: "Hello World", expected: "hello world" },

src/agents/pi-embedded-helpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export {
4242
} from "./pi-embedded-helpers/errors.js";
4343
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
4444

45-
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
45+
export {
46+
downgradeOpenAIFunctionCallReasoningPairs,
47+
downgradeOpenAIReasoningBlocks,
48+
} from "./pi-embedded-helpers/openai.js";
4649
export {
4750
isEmptyAssistantMessageContent,
4851
sanitizeSessionMessagesImages,

src/agents/pi-embedded-helpers/openai.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ type OpenAIThinkingBlock = {
66
thinkingSignature?: unknown;
77
};
88

9+
type OpenAIToolCallBlock = {
10+
type?: unknown;
11+
id?: unknown;
12+
};
13+
914
type OpenAIReasoningSignature = {
1015
id: string;
1116
type: string;
@@ -59,6 +64,141 @@ function hasFollowingNonThinkingBlock(
5964
return false;
6065
}
6166

67+
function splitOpenAIFunctionCallPairing(id: string): {
68+
callId: string;
69+
itemId?: string;
70+
} {
71+
const separator = id.indexOf("|");
72+
if (separator <= 0 || separator >= id.length - 1) {
73+
return { callId: id };
74+
}
75+
return {
76+
callId: id.slice(0, separator),
77+
itemId: id.slice(separator + 1),
78+
};
79+
}
80+
81+
function isOpenAIToolCallType(type: unknown): boolean {
82+
return type === "toolCall" || type === "toolUse" || type === "functionCall";
83+
}
84+
85+
/**
86+
* OpenAI can reject replayed `function_call` items with an `fc_*` id if the
87+
* matching `reasoning` item is absent in the same assistant turn.
88+
*
89+
* When that pairing is missing, strip the `|fc_*` suffix from tool call ids so
90+
* pi-ai omits `function_call.id` on replay.
91+
*/
92+
export function downgradeOpenAIFunctionCallReasoningPairs(
93+
messages: AgentMessage[],
94+
): AgentMessage[] {
95+
let changed = false;
96+
const rewrittenMessages: AgentMessage[] = [];
97+
let pendingRewrittenIds: Map<string, string> | null = null;
98+
99+
for (const msg of messages) {
100+
if (!msg || typeof msg !== "object") {
101+
pendingRewrittenIds = null;
102+
rewrittenMessages.push(msg);
103+
continue;
104+
}
105+
106+
const role = (msg as { role?: unknown }).role;
107+
if (role === "assistant") {
108+
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
109+
if (!Array.isArray(assistantMsg.content)) {
110+
pendingRewrittenIds = null;
111+
rewrittenMessages.push(msg);
112+
continue;
113+
}
114+
115+
const localRewrittenIds = new Map<string, string>();
116+
let seenReplayableReasoning = false;
117+
let assistantChanged = false;
118+
const nextContent = assistantMsg.content.map((block) => {
119+
if (!block || typeof block !== "object") {
120+
return block;
121+
}
122+
123+
const thinkingBlock = block as OpenAIThinkingBlock;
124+
if (
125+
thinkingBlock.type === "thinking" &&
126+
parseOpenAIReasoningSignature(thinkingBlock.thinkingSignature)
127+
) {
128+
seenReplayableReasoning = true;
129+
return block;
130+
}
131+
132+
const toolCallBlock = block as OpenAIToolCallBlock;
133+
if (!isOpenAIToolCallType(toolCallBlock.type) || typeof toolCallBlock.id !== "string") {
134+
return block;
135+
}
136+
137+
const pairing = splitOpenAIFunctionCallPairing(toolCallBlock.id);
138+
if (seenReplayableReasoning || !pairing.itemId || !pairing.itemId.startsWith("fc_")) {
139+
return block;
140+
}
141+
142+
assistantChanged = true;
143+
localRewrittenIds.set(toolCallBlock.id, pairing.callId);
144+
return {
145+
...(block as unknown as Record<string, unknown>),
146+
id: pairing.callId,
147+
} as typeof block;
148+
});
149+
150+
pendingRewrittenIds = localRewrittenIds.size > 0 ? localRewrittenIds : null;
151+
if (!assistantChanged) {
152+
rewrittenMessages.push(msg);
153+
continue;
154+
}
155+
changed = true;
156+
rewrittenMessages.push({ ...assistantMsg, content: nextContent } as AgentMessage);
157+
continue;
158+
}
159+
160+
if (role === "toolResult" && pendingRewrittenIds && pendingRewrittenIds.size > 0) {
161+
const toolResult = msg as Extract<AgentMessage, { role: "toolResult" }> & {
162+
toolUseId?: unknown;
163+
};
164+
let toolResultChanged = false;
165+
const updates: Record<string, string> = {};
166+
167+
if (typeof toolResult.toolCallId === "string") {
168+
const nextToolCallId = pendingRewrittenIds.get(toolResult.toolCallId);
169+
if (nextToolCallId && nextToolCallId !== toolResult.toolCallId) {
170+
updates.toolCallId = nextToolCallId;
171+
toolResultChanged = true;
172+
}
173+
}
174+
175+
if (typeof toolResult.toolUseId === "string") {
176+
const nextToolUseId = pendingRewrittenIds.get(toolResult.toolUseId);
177+
if (nextToolUseId && nextToolUseId !== toolResult.toolUseId) {
178+
updates.toolUseId = nextToolUseId;
179+
toolResultChanged = true;
180+
}
181+
}
182+
183+
if (!toolResultChanged) {
184+
rewrittenMessages.push(msg);
185+
continue;
186+
}
187+
changed = true;
188+
rewrittenMessages.push({
189+
...toolResult,
190+
...updates,
191+
} as AgentMessage);
192+
continue;
193+
}
194+
195+
pendingRewrittenIds = null;
196+
rewrittenMessages.push(msg);
197+
}
198+
199+
return changed ? rewrittenMessages : messages;
200+
}
201+
62202
/**
63203
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
64204
* without the required following item.

0 commit comments

Comments
 (0)