inference: omit gateway fallback on pinned grants (refs OpenAgentsInc/openagents#258)

52a9102bcbd6 · AtlantisPleb · · parent a37c49dee350

inference: omit gateway fallback on pinned grants (refs OpenAgentsInc/openagents#258)

A grant that named a model is a pin. The Vercel adapter no longer attaches
providerOptions.gateway.models on that call, and keeps the Vertex order pin.
Unnamed-model selection still sends the configured fallback list.

If the gateway still substitutes on a pinned grant, the proxy does not return
200: the serving model must be the grant's public id or that entry's
provider_model, or the call is a typed model_substituted refusal.

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/providers/vercel_gateway.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified test/openagents/providers/vercel_gateway_test.exs
  • modified test/openagents_web/controllers/inference_proxy_fallback_test.exs

Diff

5 files changed, +295 -92

INVARIANTS.md modified +17 -9

@@ -1577,15 +1577,23 @@ Concretely:

1577 1577
  that named one gets that one or an error.
1578 1578
- Amended 2026-08-25 (#250): one lane can be substituted for *by its provider*,
1579 1579
  and the response says so rather than the host pretending otherwise. Where
1580
  `config :openagents, :vercel_gateway_fallback_models` is set, Vercel answers
1581
  a failed primary with another model and returns 200. The proxy reads the
1582
  serving model back off the response and attributes that, so the header and
1583
  the chunks name what actually ran. Where such a lane discloses no model, the
1584
  attribution is the word `unresolved` — not the requested model, which the
1585
  deployment cannot claim served. An adapter says whether it can be
1586
  substituted for (`OpenAgents.Providers.Provider.substitutable?/0`, false
1587
  where it is not exported), so silence from a lane that cannot substitute
1588
  still means the model that was asked for.
1580
  `config :openagents, :vercel_gateway_fallback_models` is set **and the call
1581
  did not name a model**, Vercel answers a failed primary with another model
1582
  and returns 200. The proxy reads the serving model back off the response
1583
  and attributes that, so the header and the chunks name what actually ran.
1584
  Where such a lane discloses no model, the attribution is the word
1585
  `unresolved` — not the requested model, which the deployment cannot claim
1586
  served. An adapter says whether it can be substituted for
1587
  (`OpenAgents.Providers.Provider.substitutable?/0`, false where it is not
1588
  exported), so silence from a lane that cannot substitute still means the
1589
  model that was asked for.
1590
- Amended 2026-08-27 (#258): a grant that named a model is a pin. The proxy
1591
  does not send `providerOptions.gateway.models` on that call; the Vertex
1592
  `order` pin stays. Fallback remains only for unnamed-model selection
1593
  (neither the mint nor the call named a model). If the gateway still
1594
  substitutes on a pinned grant, the proxy does not return 200: the serving
1595
  model must be the grant's public id or that entry's `provider_model`, or
1596
  the call is a typed `model_substituted` refusal.
1589 1597
- Amended 2026-08-25 (#199): where **nothing named a model** — neither the
1590 1598
  mint nor the call — the server selects, preferring a configured lane that is
1591 1599
  not `degraded` in catalog order and falling back to the catalog default when
lib/openagents/providers/vercel_gateway.ex modified +17 -8

@@ -24,15 +24,21 @@ defmodule OpenAgents.Providers.VercelGateway do

24 24
  `providerOptions.gateway.order` tries Vertex first, because the same slug is
25 25
  also served by `google` — the Generative Language endpoint, which is not
26 26
  where the credits are. `providerOptions.gateway.models` lists the fallback
27
  models Vercel tries if the primary model fails.
28
29
  That list is why this lane reports `substitutable?/0` as true: a call for
30
  `google/gemini-3.7-flash` can be answered by `zai/glm-5.3` and still return
27
  models Vercel tries if the primary model fails, and it is attached only when
28
  the call allows fallback: unnamed-model selection. A grant that named a
29
  model is a pin (`allow_fallback: false`); that call keeps the Vertex `order`
30
  and omits the fallback list, so Vercel is not asked to answer with another
31
  model.
32
33
  The configured list is why this lane reports `substitutable?/0` as true: a
34
  call that *does* send it — unnamed-model selection for
35
  `google/gemini-3.7-flash` — can be answered by `zai/glm-5.3` and still return
31 36
  200, so the model that was asked for is not evidence of the model that
32 37
  answered. The response's `model` field is, and the chat-completions decoder
33 38
  reads it back as `{:model_served, name}` so the call is priced and attributed
34 39
  against the lane that served it rather than the lane that was requested
35
  (METER-001, PROVIDER-002).
40
  (METER-001, PROVIDER-002). A pinned grant whose response names another model
41
  is refused by the proxy rather than returned as 200.
36 42
37 43
  The wire format is OpenRouter's, so the request building and the stream
38 44
  decoding are OpenRouter's too. What differs is the endpoint, the credential,

@@ -99,7 +105,7 @@ defmodule OpenAgents.Providers.VercelGateway do

99 105
  defp gateway_options(options) do
100 106
    options
101 107
    |> Keyword.put(:endpoint, @endpoint)
102
    |> Keyword.put(:payload_extra, payload_extra())
108
    |> Keyword.put(:payload_extra, payload_extra(options))
103 109
  end
104 110
105 111
  @doc "The models Vercel may try when the requested one fails."

@@ -112,9 +118,12 @@ defmodule OpenAgents.Providers.VercelGateway do

112 118
  end
113 119
114 120
  @doc false
115
  def payload_extra do
121
  def payload_extra(options \\ []) when is_list(options) do
116 122
    providers = Application.get_env(:openagents, :vercel_gateway_providers, [])
117
    fallbacks = fallback_models()
123
    # A pin must not send the fallback list: Vercel treats `gateway.models` as
124
    # permission to answer with another model and still return 200 (#258).
125
    fallbacks =
126
      if Keyword.get(options, :allow_fallback, true), do: fallback_models(), else: []
118 127
119 128
    gateway =
120 129
      %{}
lib/openagents_web/controllers/inference_proxy_controller.ex modified +179 -59

@@ -19,14 +19,18 @@ defmodule OpenAgentsWeb.InferenceProxyController do

19 19
  and each chunk's `model` field — so a client renders what answered.
20 20
21 21
  What answered is read back off the response rather than assumed from the
22
  request. One lane can substitute: the Vercel AI Gateway is configured with a
23
  fallback model list, so a call for `google/gemini-3.7-flash` can be served by
24
  `zai/glm-5.3` and still return 200. The serving model therefore
25
  decides three things — the name attributed on the response, the lane whose
26
  health is recorded, and the rate table the usage record is priced against
27
  (METER-001). A substitutable lane whose response discloses no model is
28
  attributed `unresolved` and priced at nothing, because naming the requested
29
  model would be a claim the deployment cannot support.
22
  request. One lane can substitute, and only where nothing named a model: the
23
  Vercel AI Gateway is configured with a fallback model list, and unnamed-model
24
  selection attaches it, so a call that asked for whatever this deployment
25
  serves can be answered by `zai/glm-5.3` and still return 200. A grant that
26
  named a model is a pin. That call omits the fallback list and keeps the
27
  Vertex `order` pin; if the gateway still substitutes, the proxy does not
28
  return 200 (PROVIDER-002). The serving model therefore decides three things
29
  — the name attributed on the response, the lane whose health is recorded,
30
  and the rate table the usage record is priced against (METER-001). A
31
  substitutable unnamed call whose response discloses no model is attributed
32
  `unresolved` and priced at nothing, because naming the requested model would
33
  be a claim the deployment cannot support.
30 34
31 35
  ## The stream is flushed as it happens
32 36

@@ -73,7 +77,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

73 77
  # since been withdrawn — is refused here rather than sent to a provider that
74 78
  # does not serve it.
75 79
  defp route(grant, body) do
76
    if no_model_in_body?(body) and grant.model_id == Models.default_id() do
80
    if unnamed_selection?(grant, body) do
77 81
      {:ok, Models.select()}
78 82
    else
79 83
      case Models.fetch(grant.model_id) do

@@ -83,6 +87,13 @@ defmodule OpenAgentsWeb.InferenceProxyController do

83 87
    end
84 88
  end
85 89
90
  # Neither the mint nor the call named a model: the server selects, and the
91
  # gateway may still try its fallback list. A grant minted for a model other
92
  # than the default, or a body that names one, is a pin (#258).
93
  defp unnamed_selection?(grant, body) do
94
    no_model_in_body?(body) and grant.model_id == Models.default_id()
95
  end
96
86 97
  defp no_model_in_body?(body) do
87 98
    case Map.get(body, "model") do
88 99
      absent when absent in [nil, ""] -> true

@@ -214,59 +225,46 @@ defmodule OpenAgentsWeb.InferenceProxyController do

214 225
  @state_usage :proxy_stream_usage
215 226
  @state_served :proxy_stream_served_model
216 227
  @state_conn :proxy_stream_conn
228
  @state_raw_conn :proxy_stream_raw_conn
229
  @state_opened :proxy_stream_opened
230
  @state_allow_fallback :proxy_stream_allow_fallback
217 231
218 232
  defp run(conn, grant, model, request) do
233
    allow_fallback? = unnamed_selection?(grant, conn.body_params)
219 234
    selection = selection_properties(grant, model, request, conn.body_params)
220 235
    Analytics.capture("inference_model_selected", analytics_distinct_id(grant), selection)
221 236
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))
237
    Process.put(@state_raw_conn, conn)
238
    Process.put(@state_allow_fallback, allow_fallback?)
239
240
    # Unnamed-model selection may still be answered by a fallback, so the
241
    # stream opens before the first provider event (#263). A pin waits until
242
    # the serving model is known: opening 200 first would make a substituted
243
    # grant look like a successful turn (#258).
244
    if allow_fallback?, do: open_stream(model)
245
246
    # The provider pushes events synchronously. Once the stream is open, each
247
    # event is translated and written to the client as it arrives.
248
    result =
249
      stream_adapter(model.adapter, request, &emit_event(conn, model, &1),
250
        allow_fallback: allow_fallback?
251
      )
240 252
241 253
    case result do
242 254
      :ok ->
243 255
        events = drained_events()
244 256
245 257
        # What answered is read back off the response, never assumed from the
246
        # request: a gateway lane configured with fallback models can serve a
247
        # call for one model with another and still return 200 (METER-001).
258
        # request. Unnamed-model selection may still land on a fallback and
259
        # return 200 (METER-001). A pin must be the grant's model.
248 260
        served = served_model(model, events)
249 261
        usage = drained_usage()
250
        _ = meter(grant, usage, served)
251
        record_health(model, served)
252
253
        # The effective model is attributed on the response itself — the
254
        # header and every chunk's `model` field — so a client renders what
255
        # answered, not what it assumed (PROVIDER-002).
256
        label = model_label(model, served)
257
258
        Analytics.capture(
259
          "inference_model_served",
260
          analytics_distinct_id(grant),
261
          Map.merge(selection, %{
262
            "served_model" => label,
263
            "served_model_disclosed" => served != :unresolved,
264
            "outcome" => "served",
265
            "usage_reported" => usage != %{}
266
          })
267
        )
268 262
269
        Enum.each(sse_chunks(events, usage, label), &write_chunk(conn, &1))
263
        if allow_fallback? or served == :requested do
264
          finish_served(conn, grant, model, selection, served, usage, events)
265
        else
266
          finish_pin_violation(grant, model, selection, served, usage)
267
        end
270 268
271 269
      {:error, reason} ->
272 270
        events = drained_events()

@@ -298,16 +296,102 @@ defmodule OpenAgentsWeb.InferenceProxyController do

298 296
          })
299 297
        )
300 298
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")
299
        finish_provider_error(conn, class, status)
300
    end
301
  end
302
303
  defp stream_adapter(adapter, request, on_event, options) do
304
    if Code.ensure_loaded?(adapter) and function_exported?(adapter, :stream, 3) do
305
      adapter.stream(request, on_event, options)
306
    else
307
      adapter.stream(request, on_event)
308
    end
309
  end
310
311
  defp finish_served(conn, grant, model, selection, served, usage, events) do
312
    unless Process.get(@state_opened) do
313
      open_stream(model)
314
      flush_event_chunks(conn, model, events)
306 315
    end
307 316
317
    _ = meter(grant, usage, served)
318
    record_health(model, served)
319
320
    # The effective model is attributed on the response itself — the
321
    # header and every chunk's `model` field — so a client renders what
322
    # answered, not what it assumed (PROVIDER-002).
323
    label = model_label(model, served)
324
325
    Analytics.capture(
326
      "inference_model_served",
327
      analytics_distinct_id(grant),
328
      Map.merge(selection, %{
329
        "served_model" => label,
330
        "served_model_disclosed" => served != :unresolved,
331
        "outcome" => "served",
332
        "usage_reported" => usage != %{}
333
      })
334
    )
335
336
    Enum.each(sse_chunks(events, usage, label), &write_chunk(conn, &1))
308 337
    Process.get(@state_conn) || conn
309 338
  end
310 339
340
  defp finish_pin_violation(grant, model, selection, served, usage) do
341
    # Tokens were spent on the model that answered, even though the pin
342
    # forbids returning that as a successful turn.
343
    if usage != %{}, do: meter(grant, usage, served)
344
    OpenAgents.Inference.Health.record_failure(model.id, nil)
345
346
    served_label = model_label(model, served)
347
348
    Analytics.capture(
349
      "inference_model_failed",
350
      analytics_distinct_id(grant),
351
      Map.merge(selection, %{
352
        "outcome" => "model_substituted",
353
        "reason_code" => "model_substituted",
354
        "served_model" => served_label,
355
        "usage_reported" => usage != %{}
356
      })
357
    )
358
359
    refuse(Process.get(@state_raw_conn), {:model_substituted, model.id, served_label})
360
  end
361
362
  defp finish_provider_error(conn, class, status) do
363
    if Process.get(@state_opened) do
364
      # The 200 is already on the wire, so the failure travels as terminal
365
      # frames instead of a status: the same bounded class and upstream
366
      # status the JSON refusal would have carried, and nothing more.
367
      write_chunk(conn, data(%{"error" => stream_error(class, status)}))
368
      write_chunk(conn, "data: [DONE]\n\n")
369
      Process.get(@state_conn) || conn
370
    else
371
      refuse(
372
        Process.get(@state_raw_conn) || conn,
373
        {:provider_failed, class, status}
374
      )
375
    end
376
  end
377
378
  defp open_stream(model) do
379
    if Process.get(@state_opened) do
380
      :ok
381
    else
382
      sent =
383
        Process.get(@state_raw_conn)
384
        |> put_resp_content_type("text/event-stream")
385
        |> put_resp_header("cache-control", "no-store")
386
        |> put_resp_header("x-openagents-model", model.id)
387
        |> send_chunked(200)
388
389
      Process.put(@state_conn, sent)
390
      Process.put(@state_opened, true)
391
      :ok
392
    end
393
  end
394
311 395
  defp stream_error(class, status) do
312 396
    body = %{"code" => "provider_failed", "reason" => class}
313 397
    if status == nil, do: body, else: Map.put(body, "upstream_status", status)

@@ -316,19 +400,48 @@ defmodule OpenAgentsWeb.InferenceProxyController do

316 400
  defp emit_event(conn, model, event) do
317 401
    record_event(event)
318 402
    record_disclosure(event)
319
    chunks = event_chunks(event)
320 403
321
    if chunks != [] do
322
      label = chunk_model(model)
404
    cond do
405
      Process.get(@state_opened) ->
406
        write_event_chunks(conn, model, [event])
407
408
      pin_confirmed?(model) ->
409
        open_stream(model)
410
        write_event_chunks(conn, model, Enum.reverse(Process.get(@state_events) || []))
411
412
      true ->
413
        :ok
414
    end
415
416
    :ok
417
  end
418
419
  defp pin_confirmed?(model) do
420
    Process.get(@state_allow_fallback) == true or
421
      served_matches_pin?(model, Process.get(@state_served))
422
  end
423
424
  defp served_matches_pin?(_model, nil), do: false
425
426
  defp served_matches_pin?(model, name) when is_binary(name) do
427
    case Models.fetch(name) do
428
      {:ok, %{id: id}} -> id == model.id
429
      :error -> name == model.id or name == model.provider_model
430
    end
431
  end
432
433
  defp write_event_chunks(conn, model, events) do
434
    label = chunk_model(model)
323 435
324
      Enum.each(chunks, fn payload ->
325
        write_chunk(conn, data(Map.put(payload, "model", label)))
326
      end)
436
    for event <- events, payload <- event_chunks(event) do
437
      write_chunk(conn, data(Map.put(payload, "model", label)))
327 438
    end
328 439
329 440
    :ok
330 441
  end
331 442
443
  defp flush_event_chunks(conn, model, events), do: write_event_chunks(conn, model, events)
444
332 445
  # A fallback disclosure corrects the name on the very chunks that follow it;
333 446
  # before one arrives, every chunk names the grant's lane, exactly as the
334 447
  # pre-stream header does. METER-001/PROVIDER-002: the response says what

@@ -619,6 +732,13 @@ defmodule OpenAgentsWeb.InferenceProxyController do

619 732
     }}
620 733
  end
621 734
735
  # A pin whose provider answered with another model is not a successful turn.
736
  # The client named one model; attributing a fallback as 200 is the miss #258
737
  # closes even when PROVIDER-002 would otherwise name the substitute.
738
  defp error_for({:model_substituted, granted, served}) do
739
    {502, %{"code" => "model_substituted", "granted" => granted, "served" => served}}
740
  end
741
622 742
  # The failure class travels with the refusal. `OperationalLog.code/1` takes
623 743
  # only the reason's atom tag and bounds it to 64 characters, so it carries no
624 744
  # provider text, no prompt, and no credential — it is the same bounded word
test/openagents/providers/vercel_gateway_test.exs modified +22

@@ -42,6 +42,28 @@ defmodule OpenAgents.Providers.VercelGatewayTest do

42 42
                 }
43 43
               }
44 44
             }
45
46
      assert VercelGateway.payload_extra(allow_fallback: true) == VercelGateway.payload_extra()
47
    end
48
49
    test "omits the fallback model list when the grant pinned a model" do
50
      previous = Application.get_env(:openagents, :vercel_gateway_fallback_models)
51
52
      Application.put_env(:openagents, :vercel_gateway_fallback_models, [
53
        "zai/glm-5.3-flash",
54
        "zai/glm-5.3",
55
        "openai/gpt-5.6-luna"
56
      ])
57
58
      on_exit(fn ->
59
        Application.put_env(:openagents, :vercel_gateway_fallback_models, previous)
60
      end)
61
62
      # Vertex `order` stays; `models` is the instruction to substitute, and a
63
      # pin must not send it (#258).
64
      assert VercelGateway.payload_extra(allow_fallback: false) == %{
65
               providerOptions: %{gateway: %{order: ["vertex"]}}
66
             }
45 67
    end
46 68
  end
47 69
test/openagents_web/controllers/inference_proxy_fallback_test.exs modified +60 -16

@@ -3,11 +3,12 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

3 3
  What a metered call says when a fallback served it.
4 4
5 5
  `config :openagents, :vercel_gateway_fallback_models` tells Vercel to answer a
6
  failed `google/gemini-3.7-flash` call with `openai/gpt-5.6-luna` and still
7
  return 200. The adapter never read back which model answered, so the usage
8
  record was priced against Gemini's rates for a call Luna served, the thread's
9
  cost totalled as though it were known, and the Gemini lane was recorded
10
  healthy on the strength of a call it did not serve (METER-001, PROVIDER-002).
6
  failed primary with `openai/gpt-5.6-luna` and still return 200 — but only
7
  unnamed-model selection may send that list. A grant that named a model is a
8
  pin; fallback on that call is a miss even when the substitute is attributed
9
  honestly (#258). Unnamed selection still reads the serving model back so a
10
  rescued call is priced against the lane that ran, not the one that was asked
11
  for (METER-001, PROVIDER-002).
11 12
  """
12 13
13 14
  use OpenAgentsWeb.ConnCase, async: false

@@ -60,6 +61,21 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

60 61
    %{grant: grant, token: token}
61 62
  end
62 63
64
  # Neither the mint nor the body names a model, so the proxy may still attach
65
  # the gateway fallback list (#258).
66
  defp unnamed_grant(key) do
67
    owner = github_user("fallback-#{key}")
68
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
69
70
    {:ok, grant, token} =
71
      Inference.mint(%{
72
        owner_visitor_id: conversation.visitor_id,
73
        conversation_id: conversation.id
74
      })
75
76
    %{grant: grant, token: token}
77
  end
78
63 79
  defp call(conn, token) do
64 80
    conn
65 81
    |> put_req_header("authorization", "Bearer #{token}")

@@ -70,15 +86,34 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

70 86
    )
71 87
  end
72 88
89
  describe "a pinned grant a fallback model served" do
90
    test "is not a successful turn", %{conn: conn} do
91
      # Explore pins `gemini-3.7-flash`. Answering with GLM and calling that
92
      # 200 is the miss even when the substitute is a catalog model (#258).
93
      serve_as("zai/glm-5.3-flash")
94
      %{grant: grant, token: token} = gemini_grant("pinned-glm")
95
96
      conn = call(conn, token)
97
      refute conn.status == 200
98
99
      error = Jason.decode!(conn.resp_body)["error"]
100
      assert error["code"] == "model_substituted"
101
      assert error["granted"] == @gemini
102
      assert error["served"] == "glm-5.3-flash"
103
104
      metered = Repo.get(Grant, grant.id)
105
      refute metered.usage["served_model"] == @gemini
106
    end
107
  end
108
73 109
  describe "a call a fallback model served" do
74 110
    test "is priced against the model that served it, not the one requested", %{conn: conn} do
75 111
      # The requested lane has rates; the lane Vercel fell back to has none.
76 112
      # Pricing the call at the requested lane's rates is the bug: it produces
77 113
      # a figure, and the figure is for a call that never ran there.
78
      assert Pricing.basis(@gemini) == "provisional"
79 114
      serve_as("openai/gpt-5.6-luna")
80 115
81
      %{grant: grant, token: token} = gemini_grant("priced")
116
      %{grant: grant, token: token} = unnamed_grant("priced")
82 117
      assert call(conn, token).status == 200
83 118
84 119
      metered = Repo.get(Grant, grant.id)

@@ -91,14 +126,14 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

91 126
92 127
    test "is attributed to the model that served it on the response", %{conn: conn} do
93 128
      serve_as("openai/gpt-5.6-luna")
94
      %{token: token} = gemini_grant("attributed")
129
      %{grant: grant, token: token} = unnamed_grant("attributed")
95 130
96 131
      conn = call(conn, token)
97 132
98 133
      # The stream (#263) opens before any provider event, so the header names
99 134
      # the lane the call addressed. The disclosure corrects the attribution on
100 135
      # the response body itself — every chunk carries the model that answered.
101
      assert get_resp_header(conn, "x-openagents-model") == [@gemini]
136
      assert get_resp_header(conn, "x-openagents-model") == [grant.model_id]
102 137
103 138
      chunks =
104 139
        for chunk <- String.split(conn.resp_body, "\n\n", trim: true),

@@ -123,14 +158,14 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

123 158
      # reporting `available` forever, because the fallback kept rescuing it.
124 159
      luna = "openai/gpt-5.6-luna"
125 160
      serve_as(luna)
126
      {:ok, gemini} = Models.fetch(@gemini)
161
      {:ok, selected} = Models.fetch(Models.default_id())
127 162
128 163
      for index <- 1..Health.degraded_after() do
129
        %{token: token} = gemini_grant("health-#{index}")
164
        %{token: token} = unnamed_grant("health-#{index}")
130 165
        assert call(conn, token).status == 200
131 166
      end
132 167
133
      assert Models.availability(gemini) == "degraded"
168
      assert Models.availability(selected) == "degraded"
134 169
135 170
      # Nothing is recorded for the lane that actually answered either. It is
136 171
      # not a model this deployment admits, so there is no lane to credit.

@@ -169,7 +204,7 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

169 204
  describe "a call whose serving model the response did not disclose" do
170 205
    test "is recorded unresolved and priced at nothing", %{conn: conn} do
171 206
      disclose_nothing()
172
      %{grant: grant, token: token} = gemini_grant("silent")
207
      %{grant: grant, token: token} = unnamed_grant("silent")
173 208
174 209
      conn = call(conn, token)
175 210
      assert conn.status == 200

@@ -188,17 +223,26 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

188 223
      # correct it with, and naming `unresolved` there would require buffering
189 224
      # the whole stream again (#263). The unresolved attribution lives where
190 225
      # the record is: the metered usage above.
191
      assert get_resp_header(conn, "x-openagents-model") == [@gemini]
226
      assert get_resp_header(conn, "x-openagents-model") == [grant.model_id]
192 227
    end
193 228
194 229
    test "records no health for the requested lane either way", %{conn: conn} do
195 230
      disclose_nothing()
196
      %{token: token} = gemini_grant("silent-health")
231
      %{grant: grant, token: token} = unnamed_grant("silent-health")
197 232
198 233
      assert call(conn, token).status == 200
199 234
200 235
      # Neither healthy nor degraded: nothing here knows whether that lane ran.
201
      assert Health.status(@gemini) == {:unknown, nil}
236
      assert Health.status(grant.model_id) == {:unknown, nil}
237
    end
238
239
    test "is not a successful turn when the grant pinned a model", %{conn: conn} do
240
      disclose_nothing()
241
      %{token: token} = gemini_grant("silent-pin")
242
243
      conn = call(conn, token)
244
      refute conn.status == 200
245
      assert Jason.decode!(conn.resp_body)["error"]["code"] == "model_substituted"
202 246
    end
203 247
  end
204 248

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