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
1 change: 1 addition & 0 deletions changelog.d/fixes/15505-claude-empty-end-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** a finished Claude stream with `end_turn` or `stop_sequence` and no content block stays a 200 ([#15505](https://github.com/diegosouzapw/OmniRoute/pull/15505) — thanks @HouMinXi)
12 changes: 10 additions & 2 deletions open-sse/utils/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,19 +464,21 @@ type ClaudeEmptyResponseLifecycle = {
hasMessageDelta: boolean;
hasMessageStop: boolean;
hasError: boolean;
stopReason: string | null;
syntheticContentInjected: boolean;
warningLogged: boolean;
};

const SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT = "";

function createClaudeEmptyResponseLifecycle(): ClaudeEmptyResponseLifecycle {
export function createClaudeEmptyResponseLifecycle(): ClaudeEmptyResponseLifecycle {
return {
hasMessageStart: false,
hasContentBlock: false,
hasMessageDelta: false,
hasMessageStop: false,
hasError: false,
stopReason: null,
syntheticContentInjected: false,
warningLogged: false,
};
Expand All @@ -492,7 +494,7 @@ function isClaudeEventPayload(payload: unknown): boolean {
return getClaudeEventType(payload) !== null;
}

function updateClaudeEmptyResponseLifecycle(
export function updateClaudeEmptyResponseLifecycle(
lifecycle: ClaudeEmptyResponseLifecycle,
payload: unknown
) {
Expand All @@ -510,6 +512,12 @@ function updateClaudeEmptyResponseLifecycle(
break;
case "message_delta":
lifecycle.hasMessageDelta = true;
{
const delta = (payload as JsonRecord).delta;
const reason =
delta && typeof delta === "object" ? (delta as JsonRecord).stop_reason : null;
if (typeof reason === "string" && reason) lifecycle.stopReason = reason;
}
break;
case "message_stop":
lifecycle.hasMessageStop = true;
Expand Down
10 changes: 10 additions & 0 deletions open-sse/utils/streamClaudeEmptyBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,23 @@ type ClaudeEmptyLifecycleLike = {
hasMessageStart: boolean;
hasMessageDelta: boolean;
hasMessageStop: boolean;
stopReason?: string | null;
};

const CLEAN_EMPTY_STOP_REASONS = new Set(["end_turn", "stop_sequence"]);

export function shouldAbortEmptyClaudeStream(
lifecycle: ClaudeEmptyLifecycleLike,
sawAnyUpstreamPayload: boolean
): boolean {
if (lifecycle.hasError || lifecycle.hasContentBlock) return false;
if (
sawAnyUpstreamPayload &&
lifecycle.hasMessageStop &&
CLEAN_EMPTY_STOP_REASONS.has(lifecycle.stopReason || "")
) {
return false;
}
const hasPartialLifecycle =
lifecycle.hasMessageStart || lifecycle.hasMessageDelta || lifecycle.hasMessageStop;
return hasPartialLifecycle || !sawAnyUpstreamPayload;
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/stream-claude-empty-end-turn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";

const { shouldAbortEmptyClaudeStream } =
await import("../../open-sse/utils/streamClaudeEmptyBody.ts");

const complete = {
hasError: false,
hasContentBlock: false,
hasMessageStart: true,
hasMessageDelta: true,
hasMessageStop: true,
};

test("a finished Claude stream with end_turn and no content block is a real empty answer", () => {
assert.equal(shouldAbortEmptyClaudeStream({ ...complete, stopReason: "end_turn" }, true), false);
});

test("a finished Claude stream with stop_sequence and no content block is a real empty answer", () => {
assert.equal(
shouldAbortEmptyClaudeStream({ ...complete, stopReason: "stop_sequence" }, true),
false
);
});

test("content_filter with no content block still aborts", () => {
assert.equal(
shouldAbortEmptyClaudeStream({ ...complete, stopReason: "content_filter" }, true),
true
);
});

test("a complete lifecycle with no stop_reason still aborts", () => {
assert.equal(shouldAbortEmptyClaudeStream({ ...complete, stopReason: null }, true), true);
});

test("a lifecycle that never reached message_stop still aborts", () => {
assert.equal(
shouldAbortEmptyClaudeStream(
{ ...complete, hasMessageStop: false, stopReason: "end_turn" },
true
),
true
);
});

test("a zero-byte Claude stream still aborts even if a stop reason was guessed", () => {
assert.equal(shouldAbortEmptyClaudeStream({ ...complete, stopReason: "end_turn" }, false), true);
});

test("message_delta stores stop_reason for the empty-stream decision", async () => {
const { createClaudeEmptyResponseLifecycle, updateClaudeEmptyResponseLifecycle } =
await import("../../open-sse/utils/stream.ts");
const lifecycle = createClaudeEmptyResponseLifecycle();
updateClaudeEmptyResponseLifecycle(lifecycle, {
type: "message_delta",
delta: { stop_reason: "end_turn" },
});
updateClaudeEmptyResponseLifecycle(lifecycle, { type: "message_stop" });
assert.equal(lifecycle.stopReason, "end_turn");
assert.equal(lifecycle.hasMessageStop, true);
assert.equal(shouldAbortEmptyClaudeStream(lifecycle, true), false);
});
Loading