Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/chat/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
});
Expand Down
60 changes: 60 additions & 0 deletions tests/chat/incomplete-length-reason.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>): ReadableStream<Uint8Array> {
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<Record<string, unknown>>)[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<Record<string, unknown>>)[0]!;
expect(choice.finish_reason).toBe("length");
});
});
Loading