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: 8 additions & 7 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
// being reported as a clean completion (silent truncation). A graceful close is either an
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
let sawFinish = false;
let finishReason: string | undefined;

// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
Expand All @@ -615,7 +615,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
yield* flushToolCalls();
yield { type: "done", usage: pendingUsage };
yield { type: "done", usage: pendingUsage, ...(finishReason ? { stopReason: finishReason } : {}) };
return "terminate";
}

Expand Down Expand Up @@ -649,7 +649,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
// Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
// no delta) is a graceful close and must mark sawFinish even though we skip it below.
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
sawFinish = true;
finishReason = choices[0].finish_reason;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize length finish reasons before done

When an OpenAI-compatible upstream returns finish_reason: "length" (the Chat Completions signal for hitting the output-token limit), this stores and later emits stopReason: "length". The Responses bridge only converts stopReason === "max_tokens" to response.incomplete (src/bridge.ts), so both streaming here and the analogous non-streaming spread at line 769 still surface token-exhausted OpenAI-chat completions as response.completed instead of response.incomplete. Please normalize length to max_tokens before emitting the done event, or teach the bridge to treat length as the same truncation reason.

Useful? React with 👍 / 👎.

}
const delta = choices[0].delta;
if (delta) {
Expand Down Expand Up @@ -717,15 +717,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}
yield* flushToolCalls();
// Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
// a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
// a non-null finish_reason (finishReason), or a trailing usage chunk (providers emit usage only
// at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
// closed so the bridge emits a classified response.failed rather than a silent truncation.
if (!sawFinish && pendingUsage === undefined) {
if (!finishReason && pendingUsage === undefined) {
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
return;
}
// Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
yield { type: "done", usage: pendingUsage };
yield { type: "done", usage: pendingUsage, ...(finishReason ? { stopReason: finishReason } : {}) };
} finally {
reader.releaseLock();
}
Expand All @@ -742,7 +742,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}

const events: AdapterEvent[] = [];
const choices = json.choices as { message?: Record<string, unknown> }[] | undefined;
const choices = json.choices as { message?: Record<string, unknown>; finish_reason?: string }[] | undefined;
if (!Array.isArray(choices) || choices.length === 0 || !choices[0].message) {
return [{ type: "error", message: "upstream response contained no choices" }];
}
Expand All @@ -766,6 +766,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
events.push({
type: "done",
usage: usageFromOpenAIChat(usage),
...(choices[0].finish_reason ? { stopReason: choices[0].finish_reason } : {}),
});
return events;
},
Expand Down
166 changes: 166 additions & 0 deletions tests/openai-chat-finish-reason.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import type { AdapterEvent } from "../src/types";

const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" };

async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[]> {
const out: AdapterEvent[] = [];
for await (const e of gen) out.push(e);
return out;
}

function lastDone(events: AdapterEvent[]): { type: "done"; stopReason?: string } | undefined {
return events.find(e => e.type === "done") as { type: "done"; stopReason?: string } | undefined;
}

describe("openai-chat streaming finish_reason -> stopReason propagation", () => {
test("finish_reason=stop -> done with stopReason=stop", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("stop");
});

test("finish_reason=length -> done with stopReason=length", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"truncated"},"finish_reason":"length"}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("length");
});

test("finish_reason=max_tokens -> done with stopReason=max_tokens", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"x"},"finish_reason":"max_tokens"}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("max_tokens");
});

test("finish_reason=tool_calls -> done with stopReason=tool_calls", async () => {
const response = new Response('data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"f","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("tool_calls");
});

test("finish_reason=content_filter -> done with stopReason=content_filter", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"filtered"},"finish_reason":"content_filter"}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("content_filter");
});

test("[DONE] after finish_reason=length preserves the captured stopReason", async () => {
const response = new Response([
'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"length"}]}\n\n',
"data: [DONE]\n\n",
].join(""));
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("length");
});

test("[DONE] without any finish_reason -> done with stopReason=undefined", async () => {
const response = new Response([
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
"data: [DONE]\n\n",
].join(""));
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBeUndefined();
});

test("EOF without finish_reason and without [DONE] -> error (not done)", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = events.find(e => e.type === "done");
expect(done).toBeUndefined();
expect(events.at(-1)?.type).toBe("error");
});

test("usage-only final frame without finish_reason -> done with stopReason=undefined", async () => {
const response = new Response(
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n' +
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}'
);
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBeUndefined();
});
});

describe("openai-chat non-streaming finish_reason -> stopReason propagation", () => {
test("finish_reason=stop -> done with stopReason=stop", async () => {
const json = JSON.stringify({
choices: [{ message: { content: "hi" }, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("stop");
});

test("finish_reason=length -> done with stopReason=length", async () => {
const json = JSON.stringify({
choices: [{ message: { content: "truncated" }, finish_reason: "length" }],
usage: { prompt_tokens: 5, completion_tokens: 4096, total_tokens: 4101 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("length");
});

test("finish_reason=max_tokens -> done with stopReason=max_tokens", async () => {
const json = JSON.stringify({
choices: [{ message: { content: "truncated" }, finish_reason: "max_tokens" }],
usage: { prompt_tokens: 5, completion_tokens: 4096, total_tokens: 4101 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("max_tokens");
});

test("finish_reason=content_filter -> done with stopReason=content_filter", async () => {
const json = JSON.stringify({
choices: [{ message: { content: "" }, finish_reason: "content_filter" }],
usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("content_filter");
});

test("finish_reason=tool_calls -> done with stopReason=tool_calls", async () => {
const json = JSON.stringify({
choices: [{ message: { tool_calls: [{ id: "c1", function: { name: "f", arguments: "{}" } }] }, finish_reason: "tool_calls" }],
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBe("tool_calls");
});

test("missing finish_reason -> done with stopReason=undefined", async () => {
const json = JSON.stringify({
choices: [{ message: { content: "no finish reason" } }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
});
const events = await createOpenAIChatAdapter(provider).parseResponse(new Response(json));
const done = lastDone(events);
expect(done).toBeDefined();
expect(done!.stopReason).toBeUndefined();
});
});
Loading