Price a metered call against the model that actually served it

b2e5ee0634de · AtlantisPleb · · parent 09b083dfa835

Price a metered call against the model that actually served it

`vercel_gateway_fallback_models` tells the gateway to answer a failed
`google/gemini-3.7-flash` call with `openai/gpt-5.6-luna` and return 200.
Nothing read back which model answered, so the usage record was priced at
Gemini's rates for a call Luna served — $2.25 of measured-looking cost from
a lane this deployment has no rates for — the thread totalled it as a known
figure, and the Gemini lane was recorded healthy on the strength of a call
it did not serve.

The response says which model served it. The chat-completions decoder now
carries that out as `{:model_served, name}`, an adapter says whether its
provider may substitute, and the serving model decides three things: the
rate table the record is priced against, the lane whose health is recorded,
and the name attributed on the response. Where a substitutable lane
discloses nothing the record says `unresolved` and prices nothing, because
naming the requested model would be a claim the deployment cannot support.

Closes #250

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#250

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 392 · 2026-08-25T14:57:36.781305Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/inference.ex
  • modified lib/openagents/providers/open_router/stream_decoder.ex
  • modified lib/openagents/providers/provider.ex
  • modified lib/openagents/providers/provider_event.ex
  • modified lib/openagents/providers/vercel_gateway.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified test/openagents/providers/open_router/stream_decoder_test.exs
  • modified test/openagents/providers/persona_boundary_test.exs
  • modified test/openagents/providers/vercel_gateway_test.exs
  • added test/openagents_web/controllers/inference_proxy_fallback_test.exs
  • added test/support/providers/fallback_test_provider.ex

Diff

13 files changed, +659 -31

INVARIANTS.md modified +51 -8

@@ -1224,9 +1224,20 @@ Concretely:

1224 1224
  `model_mismatch` naming both, rather than silently answered by the grant's.
1225 1225
- Every successful proxy response attributes the effective model — the
1226 1226
  `x-openagents-model` header and each SSE chunk's `model` field — so a
1227
  client renders what answered rather than what it assumed. A named model is
1228
  never substituted: because a mismatch is refused, a caller that named one
1229
  gets that one or an error.
1227
  client renders what answered rather than what it assumed. Nothing in this
1228
  application substitutes a named model: a mismatch is refused, so a caller
1229
  that named one gets that one or an error.
1230
- Amended 2026-08-25 (#250): one lane can be substituted for *by its provider*,
1231
  and the response says so rather than the host pretending otherwise. Where
1232
  `config :openagents, :vercel_gateway_fallback_models` is set, Vercel answers
1233
  a failed primary with another model and returns 200. The proxy reads the
1234
  serving model back off the response and attributes that, so the header and
1235
  the chunks name what actually ran. Where such a lane discloses no model, the
1236
  attribution is the word `unresolved` — not the requested model, which the
1237
  deployment cannot claim served. An adapter says whether it can be
1238
  substituted for (`OpenAgents.Providers.Provider.substitutable?/0`, false
1239
  where it is not exported), so silence from a lane that cannot substitute
1240
  still means the model that was asked for.
1230 1241
- Amended 2026-08-25 (#199): where **nothing named a model** — neither the
1231 1242
  mint nor the call — the server selects, preferring a configured lane that is
1232 1243
  not `degraded` in catalog order and falling back to the catalog default when

@@ -1255,7 +1266,8 @@ unsupported `model` on `POST /api/v1/chat/turns` is a typed `422`, and

1255 1266
Evidence: `OpenAgents.Inference.Models`, `OpenAgentsWeb.ModelCatalogController`,
1256 1267
`OpenAgentsWeb.InferenceProxyController`, `OpenAgentsWeb.ThreadController`,
1257 1268
`OpenAgents.Inference.ModelsTest`, `OpenAgentsWeb.ModelCatalogControllerTest`,
1258
`OpenAgentsWeb.InferenceProxyControllerTest`, and
1269
`OpenAgentsWeb.InferenceProxyControllerTest`,
1270
`OpenAgentsWeb.InferenceProxyFallbackTest`, and
1259 1271
`OpenAgentsWeb.ThreadControllerTest`.
1260 1272
1261 1273
### METER-001 — A cost is reported only where a price exists, and never as zero

@@ -1304,6 +1316,35 @@ Concretely:

1304 1316
  `unpriced_calls/1` is above zero; `balance/1` publishes `complete?` so no
1305 1317
  reader shows a balance as whole when it is not.
1306 1318
1319
Amended 2026-08-25 (#250): a record is priced against the model that **served**
1320
the call, never the one that was asked for. The two are not always the same
1321
name. `config :openagents, :vercel_gateway_fallback_models` instructs the
1322
Vercel AI Gateway to answer a failed `google/gemini-3.7-flash` call with
1323
`openai/gpt-5.6-luna` and return 200, and the adapter did not read back which
1324
model answered — so a Luna call was priced at Gemini's rates and produced
1325
`$2.25` of measured-looking cost from a lane this deployment has no rates for.
1326
1327
So every metered record also names its lane. `served_model` is read off the
1328
response's own `model` field, carried out of the chat-completions decoder as
1329
`{:model_served, name}`, and written as the catalog id where the catalog serves
1330
that model. Three values are not model names and none of them resolves to a
1331
rate table, so all three price at nothing:
1332
1333
- `unresolved` — the lane may substitute (`Provider.substitutable?/0`, true for
1334
  the gateway exactly while a fallback list is configured) and the response
1335
  disclosed no model. Naming the requested model here would be a claim the
1336
  deployment cannot support.
1337
- `mixed` — one grant's calls were served by more than one model. A single
1338
  accumulated total cannot be charged at two rate tables, and picking one would
1339
  be a guess.
1340
- any model outside the catalog — a fallback the operator never priced.
1341
1342
`OpenAgents.Threads.spend/1` reports the serving lane in `cost.unpriced_models`
1343
rather than the requested one, so an operator is sent to price the lane that
1344
actually ran. A lane that cannot be substituted for needs no disclosure: it
1345
gets the model it asked for or an error, so its silence still means the grant's
1346
model.
1347
1307 1348
An unpriced lane is not a free lane and not an error. It is the deployment
1308 1349
saying it does not know what a call cost, which is a different fact from the
1309 1350
call having cost nothing, and the distinction survives to every read surface.

@@ -1317,8 +1358,10 @@ edit. No code here may guess one.

1317 1358
Evidence: `OpenAgents.Inference.Pricing`, `OpenAgents.Inference.PricingTest`,
1318 1359
`OpenAgents.Inference.CreditTest`, `OpenAgents.ThreadsTest`,
1319 1360
`OpenAgentsWeb.ModelCatalogControllerTest`,
1320
`OpenAgentsWeb.ThreadControllerTest`, `OpenAgentsWeb.ThreadShowLiveTest`, and
1321
`OpenAgentsWeb.ModelCatalogLiveTest`.
1361
`OpenAgentsWeb.ThreadControllerTest`, `OpenAgentsWeb.ThreadShowLiveTest`,
1362
`OpenAgentsWeb.ModelCatalogLiveTest`,
1363
`OpenAgentsWeb.InferenceProxyFallbackTest`, and
1364
`OpenAgents.Providers.OpenRouter.StreamDecoderTest`.
1322 1365
1323 1366
## Durable effects
1324 1367

@@ -5603,8 +5646,8 @@ contract; the invariant prose above defines the assertion, not the filename.

5603 5646
| TURN-005 | `test/openagents/turn_tool_loop_test.exs` |
5604 5647
| PROVENANCE-001 | `test/openagents/turn_provenance_test.exs` |
5605 5648
| PROVIDER-001 | `test/openagents/providers/provider_contract_test.exs`, `test/openagents/turn_provider_events_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5606
| PROVIDER-002 | `test/openagents/inference/models_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs` |
5607
| METER-001 | `test/openagents/inference/pricing_test.exs`, `test/openagents/inference/credit_test.exs`, `test/openagents/threads_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs`, `test/openagents_web/live/thread_show_live_test.exs`, `test/openagents_web/live/model_catalog_live_test.exs` |
5649
| PROVIDER-002 | `test/openagents/inference/models_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_fallback_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs` |
5650
| METER-001 | `test/openagents/inference/pricing_test.exs`, `test/openagents/inference/credit_test.exs`, `test/openagents/threads_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_fallback_test.exs`, `test/openagents/providers/open_router/stream_decoder_test.exs`, `test/openagents_web/live/thread_show_live_test.exs`, `test/openagents_web/live/model_catalog_live_test.exs` |
5608 5651
| EFFECT-001 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5609 5652
| EFFECT-002 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5610 5653
| TOOL-001 | `test/openagents/tools/registry_and_runner_test.exs` |
lib/openagents/inference.ex modified +81 -5

@@ -198,9 +198,22 @@ defmodule OpenAgents.Inference do

198 198
  token usage, prices it, increments the call count, and flips the grant to
199 199
  `exhausted` when any ceiling is reached — atomically, re-reading under a row
200 200
  lock so concurrent calls cannot exceed the budget.
201
202
  `served` names the model that actually answered, because the grant's model is
203
  what was asked for and the two are not always the same name. `:requested`
204
  says the lane cannot be substituted for, so the grant's model served it.
205
  `:unresolved` says the lane can be substituted for and the response did not
206
  disclose what answered, which is priced at nothing rather than at the
207
  requested model's rate. A binary is the serving model's own name (METER-001).
201 208
  """
202 209
  @spec record_usage(Grant.t(), map()) :: {:ok, Grant.t()} | {:error, term()}
203
  def record_usage(%Grant{id: id}, provider_usage) when is_map(provider_usage) do
210
  def record_usage(%Grant{} = grant, provider_usage) when is_map(provider_usage),
211
    do: record_usage(grant, provider_usage, :requested)
212
213
  @spec record_usage(Grant.t(), map(), :requested | :unresolved | String.t()) ::
214
          {:ok, Grant.t()} | {:error, term()}
215
  def record_usage(%Grant{id: id}, provider_usage, served)
216
      when is_map(provider_usage) and (served in [:requested, :unresolved] or is_binary(served)) do
204 217
    Repo.transaction(fn ->
205 218
      grant =
206 219
        Grant

@@ -210,7 +223,7 @@ defmodule OpenAgents.Inference do

210 223
211 224
      case grant do
212 225
        %Grant{status: "active"} = grant ->
213
          merged = merge_usage(grant.usage, provider_usage, grant.model_id)
226
          merged = merge_usage(grant.usage, provider_usage, served_name(served, grant.model_id))
214 227
          would_exhaust = would_exhaust?(grant, merged)
215 228
          next_status = if would_exhaust, do: "exhausted", else: "active"
216 229

@@ -385,9 +398,37 @@ defmodule OpenAgents.Inference do

385 398
  @cost_fields ~w(input_tokens output_tokens total_tokens reasoning_tokens
386 399
                  cache_read_input_tokens cache_write_input_tokens)
387 400
388
  @doc false
389
  def merge_usage(existing, provider_usage, model_id) do
401
  # The two names a usage record's `served_model` can carry that are not a
402
  # model. Neither resolves in the catalog, so `Pricing.price/2` prices both at
403
  # nothing — which is the point: an unknown is not a rate.
404
  @unresolved_model "unresolved"
405
  @mixed_model "mixed"
406
407
  @doc "What a usage record's `served_model` says when the provider disclosed nothing."
408
  @spec unresolved_model() :: String.t()
409
  def unresolved_model, do: @unresolved_model
410
411
  @doc "What a usage record's `served_model` says when more than one model served it."
412
  @spec mixed_model() :: String.t()
413
  def mixed_model, do: @mixed_model
414
415
  defp served_name(:requested, model_id), do: model_id
416
  defp served_name(:unresolved, _model_id), do: @unresolved_model
417
  defp served_name(name, _model_id) when is_binary(name), do: name
418
419
  @doc """
420
  Merge one call's provider usage into a grant's record and price the total.
421
422
  `served_model_name` is the model that answered this call. A record is priced
423
  against the model that served it, never against the one that was asked for,
424
  and a record whose calls were served by more than one model is priced at
425
  nothing: a single total cannot be charged at two rates, and picking one would
426
  be a guess (METER-001).
427
  """
428
  @spec merge_usage(map() | nil, map(), String.t() | nil) :: map()
429
  def merge_usage(existing, provider_usage, served_model_name) do
390 430
    normalized = normalize_usage(provider_usage)
431
    served = served_model(existing, served_model_name)
391 432
392 433
    merged =
393 434
      Enum.reduce(@cost_fields, %{}, fn field, acc ->

@@ -408,10 +449,45 @@ defmodule OpenAgents.Inference do

408 449
409 450
    merged
410 451
    |> Map.put("total_tokens", derived_total(existing, merged))
411
    |> Pricing.price(model_id)
452
    |> Map.put("served_model", served)
453
    |> Pricing.price(served)
412 454
    |> Map.put("schema", @usage_schema)
413 455
  end
414 456
457
  # The model a record says served it. A record already naming one model that
458
  # a later call did not use becomes `mixed`, because the accumulated total is
459
  # then a sum across two rate tables and no single one prices it.
460
  #
461
  # A record written before this key existed names nothing, so the first call
462
  # after that names the model that served it. That is the honest reading:
463
  # nothing recorded which model served the earlier calls, and inventing an
464
  # agreement in order to keep a price would be the failure this exists to
465
  # prevent.
466
  defp served_model(existing, name) do
467
    name = canonical_model(name)
468
469
    case Map.get(existing || %{}, "served_model") do
470
      nil -> name
471
      ^name -> name
472
      _different -> @mixed_model
473
    end
474
  end
475
476
  # One model, one name. A provider reports the vendor spelling
477
  # (`google/gemini-3.7-flash`) and a grant may carry either, so both are
478
  # written as the catalog id — otherwise two spellings of one model would read
479
  # as two models and make a record `mixed` that never left its lane. A name
480
  # the catalog does not serve stays as it came, which is exactly the case that
481
  # prices at nothing.
482
  defp canonical_model(nil), do: @unresolved_model
483
484
  defp canonical_model(name) when is_binary(name) do
485
    case Models.fetch(name) do
486
      {:ok, %{id: id}} -> id
487
      :error -> name
488
    end
489
  end
490
415 491
  defp derived_total(existing, merged) do
416 492
    explicit = integer(merged["total_tokens"])
417 493
lib/openagents/providers/open_router/stream_decoder.ex modified +29 -2

@@ -9,11 +9,17 @@ defmodule OpenAgents.Providers.OpenRouter.StreamDecoder do

9 9
  @identifier_regex ~r/\A[a-zA-Z0-9_.:\/-]+\z/
10 10
  @tool_name_regex ~r/\A[a-zA-Z0-9_-]+\z/
11 11
12
  defstruct buffer: "", response_id: nil, terminal?: false, failed?: false, calls: %{}
12
  defstruct buffer: "",
13
            response_id: nil,
14
            served_model: nil,
15
            terminal?: false,
16
            failed?: false,
17
            calls: %{}
13 18
14 19
  @type t :: %__MODULE__{
15 20
          buffer: String.t(),
16 21
          response_id: String.t() | nil,
22
          served_model: String.t() | nil,
17 23
          terminal?: boolean(),
18 24
          failed?: boolean(),
19 25
          calls: %{optional(integer()) => map()}

@@ -109,9 +115,10 @@ defmodule OpenAgents.Providers.OpenRouter.StreamDecoder do

109 115
110 116
  defp decode_json(state, {:ok, %{} = chunk}) do
111 117
    with {:ok, state, start_events} <- start_response(state, chunk["id"]),
118
         {:ok, state, model_events} <- served_model(state, chunk["model"]),
112 119
         {:ok, state, choice_events} <- choices(state, chunk["choices"]),
113 120
         {:ok, usage_events} <- usage(chunk["usage"]) do
114
      {:ok, state, start_events ++ choice_events ++ usage_events}
121
      {:ok, state, start_events ++ model_events ++ choice_events ++ usage_events}
115 122
    end
116 123
  end
117 124

@@ -252,6 +259,26 @@ defmodule OpenAgents.Providers.OpenRouter.StreamDecoder do

252 259
253 260
  defp start_response(%__MODULE__{} = state, _response_id), do: {:ok, state, []}
254 261
262
  # Every chat-completions chunk names the model that produced it, and that
263
  # name is not always the one the request asked for: the Vercel AI Gateway is
264
  # configured with a fallback list, so a call for `google/gemini-3.7-flash`
265
  # can come back served by `openai/gpt-5.6-luna`. Reading the field back is
266
  # the only way the host learns which lane to price and attribute the call
267
  # against, so it is carried out as an event rather than dropped.
268
  #
269
  # Reported once. A chunk whose model is missing or unreadable reports
270
  # nothing rather than a guess, and the host treats silence from a
271
  # substitutable adapter as unresolved rather than as the requested model.
272
  defp served_model(%__MODULE__{served_model: nil} = state, model) do
273
    if valid_identifier?(model) do
274
      {:ok, %{state | served_model: model}, [{:model_served, model}]}
275
    else
276
      {:ok, state, []}
277
    end
278
  end
279
280
  defp served_model(%__MODULE__{} = state, _model), do: {:ok, state, []}
281
255 282
  defp usage(nil), do: {:ok, []}
256 283
257 284
  defp usage(usage) when is_map(usage) do
lib/openagents/providers/provider.ex modified +16 -1

@@ -23,7 +23,22 @@ defmodule OpenAgents.Providers.Provider do

23 23
  """
24 24
  @callback configured?() :: boolean()
25 25
26
  @optional_callbacks configured?: 0
26
  @doc """
27
  Whether this adapter may have its request answered by a model other than the
28
  one it asked for.
29
30
  Optional, and `false` where it is not exported: an adapter that calls one
31
  vendor with one model gets that model or an error. It is `true` only where
32
  the deployment has configured the lane to substitute — the Vercel AI Gateway
33
  with a fallback model list — and the answer bounds what silence means. A
34
  substitutable adapter whose response does not disclose the serving model has
35
  not told the host what answered, so the host records the model as unresolved
36
  and prices nothing, rather than recording the requested model as though it
37
  served (METER-001).
38
  """
39
  @callback substitutable?() :: boolean()
40
41
  @optional_callbacks configured?: 0, substitutable?: 0
27 42
28 43
  @callback stream(
29 44
              OpenAgents.Providers.Request.t(),
lib/openagents/providers/provider_event.ex modified +12 -1

@@ -1,5 +1,15 @@

1 1
defmodule OpenAgents.Providers.ProviderEvent do
2
  @moduledoc "Provider-neutral lifecycle and content events consumed by Sarah's turn runtime."
2
  @moduledoc """
3
  Provider-neutral lifecycle and content events consumed by Sarah's turn runtime.
4
5
  `{:model_served, name}` is what the provider said answered, read back off the
6
  response rather than assumed from the request. It exists because a request
7
  and its answer can name different models: the Vercel AI Gateway is configured
8
  with a fallback list, and a primary that fails is replaced by another model
9
  without the request being refused. An adapter that can be substituted for
10
  emits this so the host prices and attributes the call against the model that
11
  actually served it (METER-001, PROVIDER-002).
12
  """
3 13
4 14
  defmodule ToolCall do
5 15
    @moduledoc "A provider-requested function call awaiting host admission and validation."

@@ -29,6 +39,7 @@ defmodule OpenAgents.Providers.ProviderEvent do

29 39
30 40
  @type t ::
31 41
          {:response_started, String.t()}
42
          | {:model_served, String.t()}
32 43
          | {:text_delta, String.t()}
33 44
          | {:reasoning_delta, String.t()}
34 45
          | {:tool_call, ToolCall.t()}
lib/openagents/providers/vercel_gateway.ex modified +31 -1

@@ -26,6 +26,14 @@ defmodule OpenAgents.Providers.VercelGateway do

26 26
  where the credits are. `providerOptions.gateway.models` lists the fallback
27 27
  models Vercel tries if the primary model fails.
28 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 `openai/gpt-5.6-luna` and still
31
  return 200, so the model that was asked for is not evidence of the model that
32
  answered. The response's `model` field is, and the chat-completions decoder
33
  reads it back as `{:model_served, name}` so the call is priced and attributed
34
  against the lane that served it rather than the lane that was requested
35
  (METER-001, PROVIDER-002).
36
29 37
  The wire format is OpenRouter's, so the request building and the stream
30 38
  decoding are OpenRouter's too. What differs is the endpoint, the credential,
31 39
  and the pin.

@@ -48,6 +56,19 @@ defmodule OpenAgents.Providers.VercelGateway do

48 56
    match?({:ok, _key}, OpenAgents.RuntimeConfig.fetch_secret(:vercel_gateway_api_key))
49 57
  end
50 58
59
  @doc """
60
  Whether a call on this lane may be answered by a different model.
61
62
  True exactly while a fallback list is configured. `providerOptions.gateway.models`
63
  is an instruction to Vercel to try another model when the primary fails, so a
64
  request for `google/gemini-3.7-flash` can be answered by `openai/gpt-5.6-luna`
65
  and return 200. The host reads the serving model back off the response; this
66
  says what its silence means, because a lane that cannot be substituted for
67
  needs no disclosure to be attributed correctly.
68
  """
69
  @impl true
70
  def substitutable?, do: fallback_models() != []
71
51 72
  @impl true
52 73
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
53 74
    stream(request, on_event, [])

@@ -81,10 +102,19 @@ defmodule OpenAgents.Providers.VercelGateway do

81 102
    |> Keyword.put(:payload_extra, payload_extra())
82 103
  end
83 104
105
  @doc "The models Vercel may try when the requested one fails."
106
  @spec fallback_models() :: [String.t()]
107
  def fallback_models do
108
    case Application.get_env(:openagents, :vercel_gateway_fallback_models, []) do
109
      models when is_list(models) -> models
110
      _not_a_list -> []
111
    end
112
  end
113
84 114
  @doc false
85 115
  def payload_extra do
86 116
    providers = Application.get_env(:openagents, :vercel_gateway_providers, [])
87
    fallbacks = Application.get_env(:openagents, :vercel_gateway_fallback_models, [])
117
    fallbacks = fallback_models()
88 118
89 119
    gateway =
90 120
      %{}
lib/openagents/threads.ex modified +8 -1

@@ -818,9 +818,16 @@ defmodule OpenAgents.Threads do

818 818
819 819
    unpriced_calls = Enum.sum(Enum.map(unpriced, fn {calls, _usage, _model} -> calls end))
820 820
821
    # The lane named here is the one that made the total unknown, which is the
822
    # model that served the call rather than the model the grant asked for.
823
    # A gateway fallback answers a request for one model with another, and
824
    # naming the requested lane would send an operator to price a lane that was
825
    # already priced (METER-001).
821 826
    unpriced_models =
822 827
      unpriced
823
      |> Enum.map(fn {_calls, _usage, model_id} -> model_id end)
828
      |> Enum.map(fn {_calls, usage, model_id} ->
829
        Map.get(usage || %{}, "served_model") || model_id
830
      end)
824 831
      |> Enum.reject(&is_nil/1)
825 832
      |> Enum.uniq()
826 833
      |> Enum.sort()
lib/openagents_web/controllers/inference_proxy_controller.ex modified +89 -12

@@ -18,6 +18,16 @@ defmodule OpenAgentsWeb.InferenceProxyController do

18 18
  Every 200 attributes the effective model — the `x-openagents-model` header
19 19
  and each chunk's `model` field — so a client renders what answered.
20 20
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
  `openai/gpt-5.6-luna` 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.
30
21 31
  The probe→proxy hop is buffered (the provider still streams from the vendor
22 32
  internally); probe's transport reads the whole body before parsing, so this
23 33
  matches its consumer and keeps failure handling honest.

@@ -195,27 +205,31 @@ defmodule OpenAgentsWeb.InferenceProxyController do

195 205
196 206
    case result do
197 207
      :ok ->
208
        # What answered is read back off the response, never assumed from the
209
        # request: a gateway lane configured with fallback models can serve a
210
        # call for one model with another and still return 200 (METER-001).
211
        served = served_model(model, events)
198 212
        usage = usage_of(events)
199
        _ = meter(grant, usage)
213
        _ = meter(grant, usage, served)
214
        record_health(model, served)
200 215
201 216
        # The effective model is attributed on the response itself — the
202 217
        # header and every chunk's `model` field — so a client renders what
203
        # answered, not what it assumed (PROVIDER-002). Because a mismatched
204
        # request was refused above, requested and effective are the same
205
        # name on every 200.
206
        OpenAgents.Inference.Health.record_success(model.id)
218
        # answered, not what it assumed (PROVIDER-002).
219
        label = model_label(model, served)
207 220
208 221
        conn
209 222
        |> put_resp_content_type("text/event-stream")
210 223
        |> put_resp_header("cache-control", "no-store")
211
        |> put_resp_header("x-openagents-model", model.id)
212
        |> send_resp(200, sse_body(events, model.id))
224
        |> put_resp_header("x-openagents-model", label)
225
        |> send_resp(200, sse_body(events, label))
213 226
214 227
      {:error, reason} ->
215
        # A failure that produced partial usage is still metered; the probe
216
        # sees a provider error, never raw provider detail.
228
        # A failure that produced partial usage is still metered, against
229
        # whatever the partial response said was serving it — the tokens were
230
        # spent on that model whether or not the stream finished.
217 231
        usage = usage_of(events)
218
        if usage != %{}, do: meter(grant, usage)
232
        if usage != %{}, do: meter(grant, usage, served_model(model, events))
219 233
        class = OpenAgents.OperationalLog.code(reason)
220 234
        status = OpenAgents.OperationalLog.status(reason)
221 235
        # What the catalog publishes about this lane follows from what it

@@ -239,8 +253,71 @@ defmodule OpenAgentsWeb.InferenceProxyController do

239 253
    end
240 254
  end
241 255
242
  defp meter(grant, usage) when usage == %{}, do: {:ok, grant}
243
  defp meter(grant, usage), do: Inference.record_usage(grant, usage)
256
  defp meter(grant, usage, _served) when usage == %{}, do: {:ok, grant}
257
  defp meter(grant, usage, served), do: Inference.record_usage(grant, usage, served)
258
259
  # Which model actually served this call.
260
  #
261
  # `:requested` where the response named the model the grant pins, and where a
262
  # lane that cannot be substituted for named nothing — such a lane gets the
263
  # model it asked for or an error, so silence there is not ambiguity.
264
  # `:unresolved` where a lane that *can* be substituted for named nothing: the
265
  # deployment does not know what answered, and saying the requested model
266
  # would be a claim it cannot support. Otherwise the name the provider gave.
267
  defp served_model(model, events) do
268
    case Enum.find_value(events, fn
269
           {:model_served, name} -> name
270
           _event -> nil
271
         end) do
272
      name when is_binary(name) ->
273
        case Models.fetch(name) do
274
          {:ok, %{id: id}} when id == model.id -> :requested
275
          _other_or_unserved -> name
276
        end
277
278
      nil ->
279
        if substitutable?(model.adapter), do: :unresolved, else: :requested
280
    end
281
  end
282
283
  defp substitutable?(adapter) do
284
    Code.ensure_loaded?(adapter) and function_exported?(adapter, :substitutable?, 0) and
285
      adapter.substitutable?()
286
  end
287
288
  # Health is a claim about a lane, so it follows the lane that answered.
289
  #
290
  # A fallback that rescued a call is not evidence that the requested lane is
291
  # working — it is evidence that it is not, which is exactly what `GET
292
  # /api/v1/models` availability exists to publish (#238). An unresolved
293
  # response records nothing at all: it says neither that the lane answered nor
294
  # that it failed, and health that reports what it does not know is the fault
295
  # being fixed rather than a smaller version of it.
296
  defp record_health(model, :requested), do: OpenAgents.Inference.Health.record_success(model.id)
297
  defp record_health(_model, :unresolved), do: :ok
298
299
  defp record_health(model, name) when is_binary(name) do
300
    OpenAgents.Inference.Health.record_failure(model.id, nil)
301
302
    case Models.fetch(name) do
303
      {:ok, %{id: id}} -> OpenAgents.Inference.Health.record_success(id)
304
      :error -> :ok
305
    end
306
  end
307
308
  # The name the response carries. A model the catalog serves is named as a
309
  # client would ask for it; one it does not is named as the provider reported
310
  # it; and where nothing disclosed what answered, the word `unresolved` says
311
  # so rather than naming a model that may not have run.
312
  defp model_label(model, :requested), do: model.id
313
  defp model_label(_model, :unresolved), do: Inference.unresolved_model()
314
315
  defp model_label(_model, name) when is_binary(name) do
316
    case Models.fetch(name) do
317
      {:ok, %{id: id}} -> id
318
      :error -> name
319
    end
320
  end
244 321
245 322
  defp usage_of(events) do
246 323
    Enum.reduce(events, %{}, fn
test/openagents/providers/open_router/stream_decoder_test.exs modified +40

@@ -142,6 +142,46 @@ defmodule OpenAgents.Providers.OpenRouter.StreamDecoderTest do

142 142
    assert StreamDecoder.finish(decoder) == {:error, :truncated_stream}
143 143
  end
144 144
145
  # METER-001, PROVIDER-002. The Vercel gateway shares this decoder, and its
146
  # fallback list means the model that answers is not always the model that was
147
  # asked for. The `model` field is the only place the answer says which one
148
  # ran, so dropping it left the host pricing a Luna call at Gemini's rates.
149
  test "reports the model the response says served it, once" do
150
    stream =
151
      frame(%{
152
        "id" => "gen-f",
153
        "model" => "openai/gpt-5.6-luna",
154
        "choices" => [%{"delta" => %{"content" => "Hi"}}]
155
      }) <>
156
        frame(%{
157
          "id" => "gen-f",
158
          "model" => "openai/gpt-5.6-luna",
159
          "choices" => [%{"delta" => %{}, "finish_reason" => "stop"}]
160
        }) <> "data: [DONE]\n\n"
161
162
    assert {:ok, decoder, events} = feed_in_pieces(stream, 11)
163
    assert {:ok, _decoder, final} = StreamDecoder.finish(decoder)
164
165
    assert events ++ final == [
166
             {:response_started, "gen-f"},
167
             {:model_served, "openai/gpt-5.6-luna"},
168
             {:text_delta, "Hi"},
169
             {:response_completed, "gen-f"}
170
           ]
171
  end
172
173
  test "reports no served model where the response names none" do
174
    stream =
175
      frame(%{"id" => "gen-s", "choices" => [%{"delta" => %{"content" => "Hi"}}]}) <>
176
        frame(%{"id" => "gen-s", "choices" => [%{"delta" => %{}, "finish_reason" => "stop"}]}) <>
177
        "data: [DONE]\n\n"
178
179
    assert {:ok, decoder, events} = feed_in_pieces(stream, 9)
180
    assert {:ok, _decoder, final} = StreamDecoder.finish(decoder)
181
182
    refute Enum.any?(events ++ final, &match?({:model_served, _}, &1))
183
  end
184
145 185
  test "refuses a frame that is not JSON" do
146 186
    assert StreamDecoder.feed(StreamDecoder.new(), "data: {not json\n\n") ==
147 187
             {:error, :invalid_provider_event}
test/openagents/providers/persona_boundary_test.exs modified +2

@@ -66,6 +66,7 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

66 66
    OpenAgents.Providers.OpenAI => :outbound_http,
67 67
    OpenAgents.Providers.OpenRouter => :outbound_http,
68 68
    OpenAgents.Providers.VercelGateway => :outbound_http,
69
    OpenAgents.Providers.FallbackTestProvider => :in_process,
69 70
    OpenAgents.Providers.RecordingTestProvider => :in_process,
70 71
    OpenAgents.Providers.Test => :in_process,
71 72
    OpenAgents.Providers.UnconfiguredTestProvider => :in_process,

@@ -101,6 +102,7 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

101 102
    OpenAgents.Providers.OpenAI => :adapter,
102 103
    OpenAgents.Providers.OpenRouter => :adapter,
103 104
    OpenAgents.Providers.VercelGateway => :adapter,
105
    OpenAgents.Providers.FallbackTestProvider => :adapter,
104 106
    OpenAgents.Providers.RecordingTestProvider => :adapter,
105 107
    OpenAgents.Providers.Test => :adapter,
106 108
    OpenAgents.Providers.Request => :the_struct_itself
test/openagents/providers/vercel_gateway_test.exs modified +24

@@ -45,6 +45,30 @@ defmodule OpenAgents.Providers.VercelGatewayTest do

45 45
    end
46 46
  end
47 47
48
  # METER-001, PROVIDER-002. The fallback list is an instruction to answer a
49
  # failed call with a different model, so this lane's request is not evidence
50
  # of what served it. Saying so is what makes the host read an undisclosed
51
  # model as unresolved rather than as the model it asked for.
52
  describe "whether this lane can be substituted for" do
53
    setup do
54
      previous = Application.get_env(:openagents, :vercel_gateway_fallback_models)
55
56
      on_exit(fn ->
57
        Application.put_env(:openagents, :vercel_gateway_fallback_models, previous)
58
      end)
59
60
      :ok
61
    end
62
63
    test "is true exactly while a fallback list is configured" do
64
      Application.put_env(:openagents, :vercel_gateway_fallback_models, ["openai/gpt-5.6-luna"])
65
      assert VercelGateway.substitutable?()
66
67
      Application.put_env(:openagents, :vercel_gateway_fallback_models, [])
68
      refute VercelGateway.substitutable?()
69
    end
70
  end
71
48 72
  describe "the credential" do
49 73
    test "is its own, not OpenRouter's" do
50 74
      previous = Application.get_env(:openagents, :vercel_gateway_api_key)
test/openagents_web/controllers/inference_proxy_fallback_test.exs added +232

@@ -0,0 +1,232 @@

1
defmodule OpenAgentsWeb.InferenceProxyFallbackTest do
2
  @moduledoc """
3
  What a metered call says when a fallback served it.
4
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).
11
  """
12
13
  use OpenAgentsWeb.ConnCase, async: false
14
15
  alias OpenAgents.Inference
16
  alias OpenAgents.Inference.Grant
17
  alias OpenAgents.Inference.Health
18
  alias OpenAgents.Inference.Models
19
  alias OpenAgents.Inference.Pricing
20
  alias OpenAgents.Repo
21
  alias OpenAgents.Threads
22
23
  @gemini "gemini-3.7-flash"
24
25
  setup do
26
    previous = Application.get_env(:openagents, :vercel_gateway_provider)
27
28
    Application.put_env(
29
      :openagents,
30
      :vercel_gateway_provider,
31
      OpenAgents.Providers.FallbackTestProvider
32
    )
33
34
    Health.reset()
35
36
    on_exit(fn ->
37
      Application.put_env(:openagents, :vercel_gateway_provider, previous)
38
      Application.delete_env(:openagents, :test_fallback_served_model)
39
      Health.reset()
40
    end)
41
42
    :ok
43
  end
44
45
  defp serve_as(name), do: Application.put_env(:openagents, :test_fallback_served_model, name)
46
47
  defp disclose_nothing, do: Application.delete_env(:openagents, :test_fallback_served_model)
48
49
  defp gemini_grant(key) do
50
    owner = github_user("fallback-#{key}")
51
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
52
53
    {:ok, grant, token} =
54
      Inference.mint(%{
55
        owner_visitor_id: conversation.visitor_id,
56
        conversation_id: conversation.id,
57
        model_id: @gemini
58
      })
59
60
    %{grant: grant, token: token}
61
  end
62
63
  defp call(conn, token) do
64
    conn
65
    |> put_req_header("authorization", "Bearer #{token}")
66
    |> put_req_header("content-type", "application/json")
67
    |> post(
68
      ~p"/api/inference/proxy",
69
      Jason.encode!(%{"messages" => [%{"role" => "user", "content" => "hello"}]})
70
    )
71
  end
72
73
  describe "a call a fallback model served" do
74
    test "is priced against the model that served it, not the one requested", %{conn: conn} do
75
      # The requested lane has rates; the lane Vercel fell back to has none.
76
      # Pricing the call at the requested lane's rates is the bug: it produces
77
      # a figure, and the figure is for a call that never ran there.
78
      assert Pricing.basis(@gemini) == "provisional"
79
      serve_as("openai/gpt-5.6-luna")
80
81
      %{grant: grant, token: token} = gemini_grant("priced")
82
      assert call(conn, token).status == 200
83
84
      metered = Repo.get(Grant, grant.id)
85
86
      assert metered.usage["served_model"] == "openai/gpt-5.6-luna"
87
      assert metered.usage["pricing_id"] == Pricing.unpriced()
88
      refute Map.has_key?(metered.usage, "estimated_cost_microusd")
89
      assert Pricing.cost(metered.usage) == nil
90
    end
91
92
    test "is attributed to the model that served it on the response", %{conn: conn} do
93
      serve_as("openai/gpt-5.6-luna")
94
      %{token: token} = gemini_grant("attributed")
95
96
      conn = call(conn, token)
97
98
      assert get_resp_header(conn, "x-openagents-model") == ["openai/gpt-5.6-luna"]
99
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!()
103
        assert payload["model"] == "openai/gpt-5.6-luna"
104
      end
105
    end
106
107
    test "counts against the requested lane's health, not for it", %{conn: conn} do
108
      # `GET /api/v1/models` publishes availability from this. A lane that was
109
      # rescued by a fallback did not answer, and recording it healthy is the
110
      # same class of lie that #238 fixed: every call to a dead lane would keep
111
      # reporting `available` forever, because the fallback kept rescuing it.
112
      luna = Application.fetch_env!(:openagents, :openai_model)
113
      serve_as(luna)
114
      {:ok, gemini} = Models.fetch(@gemini)
115
116
      for index <- 1..Health.degraded_after() do
117
        %{token: token} = gemini_grant("health-#{index}")
118
        assert call(conn, token).status == 200
119
      end
120
121
      assert Models.availability(gemini) == "degraded"
122
      assert Health.status(luna) == {:healthy, nil}
123
    end
124
125
    test "makes the thread's cost unpriced, and names the lane that made it so", %{conn: _conn} do
126
      # METER-001's contract, end to end: a thread whose only call was served
127
      # by an unpriced fallback reports no total at all rather than a total at
128
      # the requested model's rates.
129
      user = github_user("fallback-thread")
130
      {:ok, thread, grant, _token} = Threads.open_and_mint(user, "Fallback lane")
131
      assert grant.model_id == @gemini
132
133
      {:ok, _metered} =
134
        Inference.record_usage(
135
          grant,
136
          %{"input_tokens" => 1_000_000, "output_tokens" => 100_000},
137
          "openai/gpt-5.6-luna"
138
        )
139
140
      spend = Threads.spend(thread)
141
142
      assert spend.calls == 1
143
      refute spend.cost.microusd == 0
144
      assert spend.cost.microusd == nil
145
      assert spend.cost.basis == "unpriced"
146
      assert spend.cost.unpriced_calls == 1
147
      assert spend.cost.unpriced_models == ["openai/gpt-5.6-luna"]
148
    end
149
  end
150
151
  describe "a call whose serving model the response did not disclose" do
152
    test "is recorded unresolved and priced at nothing", %{conn: conn} do
153
      disclose_nothing()
154
      %{grant: grant, token: token} = gemini_grant("silent")
155
156
      conn = call(conn, token)
157
      assert conn.status == 200
158
159
      metered = Repo.get(Grant, grant.id)
160
161
      # The lane can substitute and the answer said nothing, so what served it
162
      # is unknown. Recording the requested model here would be inventing the
163
      # one fact the record exists to carry.
164
      assert metered.usage["served_model"] == Inference.unresolved_model()
165
      assert metered.usage["pricing_id"] == Pricing.unpriced()
166
      refute Map.has_key?(metered.usage, "estimated_cost_microusd")
167
      assert get_resp_header(conn, "x-openagents-model") == [Inference.unresolved_model()]
168
    end
169
170
    test "records no health for the requested lane either way", %{conn: conn} do
171
      disclose_nothing()
172
      %{token: token} = gemini_grant("silent-health")
173
174
      assert call(conn, token).status == 200
175
176
      # Neither healthy nor degraded: nothing here knows whether that lane ran.
177
      assert Health.status(@gemini) == {:unknown, nil}
178
    end
179
  end
180
181
  describe "a call the requested model served" do
182
    test "is priced and attributed as it always was, by either spelling", %{conn: conn} do
183
      # The gateway names the vendor spelling; the catalog names the public id.
184
      # They are one model, and reading them as two would make every Gemini
185
      # call unpriced.
186
      {:ok, gemini} = Models.fetch(@gemini)
187
      serve_as(gemini.provider_model)
188
189
      %{grant: grant, token: token} = gemini_grant("same")
190
      conn = call(conn, token)
191
192
      assert get_resp_header(conn, "x-openagents-model") == [@gemini]
193
194
      metered = Repo.get(Grant, grant.id)
195
      assert metered.usage["served_model"] == @gemini
196
      assert metered.usage["pricing_id"] == "placeholder.gemini-3.7-flash.v1"
197
      assert Pricing.cost(metered.usage) > 0
198
      assert Health.status(@gemini) == {:healthy, nil}
199
    end
200
  end
201
202
  describe "a grant whose calls were served by more than one model" do
203
    test "reports a mixed record and no total" do
204
      # Two rate tables, one accumulated sum. Charging it at either rate would
205
      # be a guess, so the record says so and prices nothing.
206
      user = github_user("fallback-mixed")
207
      {:ok, _thread, grant, _token} = Threads.open_and_mint(user, "Mixed lanes")
208
209
      {:ok, first} = Inference.record_usage(grant, %{"input_tokens" => 1_000}, :requested)
210
      assert first.usage["served_model"] == @gemini
211
      assert Pricing.cost(first.usage) > 0
212
213
      {:ok, second} =
214
        Inference.record_usage(first, %{"input_tokens" => 1_000}, "openai/gpt-5.6-luna")
215
216
      assert second.usage["served_model"] == Inference.mixed_model()
217
      assert second.usage["pricing_id"] == Pricing.unpriced()
218
      assert Pricing.cost(second.usage) == nil
219
    end
220
  end
221
222
  test "the reserved names are not names the catalog could serve" do
223
    # `unresolved` and `mixed` price at nothing because the catalog does not
224
    # resolve them. A model that took either name would be priced by accident.
225
    reserved = [Inference.unresolved_model(), Inference.mixed_model()]
226
227
    for model <- Models.all() do
228
      refute model.id in reserved
229
      refute model.provider_model in reserved
230
    end
231
  end
232
end
test/support/providers/fallback_test_provider.ex added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgents.Providers.FallbackTestProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Providers.Provider
5
6
  alias OpenAgents.Providers.Request
7
8
  @impl true
9
  def id, do: "test.fallback_provider"
10
11
  @impl true
12
  def capabilities, do: [:text, :usage]
13
14
  @impl true
15
  def configured?, do: true
16
17
  # Stands in for the Vercel AI Gateway lane with a fallback model list: the
18
  # model that answers is not necessarily the model that was asked for.
19
  @impl true
20
  def substitutable?, do: true
21
22
  @doc """
23
  Emit one response, optionally disclosing which model served it.
24
25
  `config :openagents, :test_fallback_served_model` is the name the response
26
  carries. Anything that is not a binary — the default — is a response that
27
  discloses nothing, which is the case the host must read as unresolved rather
28
  than as the requested model.
29
  """
30
  @impl true
31
  def stream(%Request{}, on_event) when is_function(on_event, 1) do
32
    on_event.({:response_started, "fallback-response"})
33
34
    case Application.get_env(:openagents, :test_fallback_served_model) do
35
      name when is_binary(name) -> on_event.({:model_served, name})
36
      _undisclosed -> :ok
37
    end
38
39
    on_event.({:text_delta, "Served."})
40
    on_event.({:usage, %{"input_tokens" => 4, "output_tokens" => 8}})
41
    on_event.({:response_completed, "fallback-response"})
42
    :ok
43
  end
44
end

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