Publish what a lane costs before anyone spends on it

f3b7314d8df1 · AtlantisPleb · · parent 7e7a4bc65202

Publish what a lane costs before anyone spends on it

Cost was a number the server produced and nobody could check. A model
now declares its rates beside its ceilings, the catalog publishes them
so a caller can see what a lane costs before choosing it, and the
recorded cost is computed from those declared rates rather than from a
constant buried in the metering path.

Cached input is priced at the cached rate where a provider offers one,
which is the reason the split was plumbed at all: on agentic traffic
most of the input is a re-sent prefix, and pricing it as fresh
overstates the bill on exactly the workloads the coder produces.

An unpriced model publishes no pricing and records no cost — absent,
not zero. A lane nobody has priced must not read as free, which is the
same rule the cached-token work follows and the one that keeps "usage
truth precedes any bill" true rather than merely stated.

The rates in config are PLACEHOLDERS and say so in the config itself.
The operator must set real provider rates before accepting any spend.

No billing, no invoices, no charging — this is the pricing surface and
the cost derivation only.

Built by a Devin child through the openagents coder's delegate tool;
the full suite is green at 4,501.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

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 363 · 2026-08-25T11:01:45.355239Z

Changed files

  • modified config/config.exs
  • modified lib/openagents/inference.ex
  • modified lib/openagents/inference/models.ex
  • modified test/openagents/inference/models_test.exs
  • modified test/openagents/inference_test.exs
  • modified test/openagents_web/controllers/model_catalog_controller_test.exs

Diff

6 files changed, +202 -24

config/config.exs modified +24 -2

@@ -126,6 +126,12 @@ config :openagents,

126 126
  # per-call output cap the proxy's adapters send for that model, and
127 127
  # `context_window` is the ceiling this deployment publishes for the lane.
128 128
  #
129
  # A model may declare a `pricing` map with `input_per_million_tokens`,
130
  # `output_per_million_tokens`, and optionally `cached_input_per_million_tokens`.
131
  # The values below are placeholders that make the existing test suite pass;
132
  # the operator must replace them with real provider rates before accepting
133
  # any spend. A model with no `pricing` key records no estimated cost.
134
  #
129 135
  # Gemini 3.7 Flash leads, so it is what a caller that names none gets: fast,
130 136
  # a million tokens of context, and steady enough to hold a conversation.
131 137
  #

@@ -147,7 +153,15 @@ config :openagents,

147 153
      provider: :vercel_gateway,
148 154
      provider_model: "google/gemini-3.7-flash",
149 155
      context_window: 1_048_576,
150
      max_output: 65_536
156
      max_output: 65_536,
157
      # Placeholder: the operator must set real provider rates before accepting
158
      # any spend. The cached-input rate is optional and should be omitted if
159
      # the provider does not offer one.
160
      pricing: %{
161
        input_per_million_tokens: 1_250_000,
162
        output_per_million_tokens: 10_000_000,
163
        cached_input_per_million_tokens: 100_000
164
      }
151 165
    },
152 166
    %{
153 167
      id: "ox-alpha",

@@ -160,7 +174,13 @@ config :openagents,

160 174
      # allowance before a single word of the answer is. At 4,096 a child agent
161 175
      # with a real task spent the whole budget reasoning and returned an empty
162 176
      # 200 after three minutes, which read as the proxy having failed.
163
      max_output: 64_000
177
      max_output: 64_000,
178
      # Placeholder: the operator must set real provider rates before accepting
179
      # any spend. This entry does not declare a cached-input rate.
180
      pricing: %{
181
        input_per_million_tokens: 500_000,
182
        output_per_million_tokens: 2_000_000
183
      }
164 184
    },
165 185
    %{
166 186
      id: {:config, :openai_model},

@@ -168,6 +188,8 @@ config :openagents,

168 188
      provider_model: {:config, :openai_model},
169 189
      context_window: 272_000,
170 190
      max_output: 4_096
191
      # This entry deliberately omits `pricing`, so a grant pinned to it records
192
      # no estimated cost rather than a made-up zero.
171 193
    }
172 194
  ],
173 195
  gemini_api_key: nil,
lib/openagents/inference.ex modified +24 -14

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

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

@@ -378,7 +378,7 @@ defmodule OpenAgents.Inference do

378 378
                  cache_read_input_tokens cache_write_input_tokens)
379 379
380 380
  @doc false
381
  def merge_usage(existing, provider_usage) do
381
  def merge_usage(existing, provider_usage, model_id) do
382 382
    normalized = normalize_usage(provider_usage)
383 383
384 384
    merged =

@@ -400,7 +400,7 @@ defmodule OpenAgents.Inference do

400 400
401 401
    merged
402 402
    |> Map.put("total_tokens", derived_total(existing, merged))
403
    |> put_cost(existing)
403
    |> put_cost(model_id)
404 404
    |> Map.put("schema", @usage_schema)
405 405
  end
406 406

@@ -414,12 +414,28 @@ defmodule OpenAgents.Inference do

414 414
    end
415 415
  end
416 416
417
  defp put_cost(merged, _existing) do
418
    cost =
419
      integer(merged["input_tokens"]) * input_price_microusd() +
420
        integer(merged["output_tokens"]) * output_price_microusd()
417
  defp put_cost(merged, model_id) do
418
    case Models.fetch(model_id) do
419
      {:ok, %{pricing: %{input_per_million_tokens: i, output_per_million_tokens: o} = pricing}} ->
420
        input = integer(merged["input_tokens"])
421
        output = integer(merged["output_tokens"])
422
        cache_read = integer(merged["cache_read_input_tokens"])
423
        cache_write = integer(merged["cache_write_input_tokens"])
424
        cached_rate = Map.get(pricing, :cached_input_per_million_tokens, i)
421 425
422
    Map.put(merged, "estimated_cost_microusd", div(cost, 1_000))
426
        # Cached read tokens are split from the rest of the input and priced at
427
        # the cached rate where one is declared. Cache write tokens are charged
428
        # as regular input because they are not a cached read.
429
        uncached = max(0, input - cache_read) + cache_write
430
431
        cost =
432
          uncached * i + cache_read * cached_rate + output * o
433
434
        Map.put(merged, "estimated_cost_microusd", div(cost, 1_000_000))
435
436
      _ ->
437
        merged
438
    end
423 439
  end
424 440
425 441
  defp normalize_usage(usage) do

@@ -500,12 +516,6 @@ defmodule OpenAgents.Inference do

500 516
  defp deadline(%{ttl_seconds: nil}), do: nil
501 517
  defp deadline(%{ttl_seconds: seconds}), do: DateTime.add(now(), seconds, :second)
502 518
503
  defp input_price_microusd,
504
    do: Application.get_env(:openagents, :inference_input_price_microusd_per_ktoken, 1_250)
505
506
  defp output_price_microusd,
507
    do: Application.get_env(:openagents, :inference_output_price_microusd_per_ktoken, 10_000)
508
509 519
  defp default_proxy_url do
510 520
    endpoint = OpenAgentsWeb.Endpoint.url()
511 521
    endpoint <> "/api/inference/proxy"
lib/openagents/inference/models.ex modified +34 -4

@@ -44,13 +44,20 @@ defmodule OpenAgents.Inference.Models do

44 44
    vercel_gateway: :vercel_gateway_provider
45 45
  }
46 46
47
  @type pricing :: %{
48
          required(:input_per_million_tokens) => pos_integer(),
49
          required(:output_per_million_tokens) => pos_integer(),
50
          optional(:cached_input_per_million_tokens) => pos_integer()
51
        }
52
47 53
  @type t :: %{
48 54
          id: String.t(),
49 55
          provider: atom(),
50 56
          adapter: module(),
51 57
          provider_model: String.t(),
52 58
          context_window: pos_integer(),
53
          max_output: pos_integer()
59
          max_output: pos_integer(),
60
          pricing: pricing() | nil
54 61
        }
55 62
56 63
  @doc "Every model in the catalog, in the order a client should offer them."

@@ -163,14 +170,16 @@ defmodule OpenAgents.Inference.Models do

163 170
164 171
  No adapter module and no credential state beyond the availability word: a
165 172
  client learns what it can select and what each selection can carry, nothing
166
  about how the server is wired.
173
  about how the server is wired. Pricing is exposed only when the deployment
174
  has declared rates for a model; an unpriced model has no `pricing` key so it
175
  is not read as zero before spend.
167 176
  """
168 177
  @spec catalog() :: [map()]
169 178
  def catalog do
170 179
    default_id = default_id()
171 180
172 181
    Enum.map(all(), fn model ->
173
      %{
182
      base = %{
174 183
        "id" => model.id,
175 184
        "provider" => Atom.to_string(model.provider),
176 185
        "context_window" => model.context_window,

@@ -178,9 +187,29 @@ defmodule OpenAgents.Inference.Models do

178 187
        "availability" => availability(model),
179 188
        "default" => model.id == default_id
180 189
      }
190
191
      case model.pricing do
192
        nil ->
193
          base
194
195
        %{} = pricing ->
196
          Map.put(base, "pricing", public_pricing(pricing))
197
      end
181 198
    end)
182 199
  end
183 200
201
  defp public_pricing(pricing) do
202
    base = %{
203
      "input_per_million_tokens" => pricing.input_per_million_tokens,
204
      "output_per_million_tokens" => pricing.output_per_million_tokens
205
    }
206
207
    case Map.fetch(pricing, :cached_input_per_million_tokens) do
208
      {:ok, value} -> Map.put(base, "cached_input_per_million_tokens", value)
209
      :error -> base
210
    end
211
  end
212
184 213
  @doc "The ids currently available to serve, for a refusal that names what is."
185 214
  @spec available_ids() :: [String.t()]
186 215
  def available_ids do

@@ -194,7 +223,8 @@ defmodule OpenAgents.Inference.Models do

194 223
      adapter: Application.fetch_env!(:openagents, Map.fetch!(@provider_lanes, entry.provider)),
195 224
      provider_model: value(entry.provider_model),
196 225
      context_window: entry.context_window,
197
      max_output: entry.max_output
226
      max_output: entry.max_output,
227
      pricing: Map.get(entry, :pricing)
198 228
    }
199 229
  end
200 230
test/openagents/inference/models_test.exs modified +39 -2

@@ -65,11 +65,23 @@ defmodule OpenAgents.Inference.ModelsTest do

65 65
    assert Enum.map(catalog, & &1["id"]) == Models.ids()
66 66
67 67
    for entry <- catalog do
68
      assert Enum.sort(Map.keys(entry)) ==
69
               ~w(availability context_window default id max_output provider)
68
      assert Enum.all?(
69
               ~w(availability context_window default id max_output provider),
70
               &(&1 in Map.keys(entry))
71
             )
70 72
71 73
      assert entry["availability"] in ["available", "unavailable"]
72 74
      refute entry["provider"] =~ "Elixir."
75
76
      if entry["pricing"] do
77
        assert Enum.all?(
78
                 ~w(input_per_million_tokens output_per_million_tokens),
79
                 &(&1 in Map.keys(entry["pricing"]))
80
               )
81
82
        assert is_integer(entry["pricing"]["input_per_million_tokens"])
83
        assert is_integer(entry["pricing"]["output_per_million_tokens"])
84
      end
73 85
    end
74 86
75 87
    assert Enum.count(catalog, & &1["default"]) == 1

@@ -115,4 +127,29 @@ defmodule OpenAgents.Inference.ModelsTest do

115 127
      assert gemini.provider_model == "google/gemini-3.7-flash"
116 128
    end
117 129
  end
130
131
  describe "pricing" do
132
    test "a priced model publishes its per-million-token rates in the public catalog" do
133
      gemini = Enum.find(Models.catalog(), &(&1["id"] == "gemini-3.7-flash"))
134
135
      assert %{"pricing" => pricing} = gemini
136
      assert pricing["input_per_million_tokens"] == 1_250_000
137
      assert pricing["output_per_million_tokens"] == 10_000_000
138
      assert pricing["cached_input_per_million_tokens"] == 100_000
139
    end
140
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))
144
145
      refute Map.has_key?(luna, "pricing")
146
    end
147
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()
150
151
      luna_id = Application.fetch_env!(:openagents, :openai_model)
152
      assert {:ok, %{pricing: nil}} = Models.fetch(luna_id)
153
    end
154
  end
118 155
end
test/openagents/inference_test.exs modified +34

@@ -186,6 +186,40 @@ defmodule OpenAgents.InferenceTest do

186 186
      {:ok, revoked} = Inference.revoke(grant)
187 187
      assert {:error, :grant_not_active} = Inference.record_usage(revoked, %{"input_tokens" => 1})
188 188
    end
189
190
    test "prices cost from the model's declared rates, with cached reads at the cached rate" do
191
      {:ok, grant, _token} = Inference.mint(scope("usage-priced"))
192
193
      input = 100
194
      output = 40
195
      cache_read = 20
196
197
      {:ok, metered} =
198
        Inference.record_usage(grant, %{
199
          "input_tokens" => input,
200
          "output_tokens" => output,
201
          "cache_read_input_tokens" => cache_read
202
        })
203
204
      expected =
205
        ((input - cache_read) * 1_250_000 + cache_read * 100_000 + output * 10_000_000)
206
        |> div(1_000_000)
207
208
      assert metered.usage["estimated_cost_microusd"] == expected
209
    end
210
211
    test "an unpriced model records no estimated cost" do
212
      luna_id = Application.fetch_env!(:openagents, :openai_model)
213
      {:ok, grant, _token} = Inference.mint(Map.put(scope("usage-unpriced"), :model_id, luna_id))
214
215
      {:ok, metered} =
216
        Inference.record_usage(grant, %{
217
          "input_tokens" => 100,
218
          "output_tokens" => 40
219
        })
220
221
      refute Map.has_key?(metered.usage, "estimated_cost_microusd")
222
    end
189 223
  end
190 224
191 225
  describe "fences" do
test/openagents_web/controllers/model_catalog_controller_test.exs modified +47 -2

@@ -22,14 +22,26 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

22 22
    assert Enum.map(body["models"], & &1["id"]) == Models.ids()
23 23
24 24
    for entry <- body["models"] do
25
      assert Enum.sort(Map.keys(entry)) ==
26
               ~w(availability context_window default id max_output provider)
25
      assert Enum.all?(
26
               ~w(availability context_window default id max_output provider),
27
               &(&1 in Map.keys(entry))
28
             )
27 29
28 30
      assert is_binary(entry["provider"]) and entry["provider"] != ""
29 31
      assert is_integer(entry["context_window"]) and entry["context_window"] > 0
30 32
      assert is_integer(entry["max_output"]) and entry["max_output"] > 0
31 33
      # Every test lane's adapter reports a configured credential.
32 34
      assert entry["availability"] == "available"
35
36
      if entry["pricing"] do
37
        assert Enum.all?(
38
                 ~w(input_per_million_tokens output_per_million_tokens),
39
                 &(&1 in Map.keys(entry["pricing"]))
40
               )
41
42
        assert is_integer(entry["pricing"]["input_per_million_tokens"])
43
        assert is_integer(entry["pricing"]["output_per_million_tokens"])
44
      end
33 45
    end
34 46
35 47
    assert [default_entry] = Enum.filter(body["models"], & &1["default"])

@@ -75,4 +87,37 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

75 87
76 88
    assert body["code"] == "unauthenticated"
77 89
  end
90
91
  describe "pricing in the published catalog" do
92
    test "a priced model exposes its per-million-token rates", %{conn: conn} do
93
      body =
94
        conn
95
        |> put_chat_api_token("model-catalog-priced")
96
        |> get(~p"/api/v1/models")
97
        |> json_response(200)
98
99
      gemini = Enum.find(body["models"], &(&1["id"] == "gemini-3.7-flash"))
100
101
      assert %{
102
               "pricing" => %{
103
                 "input_per_million_tokens" => 1_250_000,
104
                 "output_per_million_tokens" => 10_000_000,
105
                 "cached_input_per_million_tokens" => 100_000
106
               }
107
             } = gemini
108
    end
109
110
    test "an unpriced model has no pricing key", %{conn: conn} do
111
      luna_id = Application.fetch_env!(:openagents, :openai_model)
112
113
      body =
114
        conn
115
        |> put_chat_api_token("model-catalog-unpriced")
116
        |> get(~p"/api/v1/models")
117
        |> json_response(200)
118
119
      luna = Enum.find(body["models"], &(&1["id"] == luna_id))
120
      refute Map.has_key?(luna, "pricing")
121
    end
122
  end
78 123
end

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