Show a person what a lane costs, not just a client

f7b54ef8c48f · AtlantisPleb · · parent f1c0d997ef9f

Show a person what a lane costs, not just a client

`GET /api/v1/models` has published per-model rates and a pricing basis since
3445eed, and nothing rendered them. "Pricing visible before spend" (#200) was
half-true: a CLI could read the catalog and a person could not, which leaves
the reader who actually decides what to spend on the wrong side of the
contract.

`/models` is that other reader. It renders `Models.catalog/0` directly — the
same list the endpoint publishes and the same list thread admission checks
against — so the page and the endpoint cannot come to disagree about what this
deployment serves or what it charges. Same principal as the endpoint, too:
the catalog names what a grant can be minted for, so the person who reads the
prices is the caller who can spend them.

The absence rule survives into the layout, because a table is where a zero
would be easiest to slip past. An unpriced lane's rate cells carry the word
`Unpriced`, never `$0.00`. Every row names its basis and the id of the rate
table it is priced against, so a provisional figure is legible as a working
number rather than a price. A priced lane with no cached-input rate says the
cached reads are charged at the input rate, which is what `Pricing.price/2`
actually does, rather than leaving the column blank and implying a discount.
And where no lane is `declared` — which is every lane today — the page says at
the top that nothing on it is billable, derived from the catalog so the notice
cannot outlive the fact.

Availability comes from the same `Models.availability/1` word the endpoint
publishes, on a slow tick: a page about what a lane costs that showed a dead
lane as available would be lying about the more urgent half.

`priv/docs/models.md` documents the surface and the three bases, and the thread
page's model now links to the catalog, so a reader looking at what a session
spent can reach what a call on that lane costs.

METER-001 gains the page. No new table, no new data family, no rate invented:
entering real provider rates and flipping `source: :declared` remains an owner
action, and `PricingTest` still fails the moment anything claims otherwise
without that edit.

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

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 390 · 2026-08-25T14:53:45.942069Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents_web/docs_catalog.ex
  • added lib/openagents_web/live/model_catalog_live.ex
  • modified lib/openagents_web/live/thread_show_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added priv/docs/models.md
  • added test/openagents_web/live/model_catalog_live_test.exs

Diff

8 files changed, +415 -3

INVARIANTS.md modified +9 -2

@@ -1294,6 +1294,12 @@ Concretely:

1294 1294
  same shape, nulls included.
1295 1295
- The thread page shows the word `Unpriced` rather than a dollar figure, and
1296 1296
  labels a provisional figure as a working number rather than a bill.
1297
- `/models` renders that same catalog for a person, because the contract asks
1298
  that pricing be readable by the CLI **and** by the web before spend. An
1299
  unpriced lane's rate cells carry the word rather than `$0.00`, every lane
1300
  shows its basis and the id of the table it is priced against, and a
1301
  deployment where no lane is `declared` says at the top that nothing on it is
1302
  billable — derived from the catalog, so it cannot outlive the fact.
1297 1303
- `OpenAgents.Inference.Credit.spent/1` is a floor, not a total, while
1298 1304
  `unpriced_calls/1` is above zero; `balance/1` publishes `complete?` so no
1299 1305
  reader shows a balance as whole when it is not.

@@ -1311,7 +1317,8 @@ edit. No code here may guess one.

1311 1317
Evidence: `OpenAgents.Inference.Pricing`, `OpenAgents.Inference.PricingTest`,
1312 1318
`OpenAgents.Inference.CreditTest`, `OpenAgents.ThreadsTest`,
1313 1319
`OpenAgentsWeb.ModelCatalogControllerTest`,
1314
`OpenAgentsWeb.ThreadControllerTest`, and `OpenAgentsWeb.ThreadShowLiveTest`.
1320
`OpenAgentsWeb.ThreadControllerTest`, `OpenAgentsWeb.ThreadShowLiveTest`, and
1321
`OpenAgentsWeb.ModelCatalogLiveTest`.
1315 1322
1316 1323
## Durable effects
1317 1324

@@ -5597,7 +5604,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5597 5604
| PROVENANCE-001 | `test/openagents/turn_provenance_test.exs` |
5598 5605
| PROVIDER-001 | `test/openagents/providers/provider_contract_test.exs`, `test/openagents/turn_provider_events_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5599 5606
| PROVIDER-002 | `test/openagents/inference/models_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs` |
5600
| METER-001 | `test/openagents/inference/pricing_test.exs`, `test/openagents/inference/credit_test.exs`, `test/openagents/threads_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs`, `test/openagents_web/live/thread_show_live_test.exs` |
5607
| METER-001 | `test/openagents/inference/pricing_test.exs`, `test/openagents/inference/credit_test.exs`, `test/openagents/threads_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs`, `test/openagents_web/live/thread_show_live_test.exs`, `test/openagents_web/live/model_catalog_live_test.exs` |
5601 5608
| EFFECT-001 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5602 5609
| EFFECT-002 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5603 5610
| TOOL-001 | `test/openagents/tools/registry_and_runner_test.exs` |
lib/openagents_web/docs_catalog.ex modified +11

@@ -208,6 +208,17 @@ defmodule OpenAgentsWeb.DocsCatalog do

208 208
        }
209 209
      ]
210 210
    },
211
    %{
212
      title: "Inference",
213
      items: [
214
        %{
215
          slug: "models",
216
          title: "Models and pricing",
217
          icon: "dollar-circle",
218
          route: "/models"
219
        }
220
      ]
221
    },
211 222
    %{
212 223
      title: "Transparency",
213 224
      items: [
lib/openagents_web/live/model_catalog_live.ex added +220

@@ -0,0 +1,220 @@

1
defmodule OpenAgentsWeb.ModelCatalogLive do
2
  @moduledoc """
3
  `/models`: what each lane costs, and whether the figure can be trusted,
4
  before anything spends against it (#200, METER-001).
5
6
  The contract asks that per-model pricing be readable by the CLI **and** by
7
  the web before any spend. `GET /api/v1/models` answered the first half; a
8
  person could not read it. This page is that endpoint's other reader: it
9
  renders `OpenAgents.Inference.Models.catalog/0` directly, so the page and the
10
  endpoint cannot come to disagree about what this deployment serves or what it
11
  charges.
12
13
  Same principal as the endpoint, deliberately. `GET /api/v1/models` sits in
14
  the thread scope because the catalog names what a grant can be minted for, so
15
  the caller who can open a thread is the caller who reads it — and the reader
16
  of this page is the person who can open one. "Before spend" still holds:
17
  spending here requires an account.
18
19
  Two rules govern every cell. A lane with no declared rates shows the word
20
  `Unpriced`, never `$0.00` — the deployment not knowing what a call cost is a
21
  different fact from the call having cost nothing. And a rate this deployment
22
  wrote to make itself run is labelled `provisional`, so nobody reads a working
23
  figure as a price. Today that is every rate in the catalog, and the page says
24
  so at the top rather than leaving a reader to infer it column by column.
25
26
  Availability is `Models.availability/1`, the same word the endpoint
27
  publishes, refreshed on a slow tick: a page about what a lane costs that
28
  showed a dead lane as available would be lying about the more urgent half.
29
  """
30
31
  use OpenAgentsWeb, :live_view
32
33
  alias OpenAgents.Inference.Models
34
35
  @tick_ms 15_000
36
37
  @impl true
38
  def mount(_params, _session, socket) do
39
    if connected?(socket), do: Process.send_after(self(), :tick, @tick_ms)
40
41
    {:ok,
42
     socket
43
     |> assign(:page_title, "Models · OpenAgents")
44
     |> assign_catalog()}
45
  end
46
47
  @impl true
48
  def handle_info(:tick, socket) do
49
    Process.send_after(self(), :tick, @tick_ms)
50
    {:noreply, assign_catalog(socket)}
51
  end
52
53
  defp assign_catalog(socket) do
54
    models = Models.catalog()
55
56
    socket
57
    |> assign(:models, models)
58
    |> assign(:default_id, Models.default_id())
59
    |> assign(:billable?, Enum.any?(models, &(&1["pricing_basis"] == "declared")))
60
  end
61
62
  @impl true
63
  def render(assigns) do
64
    ~H"""
65
    <Layouts.app
66
      flash={@flash}
67
      sidebar_sections={assigns[:sidebar_sections]}
68
      current_scope={@current_scope}
69
    >
70
      <main id="model-catalog" class="mx-auto w-full max-w-5xl space-y-6 px-4 py-10">
71
        <.header>
72
          Models
73
          <:subtitle>
74
            Every model this deployment serves, what each lane charges, and on whose
75
            authority. The same catalog
76
            <.kbd>GET /api/v1/models</.kbd>
77
            publishes.
78
          </:subtitle>
79
        </.header>
80
81
        <.alert
82
          :if={!@billable?}
83
          id="model-catalog-not-billable"
84
          variant={:warning}
85
          label="Nothing here is billable"
86
        >
87
          No lane on this deployment carries rates read off a provider's price page, so
88
          every figure below is a working number and nothing bills from any of them.
89
          A lane marked <strong>Unpriced</strong>
90
          has no rates at all: its calls record no cost, and the sessions that touch it
91
          report no total rather than a zero.
92
        </.alert>
93
94
        <.table id="model-catalog-table" rows={@models} row_id={&"model-#{&1["id"]}"}>
95
          <:col :let={model} label="Model">
96
            <span class="font-mono text-xs">{model["id"]}</span>
97
            <.badge :if={model["id"] == @default_id} class="ml-2" variant={:dim}>default</.badge>
98
            <p class="mt-0.5 text-xs text-muted-foreground">{model["provider"]} lane</p>
99
          </:col>
100
101
          <:col :let={model} label="Availability">
102
            <.badge
103
              id={"model-availability-#{model["id"]}"}
104
              variant={availability_variant(model["availability"])}
105
            >
106
              {model["availability"]}
107
            </.badge>
108
          </:col>
109
110
          <:col :let={model} label="Input">
111
            <span
112
              id={"model-input-rate-#{model["id"]}"}
113
              class="tabular-nums"
114
            >{rate(model, "input_per_million_tokens")}</span>
115
          </:col>
116
117
          <:col :let={model} label="Cached input">
118
            <span class="tabular-nums">{cached_rate(model)}</span>
119
          </:col>
120
121
          <:col :let={model} label="Output">
122
            <span
123
              id={"model-output-rate-#{model["id"]}"}
124
              class="tabular-nums"
125
            >{rate(model, "output_per_million_tokens")}</span>
126
          </:col>
127
128
          <:col :let={model} label="Basis">
129
            <.badge
130
              id={"model-basis-#{model["id"]}"}
131
              variant={basis_variant(model["pricing_basis"])}
132
              data-basis={model["pricing_basis"]}
133
            >
134
              {model["pricing_basis"]}
135
            </.badge>
136
            <p
137
              id={"model-pricing-id-#{model["id"]}"}
138
              class="mt-0.5 font-mono text-xs text-muted-foreground"
139
            >
140
              {get_in(model, ["pricing", "id"]) || "unpriced"}
141
            </p>
142
          </:col>
143
144
          <:col :let={model} label="Context">
145
            <span class="tabular-nums">{tokens(model["context_window"])}</span>
146
            <p class="mt-0.5 text-xs text-muted-foreground">
147
              {tokens(model["max_output"])} out
148
            </p>
149
          </:col>
150
        </.table>
151
152
        <p class="text-xs text-muted-foreground">
153
          Rates are US dollars per million tokens. Cached reads are charged at the cached
154
          rate where a lane declares one and at the input rate where it does not, because
155
          a lane that has not said otherwise has not offered a discount.
156
        </p>
157
158
        <section id="model-catalog-bases" class="space-y-2">
159
          <h2 class="text-sm font-semibold">What the basis means</h2>
160
          <.list>
161
            <:item title="declared">
162
              The operator entered the provider's published rates. This is the only basis
163
              anything may bill from.
164
            </:item>
165
            <:item title="provisional">
166
              Rates this deployment wrote to make itself run, or rates whose table it can
167
              no longer resolve. A figure, not a bill.
168
            </:item>
169
            <:item title="unpriced">
170
              No rates. Calls on this lane record no cost, and every surface that reads
171
              them shows the word rather than a zero.
172
            </:item>
173
          </.list>
174
        </section>
175
      </main>
176
    </Layouts.app>
177
    """
178
  end
179
180
  defp availability_variant("available"), do: :success
181
  defp availability_variant("degraded"), do: :warning
182
  defp availability_variant(_unavailable), do: :danger
183
184
  defp basis_variant("declared"), do: :success
185
  defp basis_variant("provisional"), do: :warning
186
  defp basis_variant(_unpriced), do: :dim
187
188
  # A lane with no rates gets the word, never a figure. `$0.00` here would be
189
  # the most confident wrong number on the page.
190
  defp rate(%{"pricing" => %{} = pricing}, key), do: dollars(Map.get(pricing, key))
191
  defp rate(_model, _key), do: "Unpriced"
192
193
  # A priced lane that declares no cached rate is not offering free cache
194
  # reads: `Pricing.price/2` charges them at the input rate, so that is what
195
  # this cell says.
196
  defp cached_rate(%{"pricing" => %{} = pricing}) do
197
    case Map.fetch(pricing, "cached_input_per_million_tokens") do
198
      {:ok, value} -> dollars(value)
199
      :error -> "at input rate"
200
    end
201
  end
202
203
  defp cached_rate(_model), do: "Unpriced"
204
205
  defp dollars(nil), do: "Unpriced"
206
207
  defp dollars(microusd) when is_integer(microusd) do
208
    "$" <> :erlang.float_to_binary(microusd / 1_000_000, decimals: 2)
209
  end
210
211
  defp tokens(count) when is_integer(count) do
212
    count
213
    |> Integer.to_string()
214
    |> String.reverse()
215
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
216
    |> String.reverse()
217
  end
218
219
  defp tokens(_count), do: "—"
220
end
lib/openagents_web/live/thread_show_live.ex modified +8 -1

@@ -102,7 +102,14 @@ defmodule OpenAgentsWeb.ThreadShowLive do

102 102
          >
103 103
            <div class="flex gap-1.5">
104 104
              <dt>Model</dt>
105
              <dd class="font-mono text-xs leading-5">{@thread.model}</dd>
105
              <%!-- To the catalog, where this lane's rates and the authority
106
              behind them are readable. The cost cell below says what this
107
              session spent; this says what a call on it costs. --%>
108
              <dd class="font-mono text-xs leading-5">
109
                <.link navigate={~p"/models"} id="thread-model-link" class="hover:underline">
110
                  {@thread.model}
111
                </.link>
112
              </dd>
106 113
            </div>
107 114
            <div class="flex gap-1.5">
108 115
              <dt>Reasoning</dt>
lib/openagents_web/route_authority.ex modified +5

@@ -56,6 +56,10 @@ defmodule OpenAgentsWeb.RouteAuthority do

56 56
    # authenticated even where a thread's own tier widens who may read it
57 57
    # (THREAD-002): the wider audience is still signed in.
58 58
    "/threads",
59
    # The model catalog and its rates. Same principal as `GET /api/v1/models`,
60
    # which sits in the thread scope because the catalog names what a grant can
61
    # be minted for: the reader of the prices is the caller who can spend them.
62
    "/models",
59 63
    "/settings/api-tokens",
60 64
    "/github/connection",
61 65
    "/api/tokens",

@@ -709,6 +713,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

709 713
  defp browser_scope("/github/connection"), do: "github-tools:self"
710 714
  defp browser_scope("/notifications"), do: "notification:self"
711 715
  defp browser_scope("/settings/api-tokens"), do: "api-token:self"
716
  defp browser_scope("/models"), do: "model-catalog:read"
712 717
  defp browser_scope(_path), do: "product:self"
713 718
714 719
  defp browser_mutation?(path, :get),
lib/openagents_web/router.ex modified +3

@@ -218,6 +218,9 @@ defmodule OpenAgentsWeb.Router do

218 218
      live "/artifact-catalog", ArtifactCatalogLive, :index
219 219
      live "/notifications", NotificationsLive, :index
220 220
      live "/settings/api-tokens", ApiTokensLive, :index
221
      # What each lane costs, before a thread spends against it. Same principal
222
      # as `GET /api/v1/models`, which the page renders (METER-001).
223
      live "/models", ModelCatalogLive, :index
221 224
      live "/threads", ThreadIndexLive, :index
222 225
      live "/threads/:id", ThreadShowLive, :show
223 226
      live "/device", DeviceAuthorizationLive, :show
priv/docs/models.md added +65

@@ -0,0 +1,65 @@

1
# Models and pricing
2
3
[The models page](/models) lists every model this deployment serves, what each
4
one charges per million tokens, and where those rates came from. Read it before
5
you open a thread: a thread pins one model for its whole life, and the grant
6
that pays for it meters against that model's rates.
7
8
The page renders the same catalog as `GET /api/v1/models`, so the CLI and the
9
web cannot disagree about what is served or what it costs.
10
11
## What each column tells you
12
13
- **Model** — the id you name when you open a thread, and the provider lane that
14
  serves it. The default is the model a thread pins when you name none.
15
- **Availability** — `available`, `degraded`, or `unavailable`. A lane whose
16
  credential is not configured is listed as `unavailable` rather than hidden, and
17
  selecting it is refused by name. No call is ever answered by a different model
18
  than the one you asked for.
19
- **Input**, **Cached input**, and **Output** — US dollars per million tokens.
20
  Where a lane declares no cached rate, cached reads are charged at the input
21
  rate.
22
- **Basis** — where the rates came from, and the id of the rate table a call is
23
  priced against.
24
- **Context** — the context window, and the maximum output tokens one call can
25
  produce.
26
27
## Reading the basis
28
29
A price on this page is only as good as its source, so every lane says what its
30
source was.
31
32
- `declared` — the operator entered the provider's published rates. This is the
33
  only basis anything may bill from.
34
- `provisional` — rates written to make the system run, or rates whose table this
35
  deployment can no longer resolve. A working figure, not a bill.
36
- `unpriced` — no rates at all. Calls on this lane record no cost.
37
38
An unpriced lane is not a free lane. It is the deployment saying it does not know
39
what a call cost, which is a different fact from the call having cost nothing. So
40
an unpriced lane shows the word `Unpriced` instead of `$0.00`, a thread that
41
touched one reports no total spend rather than a total that looks complete, and
42
the lanes that made the total unknowable are named beside it.
43
44
That distinction survives everywhere the number goes: the models page, the thread
45
page, `GET /api/v1/threads/{id}`, and your account balance, which publishes
46
whether it is complete.
47
48
## Ceilings
49
50
A grant carries call, token, and cost ceilings. A cost ceiling can only stop
51
spend it can measure, so a grant on an unpriced lane is bounded by its call and
52
token ceilings instead. The thread page shows both what a grant has spent and
53
what it may spend, and an unbounded ceiling reads as `∞` rather than as a blank.
54
55
## Reading the catalog from the CLI
56
57
The same list is available over the API:
58
59
```sh
60
openagents api models
61
```
62
63
Every entry carries `availability` and `pricing_basis`. An unpriced model
64
publishes no `pricing` block, so a client that checks for the key sees no price
65
rather than a zero.
test/openagents_web/live/model_catalog_live_test.exs added +94

@@ -0,0 +1,94 @@

1
defmodule OpenAgentsWeb.ModelCatalogLiveTest do
2
  @moduledoc """
3
  `/models`: the half of "pricing visible before spend" a person can read
4
  (#200, METER-001).
5
6
  The endpoint answered the CLI; nothing rendered the catalog for a human. What
7
  matters here is not that the page exists but that it carries the same absence
8
  the endpoint does: an unpriced lane shows the word rather than `$0.00`, and a
9
  deployment with no declared rates says so at the top instead of presenting a
10
  column of working figures as a price list.
11
  """
12
  use OpenAgentsWeb.ConnCase, async: false
13
14
  import Phoenix.LiveViewTest
15
16
  alias OpenAgents.Inference.Models
17
18
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
19
20
  defp reader(conn, handle), do: signed_in(conn, github_user(handle))
21
22
  defp unpriced_id, do: Application.fetch_env!(:openagents, :openai_model)
23
24
  test "every model this deployment serves has a row", %{conn: conn} do
25
    {:ok, view, _html} = live(reader(conn, "model-catalog-rows"), ~p"/models")
26
27
    assert has_element?(view, "#model-catalog-table")
28
29
    for id <- Models.ids() do
30
      assert has_element?(view, ~s([id="model-#{id}"])), "no row for #{id}"
31
    end
32
  end
33
34
  test "an unpriced lane shows the word, never a zero", %{conn: conn} do
35
    id = unpriced_id()
36
    {:ok, view, _html} = live(reader(conn, "model-catalog-unpriced"), ~p"/models")
37
38
    assert view |> element(~s([id="model-basis-#{id}"])) |> render() =~ "unpriced"
39
    assert view |> element(~s([id="model-input-rate-#{id}"])) |> render() =~ "Unpriced"
40
    assert view |> element(~s([id="model-output-rate-#{id}"])) |> render() =~ "Unpriced"
41
42
    refute view |> element(~s([id="model-input-rate-#{id}"])) |> render() =~ "$0.00"
43
    refute view |> element(~s([id="model-output-rate-#{id}"])) |> render() =~ "$0.00"
44
  end
45
46
  test "a lane with rates publishes them and names the table they came from", %{conn: conn} do
47
    {:ok, view, _html} = live(reader(conn, "model-catalog-priced"), ~p"/models")
48
49
    # 1_250_000 microUSD per million tokens is $1.25 per million tokens.
50
    assert view |> element(~s([id="model-input-rate-gemini-3.7-flash"])) |> render() =~ "$1.25"
51
    assert view |> element(~s([id="model-basis-gemini-3.7-flash"])) |> render() =~ "provisional"
52
53
    assert view |> element(~s([id="model-pricing-id-gemini-3.7-flash"])) |> render() =~
54
             "placeholder.gemini-3.7-flash.v1"
55
  end
56
57
  test "the page says nothing is billable while no lane declares its rates", %{conn: conn} do
58
    {:ok, view, _html} = live(reader(conn, "model-catalog-not-billable"), ~p"/models")
59
60
    assert has_element?(view, "#model-catalog-not-billable")
61
  end
62
63
  test "the billable notice is derived from the catalog, not written into the page",
64
       %{conn: conn} do
65
    previous = Application.fetch_env!(:openagents, :model_catalog)
66
67
    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
        })
75
      end)
76
77
    Application.put_env(:openagents, :model_catalog, declared)
78
    on_exit(fn -> Application.put_env(:openagents, :model_catalog, previous) end)
79
80
    {:ok, view, _html} = live(reader(conn, "model-catalog-declared"), ~p"/models")
81
82
    refute has_element?(view, "#model-catalog-not-billable")
83
    assert view |> element(~s([id="model-basis-gemini-3.7-flash"])) |> render() =~ "declared"
84
    assert view |> element(~s([id="model-input-rate-gemini-3.7-flash"])) |> render() =~ "$3.00"
85
86
    # The lane with no rates is untouched by another lane becoming billable.
87
    assert view |> element(~s([id="model-input-rate-#{unpriced_id()}"])) |> render() =~ "Unpriced"
88
  end
89
90
  test "an anonymous visitor is sent to sign in", %{conn: conn} do
91
    assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/models")
92
    refute path == ~p"/models"
93
  end
94
end

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