Skip to content

Commit afdb870

Browse files
galinilievGalin Iliev
authored andcommitted
fix: stabilize OpenAI tool payload ordering
1 parent 6720aa9 commit afdb870

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,6 +2932,53 @@ describe("openai transport stream", () => {
29322932
expect(params.tool_choice).toBe("required");
29332933
});
29342934

2935+
it("sorts Responses tools by name for stable prompt-cache payloads", () => {
2936+
const model = {
2937+
id: "gpt-5.4",
2938+
name: "GPT-5.4",
2939+
api: "openai-responses",
2940+
provider: "openai",
2941+
baseUrl: "https://api.openai.com/v1",
2942+
reasoning: true,
2943+
input: ["text"],
2944+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2945+
contextWindow: 200000,
2946+
maxTokens: 8192,
2947+
} satisfies Model<"openai-responses">;
2948+
const zetaTool = {
2949+
name: "zeta",
2950+
description: "Z",
2951+
parameters: { type: "object", properties: {}, additionalProperties: false },
2952+
};
2953+
const alphaTool = {
2954+
name: "alpha",
2955+
description: "A",
2956+
parameters: { type: "object", properties: {}, additionalProperties: false },
2957+
};
2958+
2959+
const first = buildOpenAIResponsesParams(
2960+
model,
2961+
{
2962+
systemPrompt: "system",
2963+
messages: [],
2964+
tools: [zetaTool, alphaTool],
2965+
} as never,
2966+
{ sessionId: "session-123" } as never,
2967+
) as { tools?: Array<{ name?: string }> };
2968+
const second = buildOpenAIResponsesParams(
2969+
model,
2970+
{
2971+
systemPrompt: "system",
2972+
messages: [],
2973+
tools: [alphaTool, zetaTool],
2974+
} as never,
2975+
{ sessionId: "session-123" } as never,
2976+
) as { tools?: Array<{ name?: string }> };
2977+
2978+
expect(first.tools?.map((tool) => tool.name)).toEqual(["alpha", "zeta"]);
2979+
expect(first.tools).toEqual(second.tools);
2980+
});
2981+
29352982
it("falls back to strict:false when a native OpenAI tool schema is not strict-compatible", () => {
29362983
const params = buildOpenAIResponsesParams(
29372984
{
@@ -3809,6 +3856,54 @@ describe("openai transport stream", () => {
38093856
expect(notOptedIn.prompt_cache_key).toBeUndefined();
38103857
});
38113858

3859+
it("sorts Chat Completions tools by function name for stable prompt-cache payloads", () => {
3860+
const model = {
3861+
id: "custom-model",
3862+
name: "Custom Model",
3863+
api: "openai-completions",
3864+
provider: "custom-cpa",
3865+
baseUrl: "https://proxy.example.com/v1",
3866+
compat: { supportsPromptCacheKey: true },
3867+
reasoning: false,
3868+
input: ["text"],
3869+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3870+
contextWindow: 32768,
3871+
maxTokens: 8192,
3872+
} as unknown as Model<"openai-completions">;
3873+
const zetaTool = {
3874+
name: "zeta",
3875+
description: "Z",
3876+
parameters: { type: "object", properties: {} },
3877+
};
3878+
const alphaTool = {
3879+
name: "alpha",
3880+
description: "A",
3881+
parameters: { type: "object", properties: {} },
3882+
};
3883+
3884+
const first = buildOpenAICompletionsParams(
3885+
model,
3886+
{
3887+
systemPrompt: "system",
3888+
messages: [],
3889+
tools: [zetaTool, alphaTool],
3890+
} as never,
3891+
{ sessionId: "session-123" },
3892+
) as { tools?: Array<{ function?: { name?: string } }> };
3893+
const second = buildOpenAICompletionsParams(
3894+
model,
3895+
{
3896+
systemPrompt: "system",
3897+
messages: [],
3898+
tools: [alphaTool, zetaTool],
3899+
} as never,
3900+
{ sessionId: "session-123" },
3901+
) as { tools?: Array<{ function?: { name?: string } }> };
3902+
3903+
expect(first.tools?.map((tool) => tool.function?.name)).toEqual(["alpha", "zeta"]);
3904+
expect(first.tools).toEqual(second.tools);
3905+
});
3906+
38123907
it("disables developer-role-only compat defaults for configured custom proxy completions providers", () => {
38133908
const params = buildOpenAICompletionsParams(
38143909
{

src/agents/openai-transport-stream.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,7 @@ function convertResponsesTools(
950950
transport: "responses",
951951
model,
952952
});
953-
return tools.map((tool): FunctionTool => {
953+
return sortTransportToolsByName(tools).map((tool): FunctionTool => {
954954
const base = {
955955
type: "function" as const,
956956
name: tool.name,
@@ -2686,7 +2686,7 @@ function convertTools(
26862686
model,
26872687
},
26882688
);
2689-
return tools.map((tool) => ({
2689+
return sortTransportToolsByName(tools).map((tool) => ({
26902690
type: "function",
26912691
function: {
26922692
name: tool.name,
@@ -2701,6 +2701,28 @@ function convertTools(
27012701
}));
27022702
}
27032703

2704+
function compareTransportToolText(left: string | undefined, right: string | undefined): number {
2705+
const leftText = left ?? "";
2706+
const rightText = right ?? "";
2707+
if (leftText < rightText) {
2708+
return -1;
2709+
}
2710+
if (leftText > rightText) {
2711+
return 1;
2712+
}
2713+
return 0;
2714+
}
2715+
2716+
function sortTransportToolsByName<T extends { name?: string; description?: string }>(
2717+
tools: readonly T[],
2718+
): T[] {
2719+
return tools.toSorted(
2720+
(left, right) =>
2721+
compareTransportToolText(left.name, right.name) ||
2722+
compareTransportToolText(left.description, right.description),
2723+
);
2724+
}
2725+
27042726
function extractGoogleThoughtSignature(toolCall: unknown): string | undefined {
27052727
const tc = toolCall as Record<string, unknown> | undefined;
27062728
if (!tc) {

0 commit comments

Comments
 (0)