fix: stabilize OpenAI tool payload ordering

This commit is contained in:
Galin Iliev
2026-05-16 22:38:26 -07:00
committed by Galin Iliev
parent 6720aa9c42
commit afdb8705e9
2 changed files with 119 additions and 2 deletions

View File

@@ -2932,6 +2932,53 @@ describe("openai transport stream", () => {
expect(params.tool_choice).toBe("required");
});
it("sorts Responses tools by name for stable prompt-cache payloads", () => {
const model = {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-responses">;
const zetaTool = {
name: "zeta",
description: "Z",
parameters: { type: "object", properties: {}, additionalProperties: false },
};
const alphaTool = {
name: "alpha",
description: "A",
parameters: { type: "object", properties: {}, additionalProperties: false },
};
const first = buildOpenAIResponsesParams(
model,
{
systemPrompt: "system",
messages: [],
tools: [zetaTool, alphaTool],
} as never,
{ sessionId: "session-123" } as never,
) as { tools?: Array<{ name?: string }> };
const second = buildOpenAIResponsesParams(
model,
{
systemPrompt: "system",
messages: [],
tools: [alphaTool, zetaTool],
} as never,
{ sessionId: "session-123" } as never,
) as { tools?: Array<{ name?: string }> };
expect(first.tools?.map((tool) => tool.name)).toEqual(["alpha", "zeta"]);
expect(first.tools).toEqual(second.tools);
});
it("falls back to strict:false when a native OpenAI tool schema is not strict-compatible", () => {
const params = buildOpenAIResponsesParams(
{
@@ -3809,6 +3856,54 @@ describe("openai transport stream", () => {
expect(notOptedIn.prompt_cache_key).toBeUndefined();
});
it("sorts Chat Completions tools by function name for stable prompt-cache payloads", () => {
const model = {
id: "custom-model",
name: "Custom Model",
api: "openai-completions",
provider: "custom-cpa",
baseUrl: "https://proxy.example.com/v1",
compat: { supportsPromptCacheKey: true },
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
maxTokens: 8192,
} as unknown as Model<"openai-completions">;
const zetaTool = {
name: "zeta",
description: "Z",
parameters: { type: "object", properties: {} },
};
const alphaTool = {
name: "alpha",
description: "A",
parameters: { type: "object", properties: {} },
};
const first = buildOpenAICompletionsParams(
model,
{
systemPrompt: "system",
messages: [],
tools: [zetaTool, alphaTool],
} as never,
{ sessionId: "session-123" },
) as { tools?: Array<{ function?: { name?: string } }> };
const second = buildOpenAICompletionsParams(
model,
{
systemPrompt: "system",
messages: [],
tools: [alphaTool, zetaTool],
} as never,
{ sessionId: "session-123" },
) as { tools?: Array<{ function?: { name?: string } }> };
expect(first.tools?.map((tool) => tool.function?.name)).toEqual(["alpha", "zeta"]);
expect(first.tools).toEqual(second.tools);
});
it("disables developer-role-only compat defaults for configured custom proxy completions providers", () => {
const params = buildOpenAICompletionsParams(
{

View File

@@ -950,7 +950,7 @@ function convertResponsesTools(
transport: "responses",
model,
});
return tools.map((tool): FunctionTool => {
return sortTransportToolsByName(tools).map((tool): FunctionTool => {
const base = {
type: "function" as const,
name: tool.name,
@@ -2686,7 +2686,7 @@ function convertTools(
model,
},
);
return tools.map((tool) => ({
return sortTransportToolsByName(tools).map((tool) => ({
type: "function",
function: {
name: tool.name,
@@ -2701,6 +2701,28 @@ function convertTools(
}));
}
function compareTransportToolText(left: string | undefined, right: string | undefined): number {
const leftText = left ?? "";
const rightText = right ?? "";
if (leftText < rightText) {
return -1;
}
if (leftText > rightText) {
return 1;
}
return 0;
}
function sortTransportToolsByName<T extends { name?: string; description?: string }>(
tools: readonly T[],
): T[] {
return tools.toSorted(
(left, right) =>
compareTransportToolText(left.name, right.name) ||
compareTransportToolText(left.description, right.description),
);
}
function extractGoogleThoughtSignature(toolCall: unknown): string | undefined {
const tc = toolCall as Record<string, unknown> | undefined;
if (!tc) {