diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index f53cad359101..75d54d5e5684 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -26,6 +26,7 @@ import { calculateCost } from "../llm/model-utils.js"; import { resolveAzureDeploymentNameFromMap } from "../llm/providers/azure-deployment-map.js"; import { convertMessages } from "../llm/providers/openai-completions.js"; import { clampOpenAIPromptCacheKey } from "../llm/providers/openai-prompt-cache.js"; +import { mapOpenAIStopReason } from "../llm/providers/openai-stop-reason.js"; import type { Api, Context, Model } from "../llm/types.js"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { parseStreamingJson } from "../llm/utils/json-parse.js"; @@ -3101,7 +3102,9 @@ async function processOpenAICompletionsStream( hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(choiceUsage); } if (choice.finish_reason) { - const finishReasonResult = mapStopReason(choice.finish_reason); + const finishReasonResult = mapOpenAIStopReason(choice.finish_reason, { + allowSingularToolCall: true, + }); output.stopReason = finishReasonResult.stopReason; if (finishReasonResult.stopReason === "stop") { sawStopFinishReason = true; @@ -4478,32 +4481,6 @@ function hasOpenAICompletionsReasoningUsageActivity( ); } -function mapStopReason(reason: string | null) { - if (reason === null) { - return { stopReason: "stop" }; - } - switch (reason) { - case "stop": - case "end": - return { stopReason: "stop" }; - case "length": - return { stopReason: "length" }; - case "function_call": - case "tool_call": - case "tool_calls": - return { stopReason: "toolUse" }; - case "content_filter": - return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; - case "network_error": - return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; - default: - return { - stopReason: "error", - errorMessage: `Provider finish_reason: ${reason}`, - }; - } -} - export const testing = { getCompat, assertCodeModeResponsesToolSurface, diff --git a/src/llm/providers/openai-completions.ts b/src/llm/providers/openai-completions.ts index 0adc9fdb7a96..489d7f96e078 100644 --- a/src/llm/providers/openai-completions.ts +++ b/src/llm/providers/openai-completions.ts @@ -33,7 +33,6 @@ import type { Model, OpenAICompletionsCompat, SimpleStreamOptions, - StopReason, StreamFunction, StreamOptions, TextContent, @@ -50,6 +49,7 @@ import { resolveCacheRetention } from "./cache-retention.js"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; +import { mapOpenAIStopReason } from "./openai-stop-reason.js"; import { buildBaseOptions } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -372,7 +372,7 @@ export const streamOpenAICompletions: StreamFunction< } if (choice.finish_reason) { - const finishReasonResult = mapStopReason(choice.finish_reason); + const finishReasonResult = mapOpenAIStopReason(choice.finish_reason); output.stopReason = finishReasonResult.stopReason; if (finishReasonResult.errorMessage) { output.errorMessage = finishReasonResult.errorMessage; @@ -1243,34 +1243,6 @@ function parseChunkUsage( return usage; } -function mapStopReason(reason: string): { - stopReason: StopReason; - errorMessage?: string; -} { - if (reason === null) { - return { stopReason: "stop" }; - } - switch (reason) { - case "stop": - case "end": - return { stopReason: "stop" }; - case "length": - return { stopReason: "length" }; - case "function_call": - case "tool_calls": - return { stopReason: "toolUse" }; - case "content_filter": - return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; - case "network_error": - return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; - default: - return { - stopReason: "error", - errorMessage: `Provider finish_reason: ${reason}`, - }; - } -} - /** * Detect compatibility settings from provider and baseUrl for known providers. * Provider takes precedence over URL-based detection since it's explicitly configured. diff --git a/src/llm/providers/openai-stop-reason.test.ts b/src/llm/providers/openai-stop-reason.test.ts new file mode 100644 index 000000000000..3c7272c216b9 --- /dev/null +++ b/src/llm/providers/openai-stop-reason.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { mapOpenAIStopReason } from "./openai-stop-reason.js"; + +describe("mapOpenAIStopReason", () => { + it.each([ + ["stop", { stopReason: "stop" }], + ["end", { stopReason: "stop" }], + ["length", { stopReason: "length" }], + ["function_call", { stopReason: "toolUse" }], + ["tool_calls", { stopReason: "toolUse" }], + [null, { stopReason: "stop" }], + ] as const)("maps %s", (reason, expected) => { + expect(mapOpenAIStopReason(reason)).toEqual(expected); + }); + + it("keeps singular tool_call opt-in", () => { + expect(mapOpenAIStopReason("tool_call")).toEqual({ + stopReason: "error", + errorMessage: "Provider finish_reason: tool_call", + }); + expect(mapOpenAIStopReason("tool_call", { allowSingularToolCall: true })).toEqual({ + stopReason: "toolUse", + }); + }); + + it("surfaces provider errors and unknown reasons", () => { + expect(mapOpenAIStopReason("content_filter")).toEqual({ + stopReason: "error", + errorMessage: "Provider finish_reason: content_filter", + }); + expect(mapOpenAIStopReason("unexpected")).toEqual({ + stopReason: "error", + errorMessage: "Provider finish_reason: unexpected", + }); + }); +}); diff --git a/src/llm/providers/openai-stop-reason.ts b/src/llm/providers/openai-stop-reason.ts new file mode 100644 index 000000000000..81777a8573f8 --- /dev/null +++ b/src/llm/providers/openai-stop-reason.ts @@ -0,0 +1,40 @@ +import type { StopReason } from "../types.js"; + +export type OpenAIStopReasonResult = { + stopReason: StopReason; + errorMessage?: string; +}; + +export function mapOpenAIStopReason( + reason: string | null, + options?: { allowSingularToolCall?: boolean }, +): OpenAIStopReasonResult { + if (reason === null) { + return { stopReason: "stop" }; + } + + switch (reason) { + case "stop": + case "end": + return { stopReason: "stop" }; + case "length": + return { stopReason: "length" }; + case "function_call": + case "tool_calls": + return { stopReason: "toolUse" }; + case "tool_call": + if (options?.allowSingularToolCall) { + return { stopReason: "toolUse" }; + } + break; + case "content_filter": + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; + } + + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; +}