fix(openai-path): SSE stream reassembly, tool-call preservation, empty-response detection - #228
Conversation
…, and empty-response handling
…/non-stream paths
…erage complete for all protocols
…repeated empties - response_headers frames carry no data field; judging them made every stream look empty and drove an endless account-switch loop - candidates:[]/choices:[] frames without terminal evidence are not empty - promptFeedback.blockReason passes through (blocked, not empty) - context disposal now requires 3 consecutive empty judgments on the same context; 429/403/5xx never dispose, keeping accounts hot
…eam start; per-account empty counters; shared empty-response helper - dump only fires on content frames with defined data (response_headers frames produced false 'empty' records) - error after stream start now goes through SSE error chunk instead of a second HTTP response (ERR_HTTP_HEADERS_SENT) - non-empty failure clears only the failing account's empty counter instead of wiping all accounts' counters
Attribute concurrent failures to the browser account that produced them. Start recovery from that account, skip fallback to it, and fail safe on unknown Anthropic content blocks.
Exclude local browser/tooling artifacts from repository-wide checks and normalize the existing converter fixtures and client shell.
Allow zero to disable inter-chunk timeouts, retain opt-in positive timeouts, and clamp configured values at 300 seconds.
Translate reasoning_effort into Gemini thinking settings without overriding native config. Expose cached input tokens in Chat and Responses usage.
|
@/tmp/comment-228.md |
- Flush trailing partial SSE before empty judgment at STREAM_END (Claude real stream, OpenAI real stream, Responses real stream), so a fragmented final event that reassembles in the buffer is not misjudged as empty. - Emit a protocol-safe SSE error via _sendErrorChunkToClient after headers are already sent (previously a silent _sendErrorResponse no-op), keeping exactly one auth switch + one error per true-empty stream. - Preserve control finish reasons (Gemini SAFETY/RECITATION/BLOCKLIST/ PROHIBITED_CONTENT/IMAGE_SAFETY; OpenAI content_filter/safety) as valid terminal results even with zero completion tokens; unknown/OTHER reasons are not exempted. - Catch Claude fake-stream aggregate terminal-empty and Responses initial complete-empty chunk, routing both into the existing single auth-failure + SSE error/retry flow instead of translating an empty stream. - Reset the consecutive empty-judgment counter for the served auth index on every success via shared _resetFailureStateOnSuccess helper. - Map Responses reasoning.effort (and top-level reasoning_effort alias) through THINKING_LEVEL_MAP to thinkingLevel, matching the chat path.
Studio clean-fix publish — current-head evidence (commit
|
| Gate | Result |
|---|---|
Targeted node --test test/formatConverter.test.js test/requestHandler.test.js |
67/67 pass |
npm test |
83/83 pass |
npm run lint |
0 errors (1 pre-existing warning in ui/app/pages/StatusPage.vue, not in this diff) |
npm run format:check |
clean (all files Prettier) |
npm run build:ui |
success (benign chunk-size warning only) |
git diff --check |
clean |
| Stage scope | staged name list == exactly the 5 files above; protected paths excluded |
Root causes fixed
- Terminal empty misjudgment before SSE flush — Claude real stream, OpenAI real stream, and Responses real stream now flush the trailing partial SSE event before judging emptiness at
STREAM_END; a fragmented final event that reassembles in the buffer is no longer misjudged as empty. - Silent no-op error after headers sent — the old JSON
_sendErrorResponsewas a silent no-op once headers were sent mid-stream. The flows now emit a protocol-safe SSE error via_sendErrorChunkToClient(JSON error only when headers not yet sent), keeping exactly one auth switch + one error per true-empty stream. - Legitimate control finishes misjudged as empty — Gemini
SAFETY/RECITATION/BLOCKLIST/PROHIBITED_CONTENT/IMAGE_SAFETYand OpenAIcontent_filter/safetyare preserved as valid terminal results even with zero completion tokens; unknown/OTHERreasons are not exempted. - Claude fake-stream aggregate terminal-empty — now judged via
_isEmptyUpstreamResponseand routed into the existing single auth-failure + SSE error flow instead of translating an empty stream to the client. - Responses initial complete-empty chunk — converted into the existing error/retry flow (one switch only, like the OpenAI chat real-stream path) before the translator can set
responseSent. - Empty counter not reset on success —
AuthSwitcher.resetEmptyJudgmentCountForAuth(index)clears the consecutive empty-judgment counter for the served auth index; all success sites route through the shared_resetFailureStateOnSuccesshelper. - Responses reasoning effort not mapped —
reasoning.effort(and the top-levelreasoning_effortalias) now maps throughTHINKING_LEVEL_MAPtothinkingLevel, matching the chat path; unknown efforts keepincludeThoughtsonly.
Remote PR state (verified via GitHub API)
headRefOid=faed97692e07c56762681b02737c037a321081da✅mergeable=MERGEABLE,mergeStateStatus=CLEAN✅- Checks: none reported — Studio has no pull_request CI by design.
Final tracked-clean status
git status shows zero tracked modifications; the only remaining untracked items are the protected exclusions (.npm-cache/, .serena/, configs/GASauth.zip, lima.REJECTED.yaml) which were never read, staged, or deleted.
… queue default Two independent regressions were identified and closed on the OpenAI chat fake-stream path and the message queue default timeout. Root cause 1: OpenAI chat fake-stream terminal-empty leak When the upstream body aggregates to empty (terminal emptiness), the code previously fell through into the normal translation/DONE path, producing a leaked empty completion instead of the established single auth-failure + SSE error flow. This mirrors the Response API and Claude fake-stream paths, which already perform a terminal emptiness judgment. The addition routes an empty aggregate through _handleAuthFailure exactly once (no duplicate switch) and _handleRequestError, ending the stream with no leaked empty completion. It also emits upstream correlation metadata for the openai-chat-fake-stream case. Root cause 2: no-arg MessageQueue timeout divergence The constructor default was 0, making a no-arg queue unlimited, which broke stream block-timeout defaults (STREAM_CHUNK relies on the no-arg finite default). Restored defaultTimeout to 300000 for no-arg construction while an explicit 0 remains unlimited, preserving the documented behavior both callers depend on. Tests: - requestHandler: OpenAI chat fake-stream terminal-empty guard, exactly-one auth switch, no leaked empty completion, and SSE error emission. - messageQueue: no-arg retains finite 300000ms default and times out; explicit 0 stays unlimited and remains pending until enqueue. Verified: 87/87 tests, lint, format:check, build:ui all green.
Studio final-review publish — current-head evidenceCommit: Gates (at this commit)
Scope — exactly four tracked files in this commit
Independent reviewReviewer agent-408 verdict: CLEAN — full test suite green, lint/format/build green, diff scope exactly the four files above. Changes
Worktree hygieneOnly untracked/gitignored local artifacts remain ( Please review at your convenience. |
Studio review finding — comment-only resolution (commit
|
| Gate | Result |
|---|---|
npx prettier --check src/core/RequestHandler.js |
clean |
npx eslint src/core/RequestHandler.js |
clean (0 errors) |
node --check src/core/RequestHandler.js |
pass |
npm run format:check |
all files Prettier |
npm run lint |
0 errors (1 pre-existing unrelated vue/no-v-html warning in ui/app/pages/StatusPage.vue) |
node --test |
87/87 pass, 0 fail |
Commit / push state:
- SHA
57e4f8c0a5807bdcdd4baef1f5edb1a66cef9323—docs(debug): clarify empty-response dumps - Clean fast-forward
b3f3b50..57e4f8c, no amend/reset/rebase/force. - PR head OID
57e4f8c0…matches local and remote;mergeable = MERGEABLE, state OPEN. - Staged exactly 1 file (
src/core/RequestHandler.js); tracked tree clean afterward; protected exclusions untouched.
Please review at your convenience.
|
To use Codex here, create a Codex account and connect to github. |
Studio P3 comment-only fix — current-head evidence (commit
|
|
To use Codex here, create a Codex account and connect to github. |
Summary
Fixes a class of bugs producing empty completions (
finish_reason: stop,completion_tokens: 0) and lost tool calls, especially on long thinking-model (Gemini 3.x) conversations with tool-call history:JSON.parseon raw network chunks; chunks split mid-event or carrying multiple events were silently dropped, losing content/tool calls and surfacing as empty 200s. Events are now buffered and split on\r?\n\r?\n(CRLF included), tolerant to fragmentation. Applied to all streaming paths (OpenAI chat, OpenAI Responses API, Claude/v1/messages).thoughtSignature(mandatory on Gemini 3.x) were filtered out as "thinking" in response translators — pure tool-call answers became empty responses. FunctionCall parts are now always emitted; only genuine thought text is filtered.functionResponseparts get their real names mapped from the assistant'stool_calls(was hardcodedunknown_function); consecutive same-rolecontentsentries are merged (strict user/model alternation required upstream).completion_tokens: 0) raise a retriable502 empty_upstream_responsefeeding the account-switch/retry path instead of silently returning an empty 200. Judgment happens only at completion — thinking-first streams are never aborted mid-flight. Failing browser contexts are disposed after repeated empties instead of being rotated back in; rotation index handling after disposal fixed; account switching guarded against empty pools and in-flight disposal races.JSON.parseon SSE text — all non-stream requests failed); now covered by the same detection and retry.immediateSwitchStatusCodesdefault is now[403, 429, 502, 503]— dead/limited/empty accounts trigger immediate account switch.node:testsuite (npm test, 22 tests, no new dependencies): SSE reassembly (LF/CRLF/fragmented/multi-event), empty-detection truth table, tool-call/name mapping, role merge.DUMP_EMPTY_UPSTREAM=/path.jsonlenv dumps raw payloads of judged-empty responses (inert when unset).Test plan
npm test— 22/22 pass (node:test, builtin runner)