Skip to content
Merged
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
3 changes: 3 additions & 0 deletions scripts/test-layout/layout-chat.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"chat-incomplete-length-reason.test.ts": "responses"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
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";
import { reasoningDetailEntriesForItem } from "./reasoning-details";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
});
Expand Down
3 changes: 2 additions & 1 deletion structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses-wire-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
134 changes: 134 additions & 0 deletions tests/responses/chat-incomplete-length-reason.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>): string {
return events.map(e => `event: ${String(e.type)}\ndata: ${JSON.stringify(e)}\n\n`).join("");
}

function sse(events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
const text = sseText(events);
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});
}

function lengthTurn(id: string): Array<Record<string, unknown>> {
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 ["<error>"];
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<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" }] }],
}, MODEL);
const choice = (completion.choices as Array<Record<string, unknown>>)[0]!;
expect(choice.finish_reason).toBe("length");
});
});

describe("handleChatCompletions with an openai-responses upstream reporting \"length\"", () => {
let upstream: ReturnType<typeof Bun.serve> | undefined;
let releaseSpendHome: (() => void) | undefined;
afterEach(async () => {
releaseSpendHome?.();
releaseSpendHome = undefined;
await upstream?.stop(true);
upstream = undefined;
});

async function dispatch(stream: boolean, reply: () => Response): Promise<Response> {
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" }] });
});
});
Loading