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
16 changes: 1 addition & 15 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
const pendingToolCalls: PendingToolCall[] = [];
let toolCallSeq = 0;
const flushToolCalls = function* (): Generator<AdapterEvent> {
if (pendingToolCalls.length > 0) sawOutput = true;
for (const call of pendingToolCalls) {
if (!call.id) call.id = `call_${++toolCallSeq}`;
yield { type: "tool_call_start", id: call.id, name: call.name };
Expand All @@ -675,7 +674,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
let finishReason: string | undefined;
let sawOutput = false;

// 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 Down Expand Up @@ -742,11 +740,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
const delta = choices[0].delta;
if (delta) {
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
sawOutput = true;
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
}
if (typeof delta.content === "string" && delta.content.length > 0) {
sawOutput = true;
yield { type: "text_delta", text: delta.content };
}

Expand Down Expand Up @@ -804,23 +800,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
if (buffer.length > 0) {
if ((yield* handleDataLine(buffer)) === "terminate") return;
}
const hadPendingToolCallsAtEof = pendingToolCalls.length > 0;
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
// 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.
const sawFinish = finishReason !== undefined;
if (!sawFinish && pendingUsage === undefined && hadPendingToolCallsAtEof) {
debugProviderDiagnostic("openai-chat", "stream-truncated", {
finishReason: finishReason ?? null,
hadUsage: false,
pendingToolCalls: true,
});
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) - possible truncation" };
return;
}
if (!sawFinish && pendingUsage === undefined && !sawOutput) {
if (!sawFinish && pendingUsage === undefined) {
debugProviderDiagnostic("openai-chat", "stream-truncated", {
finishReason: finishReason ?? null,
hadUsage: false,
Expand Down
19 changes: 6 additions & 13 deletions tests/openai-chat-eof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,11 @@ async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[
}

describe("openai-chat stream EOF fail-closed", () => {
test("truncated stream (no [DONE], no finish_reason) yields done when content was emitted", async () => {
test("truncated stream (no [DONE], no finish_reason) yields a terminal error, not a clean done", async () => {
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}\n\n');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
const last = events[events.length - 1];
expect(last.type).toBe("done");
expect(events.some(e => e.type === "error")).toBe(false);
});

test("empty EOF without content still errors", async () => {
const response = new Response("");
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
expect(events.at(-1)?.type).toBe("error");
expect(last.type).toBe("error");
expect(events.some(e => e.type === "done")).toBe(false);
});

Expand Down Expand Up @@ -135,11 +128,11 @@ describe("openai-chat stream EOF fail-closed", () => {
expect(events.some(e => e.type === "error")).toBe(false);
});

test("genuinely truncated stream WITHOUT a trailing newline completes when content was emitted", async () => {
// Mid-content frame, no terminator, no newline — content was yielded, so accept done.
test("genuinely truncated stream WITHOUT a trailing newline still fails closed", async () => {
// Mid-content frame, no terminator, no newline — must remain a terminal error.
const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}');
const events = await collect(createOpenAIChatAdapter(provider).parseStream(response));
expect(events.at(-1)?.type).toBe("done");
expect(events.some(e => e.type === "error")).toBe(false);
expect(events.at(-1)?.type).toBe("error");
expect(events.some(e => e.type === "done")).toBe(false);
});
});
Loading