Serve GLM 5.3 Flash, and withdraw the two models it replaces

815a5b062f01 · AtlantisPleb · · parent c67cf3943fa4

Serve GLM 5.3 Flash, and withdraw the two models it replaces

The catalog is now exactly two models, both through the Vercel gateway:
`glm-5.3-flash` on `zai/glm-5.3-flash` as the default, and
`gemini-3.7-flash` second. `ox-alpha` and `gpt-5.6-luna` are out.

The gateway resolves the new slug to z.ai against the BYOK z.ai
credentials this account holds at Vercel — the reply says
`"resolvedProvider":"zai"`, `"credentialType":"byok"`, `"cost":"0"` — so
the call spends those credits and Vercel charges nothing to route it.
OpenRouter serves the same model as `z-ai/glm-5.3-flash` and that lane
answers too, but it bills a different balance.

`ox-alpha` is the same model. `stealth/ox-alpha` was GLM 5.3 Flash under
its pre-launch name, and OpenRouter now answers that slug with a 404
saying so, so the removal is a rename rather than a substitution. The old
public id deliberately does not resolve to the new entry: answering a
withdrawn name with a survivor is what PROVIDER-002 forbids.

Withdrawal is real rather than cosmetic. A model absent from the catalog
cannot be minted a grant and cannot be called on a grant minted before it
went, so `Models.fetch/1` returning `:error` is the whole mechanism and a
test now says so.

`gpt-5.6-luna` stays last in `vercel_gateway_fallback_models`, which is a
different list: the gateway's own ordered chain, tried inside one call,
that nobody selects from. `zai/glm-5.3-flash` leads that chain because it
is the only entry the catalog also admits, so a rescued call that lands
there is priced normally rather than recording no price at all.

Everything downstream of the old name follows: the `/chat` backend id and
default, the chat OpenRouter adapter's model and label, and the OpenCode
setup script, which stays on OpenRouter because OpenCode reads
`OPENROUTER_API_KEY` and talks to OpenRouter itself. A migration renames
the recorded `ox-alpha` chat backend, because history replays only into
the backend that wrote it and rows left under the old name would have
stopped replaying without failing.

Pricing is `:placeholder`, at list price rather than the half-price offer
that ends 2026-09-09: a cost figure that silently doubles in two weeks is
worse than one that is honestly high, and neither is declared either way.

`inference_input_price_microusd_per_ktoken` and its output twin are gone.
Nothing in `lib/` read them; three test helpers did, as an undeclared
mirror of the default model's rates, which is exactly what broke when the
default moved. Those helpers read the catalog now.

Both surviving models sit on one gateway and the backup that used to
answer when neither could be served is withdrawn, so if the Vercel
gateway is unreachable this deployment serves no model at all. The
catalog comment says that rather than describing a fallback that is gone.

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 452 · 2026-08-26T16:02:38.324958Z
built
12 modules in 143.9 s
deployed
live · 12 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 12 modules on 0 nodes · push→live —

Changed files

  • modified config/config.exs
  • modified docs/runtime-configuration.md
  • modified lib/openagents/box.ex
  • modified lib/openagents/chat/account_turns.ex
  • modified lib/openagents/chat/backends.ex
  • modified lib/openagents/chat/open_router.ex
  • modified lib/openagents/inference/models.ex
  • modified lib/openagents/inference/pricing.ex
  • modified lib/openagents/providers/open_router.ex
  • modified lib/openagents/providers/open_router/stream_decoder.ex
  • modified lib/openagents/providers/vercel_gateway.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/live/chat_console_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260826160000_rename_ox_alpha_chat_backend.exs
  • modified test/fixtures/openrouter/responses_repo_edit_call.sse
  • modified test/fixtures/openrouter/responses_repo_read_call.sse
  • modified test/fixtures/openrouter/responses_repo_reread_call.sse
  • modified test/fixtures/openrouter/responses_repo_write_call.sse
  • modified test/fixtures/openrouter/responses_tool_call.sse
  • modified test/openagents/box_test.exs
  • modified test/openagents/chat/account_turns_test.exs
  • modified test/openagents/chat/backends_test.exs
  • modified test/openagents/chat/open_router/responses_stream_decoder_test.exs
  • modified test/openagents/chat/open_router_test.exs
  • modified test/openagents/gym_test.exs
  • modified test/openagents/inference/credit_test.exs
  • modified test/openagents/inference/health_test.exs
  • modified test/openagents/inference/models_select_test.exs
  • modified test/openagents/inference/models_test.exs
  • modified test/openagents/inference/pricing_test.exs
  • modified test/openagents/inference_test.exs
  • modified test/openagents/providers/open_router/request_payload_test.exs
  • modified test/openagents/providers/open_router_test.exs
  • modified test/openagents/threads/credit_race_test.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents/tools/open_pull_request_test.exs
  • modified test/openagents_web/controllers/chat_turn_controller_test.exs
  • modified test/openagents_web/controllers/credit_controller_test.exs
  • modified test/openagents_web/controllers/gym_run_controller_test.exs
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • modified test/openagents_web/controllers/inference_proxy_fallback_test.exs
  • modified test/openagents_web/controllers/model_catalog_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • modified test/openagents_web/live/chat_console_test.exs
  • modified test/openagents_web/live/chat_console_updates_test.exs
  • modified test/openagents_web/live/gym_live_test.exs
  • modified test/openagents_web/live/gym_run_live_test.exs
  • modified test/openagents_web/live/model_catalog_live_test.exs
  • modified test/openagents_web/live/thread_show_live_test.exs
  • added test/support/unpriced_lane.ex

Diff

53 files changed, +652 -332

config/config.exs modified +106 -56

@@ -114,7 +114,12 @@ config :openagents,

114 114
  openai_model: "gpt-5.6-luna",
115 115
  openai_api_key: nil,
116 116
  openrouter_api_key: nil,
117
  openrouter_model: "stealth/ox-alpha",
117
  # The OpenRouter spelling of GLM 5.3 Flash. `stealth/ox-alpha` was this same
118
  # model under its pre-launch name, and OpenRouter now answers that slug with
119
  # a 404 that says so. Note the hyphen: OpenRouter writes the creator
120
  # `z-ai`, the Vercel gateway writes it `zai`, and the two are not
121
  # interchangeable.
122
  openrouter_model: "z-ai/glm-5.3-flash",
118 123
  # The typed model catalog (`OpenAgents.Inference.Models`, PROVIDER-002):
119 124
  # every model this deployment serves, in the order a client should offer
120 125
  # them; the first entry is the default. `id` is the public name a caller

@@ -134,29 +139,103 @@ config :openagents,

134 139
  # (including omitting it) for a working figure. `OpenAgents.Inference.Pricing`
135 140
  # reads that word, and only `:declared` is billable (METER-001).
136 141
  #
137
  # Every rate below is a placeholder written to make the system run. None of
138
  # them was read off a provider's price page, so all of them say `:placeholder`
139
  # and nothing may bill from a cost they produced. Replacing them is an owner
140
  # action: enter the provider's real rates and set `source: :declared` in the
141
  # same edit.
142
  # Every rate below is a placeholder: no operator has declared any of them, so
143
  # all of them say `:placeholder` and nothing may bill from a cost they
144
  # produced. Replacing them is an owner action: enter the provider's rates and
145
  # set `source: :declared` in the same edit.
142 146
  #
143
  # A model with no `pricing` key records no estimated cost at all — not a
144
  # zero. `gpt-5.6-luna` is that model and it is the lane the coder runs on, so
145
  # the account's metered spend is a floor rather than a total until it has
146
  # rates. Every read surface says `unpriced` rather than `$0.00`.
147
  # The GLM 5.3 Flash figures are the ones to be careful with. Its posted rates
148
  # on 2026-08-26 were $0.075 in, $0.25 out, and $0.015 cached in per million
149
  # tokens — a limited-time half-price offer that ends 2026-09-09 16:00 UTC,
150
  # after which list price is $0.15, $0.50, and $0.03. The entry carries list
151
  # price, because a figure that silently doubles in two weeks is worse than
152
  # one that is honestly high, and because neither figure is declared either
153
  # way. The Gemini figures were written to make the system run and were not
154
  # read off any price page.
147 155
  #
148
  # Gemini 3.7 Flash leads, so it is what a caller that names none gets: fast,
149
  # a million tokens of context, and steady enough to hold a conversation.
156
  # A model with no `pricing` key records no estimated cost at all — not a zero
157
  # — and its usage records stamp `pricing_id: "unpriced"`. No entry below is in
158
  # that state now that `gpt-5.6-luna` has been withdrawn, but the state is
159
  # still reached: a call the gateway's fallback chain rescues is answered by a
160
  # model this catalog does not admit and therefore has no rates. Every read
161
  # surface says `unpriced` rather than `$0.00`.
150 162
  #
151
  # Ox Alpha is what delegated children run on. It is built for sustained
152
  # agentic coding, which is what a child is given, and where it stalls the
153
  # cost is one child rather than the conversation — a session opened on it
154
  # answered "sup" with nothing at all, and a child's first request returned an
155
  # empty 200 after three minutes.
163
  # This list is exactly two models, and a caller may spend nothing else. A
164
  # model absent from it cannot be minted a grant (`OpenAgents.Inference.mint/1`
165
  # refuses the name) and cannot be called on a grant minted before it was
166
  # withdrawn (`OpenAgentsWeb.InferenceProxyController` refuses
167
  # `model_unavailable`). Withdrawing an entry is therefore a real withdrawal
168
  # rather than a hidden one.
156 169
  #
157
  # Luna is the backup, and is what answers where the OpenRouter credential is
158
  # not configured and neither of the two above can be served.
170
  # GLM 5.3 Flash leads, so it is what a caller that names none gets: a million
171
  # tokens of context, and cheap enough per token to hold an ordinary
172
  # conversation on a model that reasons before it answers. Its thinking is
173
  # charged against `max_output` before a word of the answer is — a 256-token
174
  # allowance was spent 243 tokens deep in reasoning and the answer was cut off
175
  # mid-word on `finish_reason: "length"` — so the allowance below is the
176
  # ceiling the gateway publishes for the lane rather than a modest figure.
177
  #
178
  # `ox-alpha`, which used to be listed here on the OpenRouter lane, was this
179
  # same model under its pre-launch name. OpenRouter now answers
180
  # `stealth/ox-alpha` with a 404 saying so, which is why it is gone rather
181
  # than demoted: there was never a second model to keep. The old public id
182
  # deliberately does not resolve to the new entry — silently answering a
183
  # withdrawn name with a survivor is what PROVIDER-002 forbids.
184
  #
185
  # Gemini 3.7 Flash is second: fast, a million tokens of context, and steady
186
  # enough to hold a conversation. It led this list until GLM 5.3 Flash was
187
  # added.
188
  #
189
  # Both entries are on `:vercel_gateway`, and that is a single point of failure
190
  # this file no longer papers over. `gpt-5.6-luna` used to be the third entry
191
  # and the backup that answered when neither of the others could be served.
192
  # It is withdrawn from this list and nothing replaced it, so if the gateway
193
  # itself is unreachable this deployment serves no model at all: both lanes
194
  # report `unavailable` and neither is substituted for the other.
195
  #
196
  # `vercel_gateway_fallback_models` below is a different list and it still ends
197
  # with `openai/gpt-5.6-luna`, deliberately. That is the gateway's own ordered
198
  # chain, tried inside a single call when the requested model fails, and it is
199
  # not a menu: nobody selects a model from it. Luna sitting last in it is the
200
  # backstop of a backstop. Withdrawing Luna from this catalog means no caller
201
  # can choose it; it does not mean the gateway may never fall back to it.
202
  #
203
  # A call the chain rescues is still attributed honestly. The adapter reads the
204
  # serving model back off the response, so a call answered by a model this
205
  # catalog does not admit records no price at all — `pricing_id: "unpriced"` —
206
  # rather than being charged at the rates of the model that was asked for
207
  # (METER-001, PROVIDER-002).
159 208
  model_catalog: [
209
    %{
210
      id: "glm-5.3-flash",
211
      # Through the Vercel gateway, which resolves the slug to z.ai and calls
212
      # it with the BYOK z.ai credentials this account holds there. The
213
      # `providerOptions.gateway.order` pin below names Vertex, which does not
214
      # serve this model; the gateway reports `"planningReasoning":"BYOK
215
      # credentials available for: zai"` and routes to z.ai anyway, so the pin
216
      # costs this lane nothing.
217
      provider: :vercel_gateway,
218
      provider_model: "zai/glm-5.3-flash",
219
      # A million, per z.ai's listing and the gateway's. OpenRouter publishes
220
      # 1,048,576 for the same model; a million is the smaller of the two and
221
      # the number the serving lane states.
222
      context_window: 1_000_000,
223
      # The gateway's published ceiling for this lane. It has to be large: the
224
      # model reasons against this allowance, so a small one is spent thinking
225
      # and the caller is handed a truncated answer on a 200.
226
      max_output: 131_000,
227
      # Placeholder: list price ($0.15 in, $0.50 out, $0.03 cached in, per
228
      # million tokens), not the half-price offer running until 2026-09-09
229
      # 16:00 UTC. Rates an operator has not declared, so nothing may bill from
230
      # them until `source` says `:declared`.
231
      pricing: %{
232
        id: "placeholder.glm-5.3-flash.v1",
233
        source: :placeholder,
234
        input_per_million_tokens: 150_000,
235
        output_per_million_tokens: 500_000,
236
        cached_input_per_million_tokens: 30_000
237
      }
238
    },
160 239
    %{
161 240
      id: "gemini-3.7-flash",
162 241
      # Through the Vercel gateway, pinned to Vertex, so the call lands on

@@ -177,41 +256,6 @@ config :openagents,

177 256
        output_per_million_tokens: 10_000_000,
178 257
        cached_input_per_million_tokens: 100_000
179 258
      }
180
    },
181
    %{
182
      id: "ox-alpha",
183
      provider: :openrouter,
184
      provider_model: {:config, :openrouter_model},
185
      # A million, per the model's own listing. The 256,000 here was a guess
186
      # made before there was a page to read.
187
      context_window: 1_000_000,
188
      # Ox Alpha is a reasoning model, and its thinking is charged against this
189
      # allowance before a single word of the answer is. At 4,096 a child agent
190
      # with a real task spent the whole budget reasoning and returned an empty
191
      # 200 after three minutes, which read as the proxy having failed.
192
      max_output: 64_000,
193
      # Placeholder: the operator must set real provider rates and flip `source`
194
      # to `:declared` before anything bills from this. This entry does not
195
      # declare a cached-input rate.
196
      pricing: %{
197
        id: "placeholder.ox-alpha.v1",
198
        source: :placeholder,
199
        input_per_million_tokens: 500_000,
200
        output_per_million_tokens: 2_000_000
201
      }
202
    },
203
    %{
204
      id: {:config, :openai_model},
205
      provider: :openai,
206
      provider_model: {:config, :openai_model},
207
      context_window: 272_000,
208
      max_output: 4_096
209
      # This entry deliberately omits `pricing`, so a grant pinned to it records
210
      # no estimated cost rather than a made-up zero. Its usage records stamp
211
      # `pricing_id: "unpriced"`, `Threads.spend/1` refuses to total a session
212
      # that touched it, and the thread page shows the word instead of a
213
      # figure. Giving this lane real rates is the single highest-value edit in
214
      # this file: it is where the coder's spend actually goes.
215 259
    }
216 260
  ],
217 261
  gemini_api_key: nil,

@@ -221,7 +265,15 @@ config :openagents,

221 265
  # where this account's credits are — but the fallback models are not on
222 266
  # Vertex, so Vercel may try other providers for them.
223 267
  vercel_gateway_providers: ["vertex"],
268
  # The gateway's ordered fallback chain, tried within one call when the
269
  # requested model fails. `zai/glm-5.3-flash` leads it because it is the only
270
  # entry this deployment also admits in `:model_catalog`: a rescued call that
271
  # lands on an admitted model is priced and attributed normally, and every
272
  # other entry here records no price at all. `openai/gpt-5.6-luna` stays last
273
  # at owner direction — it is the backstop of a backstop, reachable only
274
  # automatically and selectable by nobody.
224 275
  vercel_gateway_fallback_models: [
276
    "zai/glm-5.3-flash",
225 277
    "zai/glm-5.3",
226 278
    "zai/glm-5.2",
227 279
    "openai/gpt-5.6-luna"

@@ -466,8 +518,6 @@ config :openagents,

466 518
  # column; only account creation reads this.
467 519
  account_credit_microusd: 20_000_000,
468 520
  visitor_credit_microusd: 2_000_000,
469
  inference_input_price_microusd_per_ktoken: 1_250,
470
  inference_output_price_microusd_per_ktoken: 10_000,
471 521
  forge_enabled: false,
472 522
  forge_boot_converge_enabled: false,
473 523
  forge_deploy_lane_enabled: false,
docs/runtime-configuration.md modified +1 -1

@@ -156,7 +156,7 @@ arguments, repository URLs, receipts, or checked-in environment files.

156 156
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY_ID` | Bounded active-key identifier prefixed with `development-`, `test-`, `staging-`, or `production-` to match the runtime |
157 157
| GitHub | `GITHUB_TOKEN_DECRYPTION_KEYS_JSON` | Optional map of at most 16 same-environment prior keys used only during rewrap; omit the active ID |
158 158
| Providers | `OPENAI_API_KEY` | Environment-specific provider secret; required by the current text provider |
159
| Providers | `OPENROUTER_API_KEY` | Environment-specific server-only credential for the `/chat` OpenRouter adapter; the `/chat` console requests `stealth/ox-alpha` only, so a turn fails rather than answering as another model; empty disables the adapter |
159
| Providers | `OPENROUTER_API_KEY` | Environment-specific server-only credential for the `/chat` OpenRouter adapter; the `/chat` console requests `z-ai/glm-5.3-flash` only, so a turn fails rather than answering as another model; empty disables the adapter |
160 160
| Providers | `OPENAGENTS_INFERENCE_PROXY_URL` | HTTPS URL without credentials when computers are enabled; empty disables |
161 161
| Computers | `OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS` | `300` through `2592000`; Gate 5 uses the 30-day maximum |
162 162
| Recording | `VOICE_RECORDING_ENCRYPTION_KEY` | Base64-encoded 32-byte key when recording is enabled; empty disables recording storage |
lib/openagents/box.ex modified +10 -2

@@ -448,12 +448,20 @@ defmodule OpenAgents.Box do

448 448
  # OPENROUTER_API_KEY environment variable natively, so the setup script
449 449
  # never touches the credential.
450 450
  #
451
  # This is the one lane that still buys inference from OpenRouter rather than
452
  # through the Vercel gateway, and it is a deliberate temporary exception:
453
  # OpenCode speaks to OpenRouter itself, so it needs OpenRouter's own spelling
454
  # of the model (`z-ai/glm-5.3-flash`, hyphenated, where the gateway writes
455
  # `zai/glm-5.3-flash`). OpenRouter charges slightly more for the same model.
456
  # Moving this lane onto the gateway is the intended destination and a
457
  # separate change.
458
  #
451 459
  # The order matters. The whole script runs under `set -euo pipefail`, so the
452 460
  # configuration is written first: an install that fails on a bad network day
453 461
  # then costs the binary and nothing else, and a later manual install finds
454
  # the model already pointed at Ox Alpha.
462
  # the model already pointed at GLM 5.3 Flash.
455 463
  defp setup_script do
456
    model = Application.get_env(:openagents, :openrouter_model, "stealth/ox-alpha")
464
    model = Application.get_env(:openagents, :openrouter_model, "z-ai/glm-5.3-flash")
457 465
458 466
    configuration =
459 467
      Jason.encode!(%{
lib/openagents/chat/account_turns.ex modified +8 -7

@@ -499,7 +499,8 @@ defmodule OpenAgents.Chat.AccountTurns do

499 499
  # output list is not a Gemini turn and vice versa, so a conversation that
500 500
  # switches backends replays only the turns the chosen backend produced rather
501 501
  # than handing one provider another's transcript shape. A row written before
502
  # backends were named is an Ox Alpha turn, which is what its `NULL` means.
502
  # backends were named is an Ox Alpha turn — GLM 5.3 Flash under its
503
  # pre-launch name — which is what its `NULL` means.
503 504
  defp provider_history(conversation_id, excluded_run_id, backend) do
504 505
    default_id = Backends.default_id()
505 506

@@ -605,12 +606,12 @@ defmodule OpenAgents.Chat.AccountTurns do

605 606
606 607
  defp usage_view(_counts, _completion), do: nil
607 608
608
  # Ox Alpha reports a reasoning count of zero for turns it plainly reasoned
609
  # through, so that zero measures nothing the turn did and the turn reports no
610
  # reasoning count rather than a count of none. The call is made here, against
611
  # the same stored reasoning the transcript renders, so a row written before
612
  # this rule reads the same way as one written after it and no stored count is
613
  # ever rewritten.
609
  # GLM 5.3 Flash reports a reasoning count of zero for turns it plainly
610
  # reasoned through, so that zero measures nothing the turn did and the turn
611
  # reports no reasoning count rather than a count of none. The call is made
612
  # here, against the same stored reasoning the transcript renders, so a row
613
  # written before this rule reads the same way as one written after it and no
614
  # stored count is ever rewritten.
614 615
  defp reasoning_view(0, completion), do: if(reasoned?(completion), do: nil, else: 0)
615 616
  defp reasoning_view(count, _completion), do: count
616 617
lib/openagents/chat/backends.ex modified +7 -4

@@ -23,11 +23,11 @@ defmodule OpenAgents.Chat.Backends do

23 23
24 24
  @backends [
25 25
    %{
26
      id: "ox-alpha",
27
      label: "Ox Alpha",
26
      id: "glm-5.3-flash",
27
      label: "GLM 5.3 Flash",
28 28
      adapter: OpenRouter,
29 29
      model: nil,
30
      description: "Ox Alpha through OpenRouter. The default when a turn names no backend.",
30
      description: "GLM 5.3 Flash through OpenRouter. The default when a turn names no backend.",
31 31
      free: false
32 32
    },
33 33
    %{

@@ -42,7 +42,10 @@ defmodule OpenAgents.Chat.Backends do

42 42
    }
43 43
  ]
44 44
45
  @default_id "ox-alpha"
45
  # `ox-alpha` was this same model under its pre-launch name, and OpenRouter
46
  # now answers that slug with a 404 saying so, so this is a rename rather than
47
  # a substitution.
48
  @default_id "glm-5.3-flash"
46 49
47 50
  @type t :: %{
48 51
          id: String.t(),
lib/openagents/chat/open_router.ex modified +7 -4

@@ -3,8 +3,8 @@ defmodule OpenAgents.Chat.OpenRouter do

3 3
  Server-side OpenRouter Responses adapter for the `/chat` console.
4 4
5 5
  The adapter keeps the OpenRouter credential and HTTP transport on the server.
6
  It requests Ox Alpha and uses chat completions only when a provider does not
7
  support Responses. It returns normalized failures without provider
6
  It requests GLM 5.3 Flash and uses chat completions only when a provider does
7
  not support Responses. It returns normalized failures without provider
8 8
  credentials or response bodies, and it separates the failures an operator can
9 9
  retry — a rate limit, an unavailable provider, an interrupted stream, and a
10 10
  malformed stream — from the ones a retry cannot fix.

@@ -14,8 +14,11 @@ defmodule OpenAgents.Chat.OpenRouter do

14 14
15 15
  @chat_completions_endpoint "https://openrouter.ai/api/v1/chat/completions"
16 16
  @responses_endpoint "https://openrouter.ai/api/v1/responses"
17
  @default_model "stealth/ox-alpha"
18
  @model_label "Ox Alpha"
17
  # `stealth/ox-alpha` was GLM 5.3 Flash under its pre-launch name; OpenRouter
18
  # answers that slug with a 404 now. Note the hyphen: OpenRouter writes the
19
  # creator `z-ai` and the Vercel gateway writes it `zai`.
20
  @default_model "z-ai/glm-5.3-flash"
21
  @model_label "GLM 5.3 Flash"
19 22
  @maximum_tool_rounds 6
20 23
  @tool_instructions """
21 24
  Ground every repository claim in repository tool output. Never claim that a file or directory exists unless a tool result confirms it. Use list_repository_directory before guessing a path, and use the returned paths exactly. Do not retry the same failed repository, path, and ref combination. If a read fails, list its parent directory once or tell the user that the requested content is unavailable.
lib/openagents/inference/models.ex modified +7 -5

@@ -15,8 +15,8 @@ defmodule OpenAgents.Inference.Models do

15 15
  client selects from what is actually served instead of guessing.
16 16
17 17
  Two names appear per model and they are not the same name. The `id` is what
18
  a client asks for and what the grant publishes — `ox-alpha`. The
19
  `provider_model` is what the provider is called with — `stealth/ox-alpha`.
18
  a client asks for and what the grant publishes — `glm-5.3-flash`. The
19
  `provider_model` is what the provider is called with — `zai/glm-5.3-flash`.
20 20
  Keeping them apart is what lets the routed vendor string change without
21 21
  invalidating grants that already name the model. A catalog entry may write
22 22
  either name as `{:config, key}` to follow a runtime-configurable value.

@@ -108,9 +108,11 @@ defmodule OpenAgents.Inference.Models do

108 108
  @doc """
109 109
  The model with this id, or `:error`.
110 110
111
  A thread opened before this list existed carries the vendor string
112
  (`stealth/ox-alpha`) in its `model` column, so a vendor spelling resolves to
113
  the model it routes rather than leaving those threads unable to mint.
111
  A thread opened before this list existed carries a vendor string rather than
112
  a public id in its `model` column, so a vendor spelling (`zai/glm-5.3-flash`)
113
  resolves to the model it routes rather than leaving those threads unable to
114
  mint. A vendor string this catalog no longer routes does not resolve: a
115
  withdrawn model is withdrawn, not quietly replaced with a survivor.
114 116
  """
115 117
  @spec fetch(String.t() | nil) :: {:ok, t()} | :error
116 118
  def fetch(id) when is_binary(id) do
lib/openagents/inference/pricing.ex modified +8 -5

@@ -3,11 +3,14 @@ defmodule OpenAgents.Inference.Pricing do

3 3
  What a metered call cost, and on whose authority.
4 4
5 5
  Metering that prices some lanes at zero is worse than no metering: it reports
6
  a number, and the number is wrong. `gpt-5.6-luna` is the lane the coder
7
  actually runs on and this deployment has never been told its rates, so a
8
  surface that read a missing cost as zero would have shown `$0.00` beside a
9
  session that spent real money — and shown it in the same typeface as a figure
10
  that was measured.
6
  a number, and the number is wrong. This deployment ran for a long time with
7
  `gpt-5.6-luna` admitted and its rates never entered, so a surface that read a
8
  missing cost as zero would have shown `$0.00` beside a session that spent
9
  real money — and shown it in the same typeface as a figure that was measured.
10
  Every admitted model carries rates now, and none of them is declared, so the
11
  live case for a selectable model is `provisional`. `unpriced` is not dead
12
  code: a call the gateway's fallback chain answers with a model the catalog
13
  does not admit reaches it, and so would any entry added without rates.
11 14
12 15
  So a cost is never a bare integer here. Every metered usage record carries a
13 16
  `pricing_id` naming the rate table it was priced against, and the id resolves
lib/openagents/providers/open_router.ex modified +5 -2

@@ -2,8 +2,11 @@ defmodule OpenAgents.Providers.OpenRouter do

2 2
  @moduledoc """
3 3
  OpenRouter chat-completions adapter for the inference proxy.
4 4
5
  This is the second provider the proxy can reach, and it exists so a grant can
6
  pin Ox Alpha: `OpenAgents.Inference.Models` names which model each provider
5
  No catalog entry names this lane today — both admitted models are served
6
  through the Vercel gateway — but the gateway adapter speaks this wire format
7
  and delegates its request building and stream decoding here, so this module
8
  is on the path of every proxied call. It also remains a lane a catalog entry
9
  may name: `OpenAgents.Inference.Models` names which model each provider
7 10
  serves, and the proxy dispatches on the grant's model rather than on one
8 11
  compiled-in module.
9 12
lib/openagents/providers/open_router/stream_decoder.ex modified +1 -1

@@ -262,7 +262,7 @@ defmodule OpenAgents.Providers.OpenRouter.StreamDecoder do

262 262
  # Every chat-completions chunk names the model that produced it, and that
263 263
  # name is not always the one the request asked for: the Vercel AI Gateway is
264 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
265
  # can come back served by `zai/glm-5.3`. Reading the field back is
266 266
  # the only way the host learns which lane to price and attribute the call
267 267
  # against, so it is carried out as an event rather than dropped.
268 268
  #
lib/openagents/providers/vercel_gateway.ex modified +4 -4

@@ -27,8 +27,8 @@ defmodule OpenAgents.Providers.VercelGateway do

27 27
  models Vercel tries if the primary model fails.
28 28
29 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
30
  `google/gemini-3.7-flash` can be answered by `zai/glm-5.3` and still return
31
  200, so the model that was asked for is not evidence of the model that
32 32
  answered. The response's `model` field is, and the chat-completions decoder
33 33
  reads it back as `{:model_served, name}` so the call is priced and attributed
34 34
  against the lane that served it rather than the lane that was requested

@@ -61,8 +61,8 @@ defmodule OpenAgents.Providers.VercelGateway do

61 61
62 62
  True exactly while a fallback list is configured. `providerOptions.gateway.models`
63 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
64
  request for `google/gemini-3.7-flash` can be answered by `zai/glm-5.3` and
65
  return 200. The host reads the serving model back off the response; this
66 66
  says what its silence means, because a lane that cannot be substituted for
67 67
  needs no disclosure to be attributed correctly.
68 68
  """
lib/openagents/threads.ex modified +2 -1

@@ -97,7 +97,8 @@ defmodule OpenAgents.Threads do

97 97
  `OpenAgents.Inference.Models.default_id/0`, `high` reasoning, and the
98 98
  `read_only` permission profile; a caller may narrow or widen only within the
99 99
  admitted vocabulary. The thread's model is the model its grant pins, so a
100
  caller that opens a thread on `ox-alpha` gets authority for `ox-alpha`.
100
  caller that opens a thread on `glm-5.3-flash` gets authority for
101
  `glm-5.3-flash`.
101 102
102 103
  Admission is where the ceiling lives. Elapsed authority is reaped first, so a
103 104
  slot held by an abandoned thread is released before the count is taken, and
lib/openagents_web/controllers/inference_proxy_controller.ex modified +1 -1

@@ -21,7 +21,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

21 21
  What answered is read back off the response rather than assumed from the
22 22
  request. One lane can substitute: the Vercel AI Gateway is configured with a
23 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
24
  `zai/glm-5.3` and still return 200. The serving model therefore
25 25
  decides three things — the name attributed on the response, the lane whose
26 26
  health is recorded, and the rate table the usage record is priced against
27 27
  (METER-001). A substitutable lane whose response discloses no model is
lib/openagents_web/controllers/thread_controller.ex modified +3 -2

@@ -36,8 +36,9 @@ defmodule OpenAgentsWeb.ThreadController do

36 36
  caller states which model it wants is the thread it opens, and the response
37 37
  publishes the model the grant carries. Admitting it at the door is what lets
38 38
  a coding session run its own turns on one model and its delegated children on
39
  another: it opens a second thread on `ox-alpha` and gets authority for
40
  `ox-alpha`, with its own budget, rather than borrowing the first thread's.
39
  another: it opens a second thread on `gemini-3.7-flash` and gets authority
40
  for `gemini-3.7-flash`, with its own budget, rather than borrowing the first
41
  thread's.
41 42
42 43
  The exception is the `local` lane, and it is an exception to the grant, not
43 44
  to the record. `"lane": "local"` opens a transcript-only thread: the model is
lib/openagents_web/live/chat_console_live.ex modified +6 -6

@@ -1,11 +1,11 @@

1 1
defmodule OpenAgentsWeb.ChatConsoleLive do
2 2
  @moduledoc """
3
  The Ox Alpha console, reachable at `/chat` by operators only.
3
  The GLM 5.3 Flash console, reachable at `/chat` by operators only.
4 4
5
  The console drives one Ox Alpha conversation per operator account. It sends
6
  every request to OpenRouter from the server, so the provider credential never
7
  reaches the browser, and it prefers the Responses API, using chat completions
8
  only when a provider cannot serve Responses.
5
  The console drives one GLM 5.3 Flash conversation per operator account. It
6
  sends every request to OpenRouter from the server, so the provider credential
7
  never reaches the browser, and it prefers the Responses API, using chat
8
  completions only when a provider cannot serve Responses.
9 9
10 10
  It shares the AI Elements components with Sarah's transcript at `/sarah` and
11 11
  shares none of her state: no persona, no voice, no work queue, and no

@@ -23,7 +23,7 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

23 23
  alias OpenAgentsWeb.LiveRefresh
24 24
25 25
  @suggestions [
26
    "Summarize what the Ox Alpha stress fleet measures today.",
26
    "Summarize what the stress fleet measures today.",
27 27
    "Draft a checklist for a cloud-computer stress run.",
28 28
    "Explain the difference between a push and a deploy here.",
29 29
    "Write a short status update for the current fleet work."
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -314,7 +314,8 @@

314 314
    20260825230000,
315 315
    20260826010000,
316 316
    20260826103000,
317
    20260826145554
317
    20260826145554,
318
    20260826160000
318 319
  ],
319 320
  "required_tables": [
320 321
    "users",
priv/repo/migrations/20260826160000_rename_ox_alpha_chat_backend.exs added +22

@@ -0,0 +1,22 @@

1
defmodule OpenAgents.Repo.Migrations.RenameOxAlphaChatBackend do
2
  use Ecto.Migration
3
4
  # Rename the recorded `ox-alpha` chat backend to `glm-5.3-flash`.
5
  #
6
  # `stealth/ox-alpha` was GLM 5.3 Flash under its pre-launch name; OpenRouter
7
  # answers that slug with a 404 saying so. The backend was renamed rather than
8
  # replaced, so the rows it wrote describe the same model under an old name.
9
  #
10
  # Leaving them would lose them. `OpenAgents.Chat.AccountTurns` replays a
11
  # conversation's history only into the backend that wrote it, matching on
12
  # this column, so a row still reading `ox-alpha` would never replay again —
13
  # the transcript would silently shorten rather than fail.
14
15
  def up do
16
    execute("UPDATE account_chat_runs SET backend = 'glm-5.3-flash' WHERE backend = 'ox-alpha'")
17
  end
18
19
  def down do
20
    execute("UPDATE account_chat_runs SET backend = 'ox-alpha' WHERE backend = 'glm-5.3-flash'")
21
  end
22
end
test/fixtures/openrouter/responses_repo_edit_call.sse modified +1 -1

@@ -10,6 +10,6 @@ data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_e

10 10
11 11
data: {"type":"response.output_item.done","response_id":"resp_repo_edit","output_index":1,"item":{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}","status":"completed"}}
12 12
13
data: {"type":"response.completed","response":{"id":"resp_repo_edit","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_edit","status":"completed","summary":[{"type":"summary_text","text":"Apply the exact replacement to the new draft."}],"encrypted_content":"encrypted-repo-edit"},{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}","status":"completed"}]}}
13
data: {"type":"response.completed","response":{"id":"resp_repo_edit","object":"response","status":"completed","model":"z-ai/glm-5.3-flash","output":[{"type":"reasoning","id":"rs_repo_edit","status":"completed","summary":[{"type":"summary_text","text":"Apply the exact replacement to the new draft."}],"encrypted_content":"encrypted-repo-edit"},{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}","status":"completed"}]}}
14 14
15 15
data: [DONE]
test/fixtures/openrouter/responses_repo_read_call.sse modified +1 -1

@@ -14,6 +14,6 @@ data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_r

14 14
15 15
data: {"type":"response.output_item.done","response_id":"resp_repo_read","output_index":2,"item":{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}}
16 16
17
data: {"type":"response.completed","response":{"id":"resp_repo_read","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_read","status":"completed","summary":[{"type":"summary_text","text":"Read the current file before changing it."}],"encrypted_content":"encrypted-repo-read"},{"type":"message","id":"msg_repo_read_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the current workspace file before changing it.","annotations":[]}]},{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
17
data: {"type":"response.completed","response":{"id":"resp_repo_read","object":"response","status":"completed","model":"z-ai/glm-5.3-flash","output":[{"type":"reasoning","id":"rs_repo_read","status":"completed","summary":[{"type":"summary_text","text":"Read the current file before changing it."}],"encrypted_content":"encrypted-repo-read"},{"type":"message","id":"msg_repo_read_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the current workspace file before changing it.","annotations":[]}]},{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
18 18
19 19
data: [DONE]
test/fixtures/openrouter/responses_repo_reread_call.sse modified +1 -1

@@ -10,6 +10,6 @@ data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_r

10 10
11 11
data: {"type":"response.output_item.done","response_id":"resp_repo_reread","output_index":1,"item":{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}}
12 12
13
data: {"type":"response.completed","response":{"id":"resp_repo_reread","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_reread","status":"completed","summary":[{"type":"summary_text","text":"Read the file again to verify the edit."}],"encrypted_content":"encrypted-repo-reread"},{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
13
data: {"type":"response.completed","response":{"id":"resp_repo_reread","object":"response","status":"completed","model":"z-ai/glm-5.3-flash","output":[{"type":"reasoning","id":"rs_repo_reread","status":"completed","summary":[{"type":"summary_text","text":"Read the file again to verify the edit."}],"encrypted_content":"encrypted-repo-reread"},{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
14 14
15 15
data: [DONE]
test/fixtures/openrouter/responses_repo_write_call.sse modified +1 -1

@@ -10,6 +10,6 @@ data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_w

10 10
11 11
data: {"type":"response.output_item.done","response_id":"resp_repo_write","output_index":1,"item":{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}","status":"completed"}}
12 12
13
data: {"type":"response.completed","response":{"id":"resp_repo_write","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_write","status":"completed","summary":[{"type":"summary_text","text":"Replace the file with the requested draft."}],"encrypted_content":"encrypted-repo-write"},{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}","status":"completed"}]}}
13
data: {"type":"response.completed","response":{"id":"resp_repo_write","object":"response","status":"completed","model":"z-ai/glm-5.3-flash","output":[{"type":"reasoning","id":"rs_repo_write","status":"completed","summary":[{"type":"summary_text","text":"Replace the file with the requested draft."}],"encrypted_content":"encrypted-repo-write"},{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}","status":"completed"}]}}
14 14
15 15
data: [DONE]
test/fixtures/openrouter/responses_tool_call.sse modified +3 -3

@@ -1,6 +1,6 @@

1
data: {"type":"response.created","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"stealth/ox-alpha","output":[]}}
1
data: {"type":"response.created","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"z-ai/glm-5.3-flash","output":[]}}
2 2
3
data: {"type":"response.in_progress","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"stealth/ox-alpha","output":[]}}
3
data: {"type":"response.in_progress","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"z-ai/glm-5.3-flash","output":[]}}
4 4
5 5
data: {"type":"response.output_item.added","response_id":"resp_demo","output_index":0,"item":{"type":"reasoning","id":"rs_demo","status":"in_progress","summary":[]}}
6 6

@@ -20,6 +20,6 @@ data: {"type":"response.function_call_arguments.done","response_id":"resp_demo",

20 20
21 21
data: {"type":"response.output_item.done","response_id":"resp_demo","output_index":2,"item":{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}","status":"completed"}}
22 22
23
data: {"type":"response.completed","response":{"id":"resp_demo","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_demo","status":"completed","summary":[{"type":"summary_text","text":"The user asked to read a repository file."}],"encrypted_content":"encrypted-demo-reasoning"},{"type":"message","id":"msg_tool_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the connected repository.","annotations":[]}]},{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}","status":"completed"}],"usage":{"input_tokens":18,"output_tokens":11,"total_tokens":29,"output_tokens_details":{"reasoning_tokens":7}}}}
23
data: {"type":"response.completed","response":{"id":"resp_demo","object":"response","status":"completed","model":"z-ai/glm-5.3-flash","output":[{"type":"reasoning","id":"rs_demo","status":"completed","summary":[{"type":"summary_text","text":"The user asked to read a repository file."}],"encrypted_content":"encrypted-demo-reasoning"},{"type":"message","id":"msg_tool_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the connected repository.","annotations":[]}]},{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}","status":"completed"}],"usage":{"input_tokens":18,"output_tokens":11,"total_tokens":29,"output_tokens_details":{"reasoning_tokens":7}}}}
24 24
25 25
data: [DONE]
test/openagents/box_test.exs modified +1 -1

@@ -90,7 +90,7 @@ defmodule OpenAgents.BoxTest do

90 90
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
91 91
        payload = Jason.decode!(raw)
92 92
        assert payload["noEnv"] == true
93
        assert payload["setupScript"] =~ "openrouter/stealth/ox-alpha"
93
        assert payload["setupScript"] =~ "openrouter/z-ai/glm-5.3-flash"
94 94
95 95
        Req.Test.json(conn, box_body(%{"state" => "provisioning", "setupStatus" => "pending"}))
96 96
      end)
test/openagents/chat/account_turns_test.exs modified +2 -2

@@ -369,7 +369,7 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

369 369
      end
370 370
    end
371 371
372
    assert {:ok, %{"id" => first_id, "model" => "ox-alpha"}} =
372
    assert {:ok, %{"id" => first_id, "model" => "glm-5.3-flash"}} =
373 373
             AccountTurns.submit(user, "First.", subscriber: self(), streamer: recorder.("ox"))
374 374
375 375
    assert_receive {:request, "ox", _first_request}

@@ -385,7 +385,7 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

385 385
    assert_receive {:request, "gemini", gemini_request}
386 386
    assert_receive {:account_chat_completed, ^second_id, {:ok, _completion}}
387 387
388
    # Gemini sees its own first turn and nothing from the Ox Alpha lane.
388
    # Gemini sees its own first turn and nothing from the GLM 5.3 Flash lane.
389 389
    assert gemini_request["messages"] == [%{"role" => "user", "content" => "Second."}]
390 390
    assert gemini_request["model"] == "gemini-3.7-flash"
391 391
test/openagents/chat/backends_test.exs modified +2 -2

@@ -28,8 +28,8 @@ defmodule OpenAgents.Chat.BackendsTest do

28 28
  end
29 29
30 30
  test "no preference resolves to the default, an unknown name does not" do
31
    assert {:ok, %{id: "ox-alpha"}} = Backends.fetch(nil)
32
    assert {:ok, %{id: "ox-alpha"}} = Backends.fetch("")
31
    assert {:ok, %{id: "glm-5.3-flash"}} = Backends.fetch(nil)
32
    assert {:ok, %{id: "glm-5.3-flash"}} = Backends.fetch("")
33 33
34 34
    # A caller that asked for one model and was quietly served another has no
35 35
    # way to tell, so an unknown name refuses rather than falling back.
test/openagents/chat/open_router/responses_stream_decoder_test.exs modified +1 -1

@@ -52,7 +52,7 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoderTest do

52 52
            "id" => "resp_test",
53 53
            "object" => "response",
54 54
            "status" => "completed",
55
            "model" => "stealth/ox-alpha"
55
            "model" => "z-ai/glm-5.3-flash"
56 56
          }
57 57
        })
58 58
test/openagents/chat/open_router_test.exs modified +30 -27

@@ -235,7 +235,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

235 235
236 236
  setup {Req.Test, :verify_on_exit!}
237 237
238
  test "sends an OpenRouter-compatible Ox Alpha request with a free fallback" do
238
  test "sends an OpenRouter-compatible GLM 5.3 Flash request with a free fallback" do
239 239
    Req.Test.expect(__MODULE__, fn conn ->
240 240
      assert conn.request_path == "/api/v1/chat/completions"
241 241
      assert ["Bearer test-openrouter-key"] = Plug.Conn.get_req_header(conn, "authorization")

@@ -248,7 +248,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

248 248
      assert String.starts_with?(referer, "http")
249 249
250 250
      assert %{
251
               "model" => "stealth/ox-alpha",
251
               "model" => "z-ai/glm-5.3-flash",
252 252
               "messages" => [%{"role" => "user", "content" => "Hello"}]
253 253
             } = conn.body_params
254 254

@@ -272,7 +272,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

272 272
    assert {:ok, completion} =
273 273
             OpenRouter.complete(
274 274
               %{
275
                 "model" => "stealth/ox-alpha",
275
                 "model" => "z-ai/glm-5.3-flash",
276 276
                 "models" => ["openrouter/free"],
277 277
                 "messages" => [%{"role" => "user", "content" => "Hello"}]
278 278
               },

@@ -290,7 +290,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

290 290
    assert {:error, :rate_limited} =
291 291
             OpenRouter.complete(
292 292
               %{
293
                 "model" => "stealth/ox-alpha",
293
                 "model" => "z-ai/glm-5.3-flash",
294 294
                 "models" => ["openrouter/free"],
295 295
                 "messages" => [%{"role" => "user", "content" => "Hello"}]
296 296
               },

@@ -367,14 +367,14 @@ defmodule OpenAgents.Chat.OpenRouterTest do

367 367
        sse(%{
368 368
          "id" => "gen-metadata",
369 369
          "object" => "chat.completion.chunk",
370
          "model" => "stealth/ox-alpha",
370
          "model" => "z-ai/glm-5.3-flash",
371 371
          "provider" => "Stealth",
372 372
          "choices" => [%{"index" => 0, "delta" => %{"content" => "Measured"}}]
373 373
        }) <>
374 374
          sse(%{
375 375
            "id" => "gen-metadata",
376 376
            "object" => "chat.completion.chunk",
377
            "model" => "stealth/ox-alpha",
377
            "model" => "z-ai/glm-5.3-flash",
378 378
            "choices" => [],
379 379
            "usage" => %{
380 380
              "prompt_tokens" => 12,

@@ -407,7 +407,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

407 407
      body =
408 408
        sse(%{
409 409
          "object" => "chat.completion.chunk",
410
          "model" => "stealth/ox-alpha",
410
          "model" => "z-ai/glm-5.3-flash",
411 411
          "choices" => [%{"index" => 0, "delta" => %{"content" => "Quiet"}}]
412 412
        }) <> "data: [DONE]\n\n"
413 413

@@ -433,7 +433,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

433 433
               Plug.Conn.get_req_header(conn, "x-openrouter-categories")
434 434
435 435
      assert %{
436
               "model" => "stealth/ox-alpha",
436
               "model" => "z-ai/glm-5.3-flash",
437 437
               "reasoning" => %{
438 438
                 "effort" => "max",
439 439
                 "exclude" => false,

@@ -481,7 +481,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

481 481
            "response" => %{
482 482
              "id" => "resp_test",
483 483
              "object" => "response",
484
              "model" => "stealth/ox-alpha",
484
              "model" => "z-ai/glm-5.3-flash",
485 485
              "output" => [
486 486
                %{
487 487
                  "type" => "reasoning",

@@ -514,7 +514,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

514 514
    assert {:ok,
515 515
            %{
516 516
              "object" => "response",
517
              "model" => "stealth/ox-alpha",
517
              "model" => "z-ai/glm-5.3-flash",
518 518
              "assistant_message_id" => "msg_test",
519 519
              "assistant_content" => "Hello world",
520 520
              "reasoning_summary" => "Checked the prior context.",

@@ -531,7 +531,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

531 531
            }} =
532 532
             OpenRouter.stream(
533 533
               %{
534
                 "model" => "stealth/ox-alpha",
534
                 "model" => "z-ai/glm-5.3-flash",
535 535
                 "models" => ["openrouter/free"],
536 536
                 "reasoning" => "max",
537 537
                 "messages" => [

@@ -575,7 +575,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

575 575
          "type" => "response.completed",
576 576
          "response" => %{
577 577
            "object" => "response",
578
            "model" => "stealth/ox-alpha",
578
            "model" => "z-ai/glm-5.3-flash",
579 579
            "output" => [
580 580
              %{
581 581
                "type" => "message",

@@ -596,7 +596,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

596 596
    assert {:ok, %{"assistant_content" => "Continued"}} =
597 597
             OpenRouter.stream(
598 598
               %{
599
                 "model" => "stealth/ox-alpha",
599
                 "model" => "z-ai/glm-5.3-flash",
600 600
                 "messages" => [
601 601
                   %{"role" => "user", "content" => "Think"},
602 602
                   %{

@@ -641,7 +641,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

641 641
        sse(%{
642 642
          "id" => "gen-stream",
643 643
          "object" => "chat.completion.chunk",
644
          "model" => "stealth/ox-alpha",
644
          "model" => "z-ai/glm-5.3-flash",
645 645
          "choices" => [%{"index" => 0, "delta" => %{"content" => "Fallback"}}]
646 646
        }) <> "data: [DONE]\n\n"
647 647

@@ -652,10 +652,10 @@ defmodule OpenAgents.Chat.OpenRouterTest do

652 652
653 653
    parent = self()
654 654
655
    assert {:ok, %{"object" => "chat.completion", "model" => "stealth/ox-alpha"}} =
655
    assert {:ok, %{"object" => "chat.completion", "model" => "z-ai/glm-5.3-flash"}} =
656 656
             OpenRouter.stream(
657 657
               %{
658
                 "model" => "stealth/ox-alpha",
658
                 "model" => "z-ai/glm-5.3-flash",
659 659
                 "messages" => [
660 660
                   %{"role" => "user", "content" => "Hello"},
661 661
                   %{

@@ -695,7 +695,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

695 695
             "The selected model does not support this tool definition."}} =
696 696
             OpenRouter.stream(
697 697
               %{
698
                 "model" => "stealth/ox-alpha",
698
                 "model" => "z-ai/glm-5.3-flash",
699 699
                 "messages" => [%{"role" => "user", "content" => "Hello"}]
700 700
               },
701 701
               fn _event -> :ok end,

@@ -747,13 +747,13 @@ defmodule OpenAgents.Chat.OpenRouterTest do

747 747
    assert {:ok,
748 748
            %{
749 749
              "object" => "response",
750
              "model" => "stealth/ox-alpha",
750
              "model" => "z-ai/glm-5.3-flash",
751 751
              "assistant_message_id" => "msg_streamed",
752 752
              "assistant_content" => "Streaming works."
753 753
            }} =
754 754
             OpenRouter.stream(
755 755
               %{
756
                 "model" => "stealth/ox-alpha",
756
                 "model" => "z-ai/glm-5.3-flash",
757 757
                 "messages" => [%{"role" => "user", "content" => "Hello"}]
758 758
               },
759 759
               fn _event -> :ok end,

@@ -816,7 +816,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

816 816
            }} =
817 817
             OpenRouter.stream(
818 818
               %{
819
                 "model" => "stealth/ox-alpha",
819
                 "model" => "z-ai/glm-5.3-flash",
820 820
                 "messages" => [%{"role" => "user", "content" => "Hello"}]
821 821
               },
822 822
               &send(parent, {:openrouter_event, &1}),

@@ -900,7 +900,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

900 900
            "type" => "response.completed",
901 901
            "response" => %{
902 902
              "object" => "response",
903
              "model" => "stealth/ox-alpha",
903
              "model" => "z-ai/glm-5.3-flash",
904 904
              "output" => [
905 905
                %{
906 906
                  "type" => "message",

@@ -930,7 +930,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

930 930
    assert {:ok, %{"assistant_content" => "OpenAgents is an agent platform."}} =
931 931
             OpenRouter.stream(
932 932
               %{
933
                 "model" => "stealth/ox-alpha",
933
                 "model" => "z-ai/glm-5.3-flash",
934 934
                 "messages" => [%{"role" => "user", "content" => "Summarize the README."}]
935 935
               },
936 936
               &send(parent, {:openrouter_event, &1}),

@@ -986,7 +986,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

986 986
            "type" => "response.completed",
987 987
            "response" => %{
988 988
              "object" => "response",
989
              "model" => "stealth/ox-alpha",
989
              "model" => "z-ai/glm-5.3-flash",
990 990
              "output" => [
991 991
                %{
992 992
                  "type" => "message",

@@ -1022,7 +1022,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

1022 1022
    assert {:ok, %{"usage" => usage} = completion} =
1023 1023
             OpenRouter.stream(
1024 1024
               %{
1025
                 "model" => "stealth/ox-alpha",
1025
                 "model" => "z-ai/glm-5.3-flash",
1026 1026
                 "messages" => [%{"role" => "user", "content" => "Summarize the README."}]
1027 1027
               },
1028 1028
               &send(parent, {:openrouter_event, &1}),

@@ -1083,7 +1083,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

1083 1083
              "id" => "resp_repo_final",
1084 1084
              "object" => "response",
1085 1085
              "status" => "completed",
1086
              "model" => "stealth/ox-alpha",
1086
              "model" => "z-ai/glm-5.3-flash",
1087 1087
              "output" => [
1088 1088
                %{
1089 1089
                  "type" => "message",

@@ -1124,7 +1124,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

1124 1124
    assert {:ok, %{"assistant_content" => "The file now contains alpha gamma."}} =
1125 1125
             OpenRouter.stream(
1126 1126
               %{
1127
                 "model" => "stealth/ox-alpha",
1127
                 "model" => "z-ai/glm-5.3-flash",
1128 1128
                 "messages" => [
1129 1129
                   %{
1130 1130
                     "role" => "user",

@@ -1264,7 +1264,10 @@ defmodule OpenAgents.Chat.OpenRouterTest do

1264 1264
1265 1265
  defp stream_hello do
1266 1266
    OpenRouter.stream(
1267
      %{"model" => "stealth/ox-alpha", "messages" => [%{"role" => "user", "content" => "Hello"}]},
1267
      %{
1268
        "model" => "z-ai/glm-5.3-flash",
1269
        "messages" => [%{"role" => "user", "content" => "Hello"}]
1270
      },
1268 1271
      fn _event -> :ok end,
1269 1272
      api_key: "test-openrouter-key",
1270 1273
      request_options: [plug: {Req.Test, __MODULE__}]
test/openagents/gym_test.exs modified +2 -2

@@ -16,7 +16,7 @@ defmodule OpenAgents.GymTest do

16 16
        "suite" => "terminal-bench@2.0",
17 17
        "agent" => "openagents-coder",
18 18
        "agent_version" => "0.3.5",
19
        "model" => "ox-alpha",
19
        "model" => "glm-5.3-flash",
20 20
        "lane" => "proxy",
21 21
        "tasks_total" => 20,
22 22
        "tasks_passed" => 13,

@@ -90,7 +90,7 @@ defmodule OpenAgents.GymTest do

90 90
      %{
91 91
        "suite" => "terminal-bench@2.0",
92 92
        "agent" => "openagents-coder",
93
        "model" => "ox-alpha",
93
        "model" => "glm-5.3-flash",
94 94
        "lane" => "proxy",
95 95
        "tasks_total" => 5
96 96
      },
test/openagents/inference/credit_test.exs modified +17 -8

@@ -18,8 +18,10 @@ defmodule OpenAgents.Inference.CreditTest do

18 18
  alias OpenAgents.DataRights
19 19
  alias OpenAgents.Inference
20 20
  alias OpenAgents.Inference.Credit
21
  alias OpenAgents.Inference.Models
21 22
  alias OpenAgents.Repo
22 23
  alias OpenAgents.Threads
24
  alias OpenAgents.UnpricedLane
23 25
24 26
  defp account(key) do
25 27
    key |> github_user() |> Conversations.ensure_owner_visitor()

@@ -33,10 +35,15 @@ defmodule OpenAgents.Inference.CreditTest do

33 35
  # Cost is priced from tokens by `OpenAgents.Inference`, never taken from a
34 36
  # caller, so spend is stated here in the output tokens that price to it.
35 37
  defp output_tokens_costing(microusd) do
36
    div(
37
      microusd * 1_000,
38
      Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
39
    )
38
    div(microusd * 1_000_000, Models.default().pricing.output_per_million_tokens)
39
  end
40
41
  # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. What it
42
  # demonstrated is unchanged, so the lane is admitted for one test at a time.
43
  defp admit_unpriced_lane do
44
    previous = UnpricedLane.admit!()
45
    on_exit(fn -> UnpricedLane.restore(previous) end)
46
    UnpricedLane.id()
40 47
  end
41 48
42 49
  # A grant names exactly one fence, so spend is recorded through a real

@@ -223,9 +230,11 @@ defmodule OpenAgents.Inference.CreditTest do

223 230
  describe "spend the deployment has no price for" do
224 231
    test "an unpriced call draws nothing down, and the balance says so" do
225 232
      owner = account("credit-unpriced")
226
      luna = Application.fetch_env!(:openagents, :openai_model)
233
      unpriced = admit_unpriced_lane()
234
235
      {:ok, thread} =
236
        Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: unpriced)
227 237
228
      {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: luna)
229 238
      {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
230 239
      {:ok, _metered} = Inference.record_usage(grant, %{"output_tokens" => 500_000})
231 240

@@ -259,9 +268,9 @@ defmodule OpenAgents.Inference.CreditTest do

259 268
260 269
    test "a grant that never bought anything is not counted as unpriced spend" do
261 270
      owner = account("credit-idle")
262
      luna = Application.fetch_env!(:openagents, :openai_model)
271
      unpriced = admit_unpriced_lane()
263 272
264
      {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Mint and stop", model: luna)
273
      {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Mint and stop", model: unpriced)
265 274
      {:ok, _fenced, _grant, _token} = Threads.mint_grant(thread)
266 275
267 276
      assert Credit.unpriced_calls(owner.id) == 0
test/openagents/inference/health_test.exs modified +2 -2

@@ -39,8 +39,8 @@ defmodule OpenAgents.Inference.HealthTest do

39 39
    end
40 40
41 41
    test "a failure with no upstream status reports none rather than inventing one" do
42
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("ox-alpha", nil)
43
      assert Health.status("ox-alpha") == {:degraded, nil}
42
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("glm-5.3-flash", nil)
43
      assert Health.status("glm-5.3-flash") == {:degraded, nil}
44 44
    end
45 45
46 46
    test "lanes are tracked apart" do
test/openagents/inference/models_select_test.exs modified +2 -1

@@ -27,7 +27,8 @@ defmodule OpenAgents.Inference.ModelsSelectTest do

27 27
28 28
    selected = Models.select()
29 29
    refute selected.id == default
30
    assert selected.id == "ox-alpha"
30
    assert selected.id == "gemini-3.7-flash"
31
    assert selected.id == Enum.at(Models.ids(), 1)
31 32
  end
32 33
33 34
  test "select returns the default when all configured lanes are degraded, not the first one" do
test/openagents/inference/models_test.exs modified +73 -40

@@ -1,40 +1,46 @@

1 1
defmodule OpenAgents.Inference.ModelsTest do
2 2
  use ExUnit.Case, async: true
3 3
4
  alias OpenAgents.Chat.OpenRouter
5 4
  alias OpenAgents.Inference.Models
6 5
7 6
  test "the default is the catalog's first entry, served by that lane's adapter" do
8 7
    default = Models.default()
9 8
10
    # Gemini 3.7 Flash leads: a caller that names no model is holding a
11
    # conversation, and that is what this deployment answers one with.
12
    assert default.id == "gemini-3.7-flash"
13
    assert default.provider_model == "google/gemini-3.7-flash"
14
    assert default.adapter == Application.fetch_env!(:openagents, :openrouter_provider)
9
    # GLM 5.3 Flash leads: a caller that names no model is holding a
10
    # conversation, and that is what this deployment answers one with. It is
11
    # reached on the gateway's own slug, not on the id a caller asks for.
12
    assert default.id == "glm-5.3-flash"
13
    assert default.provider == :vercel_gateway
14
    assert default.provider_model == "zai/glm-5.3-flash"
15
    assert default.adapter == Application.fetch_env!(:openagents, :vercel_gateway_provider)
15 16
    assert Models.default_id() == default.id
16 17
    assert Models.default_id() == hd(Models.ids())
17 18
  end
18 19
19
  test "the OpenAI lane is still served, as the backup it now is" do
20
    configured = Application.fetch_env!(:openagents, :openai_model)
21
22
    assert {:ok, luna} = Models.fetch(configured)
23
    assert luna.provider_model == luna.id
24
    assert luna.adapter == Application.fetch_env!(:openagents, :provider)
25
  end
26
27
  test "ox-alpha publishes a public id and routes the vendor string" do
28
    assert {:ok, model} = Models.fetch("ox-alpha")
29
    assert model.id == "ox-alpha"
30
    assert model.provider_model == OpenRouter.default_model()
31
    refute model.id == model.provider_model
32
    assert model.adapter == Application.fetch_env!(:openagents, :openrouter_provider)
20
  test "a withdrawn model is not served, so nothing can be minted against it" do
21
    # `gpt-5.6-luna` and `ox-alpha` were both admitted here until they were
22
    # withdrawn at owner direction. Withdrawal is what this asserts: the names
23
    # do not resolve, so `OpenAgents.Inference.mint/1` refuses a grant that
24
    # pins one and the proxy refuses a grant minted before the withdrawal.
25
    #
26
    # `ox-alpha` is the interesting one. It was `stealth/ox-alpha`, the
27
    # pre-launch name of the model now admitted as `glm-5.3-flash`, and
28
    # OpenRouter answers that slug with a 404 saying so. Resolving the old name
29
    # to the new entry would be a silent substitution of exactly the kind
30
    # PROVIDER-002 forbids, so it does not resolve at all.
31
    assert Models.fetch(Application.fetch_env!(:openagents, :openai_model)) == :error
32
    assert Models.fetch("ox-alpha") == :error
33
    assert Models.fetch("stealth/ox-alpha") == :error
34
35
    # OpenRouter's spelling of the live model is not this deployment's lane
36
    # either: the catalog reaches it through the Vercel gateway.
37
    assert Models.fetch("z-ai/glm-5.3-flash") == :error
33 38
  end
34 39
35 40
  test "the vendor spelling resolves to the same model" do
36
    assert {:ok, model} = Models.fetch(OpenRouter.default_model())
37
    assert model.id == "ox-alpha"
41
    assert {:ok, model} = Models.fetch("zai/glm-5.3-flash")
42
    assert model.id == "glm-5.3-flash"
43
    refute model.id == model.provider_model
38 44
  end
39 45
40 46
  test "every routed model is listed once, and only routed models are" do

@@ -42,7 +48,7 @@ defmodule OpenAgents.Inference.ModelsTest do

42 48
43 49
    assert ids == Enum.uniq(ids)
44 50
    assert Models.default_id() in ids
45
    assert "ox-alpha" in ids
51
    assert ids == ["glm-5.3-flash", "gemini-3.7-flash"]
46 52
    assert Enum.map(Models.all(), & &1.id) == ids
47 53
    assert Models.fetch("attacker/gpt-9-ultra") == :error
48 54
    assert Models.fetch(nil) == :error

@@ -101,20 +107,33 @@ defmodule OpenAgents.Inference.ModelsTest do

101 107
  end
102 108
103 109
  describe "the answer allowance a model publishes" do
104
    test "Ox Alpha's is large enough for a model that reasons before it answers" do
105
      # Its thinking is charged against the same allowance as its answer. At
106
      # 4,096 a child agent with a real task spent the whole budget reasoning
107
      # and returned nothing, after three minutes, on a 200.
108
      {:ok, ox} = Models.fetch("ox-alpha")
109
110
      assert ox.max_output >= 32_000
111
      assert ox.context_window >= 1_000_000
110
    test "GLM 5.3 Flash's is large enough for a model that reasons before it answers" do
111
      # Its thinking is charged against this allowance before a word of the
112
      # answer is. At 256 tokens a request spent 243 of them reasoning and the
113
      # answer was cut off mid-word on `finish_reason: "length"`, which reads
114
      # to a caller as the model having failed.
115
      {:ok, glm} = Models.fetch("glm-5.3-flash")
116
117
      assert glm.max_output == 131_000
118
      assert glm.context_window == 1_000_000
112 119
    end
113 120
114 121
    test "the published catalog carries it, so a client is not guessing" do
115
      entry = Enum.find(Models.catalog(), &(&1["id"] == "ox-alpha"))
122
      entry = Enum.find(Models.catalog(), &(&1["id"] == "glm-5.3-flash"))
116 123
117
      assert entry["max_output"] == 64_000
124
      assert entry["max_output"] == 131_000
125
      assert entry["context_window"] == 1_000_000
126
    end
127
128
    test "GLM 5.3 Flash is routed through the gateway, on the slug the gateway knows" do
129
      # `zai/glm-5.3-flash`, not `glm-5.3-flash`, which is what a caller asks
130
      # for. The gateway resolves it to z.ai against this account's BYOK z.ai
131
      # credentials, so the call spends those rather than OpenRouter's, which
132
      # serves the same model as `z-ai/glm-5.3-flash`.
133
      {:ok, glm} = Models.fetch("glm-5.3-flash")
134
135
      assert glm.provider == :vercel_gateway
136
      assert glm.provider_model == "zai/glm-5.3-flash"
118 137
    end
119 138
120 139
    test "Gemini is routed through the gateway, on the slug the gateway knows" do

@@ -138,18 +157,32 @@ defmodule OpenAgents.Inference.ModelsTest do

138 157
      assert pricing["cached_input_per_million_tokens"] == 100_000
139 158
    end
140 159
141
    test "an unpriced model has no pricing key in the public catalog" do
142
      luna_id = Application.fetch_env!(:openagents, :openai_model)
143
      luna = Enum.find(Models.catalog(), &(&1["id"] == luna_id))
160
    test "the default's rates are provisional, so nothing may bill from them" do
161
      glm = Enum.find(Models.catalog(), &(&1["id"] == "glm-5.3-flash"))
162
163
      assert glm["pricing"]["id"] == "placeholder.glm-5.3-flash.v1"
164
      assert glm["pricing"]["input_per_million_tokens"] == 150_000
165
      assert glm["pricing"]["output_per_million_tokens"] == 500_000
166
      assert glm["pricing"]["cached_input_per_million_tokens"] == 30_000
144 167
145
      refute Map.has_key?(luna, "pricing")
168
      # Read off the gateway's own listing, which still is not an operator
169
      # declaring them (METER-001).
170
      assert glm["pricing_basis"] == "provisional"
171
      assert glm["pricing"]["basis"] == "provisional"
172
    end
173
174
    test "every published lane carries a pricing block, because every one is priced" do
175
      for entry <- Models.catalog() do
176
        assert Map.has_key?(entry, "pricing")
177
        assert entry["pricing_basis"] == "provisional"
178
      end
146 179
    end
147 180
148
    test "the resolved model carries pricing, or nil when none is declared" do
149
      assert %{pricing: %{input_per_million_tokens: 1_250_000}} = Models.default()
181
    test "the resolved model carries pricing, and a withdrawn one resolves to nothing" do
182
      assert %{pricing: %{input_per_million_tokens: 150_000}} = Models.default()
150 183
151 184
      luna_id = Application.fetch_env!(:openagents, :openai_model)
152
      assert {:ok, %{pricing: nil}} = Models.fetch(luna_id)
185
      assert Models.fetch(luna_id) == :error
153 186
    end
154 187
  end
155 188
end
test/openagents/inference/pricing_test.exs modified +14 -7

@@ -13,6 +13,10 @@ defmodule OpenAgents.Inference.PricingTest do

13 13
14 14
  alias OpenAgents.Inference.Pricing
15 15
16
  # A name the catalog does not route. `gpt-5.6-luna` was an admitted lane with
17
  # no rates until it was withdrawn; now it is unpriced for the stronger reason
18
  # that it is not served at all, and either way it is what `unpriced` looks
19
  # like from the outside.
16 20
  defp unpriced_model_id, do: Application.fetch_env!(:openagents, :openai_model)
17 21
18 22
  describe "the basis of a catalog pricing map" do

@@ -139,17 +143,20 @@ defmodule OpenAgents.Inference.PricingTest do

139 143
      bases = Enum.map(OpenAgents.Inference.Models.catalog(), & &1["pricing_basis"])
140 144
141 145
      assert "declared" not in bases
142
      assert "unpriced" in bases
143 146
      assert "provisional" in bases
144 147
    end
145 148
146
    test "the unpriced lane publishes no pricing block, and says so in one word" do
147
      luna =
148
        OpenAgents.Inference.Models.catalog()
149
        |> Enum.find(&(&1["id"] == unpriced_model_id()))
149
    test "every shipped lane carries rates, so none of them reads as unpriced" do
150
      # This was not true while `gpt-5.6-luna` was admitted, and it is the one
151
      # thing that changed when it was withdrawn. `unpriced` did not stop being
152
      # reachable — a gateway fallback that answers with a model this catalog
153
      # does not admit still produces it, which is what
154
      # `OpenAgentsWeb.InferenceProxyFallbackTest` proves — but no model a
155
      # caller may select is in that state.
156
      bases = Enum.map(OpenAgents.Inference.Models.catalog(), & &1["pricing_basis"])
150 157
151
      refute Map.has_key?(luna, "pricing")
152
      assert luna["pricing_basis"] == "unpriced"
158
      assert "unpriced" not in bases
159
      assert Pricing.basis(unpriced_model_id()) == "unpriced"
153 160
    end
154 161
155 162
    test "a priced lane publishes its table and its basis beside the rates" do
test/openagents/inference_test.exs modified +19 -6

@@ -4,8 +4,10 @@ defmodule OpenAgents.InferenceTest do

4 4
5 5
  alias OpenAgents.Inference
6 6
  alias OpenAgents.Inference.Grant
7
  alias OpenAgents.Inference.Models
7 8
  alias OpenAgents.Machines
8 9
  alias OpenAgents.Repo
10
  alias OpenAgents.UnpricedLane
9 11
10 12
  defp scope(key) do
11 13
    owner = github_user("inf-#{key}")

@@ -44,9 +46,10 @@ defmodule OpenAgents.InferenceTest do

44 46
    end
45 47
46 48
    test "pins a named model, publishing the public id rather than the vendor one" do
47
      {:ok, grant, _token} = Inference.mint(Map.put(scope("mint-ox"), :model_id, "ox-alpha"))
49
      {:ok, grant, _token} =
50
        Inference.mint(Map.put(scope("mint-named"), :model_id, "glm-5.3-flash"))
48 51
49
      assert grant.model_id == "ox-alpha"
52
      assert grant.model_id == "glm-5.3-flash"
50 53
    end
51 54
52 55
    test "refuses a model the proxy cannot route, naming only the model" do

@@ -201,19 +204,29 @@ defmodule OpenAgents.InferenceTest do

201 204
          "cache_read_input_tokens" => cache_read
202 205
        })
203 206
207
      pricing = Models.default().pricing
208
204 209
      expected =
205
        ((input - cache_read) * 1_250_000 + cache_read * 100_000 + output * 10_000_000)
210
        ((input - cache_read) * pricing.input_per_million_tokens +
211
           cache_read * pricing.cached_input_per_million_tokens +
212
           output * pricing.output_per_million_tokens)
206 213
        |> div(1_000_000)
207 214
208 215
      assert metered.usage["estimated_cost_microusd"] == expected
209 216
      # The record names the table it was priced against, so the figure can be
210 217
      # dereferenced rather than trusted (METER-001).
211
      assert metered.usage["pricing_id"] == "placeholder.gemini-3.7-flash.v1"
218
      assert metered.usage["pricing_id"] == pricing.id
212 219
    end
213 220
214 221
    test "an unpriced model records no estimated cost — and no zero" do
215
      luna_id = Application.fetch_env!(:openagents, :openai_model)
216
      {:ok, grant, _token} = Inference.mint(Map.put(scope("usage-unpriced"), :model_id, luna_id))
222
      # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn.
223
      # The behaviour it demonstrated is unchanged, so the lane is admitted
224
      # here for the length of this test rather than shipped for everyone.
225
      previous = UnpricedLane.admit!()
226
      on_exit(fn -> UnpricedLane.restore(previous) end)
227
228
      {:ok, grant, _token} =
229
        Inference.mint(Map.put(scope("usage-unpriced"), :model_id, UnpricedLane.id()))
217 230
218 231
      {:ok, metered} =
219 232
        Inference.record_usage(grant, %{
test/openagents/providers/open_router/request_payload_test.exs modified +6 -6

@@ -5,7 +5,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

5 5
6 6
  test "carries the provider model, the system text, and the turns in order" do
7 7
    request = %Request{
8
      model_id: "stealth/ox-alpha",
8
      model_id: "z-ai/glm-5.3-flash",
9 9
      instructions: "  Remain OpenAgents.  ",
10 10
      input: [
11 11
        %{role: "user", content: "Write a file."},

@@ -16,7 +16,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

16 16
17 17
    payload = OpenRouter.request_payload(request)
18 18
19
    assert payload.model == "stealth/ox-alpha"
19
    assert payload.model == "z-ai/glm-5.3-flash"
20 20
    assert payload.stream == true
21 21
    assert payload.stream_options == %{include_usage: true}
22 22
    refute Map.has_key?(payload, :tools)

@@ -33,7 +33,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

33 33
34 34
  test "sends no system message when the request has no instructions" do
35 35
    request = %Request{
36
      model_id: "stealth/ox-alpha",
36
      model_id: "z-ai/glm-5.3-flash",
37 37
      instructions: "",
38 38
      input: [%{role: "user", content: "Hello."}]
39 39
    }

@@ -45,7 +45,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

45 45
46 46
  test "maps tool definitions to chat-completions functions" do
47 47
    request = %Request{
48
      model_id: "stealth/ox-alpha",
48
      model_id: "z-ai/glm-5.3-flash",
49 49
      instructions: "",
50 50
      input: [%{role: "user", content: "Search."}],
51 51
      tool_definitions: [

@@ -72,7 +72,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

72 72
73 73
  test "replays an assistant tool call and its output faithfully" do
74 74
    request = %Request{
75
      model_id: "stealth/ox-alpha",
75
      model_id: "z-ai/glm-5.3-flash",
76 76
      instructions: "",
77 77
      input: [
78 78
        %{role: "user", content: "Read the file."},

@@ -116,7 +116,7 @@ defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do

116 116
117 117
  test "carries a tool output as a labelled user turn" do
118 118
    request = %Request{
119
      model_id: "stealth/ox-alpha",
119
      model_id: "z-ai/glm-5.3-flash",
120 120
      instructions: "",
121 121
      input: [%{role: "user", content: "Search."}],
122 122
      tool_outputs: [
test/openagents/providers/open_router_test.exs modified +5 -5

@@ -7,7 +7,7 @@ defmodule OpenAgents.Providers.OpenRouterTest do

7 7
8 8
  defp request do
9 9
    %Request{
10
      model_id: "stealth/ox-alpha",
10
      model_id: "z-ai/glm-5.3-flash",
11 11
      instructions: "Remain OpenAgents.",
12 12
      input: [%{role: "user", content: "Say hello."}]
13 13
    }

@@ -46,7 +46,7 @@ defmodule OpenAgents.Providers.OpenRouterTest do

46 46
             )
47 47
48 48
    assert_received {:outbound, ["Bearer sentinel-openrouter-key"], body}
49
    assert Jason.decode!(body)["model"] == "stealth/ox-alpha"
49
    assert Jason.decode!(body)["model"] == "z-ai/glm-5.3-flash"
50 50
51 51
    assert events == [
52 52
             {:response_started, "gen-1"},

@@ -86,12 +86,12 @@ defmodule OpenAgents.Providers.OpenRouterTest do

86 86
87 87
  describe "how many tokens the answer may take" do
88 88
    test "comes from the model's catalog entry, not a literal in this module" do
89
      # Ox Alpha is a reasoning model: its thinking is charged against this
89
      # GLM 5.3 Flash is a reasoning model: its thinking is charged against this
90 90
      # allowance before a word of the answer is. Hardcoded at 4,096, a child
91 91
      # agent with a real task spent the whole budget reasoning and returned an
92 92
      # empty 200 after three minutes, which read as the proxy having failed.
93 93
      request = %Request{
94
        model_id: "stealth/ox-alpha",
94
        model_id: "z-ai/glm-5.3-flash",
95 95
        instructions: "Be brief.",
96 96
        input: [%{role: "user", content: "hello"}],
97 97
        max_output: 64_000

@@ -102,7 +102,7 @@ defmodule OpenAgents.Providers.OpenRouterTest do

102 102
103 103
    test "defaults to a figure a caller that names none still works on" do
104 104
      request = %Request{
105
        model_id: "stealth/ox-alpha",
105
        model_id: "z-ai/glm-5.3-flash",
106 106
        instructions: "Be brief.",
107 107
        input: [%{role: "user", content: "hello"}]
108 108
      }
test/openagents/threads/credit_race_test.exs modified +2 -4

@@ -28,6 +28,7 @@ defmodule OpenAgents.Threads.CreditRaceTest do

28 28
  alias OpenAgents.Inference
29 29
  alias OpenAgents.Inference.Credit
30 30
  alias OpenAgents.Inference.Grant
31
  alias OpenAgents.Inference.Models
31 32
  alias OpenAgents.Repo
32 33
  alias OpenAgents.Threads
33 34
  alias OpenAgents.Threads.Thread

@@ -182,10 +183,7 @@ defmodule OpenAgents.Threads.CreditRaceTest do

182 183
  # Cost is priced from tokens by `OpenAgents.Inference`, never taken from a
183 184
  # caller, so spend is stated here in the output tokens that price to it.
184 185
  defp output_tokens_costing(microusd) do
185
    div(
186
      microusd * 1_000,
187
      Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
188
    )
186
    div(microusd * 1_000_000, Models.default().pricing.output_per_million_tokens)
189 187
  end
190 188
191 189
  defp cap(limit) do
test/openagents/threads_test.exs modified +35 -15

@@ -7,10 +7,12 @@ defmodule OpenAgents.ThreadsTest do

7 7
  alias OpenAgents.Inference
8 8
  alias OpenAgents.Inference.Credit
9 9
  alias OpenAgents.Inference.Grant
10
  alias OpenAgents.Inference.Models
10 11
  alias OpenAgents.Repo
11 12
  alias OpenAgents.Threads
12 13
  alias OpenAgents.Threads.Event
13 14
  alias OpenAgents.Threads.Thread
15
  alias OpenAgents.UnpricedLane
14 16
15 17
  defp owner(key), do: github_user("thread-#{key}")
16 18

@@ -582,13 +584,20 @@ defmodule OpenAgents.ThreadsTest do

582 584
      set_config(:thread_grant_max_total_tokens, nil)
583 585
      user = owner("child-cost")
584 586
      {:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
585
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 1000})
586
      assert spent.usage["estimated_cost_microusd"] == 10_000
587
588
      # Output tokens priced at the default lane's own output rate, so the
589
      # arithmetic below follows the catalog rather than restating it.
590
      rate = Models.default().pricing.output_per_million_tokens
591
      output = 20_000
592
      cost = div(output * rate, 1_000_000)
593
594
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => output})
595
      assert spent.usage["estimated_cost_microusd"] == cost
587 596
588 597
      {:ok, _child, child_grant, _token} =
589 598
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
590 599
591
      assert child_grant.max_cost_microusd == 90_000
600
      assert child_grant.max_cost_microusd == 100_000 - cost
592 601
    end
593 602
  end
594 603

@@ -677,9 +686,9 @@ defmodule OpenAgents.ThreadsTest do

677 686
  # measurement rather than as the absence of one.
678 687
  describe "what a thread spent, when the deployment has no price for it" do
679 688
    test "a thread on an unpriced model reports an unknown cost, never a zero" do
680
      luna = Application.fetch_env!(:openagents, :openai_model)
689
      unpriced = admit_unpriced_lane()
681 690
      user = owner("spend-unpriced")
682
      {:ok, thread} = Threads.open(user, "Run the coder's own lane", model: luna)
691
      {:ok, thread} = Threads.open(user, "Run the coder's own lane", model: unpriced)
683 692
      {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
684 693
685 694
      {:ok, metered} =

@@ -698,7 +707,7 @@ defmodule OpenAgents.ThreadsTest do

698 707
      assert spend.cost.microusd == nil
699 708
      assert spend.cost.basis == "unpriced"
700 709
      assert spend.cost.unpriced_calls == 1
701
      assert spend.cost.unpriced_models == [luna]
710
      assert spend.cost.unpriced_models == [unpriced]
702 711
    end
703 712
704 713
    test "a thread on a priced model reports a total, labelled by its basis" do

@@ -708,14 +717,16 @@ defmodule OpenAgents.ThreadsTest do

708 717
709 718
      spend = Threads.spend(thread)
710 719
711
      assert spend.cost.microusd == 1_250_000
712
      assert spend.cost.priced_microusd == 1_250_000
720
      rate = Models.default().pricing.input_per_million_tokens
721
722
      assert spend.cost.microusd == rate
723
      assert spend.cost.priced_microusd == rate
713 724
      assert spend.cost.basis == "provisional"
714 725
      assert spend.cost.unpriced_models == []
715 726
    end
716 727
717 728
    test "one unpriced grant makes the whole session's total unknown, and names why" do
718
      luna = Application.fetch_env!(:openagents, :openai_model)
729
      unpriced = admit_unpriced_lane()
719 730
      user = owner("spend-mixed")
720 731
      {:ok, thread, first, _token} = Threads.open_and_mint(user, "Start priced")
721 732
      {:ok, _} = Inference.record_usage(first, %{"input_tokens" => 1_000_000})

@@ -724,7 +735,7 @@ defmodule OpenAgents.ThreadsTest do

724 735
      # session whose grants ran on different lanes is exactly the case a total
725 736
      # has to survive honestly.
726 737
      {:ok, _revoked} = Inference.revoke(first)
727
      {:ok, second, _token} = unpriced_grant_for(thread, luna)
738
      {:ok, second, _token} = unpriced_grant_for(thread, unpriced)
728 739
      {:ok, _} = Inference.record_usage(second, %{"input_tokens" => 50_000})
729 740
730 741
      spend = Threads.spend(thread)

@@ -732,27 +743,36 @@ defmodule OpenAgents.ThreadsTest do

732 743
      assert spend.cost.microusd == nil
733 744
      # Nothing measured is thrown away — the priced half is still reported,
734 745
      # just not as the answer to "what did this cost".
735
      assert spend.cost.priced_microusd == 1_250_000
746
      assert spend.cost.priced_microusd == Models.default().pricing.input_per_million_tokens
736 747
      assert spend.cost.basis == "unpriced"
737
      assert spend.cost.unpriced_models == [luna]
748
      assert spend.cost.unpriced_models == [unpriced]
738 749
    end
739 750
740 751
    test "a grant that was minted and never called does not make the total unknown" do
741
      luna = Application.fetch_env!(:openagents, :openai_model)
752
      unpriced = admit_unpriced_lane()
742 753
      user = owner("spend-idle-unpriced")
743 754
      {:ok, thread, first, _token} = Threads.open_and_mint(user, "Priced work")
744 755
      {:ok, _} = Inference.record_usage(first, %{"input_tokens" => 1_000_000})
745 756
746 757
      {:ok, _revoked} = Inference.revoke(first)
747
      {:ok, _idle, _token} = unpriced_grant_for(thread, luna)
758
      {:ok, _idle, _token} = unpriced_grant_for(thread, unpriced)
748 759
749 760
      spend = Threads.spend(thread)
750 761
751 762
      assert spend.grants == 2
752
      assert spend.cost.microusd == 1_250_000
763
      assert spend.cost.microusd == Models.default().pricing.input_per_million_tokens
753 764
      assert spend.cost.unpriced_calls == 0
754 765
    end
755 766
767
    # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. The
768
    # invariant it demonstrated did not go with it, so the lane is admitted
769
    # here for the length of one test instead.
770
    defp admit_unpriced_lane do
771
      previous = UnpricedLane.admit!()
772
      on_exit(fn -> UnpricedLane.restore(previous) end)
773
      UnpricedLane.id()
774
    end
775
756 776
    # A thread holds at most one active grant, so a second lane is reached the
757 777
    # way a resume reaches it: revoke, then mint again against the same fence.
758 778
    defp unpriced_grant_for(thread, model_id) do
test/openagents/tools/open_pull_request_test.exs modified +6 -6

@@ -261,7 +261,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

261 261
    assert stale["error"]["code"] == "publication_receipt_stale"
262 262
  end
263 263
264
  test "Ox Alpha completes an approved pull request through the Responses tool loop", %{
264
  test "GLM 5.3 Flash completes an approved pull request through the Responses tool loop", %{
265 265
    context: context,
266 266
    publication: publication
267 267
  } do

@@ -304,7 +304,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

304 304
            "id" => "resp_open_pr",
305 305
            "object" => "response",
306 306
            "status" => "in_progress",
307
            "model" => "stealth/ox-alpha",
307
            "model" => "z-ai/glm-5.3-flash",
308 308
            "output" => []
309 309
          }
310 310
        }) <>

@@ -314,7 +314,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

314 314
              "id" => "resp_open_pr",
315 315
              "object" => "response",
316 316
              "status" => "in_progress",
317
              "model" => "stealth/ox-alpha",
317
              "model" => "z-ai/glm-5.3-flash",
318 318
              "output" => []
319 319
            }
320 320
          }) <>

@@ -382,7 +382,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

382 382
              "id" => "resp_open_pr",
383 383
              "object" => "response",
384 384
              "status" => "completed",
385
              "model" => "stealth/ox-alpha",
385
              "model" => "z-ai/glm-5.3-flash",
386 386
              "output" => provider_output
387 387
            }
388 388
          }) <> "data: [DONE]\n\n"

@@ -430,7 +430,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

430 430
            "response" => %{
431 431
              "object" => "response",
432 432
              "status" => "completed",
433
              "model" => "stealth/ox-alpha",
433
              "model" => "z-ai/glm-5.3-flash",
434 434
              "output" => [
435 435
                %{
436 436
                  "type" => "message",

@@ -460,7 +460,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

460 460
    assert {:ok, %{"assistant_content" => "I opened the approved draft pull request."}} =
461 461
             OpenRouter.stream(
462 462
               %{
463
                 "model" => "stealth/ox-alpha",
463
                 "model" => "z-ai/glm-5.3-flash",
464 464
                 "messages" => [
465 465
                   %{"role" => "user", "content" => "Open the approved publication."}
466 466
                 ]
test/openagents_web/controllers/chat_turn_controller_test.exs modified +2 -2

@@ -193,7 +193,7 @@ defmodule OpenAgentsWeb.ChatTurnControllerTest do

193 193
  describe "backend selection" do
194 194
    test "a turn names the backend it went to, whether or not it chose one", %{conn: conn} do
195 195
      for {sent, expected} <- [
196
            {%{}, "ox-alpha"},
196
            {%{}, "glm-5.3-flash"},
197 197
            {%{"model" => "gemini-3.7-flash"}, "gemini-3.7-flash"}
198 198
          ] do
199 199
        key = "chat-backend-" <> expected

@@ -232,7 +232,7 @@ defmodule OpenAgentsWeb.ChatTurnControllerTest do

232 232
        |> post(~p"/api/v1/chat/turns", %{"message" => "Hello.", "model" => ""})
233 233
        |> json_response(202)
234 234
235
      assert response["turn"]["model"] == "ox-alpha"
235
      assert response["turn"]["model"] == "glm-5.3-flash"
236 236
    end
237 237
  end
238 238
end
test/openagents_web/controllers/credit_controller_test.exs modified +17 -8

@@ -19,18 +19,25 @@ defmodule OpenAgentsWeb.CreditControllerTest do

19 19
  alias OpenAgents.Conversations.Visitor
20 20
  alias OpenAgents.Inference
21 21
  alias OpenAgents.Inference.Credit
22
  alias OpenAgents.Inference.Models
22 23
  alias OpenAgents.Repo
23 24
  alias OpenAgents.Threads
25
  alias OpenAgents.UnpricedLane
24 26
25 27
  # `put_chat_api_token/2` mints the token for this account, so naming the key
26 28
  # the same way is how a test reaches the user behind the credential it sent.
27 29
  defp account(key), do: github_user("api-token-" <> key)
28 30
29 31
  defp output_tokens_costing(microusd) do
30
    div(
31
      microusd * 1_000,
32
      Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
33
    )
32
    div(microusd * 1_000_000, Models.default().pricing.output_per_million_tokens)
33
  end
34
35
  # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. What it
36
  # demonstrated is unchanged, so the lane is admitted for one test at a time.
37
  defp admit_unpriced_lane do
38
    previous = UnpricedLane.admit!()
39
    on_exit(fn -> UnpricedLane.restore(previous) end)
40
    UnpricedLane.id()
34 41
  end
35 42
36 43
  defp minted(visitor_id) do

@@ -75,13 +82,15 @@ defmodule OpenAgentsWeb.CreditControllerTest do

75 82
    assert body["credit"]["complete"] == true
76 83
  end
77 84
78
  # The path the coder's own lane is on today. The figure does not move, and
79
  # the response has to say why rather than leave the client to discover it.
85
  # No lane this deployment admits is unpriced any more, but a call the
86
  # gateway's fallback chain answers with a model outside the catalog still
87
  # records no cost. The figure does not move, and the response has to say why
88
  # rather than leave the client to discover it.
80 89
  test "an unpriced call leaves the remainder still and says so", %{conn: conn} do
81 90
    owner = "credit-unpriced" |> account() |> Conversations.ensure_owner_visitor()
82
    luna = Application.fetch_env!(:openagents, :openai_model)
91
    unpriced = admit_unpriced_lane()
83 92
84
    {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: luna)
93
    {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: unpriced)
85 94
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
86 95
    {:ok, _metered} = Inference.record_usage(grant, %{"output_tokens" => 500_000})
87 96
test/openagents_web/controllers/gym_run_controller_test.exs modified +2 -2

@@ -17,7 +17,7 @@ defmodule OpenAgentsWeb.GymRunControllerTest do

17 17
      %{
18 18
        "suite" => "terminal-bench@2.0",
19 19
        "agent" => "openagents-coder",
20
        "model" => "ox-alpha",
20
        "model" => "glm-5.3-flash",
21 21
        "tasks_total" => 10,
22 22
        "tasks_passed" => 7,
23 23
        "recipe_digest" => @digest

@@ -104,7 +104,7 @@ defmodule OpenAgentsWeb.GymRunControllerTest do

104 104
      %{
105 105
        "suite" => "terminal-bench@2.0",
106 106
        "agent" => "openagents-coder",
107
        "model" => "ox-alpha",
107
        "model" => "glm-5.3-flash",
108 108
        "lane" => "proxy",
109 109
        "tasks_total" => 5
110 110
      },
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +20 -16

@@ -214,30 +214,30 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

214 214
215 215
    conn =
216 216
      post_chat(conn, token, %{
217
        "model" => "ox-alpha",
217
        "model" => "gemini-3.7-flash",
218 218
        "messages" => [%{"role" => "user", "content" => "hello"}]
219 219
      })
220 220
221 221
    assert conn.status == 422
222 222
    error = Jason.decode!(conn.resp_body)["error"]
223 223
    assert error["code"] == "model_mismatch"
224
    assert error["requested"] == "ox-alpha"
224
    assert error["requested"] == "gemini-3.7-flash"
225 225
    assert error["granted"] == OpenAgents.Inference.Models.default_id()
226 226
    assert error["served"] == OpenAgents.Inference.Models.ids()
227 227
  end
228 228
229 229
  test "the vendor spelling of the grant's model is the same name, not a mismatch",
230 230
       %{conn: conn} do
231
    %{token: token} = grant("model-vendor-spelling", model_id: "ox-alpha")
231
    %{token: token} = grant("model-vendor-spelling", model_id: "glm-5.3-flash")
232 232
233 233
    conn =
234 234
      post_chat(conn, token, %{
235
        "model" => OpenAgents.Chat.OpenRouter.default_model(),
235
        "model" => "zai/glm-5.3-flash",
236 236
        "messages" => [%{"role" => "user", "content" => "hi"}]
237 237
      })
238 238
239 239
    assert conn.status == 200
240
    assert get_resp_header(conn, "x-openagents-model") == ["ox-alpha"]
240
    assert get_resp_header(conn, "x-openagents-model") == ["glm-5.3-flash"]
241 241
  end
242 242
243 243
  test "a grant on a lane without a credential is refused before any provider call",

@@ -245,17 +245,17 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

245 245
    # Minted while the lane was configured; the credential goes away under a
246 246
    # live grant. The UnconfiguredTestProvider raises from `stream/2`, so a
247 247
    # 503 here also proves no provider was called.
248
    %{token: token} = grant("model-lane-unavailable", model_id: "ox-alpha")
248
    %{token: token} = grant("model-lane-unavailable", model_id: "glm-5.3-flash")
249 249
250
    previous = Application.get_env(:openagents, :openrouter_provider)
250
    previous = Application.get_env(:openagents, :vercel_gateway_provider)
251 251
252 252
    Application.put_env(
253 253
      :openagents,
254
      :openrouter_provider,
254
      :vercel_gateway_provider,
255 255
      OpenAgents.Providers.UnconfiguredTestProvider
256 256
    )
257 257
258
    on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
258
    on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_provider, previous) end)
259 259
260 260
    conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
261 261

@@ -316,9 +316,10 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

316 316
317 317
  describe "routing the grant's model" do
318 318
    setup do
319
      # Both routed lanes, because these tests are about which model string
320
      # reaches a provider and the models are spread across them: Ox Alpha on
321
      # OpenRouter, Gemini on the gateway.
319
      # Both routed lanes. Every admitted model is on the gateway today, but
320
      # these tests are about which model string reaches a provider, and
321
      # swapping only the lane a model happens to sit on would stop proving
322
      # that the moment the catalog moves one.
322 323
      lanes = [:openrouter_provider, :vercel_gateway_provider]
323 324
      previous = Map.new(lanes, &{&1, Application.get_env(:openagents, &1)})
324 325

@@ -331,19 +332,22 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

331 332
      end)
332 333
    end
333 334
334
    test "an ox-alpha grant reaches the OpenRouter lane with the vendor model", %{conn: conn} do
335
      %{token: token} = grant("ox-alpha", model_id: "ox-alpha")
335
    test "a glm-5.3-flash grant reaches the gateway lane with the vendor model", %{conn: conn} do
336
      %{token: token} = grant("glm-5.3-flash", model_id: "glm-5.3-flash")
336 337
337 338
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
338 339
339 340
      assert conn.status == 200
340 341
      assert_received {:recorded_request, "test.recording_provider", request}
341
      assert request.model_id == OpenAgents.Chat.OpenRouter.default_model()
342
343
      # The gateway's slug, not the public id and not OpenRouter's `z-ai/`
344
      # spelling of the same model.
345
      assert request.model_id == "zai/glm-5.3-flash"
342 346
    end
343 347
344 348
    test "tool declarations, a replayed call, and its output reach the provider intact",
345 349
         %{conn: conn} do
346
      %{token: token} = grant("tool-fidelity", model_id: "ox-alpha")
350
      %{token: token} = grant("tool-fidelity", model_id: "glm-5.3-flash")
347 351
348 352
      conn =
349 353
        post_chat(conn, token, %{
test/openagents_web/controllers/inference_proxy_fallback_test.exs modified +12 -4

@@ -109,7 +109,7 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

109 109
      # rescued by a fallback did not answer, and recording it healthy is the
110 110
      # same class of lie that #238 fixed: every call to a dead lane would keep
111 111
      # reporting `available` forever, because the fallback kept rescuing it.
112
      luna = Application.fetch_env!(:openagents, :openai_model)
112
      luna = "openai/gpt-5.6-luna"
113 113
      serve_as(luna)
114 114
      {:ok, gemini} = Models.fetch(@gemini)
115 115

@@ -119,7 +119,10 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

119 119
      end
120 120
121 121
      assert Models.availability(gemini) == "degraded"
122
      assert Health.status(luna) == {:healthy, nil}
122
123
      # Nothing is recorded for the lane that actually answered either. It is
124
      # not a model this deployment admits, so there is no lane to credit.
125
      assert Health.status(luna) == {:unknown, nil}
123 126
    end
124 127
125 128
    test "makes the thread's cost unpriced, and names the lane that made it so", %{conn: _conn} do

@@ -127,7 +130,10 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

127 130
      # by an unpriced fallback reports no total at all rather than a total at
128 131
      # the requested model's rates.
129 132
      user = github_user("fallback-thread")
130
      {:ok, thread, grant, _token} = Threads.open_and_mint(user, "Fallback lane")
133
134
      {:ok, thread, grant, _token} =
135
        Threads.open_and_mint(user, "Fallback lane", model: @gemini)
136
131 137
      assert grant.model_id == @gemini
132 138
133 139
      {:ok, _metered} =

@@ -204,7 +210,9 @@ defmodule OpenAgentsWeb.InferenceProxyFallbackTest do

204 210
      # Two rate tables, one accumulated sum. Charging it at either rate would
205 211
      # be a guess, so the record says so and prices nothing.
206 212
      user = github_user("fallback-mixed")
207
      {:ok, _thread, grant, _token} = Threads.open_and_mint(user, "Mixed lanes")
213
214
      {:ok, _thread, grant, _token} =
215
        Threads.open_and_mint(user, "Mixed lanes", model: @gemini)
208 216
209 217
      {:ok, first} = Inference.record_usage(grant, %{"input_tokens" => 1_000}, :requested)
210 218
      assert first.usage["served_model"] == @gemini
test/openagents_web/controllers/model_catalog_controller_test.exs modified +23 -12

@@ -10,6 +10,15 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

10 10
  use OpenAgentsWeb.ConnCase, async: false
11 11
12 12
  alias OpenAgents.Inference.Models
13
  alias OpenAgents.UnpricedLane
14
15
  # No shipped lane is unpriced since `gpt-5.6-luna` was withdrawn, so the
16
  # tests that read the unpriced projection admit a lane of their own.
17
  defp admit_unpriced_lane do
18
    previous = UnpricedLane.admit!()
19
    on_exit(fn -> UnpricedLane.restore(previous) end)
20
    :ok
21
  end
13 22
14 23
  test "the catalog lists every served model in its typed shape", %{conn: conn} do
15 24
    body =

@@ -49,10 +58,12 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

49 58
  end
50 59
51 60
  test "a lane without a configured credential is listed unavailable, not omitted", %{conn: conn} do
52
    # The OpenAI lane, because the default now sits on the OpenRouter one and
53
    # this test is about a lane going dark *without* taking the default with
54
    # it: "served here, not currently configured" has to be distinguishable
55
    # from "not served here" while the deployment still answers.
61
    # A lane on the OpenAI adapter, because both shipped models sit on the
62
    # Vercel gateway and this test is about a lane going dark *without* taking
63
    # the default with it: "served here, not currently configured" has to be
64
    # distinguishable from "not served here" while the deployment still
65
    # answers.
66
    :ok = admit_unpriced_lane()
56 67
    previous = Application.get_env(:openagents, :provider)
57 68
58 69
    Application.put_env(:openagents, :provider, OpenAgents.Providers.UnconfiguredTestProvider)

@@ -70,10 +81,9 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

70 81
    # availability changes.
71 82
    assert Enum.map(body["models"], & &1["id"]) == Models.ids()
72 83
73
    luna =
74
      Enum.find(body["models"], &(&1["id"] == Application.fetch_env!(:openagents, :openai_model)))
84
    dark = Enum.find(body["models"], &(&1["id"] == UnpricedLane.id()))
75 85
76
    assert luna["availability"] == "unavailable"
86
    assert dark["availability"] == "unavailable"
77 87
78 88
    default_entry = Enum.find(body["models"], &(&1["id"] == body["default"]))
79 89
    assert default_entry["availability"] == "available"

@@ -108,7 +118,7 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

108 118
    end
109 119
110 120
    test "an unpriced model has no pricing key", %{conn: conn} do
111
      luna_id = Application.fetch_env!(:openagents, :openai_model)
121
      :ok = admit_unpriced_lane()
112 122
113 123
      body =
114 124
        conn

@@ -116,12 +126,12 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

116 126
        |> get(~p"/api/v1/models")
117 127
        |> json_response(200)
118 128
119
      luna = Enum.find(body["models"], &(&1["id"] == luna_id))
120
      refute Map.has_key?(luna, "pricing")
129
      unpriced = Enum.find(body["models"], &(&1["id"] == UnpricedLane.id()))
130
      refute Map.has_key?(unpriced, "pricing")
121 131
    end
122 132
123 133
    test "every entry says in one word whether its price can be trusted", %{conn: conn} do
124
      luna_id = Application.fetch_env!(:openagents, :openai_model)
134
      :ok = admit_unpriced_lane()
125 135
126 136
      body =
127 137
        conn

@@ -137,7 +147,8 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

137 147
               &(&1["pricing_basis"] in ~w(declared provisional unpriced))
138 148
             )
139 149
140
      assert Enum.find(body["models"], &(&1["id"] == luna_id))["pricing_basis"] == "unpriced"
150
      assert Enum.find(body["models"], &(&1["id"] == UnpricedLane.id()))["pricing_basis"] ==
151
               "unpriced"
141 152
142 153
      gemini = Enum.find(body["models"], &(&1["id"] == "gemini-3.7-flash"))
143 154
      assert gemini["pricing_basis"] == "provisional"
test/openagents_web/controllers/thread_controller_test.exs modified +36 -14

@@ -11,8 +11,18 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

11 11
  alias OpenAgents.Inference
12 12
  alias OpenAgents.Inference.Credit
13 13
  alias OpenAgents.Inference.Grant
14
  alias OpenAgents.Inference.Models
14 15
  alias OpenAgents.Repo
15 16
  alias OpenAgents.Threads
17
  alias OpenAgents.UnpricedLane
18
19
  # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. What it
20
  # demonstrated is unchanged, so the lane is admitted for one test at a time.
21
  defp admit_unpriced_lane do
22
    previous = UnpricedLane.admit!()
23
    on_exit(fn -> UnpricedLane.restore(previous) end)
24
    UnpricedLane.id()
25
  end
16 26
17 27
  describe "POST /api/v1/threads" do
18 28
    test "opens a thread and returns a grant that names it", %{conn: conn} do

@@ -132,14 +142,14 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

132 142
    test "a caller may open a thread on another routed model", %{conn: conn} do
133 143
      body =
134 144
        conn
135
        |> put_chat_api_token("thread-ox-alpha")
145
        |> put_chat_api_token("thread-glm-5.3-flash")
136 146
        |> post(~p"/api/v1/threads", %{
137 147
          "objective" => "Delegate the edit.",
138
          "model" => "ox-alpha"
148
          "model" => "glm-5.3-flash"
139 149
        })
140 150
        |> json_response(201)
141 151
142
      assert body["grant"]["model"] == "ox-alpha"
152
      assert body["grant"]["model"] == "glm-5.3-flash"
143 153
    end
144 154
145 155
    test "a thread names the default model when its caller names none", %{conn: conn} do

@@ -154,22 +164,28 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

154 164
155 165
    test "a catalog model whose provider is not configured is refused, never substituted",
156 166
         %{conn: conn} do
157
      previous = Application.get_env(:openagents, :openrouter_provider)
167
      # Both shipped models sit on the Vercel gateway, so taking that lane down
168
      # takes the whole catalog with it and the refusal would have nothing left
169
      # to name. A lane on a second adapter is admitted first, which is what
170
      # makes "refused, never substituted" a claim with teeth: there is another
171
      # model available and the request still does not get it.
172
      _survivor = admit_unpriced_lane()
173
      previous = Application.get_env(:openagents, :vercel_gateway_provider)
158 174
159 175
      Application.put_env(
160 176
        :openagents,
161
        :openrouter_provider,
177
        :vercel_gateway_provider,
162 178
        OpenAgents.Providers.UnconfiguredTestProvider
163 179
      )
164 180
165
      on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
181
      on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_provider, previous) end)
166 182
167 183
      body =
168 184
        conn
169 185
        |> put_chat_api_token("thread-unavailable-model")
170 186
        |> post(~p"/api/v1/threads", %{
171 187
          "objective" => "Ask for the unconfigured lane.",
172
          "model" => "ox-alpha"
188
          "model" => "glm-5.3-flash"
173 189
        })
174 190
        |> json_response(503)
175 191

@@ -280,10 +296,12 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

280 296
281 297
      grant = Repo.get_by!(Grant, thread_id: opened["thread"]["id"])
282 298
      allowance = Credit.allowance(grant.owner_visitor_id)
283
      price = Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
299
      rate = Models.default().pricing.output_per_million_tokens
284 300
285 301
      {:ok, _metered} =
286
        Inference.record_usage(grant, %{"output_tokens" => div(allowance, price) * 1_000})
302
        Inference.record_usage(grant, %{
303
          "output_tokens" => div(allowance * 1_000_000, rate)
304
        })
287 305
288 306
      assert Credit.remaining(grant.owner_visitor_id) == 0
289 307

@@ -1483,14 +1501,14 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

1483 1501
  # so it is where an unpriced lane has to stop looking like a free one.
1484 1502
  describe "reporting cost the deployment cannot price" do
1485 1503
    test "a thread on an unpriced model reports a null cost, not a zero", %{conn: conn} do
1486
      luna = Application.fetch_env!(:openagents, :openai_model)
1504
      unpriced = admit_unpriced_lane()
1487 1505
      authenticated = put_chat_api_token(conn, "thread-cost-unpriced")
1488 1506
1489 1507
      created =
1490 1508
        authenticated
1491 1509
        |> post(~p"/api/v1/threads", %{
1492 1510
          "objective" => "Run the coder's own lane.",
1493
          "model" => luna
1511
          "model" => unpriced
1494 1512
        })
1495 1513
        |> json_response(201)
1496 1514

@@ -1508,7 +1526,7 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

1508 1526
      assert is_nil(body["thread"]["spend"]["cost"]["microusd"])
1509 1527
      assert body["thread"]["spend"]["cost"]["basis"] == "unpriced"
1510 1528
      assert body["thread"]["spend"]["cost"]["unpriced_calls"] == 1
1511
      assert body["thread"]["spend"]["cost"]["unpriced_models"] == [luna]
1529
      assert body["thread"]["spend"]["cost"]["unpriced_models"] == [unpriced]
1512 1530
1513 1531
      # And the same refusal on the grant: no cost spent, no cost remainder.
1514 1532
      assert body["grant"]["pricing"]["basis"] == "unpriced"

@@ -1533,16 +1551,20 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

1533 1551
      grant = Repo.get_by!(Grant, thread_id: id)
1534 1552
      {:ok, _spent} = Inference.record_usage(grant, %{"input_tokens" => 1_000_000})
1535 1553
1554
      # A million input tokens costs exactly the default lane's per-million
1555
      # rate, whatever the catalog's head happens to be.
1556
      rate = Models.default().pricing.input_per_million_tokens
1557
1536 1558
      body = authenticated |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
1537 1559
1538
      assert body["thread"]["spend"]["cost"]["microusd"] == 1_250_000
1560
      assert body["thread"]["spend"]["cost"]["microusd"] == rate
1539 1561
      assert body["thread"]["spend"]["cost"]["basis"] == "provisional"
1540 1562
      assert body["thread"]["spend"]["cost"]["unpriced_models"] == []
1541 1563
1542 1564
      # Priced is not the same as billable: these are placeholder rates.
1543 1565
      assert body["grant"]["pricing"]["basis"] == "provisional"
1544 1566
      assert body["grant"]["pricing"]["billable"] == false
1545
      assert body["grant"]["spent"]["cost_microusd"] == 1_250_000
1567
      assert body["grant"]["spent"]["cost_microusd"] == rate
1546 1568
    end
1547 1569
  end
1548 1570
end
test/openagents_web/live/chat_console_test.exs modified +13 -13

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgentsWeb.ChatConsoleTest do
2 2
  @moduledoc """
3
  `/chat` is the operator-only Ox Alpha console.
3
  `/chat` is the operator-only GLM 5.3 Flash console.
4 4
5 5
  The gate is worth its own file because it fails in two quiet directions: a
6 6
  missing plug lets a signed-in non-operator read the page, and a missing

@@ -41,7 +41,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

41 41
42 42
    assert has_element?(view, "#chat-console-transcript")
43 43
    assert has_element?(view, "#chat-console-empty")
44
    assert has_element?(view, "#chat-console-model", "Ox Alpha")
44
    assert has_element?(view, "#chat-console-model", "GLM 5.3 Flash")
45 45
    assert has_element?(view, "#chat-console-operator-notice", "Operator-only console")
46 46
    assert has_element?(view, "#chat-console-token-list", "No tokens yet")
47 47
    refute has_element?(view, "#chat-console-token-total")

@@ -61,7 +61,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

61 61
62 62
    html = view |> element("#chat-console-suggestion-0") |> render_click()
63 63
64
    assert html =~ "Ox Alpha stress fleet measures"
64
    assert html =~ "stress fleet measures"
65 65
  end
66 66
67 67
  test "a turn in flight can be stopped", %{conn: conn} do

@@ -100,7 +100,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

100 100
      {:ok,
101 101
       %{
102 102
         "object" => "chat.completion",
103
         "model" => "stealth/ox-alpha",
103
         "model" => "z-ai/glm-5.3-flash",
104 104
         "assistant_content" => "The fleet is idle.",
105 105
         "provider" => "Stealth",
106 106
         "request_id" => "gen-123",

@@ -124,7 +124,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

124 124
    {:ok, view, _html} = live(conn, ~p"/chat")
125 125
126 126
    metadata = "#chat-console-response-metadata-#{run_id}"
127
    assert has_element?(view, metadata, "Ox Alpha")
127
    assert has_element?(view, metadata, "GLM 5.3 Flash")
128 128
    assert has_element?(view, metadata, "lane Stealth")
129 129
    assert has_element?(view, metadata, "request gen-123")
130 130
    assert has_element?(view, metadata, "ms")

@@ -152,7 +152,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

152 152
      {:ok,
153 153
       %{
154 154
         "object" => "response",
155
         "model" => "stealth/ox-alpha",
155
         "model" => "z-ai/glm-5.3-flash",
156 156
         "assistant_content" => "The fleet is idle.",
157 157
         "reasoning_summary" => "Weighing the fleet.",
158 158
         "usage" => %{

@@ -190,7 +190,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

190 190
      {:ok,
191 191
       %{
192 192
         "object" => "response",
193
         "model" => "stealth/ox-alpha",
193
         "model" => "z-ai/glm-5.3-flash",
194 194
         "assistant_content" => "The fleet is idle.",
195 195
         "usage" => %{"input_tokens" => 24, "output_tokens" => 8, "total_tokens" => 32}
196 196
       }}

@@ -220,7 +220,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

220 220
      {:ok,
221 221
       %{
222 222
         "object" => "response",
223
         "model" => "stealth/ox-alpha",
223
         "model" => "z-ai/glm-5.3-flash",
224 224
         "assistant_content" => "The fleet is idle.",
225 225
         "usage" => %{
226 226
           "input_tokens" => 24,

@@ -261,7 +261,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

261 261
      {:ok,
262 262
       %{
263 263
         "object" => "response",
264
         "model" => "stealth/ox-alpha",
264
         "model" => "z-ai/glm-5.3-flash",
265 265
         "assistant_content" => "The fleet is idle.",
266 266
         "usage" => %{
267 267
           "input_tokens" => 24,

@@ -279,7 +279,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

279 279
      {:ok,
280 280
       %{
281 281
         "object" => "response",
282
         "model" => "stealth/ox-alpha",
282
         "model" => "z-ai/glm-5.3-flash",
283 283
         "assistant_content" => "Two boxes are queued.",
284 284
         "reasoning_summary" => "Weighing the queue.",
285 285
         "usage" => %{

@@ -328,7 +328,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

328 328
      {:ok,
329 329
       %{
330 330
         "object" => "response",
331
         "model" => "stealth/ox-alpha",
331
         "model" => "z-ai/glm-5.3-flash",
332 332
         "assistant_content" => "The fleet is idle.",
333 333
         "reasoning_summary" => "Weighing the fleet.",
334 334
         "usage" => %{

@@ -377,7 +377,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

377 377
      {:ok,
378 378
       %{
379 379
         "object" => "response",
380
         "model" => "stealth/ox-alpha",
380
         "model" => "z-ai/glm-5.3-flash",
381 381
         "assistant_content" => "The fleet is idle.",
382 382
         "usage" => %{"input_tokens" => 24, "output_tokens" => 8}
383 383
       }}

@@ -481,7 +481,7 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

481 481
482 482
    # The backend is named, not the gateway behind it, so a turn answered by a
483 483
    # second backend cannot report the first one as the thing that failed.
484
    assert has_element?(view, ~s([role="alert"]), "Ox Alpha is not configured")
484
    assert has_element?(view, ~s([role="alert"]), "GLM 5.3 Flash is not configured")
485 485
    assert has_element?(view, ~s(#chat_message), "Draft the release notes.")
486 486
  end
487 487
test/openagents_web/live/chat_console_updates_test.exs modified +1 -1

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgentsWeb.ChatConsoleUpdatesTest do
2 2
  @moduledoc """
3
  The Ox Alpha console as a live surface (#159, following #154).
3
  The GLM 5.3 Flash console as a live surface (#159, following #154).
4 4
5 5
  The console renders `AccountTurns.list_messages/1`, a projection of
6 6
  `account_chat_runs`. Those writes never create a `Conversations.Message` and
test/openagents_web/live/gym_live_test.exs modified +2 -2

@@ -17,7 +17,7 @@ defmodule OpenAgentsWeb.GymLiveTest do

17 17
        "suite" => suite,
18 18
        "agent" => "openagents-coder",
19 19
        "agent_version" => "0.3.5",
20
        "model" => "ox-alpha",
20
        "model" => "glm-5.3-flash",
21 21
        "lane" => "proxy",
22 22
        "tasks_total" => 10,
23 23
        "tasks_passed" => 8,

@@ -73,7 +73,7 @@ defmodule OpenAgentsWeb.GymLiveTest do

73 73
        Gym.start_run(%{
74 74
          "suite" => "terminal-bench@2.0",
75 75
          "agent" => "openagents-coder",
76
          "model" => "ox-alpha",
76
          "model" => "glm-5.3-flash",
77 77
          "lane" => "proxy",
78 78
          "tasks_total" => 2
79 79
        })
test/openagents_web/live/gym_run_live_test.exs modified +2 -2

@@ -22,7 +22,7 @@ defmodule OpenAgentsWeb.GymRunLiveTest do

22 22
            "suite" => "terminal-bench@2.0",
23 23
            "agent" => "openagents-coder",
24 24
            "agent_version" => "0.3.5",
25
            "model" => "ox-alpha",
25
            "model" => "glm-5.3-flash",
26 26
            "lane" => "proxy",
27 27
            "tasks_total" => 3
28 28
          },

@@ -66,7 +66,7 @@ defmodule OpenAgentsWeb.GymRunLiveTest do

66 66
67 67
      assert html =~ "terminal-bench@2.0"
68 68
      assert html =~ "openagents-coder"
69
      assert html =~ "ox-alpha"
69
      assert html =~ "glm-5.3-flash"
70 70
      assert view |> element("#gym-run-status") |> render() =~ "running"
71 71
      assert has_element?(view, "#gym-run-trials-empty")
72 72
test/openagents_web/live/model_catalog_live_test.exs modified +23 -8

@@ -14,12 +14,21 @@ defmodule OpenAgentsWeb.ModelCatalogLiveTest do

14 14
  import Phoenix.LiveViewTest
15 15
16 16
  alias OpenAgents.Inference.Models
17
  alias OpenAgents.UnpricedLane
17 18
18 19
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
19 20
20 21
  defp reader(conn, handle), do: signed_in(conn, github_user(handle))
21 22
22
  defp unpriced_id, do: Application.fetch_env!(:openagents, :openai_model)
23
  defp unpriced_id, do: UnpricedLane.id()
24
25
  # No shipped lane is unpriced since `gpt-5.6-luna` was withdrawn, so the
26
  # tests that read the unpriced rendering admit a lane of their own.
27
  defp admit_unpriced_lane do
28
    previous = UnpricedLane.admit!()
29
    on_exit(fn -> UnpricedLane.restore(previous) end)
30
    :ok
31
  end
23 32
24 33
  test "every model this deployment serves has a row", %{conn: conn} do
25 34
    {:ok, view, _html} = live(reader(conn, "model-catalog-rows"), ~p"/models")

@@ -32,6 +41,7 @@ defmodule OpenAgentsWeb.ModelCatalogLiveTest do

32 41
  end
33 42
34 43
  test "an unpriced lane shows the word, never a zero", %{conn: conn} do
44
    :ok = admit_unpriced_lane()
35 45
    id = unpriced_id()
36 46
    {:ok, view, _html} = live(reader(conn, "model-catalog-unpriced"), ~p"/models")
37 47

@@ -62,16 +72,21 @@ defmodule OpenAgentsWeb.ModelCatalogLiveTest do

62 72
63 73
  test "the billable notice is derived from the catalog, not written into the page",
64 74
       %{conn: conn} do
75
    :ok = admit_unpriced_lane()
65 76
    previous = Application.fetch_env!(:openagents, :model_catalog)
66 77
67 78
    declared =
68
      List.update_at(previous, 0, fn entry ->
69
        Map.put(entry, :pricing, %{
70
          id: "declared.test.v1",
71
          source: :declared,
72
          input_per_million_tokens: 3_000_000,
73
          output_per_million_tokens: 15_000_000
74
        })
79
      Enum.map(previous, fn
80
        %{id: "gemini-3.7-flash"} = entry ->
81
          Map.put(entry, :pricing, %{
82
            id: "declared.test.v1",
83
            source: :declared,
84
            input_per_million_tokens: 3_000_000,
85
            output_per_million_tokens: 15_000_000
86
          })
87
88
        entry ->
89
          entry
75 90
      end)
76 91
77 92
    Application.put_env(:openagents, :model_catalog, declared)
test/openagents_web/live/thread_show_live_test.exs modified +18 -4

@@ -1,9 +1,13 @@

1 1
defmodule OpenAgentsWeb.ThreadShowLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  # `async: false` because the unpriced-lane test below rewrites
3
  # `:model_catalog`, which every process in the node reads.
4
  use OpenAgentsWeb.ConnCase, async: false
3 5
4 6
  import Phoenix.LiveViewTest
5 7
8
  alias OpenAgents.Inference.Models
6 9
  alias OpenAgents.Threads
10
  alias OpenAgents.UnpricedLane
7 11
8 12
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
9 13

@@ -119,9 +123,13 @@ defmodule OpenAgentsWeb.ThreadShowLiveTest do

119 123
  describe "the budget card's cost cell" do
120 124
    test "an unpriced lane shows the word, never a dollar figure", %{conn: conn} do
121 125
      owner = github_user("thread-show-unpriced")
122
      luna = Application.fetch_env!(:openagents, :openai_model)
123 126
124
      {:ok, thread} = Threads.open(owner, "Run the unpriced lane", model: luna)
127
      # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn.
128
      previous = UnpricedLane.admit!()
129
      on_exit(fn -> UnpricedLane.restore(previous) end)
130
      unpriced = UnpricedLane.id()
131
132
      {:ok, thread} = Threads.open(owner, "Run the unpriced lane", model: unpriced)
125 133
      {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
126 134
127 135
      {:ok, _metered} =

@@ -145,7 +153,13 @@ defmodule OpenAgentsWeb.ThreadShowLiveTest do

145 153
146 154
      {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
147 155
148
      assert view |> element("#thread-budget-cost") |> render() =~ "$1.25"
156
      # A million input tokens costs exactly the default lane's per-million
157
      # rate, rendered in dollars.
158
      dollars = Models.default().pricing.input_per_million_tokens / 1_000_000
159
160
      assert view |> element("#thread-budget-cost") |> render() =~
161
               "$#{:erlang.float_to_binary(dollars, decimals: 2)}"
162
149 163
      assert view |> element("#thread-budget-cost-note") |> render() =~ "not a bill"
150 164
    end
151 165
test/support/unpriced_lane.ex added +55

@@ -0,0 +1,55 @@

1
defmodule OpenAgents.UnpricedLane do
2
  @moduledoc """
3
  A catalog lane with no rates, for the tests that prove what `unpriced` means.
4
5
  `:model_catalog` shipped an unpriced lane for as long as `gpt-5.6-luna` was
6
  admitted, so every test that needed one reached for it. Withdrawing that
7
  model at owner direction left the invariant intact and the fixture gone:
8
  `OpenAgents.Inference.Pricing` still answers `unpriced`, `OpenAgents.Threads`
9
  still refuses to total a session that touched such a lane, and every read
10
  surface still shows the word rather than `$0.00` — but no shipped model is in
11
  that state to demonstrate it with.
12
13
  So the fixture is declared here rather than in the deployment's catalog. That
14
  is the honest arrangement: the production list says what this deployment
15
  actually serves, and a test that needs an unpriced lane says so itself.
16
17
  Only a synchronous test may use this. It rewrites application configuration,
18
  which every process in the node reads.
19
  """
20
21
  @id "test-unpriced-lane"
22
23
  @doc "The id of the lane this module admits."
24
  @spec id() :: String.t()
25
  def id, do: @id
26
27
  @doc """
28
  Admit an unpriced lane for the duration of one test.
29
30
  Returns the catalog that was in place, which the caller restores in
31
  `on_exit/1`.
32
  """
33
  @spec admit!() :: [map()]
34
  def admit! do
35
    previous = Application.fetch_env!(:openagents, :model_catalog)
36
    Application.put_env(:openagents, :model_catalog, previous ++ [entry()])
37
    previous
38
  end
39
40
  @doc "Put back the catalog `admit!/0` replaced."
41
  @spec restore([map()]) :: :ok
42
  def restore(previous), do: Application.put_env(:openagents, :model_catalog, previous)
43
44
  @doc "The lane itself: a routed model that deliberately declares no `pricing`."
45
  @spec entry() :: map()
46
  def entry do
47
    %{
48
      id: @id,
49
      provider: :openai,
50
      provider_model: @id,
51
      context_window: 272_000,
52
      max_output: 4_096
53
    }
54
  end
55
end

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