diff --git a/scripts/test-layout/layout-chat.json b/scripts/test-layout/layout-chat.json new file mode 100644 index 00000000000..02276512f61 --- /dev/null +++ b/scripts/test-layout/layout-chat.json @@ -0,0 +1,3 @@ +{ + "chat-incomplete-length-reason.test.ts": "responses" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0d1c7696e5d..ea926630522 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json"], "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 191efcc460e..b45eac97291 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"; import { reasoningDetailEntriesForItem } from "./reasoning-details"; @@ -243,8 +244,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 @@ -871,8 +878,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/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 121dd38b79a..01e7ce86769 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -248,7 +248,8 @@ Tool calls gain their array-order stream `index`; the stream retains one assista one terminal choice, and one `[DONE]`. Both native and translated JSON fallbacks share `jsonCompletionSse`; its temporary frame strings and final body ownership are charged to the existing translator budget. Known incomplete limits take precedence over tool finish reasons; -unmapped incomplete boundaries remain errors. The existing response-body lifecycle owns translation-budget +"known" means the shared [incomplete-reason vocabulary](../transports/responses-wire-shapes.md#adapter-incomplete-reasons), +and unmapped incomplete boundaries remain errors. The existing response-body lifecycle owns translation-budget release on consumption or cancellation. Actual upstream SSE and native Chat bypass this fallback. > Decision record: [ADR-0062](../decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md) diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 3c57d7dd0f5..1578413634c 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -7,7 +7,7 @@ With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_err ## Adapter incomplete reasons `src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. `src/responses/incomplete-reason.ts` applies the same classifier to native Responses JSON and SSE delivery in `src/server/responses/passthrough-delivery.ts` and to WebSocket frames in `src/server/responses/codex-ws-wire.ts`, including native control chains. It copies only changed `incomplete_details.reason` metadata, leaving output content and all other response fields intact. The compaction parser in `src/adapters/openai-responses/passthrough.ts` also normalizes its internal incomplete event. `tests/responses/incomplete-reason-normalization.test.ts` covers native HTTP delivery, WS terminal normalization and both adapter parser formats. -Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. +Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. The Chat writer does not depend on that upstream pass alone: `chatCompletionsIncompleteOutcome` in `src/chat/outbound.ts` classifies through the same `truncationReasonFor` table for the SSE converter (and the collector that folds it), the direct encoder in `src/protocols/encoders/chat.ts` and `responsesJsonToChatCompletion`, so a raw alias such as Volcengine Ark's `length` still ends with `finish_reason: "length"` (or `content_filter`). Reasons outside the table, including `adapter_eof`, `upstream_stall_timeout`, `missing_terminal_event`, `max_messages` and `steered`, remain Chat errors. `tests/responses/chat-incomplete-length-reason.test.ts` covers the converter outcomes and the handler's streamed and buffered wire. ## Compaction image input diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 20aa180eed2..11088ee4b28 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -333,6 +333,7 @@ "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", + "chat-incomplete-length-reason.test.ts": "responses", "chat-media-translation.test.ts": "responses", "chat-native-developer-role.test.ts": "responses", "chat-native-reasoning-replay.test.ts": "responses", "chat-native-reasoning-scope.test.ts": "responses", diff --git a/tests/responses/chat-incomplete-length-reason.test.ts b/tests/responses/chat-incomplete-length-reason.test.ts new file mode 100644 index 00000000000..504aeaf111c --- /dev/null +++ b/tests/responses/chat-incomplete-length-reason.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chatCompletionsIncompleteOutcome, + collectChatCompletion, + responsesJsonToChatCompletion, + responsesSseToChatCompletionsSse, +} from "../../src/chat/outbound"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +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. + +const MODEL = "volcengine-coding-plan/glm-5.3-flash"; +const usage = { input_tokens: 12, output_tokens: 16, total_tokens: 28 }; + +function sseText(events: Array>): string { + return events.map(e => `event: ${String(e.type)}\ndata: ${JSON.stringify(e)}\n\n`).join(""); +} + +function sse(events: Array>): ReadableStream { + const text = sseText(events); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +function lengthTurn(id: string): Array> { + return [ + { type: "response.created", response: { id, status: "in_progress" } }, + { type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "o" }, + { type: "response.incomplete", response: { id, status: "incomplete", incomplete_details: { reason: "length" }, usage } }, + ]; +} + +function dataFrames(wire: string): string[] { + return wire.split("\n").filter(line => line.startsWith("data: ")).map(line => line.slice(6)); +} + +function finishReasons(wire: string): unknown[] { + return dataFrames(wire).filter(data => data !== "[DONE]").flatMap(data => { + const chunk = JSON.parse(data) as { choices?: Array<{ finish_reason?: unknown }>; error?: unknown }; + if (chunk.error !== undefined) return [""]; + return (chunk.choices ?? []).map(choice => choice.finish_reason).filter(reason => reason != null); + }); +} + +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", "max_messages", "steered", undefined]) { + expect("failMessage" in chatCompletionsIncompleteOutcome(reason, undefined)).toBe(true); + } + }); + + test("streamed SSE wire ends with finish_reason length and [DONE]", async () => { + const budget = createTestTranslatorBudget(); + const wire = await new Response(responsesSseToChatCompletionsSse(sse(lengthTurn("resp_s")), MODEL, { translatorBudget: budget })).text(); + expect(finishReasons(wire)).toEqual(["length"]); + expect(dataFrames(wire).at(-1)).toBe("[DONE]"); + }); + + test("buffered (stream:false) projection of an SSE turn returns finish_reason length", async () => { + const budget = createTestTranslatorBudget(); + const chat = responsesSseToChatCompletionsSse(sse(lengthTurn("resp_1")), MODEL, { translatorBudget: budget }); + const completion = await collectChatCompletion(chat, MODEL, 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" }] }], + }, MODEL); + const choice = (completion.choices as Array>)[0]!; + expect(choice.finish_reason).toBe("length"); + }); +}); + +describe("handleChatCompletions with an openai-responses upstream reporting \"length\"", () => { + let upstream: ReturnType | undefined; + let releaseSpendHome: (() => void) | undefined; + afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = undefined; + }); + + async function dispatch(stream: boolean, reply: () => Response): Promise { + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: reply }); + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + authMode: "key", apiKey: "fixture-key", allowPrivateNetwork: true, models: ["model"], + } } }; + releaseSpendHome ??= acquireOwnedSpendHome(); + return handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream, max_tokens: 16, messages: [{ role: "user", content: "fixture" }] }), + }), config, { model: "", provider: "" }, { requestId: `chat-length-${crypto.randomUUID()}`, start: Date.now() }); + } + + const sseReply = () => new Response(sseText(lengthTurn("resp_h")), { headers: { "content-type": "text/event-stream" } }); + const jsonReply = () => Response.json({ + id: "resp_j", status: "incomplete", incomplete_details: { reason: "length" }, usage, + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "o" }] }], + }); + + test.each([["SSE", sseReply], ["JSON", jsonReply]] as const)("%s upstream, stream:true client gets a length wire", async (_, reply) => { + const response = await dispatch(true, reply); + expect(response.status).toBe(200); + const wire = await response.text(); + expect(finishReasons(wire)).toEqual(["length"]); + expect(dataFrames(wire).at(-1)).toBe("[DONE]"); + }); + + test.each([["SSE", sseReply], ["JSON", jsonReply]] as const)("%s upstream, stream:false client gets a length completion", async (_, reply) => { + const response = await dispatch(false, reply); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ choices: [{ finish_reason: "length" }] }); + }); +});