refactor(openai): share completion stop reason mapping

This commit is contained in:
Vincent Koc
2026-06-23 08:44:35 +08:00
parent ea56b135c8
commit 2920dc3282
4 changed files with 82 additions and 57 deletions
+4 -27
View File
@@ -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,
+2 -30
View File
@@ -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.
@@ -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",
});
});
});
+40
View File
@@ -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}`,
};
}