From 45d08af40317d229f2212ab3d9d1e23d0dc125af Mon Sep 17 00:00:00 2001 From: bylinelog-bot Date: Tue, 20 Oct 2026 01:35:44 +0800 Subject: [PATCH] fix(chat): finish with length when a Responses upstream reports incomplete reason "length" --- src/chat/outbound.ts | 15 ++++-- tests/chat/incomplete-length-reason.test.ts | 60 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/chat/incomplete-length-reason.test.ts diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index d5da058caa8..1853f5348a6 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -22,6 +22,7 @@ import { UNDECLARED_TOOL_CALL_ERROR_CODE, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; +import { truncationReasonFor } from "../responses/truncated-stop-reason"; import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite"; function isRec(v: unknown): v is Rec { @@ -242,8 +243,14 @@ export function chatCompletionsIncompleteOutcome( reason: unknown, message: unknown, ): { finishReason: "length" | "content_filter" } | { failMessage: string } { - if (reason === "max_output_tokens") return { finishReason: "length" }; - if (reason === "content_filter") return { finishReason: "content_filter" }; + // Upstream Responses endpoints do not all use OpenAI's `max_output_tokens`: Volcengine Ark + // (Coding Plan `/responses`) sends `incomplete_details.reason: "length"` for a hit output cap. + // Read the reason through the shared truncation vocabulary so such a turn ends with + // finish_reason "length" instead of a 502. Proxy reasons (adapter_eof, upstream_stall_timeout, + // missing_terminal_event) are not in that vocabulary and still fail. + const truncation = typeof reason === "string" ? truncationReasonFor(reason) : undefined; + if (truncation === "max_output_tokens") return { finishReason: "length" }; + if (truncation === "content_filter") return { finishReason: "content_filter" }; const why = typeof reason === "string" ? reason : "unknown"; return { failMessage: typeof message === "string" && message.length > 0 @@ -858,8 +865,8 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {}; let incompleteFinish: "length" | "content_filter" | undefined; if (body.status === "incomplete") { - if (incomplete.reason === "max_output_tokens") incompleteFinish = "length"; - else if (incomplete.reason === "content_filter") incompleteFinish = "content_filter"; + const outcome = chatCompletionsIncompleteOutcome(incomplete.reason, undefined); + if ("finishReason" in outcome) incompleteFinish = outcome.finishReason; else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", { code: "upstream_incomplete", type: "upstream_error", }); diff --git a/tests/chat/incomplete-length-reason.test.ts b/tests/chat/incomplete-length-reason.test.ts new file mode 100644 index 00000000000..5651ac5d34f --- /dev/null +++ b/tests/chat/incomplete-length-reason.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + chatCompletionsIncompleteOutcome, + collectChatCompletion, + responsesJsonToChatCompletion, + responsesSseToChatCompletionsSse, +} from "../../src/chat/outbound"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// Volcengine Ark Coding Plan `/responses` ends a capped turn with +// `status: "incomplete", incomplete_details: { reason: "length" }` (not OpenAI's +// `max_output_tokens`). Chat Completions must report that as finish_reason "length", not a 502. + +function sse(events: Array>): ReadableStream { + const text = events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +const usage = { input_tokens: 12, output_tokens: 16, total_tokens: 28 }; + +describe("Responses incomplete reason \"length\" on Chat Completions", () => { + test("the outcome is a length finish, not a failure", () => { + expect(chatCompletionsIncompleteOutcome("length", undefined)).toEqual({ finishReason: "length" }); + expect(chatCompletionsIncompleteOutcome("max_output_tokens", undefined)).toEqual({ finishReason: "length" }); + expect(chatCompletionsIncompleteOutcome("content_filter", undefined)).toEqual({ finishReason: "content_filter" }); + }); + + test("proxy-synthesized incompletes still fail", () => { + for (const reason of ["adapter_eof", "upstream_stall_timeout", "missing_terminal_event", undefined]) { + expect("failMessage" in chatCompletionsIncompleteOutcome(reason, undefined)).toBe(true); + } + }); + + test("buffered (stream:false) projection of an SSE turn returns finish_reason length", async () => { + const budget = createTestTranslatorBudget(); + const response = { id: "resp_1", status: "incomplete", incomplete_details: { reason: "length" }, usage }; + const chat = responsesSseToChatCompletionsSse(sse([ + { type: "response.created", response: { id: "resp_1", status: "in_progress" } }, + { type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "o" }, + { type: "response.incomplete", response }, + ]), "volcengine-coding-plan/glm-5.3-flash", { translatorBudget: budget }); + const completion = await collectChatCompletion(chat, "volcengine-coding-plan/glm-5.3-flash", budget); + const choice = (completion.choices as Array>)[0]!; + expect(choice.finish_reason).toBe("length"); + }); + + test("JSON projection returns finish_reason length", () => { + const completion = responsesJsonToChatCompletion({ + id: "resp_2", status: "incomplete", incomplete_details: { reason: "length" }, usage, + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "o" }] }], + }, "volcengine-coding-plan/glm-5.3-flash"); + const choice = (completion.choices as Array>)[0]!; + expect(choice.finish_reason).toBe("length"); + }); +});