Stream the inference proxy's provider events as they arrive (#263)

a37c49dee350 · Christopher David · · parent 58e6347eeb72

Stream the inference proxy's provider events as they arrive (#263)

The proxy buffered the whole provider stream and wrote one SSE body after
the vendor finished, so thinking tokens reached clients in one lump at the
end of a turn's message — the coder TUI showed nothing while the model
reasoned. The Vercel gateway streams reasoning deltas token by token and
our decoder already normalizes them; this hop was the buffer.

The stream now opens (chunked 200, SSE headers, the grant's lane in
x-openagents-model) before the first provider event, and every event is
translated and flushed as it arrives. A fallback disclosure corrects the
model named on the chunks that follow it, exactly as the body-level
attribution did.

Costs, accepted and tested:
- a provider failure after the stream opened can no longer be a clean
  502; it arrives as a terminal provider_failed error frame carrying the
  same bounded reason class (and upstream status when known) the JSON
  refusal carried, then [DONE]
- the response header can no longer be corrected after the fact, so a
  call an undisclosed fallback served names the requested lane in the
  header; the unresolved attribution still lives in the metered usage
  record (METER-001) and the analytics events

The Responses surface already flushed as it happened; both surfaces now
agree.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 473 · 2026-08-27T19:19:59.177981Z
built
2 modules in 164.1 s
deployed
live · 2 modules on 3 nodes · push→live 29735.1 s

Changed files

  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • modified test/openagents_web/controllers/inference_proxy_fallback_test.exs

Diff

3 files changed, +234 -64

lib/openagents_web/controllers/inference_proxy_controller.ex modified +133 -50

@@ -28,9 +28,18 @@ defmodule OpenAgentsWeb.InferenceProxyController do

28 28
  attributed `unresolved` and priced at nothing, because naming the requested
29 29
  model would be a claim the deployment cannot support.
30 30
31
  The probe→proxy hop is buffered (the provider still streams from the vendor
32
  internally); probe's transport reads the whole body before parsing, so this
33
  matches its consumer and keeps failure handling honest.
31
  ## The stream is flushed as it happens
32
33
  Provider events are written to the client one chunk at a time as the adapter
34
  emits them, so a client renders reasoning and text tokens while the vendor is
35
  still writing them (#263). The provider still streams from its vendor
36
  internally; this hop no longer collects the events and answers after the
37
  fact. The cost is honest and accepted: committing to a chunked response
38
  means a provider failure can no longer be a clean non-200 status, so a
39
  failure after the stream opened arrives as a terminal `provider_failed`
40
  frame in the stream body, carrying the same bounded reason class the JSON
41
  refusal carried (`error.reason`, with `error.upstream_status` when known) —
42
  never raw provider detail.
34 43
  """
35 44
36 45
  use OpenAgentsWeb, :controller

@@ -197,24 +206,47 @@ defmodule OpenAgentsWeb.InferenceProxyController do

197 206
198 207
  # ── run + translate ─────────────────────────────────────────────────────
199 208
209
  # Per-event state rides the process dictionary of this request's own
210
  # process: the callback cannot rebind outer variables (the conn that carries
211
  # sent chunks does not survive a closure either), and the state lives and
212
  # dies with this one request.
213
  @state_events :proxy_stream_events
214
  @state_usage :proxy_stream_usage
215
  @state_served :proxy_stream_served_model
216
  @state_conn :proxy_stream_conn
217
200 218
  defp run(conn, grant, model, request) do
201 219
    selection = selection_properties(grant, model, request, conn.body_params)
202 220
    Analytics.capture("inference_model_selected", analytics_distinct_id(grant), selection)
203 221
204
    parent = self()
205
206
    # The provider pushes events synchronously; capture them to this process's
207
    # mailbox and drain in order once the call returns.
208
    result = model.adapter.stream(request, fn event -> send(parent, {:proxy_event, event}) end)
209
    events = drain_events([])
222
    # The stream opens before the first provider event, so the status and the
223
    # model attribution commit early. The header names the grant's lane; a
224
    # fallback that answered under another name still corrects every chunk and
225
    # the final attribution exactly as it did when the whole body was written
226
    # at the end.
227
    conn =
228
      conn
229
      |> put_resp_content_type("text/event-stream")
230
      |> put_resp_header("cache-control", "no-store")
231
      |> put_resp_header("x-openagents-model", model.id)
232
      |> send_chunked(200)
233
234
    Process.put(@state_conn, conn)
235
236
    # The provider pushes events synchronously, and each one is translated and
237
    # written to the client as it arrives (#263): a reasoning or text token
238
    # reaches the caller while the vendor is still producing the next one.
239
    result = model.adapter.stream(request, &emit_event(conn, model, &1))
210 240
211 241
    case result do
212 242
      :ok ->
243
        events = drained_events()
244
213 245
        # What answered is read back off the response, never assumed from the
214 246
        # request: a gateway lane configured with fallback models can serve a
215 247
        # call for one model with another and still return 200 (METER-001).
216 248
        served = served_model(model, events)
217
        usage = usage_of(events)
249
        usage = drained_usage()
218 250
        _ = meter(grant, usage, served)
219 251
        record_health(model, served)
220 252

@@ -234,17 +266,15 @@ defmodule OpenAgentsWeb.InferenceProxyController do

234 266
          })
235 267
        )
236 268
237
        conn
238
        |> put_resp_content_type("text/event-stream")
239
        |> put_resp_header("cache-control", "no-store")
240
        |> put_resp_header("x-openagents-model", label)
241
        |> send_resp(200, sse_body(events, label))
269
        Enum.each(sse_chunks(events, usage, label), &write_chunk(conn, &1))
242 270
243 271
      {:error, reason} ->
272
        events = drained_events()
273
244 274
        # A failure that produced partial usage is still metered, against
245 275
        # whatever the partial response said was serving it — the tokens were
246 276
        # spent on that model whether or not the stream finished.
247
        usage = usage_of(events)
277
        usage = drained_usage()
248 278
        if usage != %{}, do: meter(grant, usage, served_model(model, events))
249 279
        class = OpenAgents.OperationalLog.code(reason)
250 280
        status = OpenAgents.OperationalLog.status(reason)

@@ -268,16 +298,82 @@ defmodule OpenAgentsWeb.InferenceProxyController do

268 298
          })
269 299
        )
270 300
271
        refuse(conn, {:provider_failed, class, status})
301
        # The 200 is already on the wire, so the failure travels as terminal
302
        # frames instead of a status: the same bounded class and upstream
303
        # status the JSON refusal would have carried, and nothing more.
304
        write_chunk(conn, data(%{"error" => stream_error(class, status)}))
305
        write_chunk(conn, "data: [DONE]\n\n")
306
    end
307
308
    Process.get(@state_conn) || conn
309
  end
310
311
  defp stream_error(class, status) do
312
    body = %{"code" => "provider_failed", "reason" => class}
313
    if status == nil, do: body, else: Map.put(body, "upstream_status", status)
314
  end
315
316
  defp emit_event(conn, model, event) do
317
    record_event(event)
318
    record_disclosure(event)
319
    chunks = event_chunks(event)
320
321
    if chunks != [] do
322
      label = chunk_model(model)
323
324
      Enum.each(chunks, fn payload ->
325
        write_chunk(conn, data(Map.put(payload, "model", label)))
326
      end)
327
    end
328
329
    :ok
330
  end
331
332
  # A fallback disclosure corrects the name on the very chunks that follow it;
333
  # before one arrives, every chunk names the grant's lane, exactly as the
334
  # pre-stream header does. METER-001/PROVIDER-002: the response says what
335
  # answered, not what was requested.
336
  defp record_disclosure({:model_served, name}) when is_binary(name) do
337
    case Models.fetch(name) do
338
      {:ok, %{id: id}} -> Process.put(@state_served, id)
339
      :error -> Process.put(@state_served, name)
272 340
    end
341
342
    :ok
343
  end
344
345
  defp record_disclosure(_event), do: :ok
346
347
  defp chunk_model(model) do
348
    Process.get(@state_served) || model.id
349
  end
350
351
  defp record_event({:usage, usage}) when is_map(usage) do
352
    Process.put(@state_usage, usage)
353
    Process.put(@state_events, [:usage | Process.get(@state_events) || []])
354
    :ok
355
  end
356
357
  defp record_event(event) do
358
    Process.put(@state_events, [event | Process.get(@state_events) || []])
359
    :ok
273 360
  end
274 361
275
  defp drain_events(acc) do
276
    receive do
277
      {:proxy_event, event} -> drain_events([event | acc])
278
    after
279
      0 -> Enum.reverse(acc)
362
  defp drained_events, do: Enum.reverse(Process.get(@state_events) || [])
363
364
  defp drained_usage, do: Process.get(@state_usage) || %{}
365
366
  # The latest conn always comes off the process dictionary: chunk/2 returns a
367
  # new conn carrying the accumulated body, so feeding each call the stale
368
  # closure conn would restart the body from zero, and the controller has to
369
  # return a conn that holds the whole response.
370
  defp write_chunk(conn, payload) do
371
    case Plug.Conn.chunk(Process.get(@state_conn) || conn, payload) do
372
      {:ok, sent} -> Process.put(@state_conn, sent)
373
      {:error, _closed} -> :ok
280 374
    end
375
376
    :ok
281 377
  end
282 378
283 379
  defp meter(grant, usage, _served) when usage == %{}, do: {:ok, grant}

@@ -394,53 +490,40 @@ defmodule OpenAgentsWeb.InferenceProxyController do

394 490
    end
395 491
  end
396 492
397
  defp usage_of(events) do
398
    Enum.reduce(events, %{}, fn
399
      {:usage, usage}, _acc -> usage
400
      _event, acc -> acc
401
    end)
402
  end
403
404
  # Translate the ordered provider events into a chat-completions SSE body.
405
  # Every chunk carries the effective model id, the field an OpenAI-compatible
406
  # parser already reads as "the model that answered".
407
  defp sse_body(events, model_id) do
493
  # The terminal frames a finished stream closes with: one finish_reason chunk
494
  # (tool_calls when the provider asked for a tool, stop otherwise) and the
495
  # usage chunk when the provider reported one.
496
  defp sse_chunks(events, usage, model_id) do
408 497
    saw_tool_call = Enum.any?(events, &match?({:tool_call, _}, &1))
409 498
    finish_reason = if saw_tool_call, do: "tool_calls", else: "stop"
410 499
411
    chunks =
412
      events
413
      |> Enum.with_index()
414
      |> Enum.flat_map(fn {event, index} -> event_chunks(event, index) end)
415
416 500
    finish = [%{"choices" => [%{"index" => 0, "delta" => %{}, "finish_reason" => finish_reason}]}]
417 501
418 502
    usage_chunk =
419
      case usage_of(events) do
503
      case usage do
420 504
        usage when usage == %{} -> []
421 505
        usage -> [%{"choices" => [], "usage" => wire_usage(usage)}]
422 506
      end
423 507
424
    frames =
425
      Enum.map(chunks ++ finish ++ usage_chunk, fn payload ->
426
        data(Map.put(payload, "model", model_id))
427
      end)
428
429
    IO.iodata_to_binary([frames, "data: [DONE]\n\n"])
508
    Enum.map(finish ++ usage_chunk, fn payload ->
509
      data(Map.put(payload, "model", model_id))
510
    end) ++ ["data: [DONE]\n\n"]
430 511
  end
431 512
432
  defp event_chunks({:text_delta, text}, _index) when text != "" do
513
  # One provider event in, its chat-completions chunk out, flushed before the
514
  # next event is asked for.
515
  defp event_chunks({:text_delta, text}) when text != "" do
433 516
    [%{"choices" => [%{"index" => 0, "delta" => %{"content" => text}}]}]
434 517
  end
435 518
436 519
  # Reasoning rides the OpenRouter chat-completions extension field —
437 520
  # `delta.reasoning` alongside `delta.content` — the shape the CLI's
438 521
  # OpenAI-compatible parser already expects from that vendor surface.
439
  defp event_chunks({:reasoning_delta, text}, _index) when text != "" do
522
  defp event_chunks({:reasoning_delta, text}) when text != "" do
440 523
    [%{"choices" => [%{"index" => 0, "delta" => %{"reasoning" => text}}]}]
441 524
  end
442 525
443
  defp event_chunks({:tool_call, tool_call}, index) do
526
  defp event_chunks({:tool_call, tool_call}) do
444 527
    [
445 528
      %{
446 529
        "choices" => [

@@ -449,7 +532,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

449 532
            "delta" => %{
450 533
              "tool_calls" => [
451 534
                %{
452
                  "index" => index,
535
                  "index" => 0,
453 536
                  "id" => tool_call.call_id,
454 537
                  "type" => "function",
455 538
                  "function" => %{

@@ -465,7 +548,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

465 548
    ]
466 549
  end
467 550
468
  defp event_chunks(_event, _index), do: []
551
  defp event_chunks(_event), do: []
469 552
470 553
  defp wire_usage(usage) do
471 554
    input = integer(usage["input_tokens"] || usage[:input_tokens])
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +78 -9

@@ -191,6 +191,66 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

191 191
    assert Enum.any?(decoded, &(get_in(&1, ["choices", Access.at(0), "finish_reason"]) == "stop"))
192 192
  end
193 193
194
  test "reasoning and text chunks reach the wire in the order the model wrote them", %{
195
    conn: conn
196
  } do
197
    # #263: the point of streaming is that each token is flushed as the vendor
198
    # produces it, so the reasoning deltas must appear on the wire before the
199
    # text deltas, in the provider's own order — not all at once after the
200
    # turn finished.
201
    %{token: token} = grant("reasoning-order")
202
203
    conn =
204
      post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "[reasoning]"}]})
205
206
    assert conn.status == 200
207
208
    kinds =
209
      conn.resp_body
210
      |> sse_events()
211
      |> Enum.filter(&(&1 != "[DONE]"))
212
      |> Enum.map(&Jason.decode!/1)
213
      |> Enum.map(fn chunk ->
214
        delta = get_in(chunk, ["choices", Access.at(0), "delta"]) || %{}
215
216
        cond do
217
          Map.has_key?(delta, "reasoning") -> :reasoning
218
          Map.has_key?(delta, "content") -> :content
219
          true -> :other
220
        end
221
      end)
222
      |> Enum.reject(&(&1 == :other))
223
224
    assert kinds == [:reasoning, :reasoning, :content]
225
226
    # Every chunk was flushed while the state is chunked — the response the
227
    # caller reads is a genuine incremental stream, not one buffered body.
228
    assert conn.state == :chunked
229
  end
230
231
  test "a failure after the stream opened ends it with an error frame", %{conn: conn} do
232
    # The provider emitted nothing before failing; the error frame is terminal
233
    # and no finish_reason chunk follows it.
234
    %{token: token} = grant("fail-midstream")
235
236
    conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "[fail]"}]})
237
238
    assert conn.status == 200
239
    events = sse_events(conn.resp_body)
240
    assert List.last(events) == "[DONE]"
241
242
    decoded =
243
      events
244
      |> Enum.slice(0..-2//1)
245
      |> Enum.map(&Jason.decode!/1)
246
247
    assert [%{"error" => %{"code" => "provider_failed", "reason" => "provider_failed"}}] = decoded
248
249
    refute Enum.any?(decoded, fn chunk ->
250
             get_in(chunk, ["choices", Access.at(0), "finish_reason"])
251
           end)
252
  end
253
194 254
  test "a provider tool call reaches the caller as a tool_calls delta", %{conn: conn} do
195 255
    %{token: token} = grant("tool-out")
196 256

@@ -342,20 +402,29 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

342 402
    assert Jason.decode!(conn.resp_body)["error"]["code"] == "grant_revoked"
343 403
  end
344 404
345
  test "a provider failure surfaces as a bounded error, never raw detail", %{conn: conn} do
405
  test "a provider failure surfaces as terminal error frames, never raw detail", %{conn: conn} do
346 406
    %{token: token} = grant("fail")
347 407
    conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "[fail]"}]})
348
    assert conn.status == 502
349
    body = Jason.decode!(conn.resp_body)
350
    assert body["error"]["code"] == "provider_failed"
351 408
352
    # The failure class travels with the refusal so a client can say more than
353
    # "something went wrong", but it is the reason's atom tag only.
354
    assert body["error"]["reason"] == "provider_failed"
409
    # The stream is flushed as it happens (#263), so the 200 commits before the
410
    # provider is known to have failed. The failure travels as a terminal
411
    # `provider_failed` error frame carrying the same bounded class a JSON
412
    # refusal would have carried — and nothing raw.
413
    assert conn.status == 200
414
415
    body = conn.resp_body
416
    assert body =~ ~s("code":"provider_failed")
417
    assert body =~ ~s("reason":"provider_failed")
418
419
    # The stream still closes the way a parser expects.
420
    assert body =~ "data: [DONE]"
355 421
356 422
    # No raw provider detail leaks. `OperationalLog.code/1` takes the tag and
357
    # drops the detail, which is what keeps the line above safe to send.
358
    refute conn.resp_body =~ "test_failure"
423
    # drops the detail, which is what keeps the lines above safe to send.
424
    refute body =~ "test_failure"
425
426
    # No token-shaped content chunks were emitted for a failed turn.
427
    refute body =~ ~s("delta":{"content"})
359 428
  end
360 429
361 430
  test "an empty message set is refused", %{conn: conn} do
test/openagents_web/controllers/inference_proxy_fallback_test.exs modified +23 -5

@@ -95,13 +95,25 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

95 95
96 96
      conn = call(conn, token)
97 97
98
      assert get_resp_header(conn, "x-openagents-model") == ["openai/gpt-5.6-luna"]
98
      # The stream (#263) opens before any provider event, so the header names
99
      # the lane the call addressed. The disclosure corrects the attribution on
100
      # the response body itself — every chunk carries the model that answered.
101
      assert get_resp_header(conn, "x-openagents-model") == [@gemini]
102
103
      chunks =
104
        for chunk <- String.split(conn.resp_body, "\n\n", trim: true),
105
            chunk != "data: [DONE]" do
106
          chunk |> String.replace_prefix("data: ", "") |> Jason.decode!()
107
        end
99 108
100
      for chunk <- String.split(conn.resp_body, "\n\n", trim: true),
101
          chunk != "data: [DONE]" do
102
        payload = chunk |> String.replace_prefix("data: ", "") |> Jason.decode!()
109
      for payload <- chunks do
103 110
        assert payload["model"] == "openai/gpt-5.6-luna"
104 111
      end
112
113
      # The first frame is the disclosure event's own neighbour, not a text
114
      # chunk: a silent disclosure still corrects everything on the wire after
115
      # it, including the terminal finish and usage frames.
116
      assert length(chunks) >= 2
105 117
    end
106 118
107 119
    test "counts against the requested lane's health, not for it", %{conn: conn} do

@@ -170,7 +182,13 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

170 182
      assert metered.usage["served_model"] == Inference.unresolved_model()
171 183
      assert metered.usage["pricing_id"] == Pricing.unpriced()
172 184
      refute Map.has_key?(metered.usage, "estimated_cost_microusd")
173
      assert get_resp_header(conn, "x-openagents-model") == [Inference.unresolved_model()]
185
186
      # The stream opens before any provider event, so the header names the
187
      # lane the call addressed; with the answer silent there is nothing to
188
      # correct it with, and naming `unresolved` there would require buffering
189
      # the whole stream again (#263). The unresolved attribution lives where
190
      # the record is: the metered usage above.
191
      assert get_resp_header(conn, "x-openagents-model") == [@gemini]
174 192
    end
175 193
176 194
    test "records no health for the requested lane either way", %{conn: conn} do

This page updates live while a promote is in flight · changelog