Skip to content

fix(levelcode): stop billing streams that failed upstream, and keep a silent stream alive - #452

Merged
ndemianc merged 5 commits into
developfrom
fix/levelcode-stream-upstream-failures
Oct 15, 2026
Merged

ndemianc merged 5 commits into
developfrom
fix/levelcode-stream-upstream-failures

Conversation

@ndemianc

Copy link
Copy Markdown
Member

Two fixes to Api::Levelcode::V1::AiController#stream_chat, from what Gemini 4 Argon did on Google Vertex on 2026-10-15. They are separate commits so either can be reverted alone.

What was seen

OpenRouter's log for a failing Argon turn: one upstream request, Attempts 1 (0 failed), a 200 after 2.2 s, then 187 tokens over 126 s (1.5 tok/s), recorded with a 504 and a cost of $0.00. The editor showed a bare terminated. The turn's footer still showed a credit charge.

1. A stream that failed upstream no longer bills the reservation (69a3d6c)

finalize_stream! treated "content streamed, no usage chunk" as the client having hung up and charged the reserved estimate, which is the worst case for the turn (input plus the full output ceiling). Two ways that fired for a turn the customer did not get:

  • the provider errors after some content (an error frame becomes UpstreamError);
  • the provider's connection drops. EOFError is an IOError, so it landed in the same rescue as a client hang-up.

Reproduced in a spec before the fix: the ledger got a 5,000-micro charge for a turn OpenRouter billed at $0.

Now only a failed write to the client is a hang-up (tee_chunk remembers it). A failure on the provider's side or ours releases the reservation.

Case Before After
provider error, no content released released
provider error after the usage chunk real usage billed real usage billed
client hangs up after content reservation charged reservation charged
provider error after content reservation charged released
provider connection drops after content reservation charged released

The first three rows are pinned in 197ad60 and pass on the unchanged code.

2. Keep-alive while the provider is silent (3ac3911)

The adapter drops every line that is not data:, so while a reasoning model thinks the editor gets nothing. The proxy in front of the app closes an idle connection (nginx's proxy_read_timeout is 60 s by default), which the editor reports as terminated.

While the stream has been quiet for KEEPALIVE_INTERVAL (15 s) the gateway writes : keepalive. The editor reads only data: lines (openaiCompat.js returns early on anything else), so it ignores the comment. The thread is joined before the stream closes, so nothing lands after [DONE], and an upstream error frame still arrives after a silence.

Not changed

  • Provider routing (which Google endpoint serves Argon). Whether AI Studio is healthier than Vertex is being measured separately.
  • READ_TIMEOUT stays 300 s. It is a silence limit already, and shortening it would cut reasoning models that legitimately think for minutes.
  • The editor still shows the runtime's terminated for a stream that ends without [DONE] or an error frame.

Tests

  • spec/requests/api/levelcode/v1/ai_spec.rb: 45 to 54 examples, 0 failures.
  • spec/services/levelcode, levelcode_plans_spec, levelcode_model_catalog_spec: 478 examples, 0 failures.
  • rubocop clean on both files.
  • Each rule was broken in turn and failed only its own examples: dropping && !@stream_failed (2 failures), not remembering a failed client write (1), treating every IOError as a hang-up again (1), never stopping the keep-alive thread (1), never starting it (2).

Not verified

  • That the idle proxy limit is what cut the connection. The proxy configuration is not in this repo. The keep-alive is cheap and also helps against any other idle limit, but the cause is an inference.
  • After deploy, run one long Argon turn in the editor and confirm it either completes or ends with the service-unavailable message, not terminated.
  • The billing change moves money, so it deserves a look before it ships: a client that disconnects mid-turn is still charged the estimate, as before.

Three cases, run against the unchanged gateway: a provider error before any
content releases the reservation, a provider error after the usage chunk bills
the real usage, and a client that hangs up after content is charged the
reserved estimate. Spec-only, so the next commit's behaviour change shows up
as a diff to these.
…ailed upstream

finalize_stream! treated 'content streamed, no usage chunk' as a client
hang-up and charged the reservation, which is the worst case for the turn
(input plus the full output ceiling). A provider that errors after streaming
some content, or whose connection drops (EOFError is an IOError, so it landed
in the hang-up rescue), left the customer paying that estimate for a turn
OpenRouter itself billed at $0.00. Seen on Gemini 4 Argon / Google Vertex.

Only a failed write to the client is a hang-up now: tee_chunk remembers it.
A failure on the provider's or our side releases the reservation. A usage
chunk that did arrive is still billed at its real cost, and a client that
walks away after content is still charged, both pinned in the previous commit.
…silent

A reasoning model can think for minutes before its first token, and the
gateway sent the editor nothing meanwhile. The proxy in front of the app
closes an idle connection (nginx's proxy_read_timeout defaults to 60 s), which
the editor shows as a bare 'terminated' with no explanation. Gemini 4 Argon on
Google Vertex streamed at about 1 token a second on 2026-10-15, so a single
turn ran past two minutes.

While the stream is quiet for KEEPALIVE_INTERVAL (15 s) the gateway writes an
SSE comment. The editor only reads 'data:' lines, so it ignores it; the proxy
sees traffic. The thread is joined before the stream closes, so nothing lands
after [DONE], and an upstream error frame still arrives after the silence.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 05:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Keep-alive lifecycle handling permits writes after [DONE] and skips reservation cleanup if thread startup fails.

2 open findings
What changed in this PR

Updates streaming chat to release reservations on upstream failures and keep silent provider connections alive.

Changes:

  • Distinguishes provider failures from client disconnects for billing.
  • Adds periodic SSE keep-alive comments.
  • Adds settlement and keep-alive regression tests.
File Description
spec/​requests/​api/​levelcode/​v1/​ai_spec.rb Tests billing outcomes and keep-alive behavior.
app/​controllers/​api/​levelcode/​v1/​ai_controller.rb Updates failure settlement and adds keep-alive lifecycle handling.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/controllers/api/levelcode/v1/ai_controller.rb Outdated
Comment thread app/controllers/api/levelcode/v1/ai_controller.rb
start_keepalive ran after the reservation was taken but before the begin/ensure
that releases it, so anything it raised (ThreadError under thread exhaustion)
skipped settlement and never closed the stream. It now runs inside the block.

It also no longer costs the customer the turn when it cannot start: thread
creation sits behind spawn_keepalive_thread, and a failure there is logged and
the turn streams without a keep-alive. Review finding on #452.
stop_keepalive ran only in the ensure, after [DONE] had been written. A worker
that had already passed its idle check could still write its comment after the
terminator, and joining it afterwards only waited for that late write. The new
spec holds the worker mid-write as the stream ends and saw
'data: [DONE]' followed by ': keepalive'.

stop_keepalive is now called immediately before [DONE] and is idempotent, so
the ensure still calls it on every error path. Review finding on #452.
@ndemianc
ndemianc merged commit 241951b into develop Oct 15, 2026
3 checks passed
@ndemianc
ndemianc deleted the fix/levelcode-stream-upstream-failures branch October 15, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants