lib/openagents_web/live/model_catalog_live.ex

436d37facc96 · 7 KB

defmodule OpenAgentsWeb.ModelCatalogLive do
  @moduledoc """
  `/models`: what each lane costs, and whether the figure can be trusted,
  before anything spends against it (#200, METER-001).

  The contract asks that per-model pricing be readable by the CLI **and** by
  the web before any spend. `GET /api/v1/models` answered the first half; a
  person could not read it. This page is that endpoint's other reader: it
  renders `OpenAgents.Inference.Models.catalog/0` directly, so the page and the
  endpoint cannot come to disagree about what this deployment serves or what it
  charges.

  Same principal as the endpoint, deliberately. `GET /api/v1/models` sits in
  the thread scope because the catalog names what a grant can be minted for, so
  the caller who can open a thread is the caller who reads it — and the reader
  of this page is the person who can open one. "Before spend" still holds:
  spending here requires an account.

  Two rules govern every cell. A lane with no declared rates shows the word
  `Unpriced`, never `$0.00` — the deployment not knowing what a call cost is a
  different fact from the call having cost nothing. And a rate this deployment
  wrote to make itself run is labelled `provisional`, so nobody reads a working
  figure as a price. The Coder Free lane declares zero rates because its router
  only selects free models; GLM 5.3 Flash and Gemini 3.7 Flash declare the
  provider's published rates, including any active promotion.

  Availability is `Models.availability/1`, the same word the endpoint
  publishes, refreshed 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.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Inference.Models

  @tick_ms 15_000

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, @tick_ms)

    {:ok,
     socket
     |> assign(:page_title, "Models · OpenAgents")
     |> assign_catalog()}
  end

  @impl true
  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, @tick_ms)
    {:noreply, assign_catalog(socket)}
  end

  defp assign_catalog(socket) do
    models = Models.catalog()

    socket
    |> assign(:models, models)
    |> assign(:default_id, Models.default_id())
    |> assign(:billable?, Enum.any?(models, &(&1["pricing_basis"] == "declared")))
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
    >
      <main id="model-catalog" class="mx-auto w-full max-w-5xl space-y-6 px-4 py-10">
        <.header>
          Models
          <:subtitle>
            Every model this deployment serves, what each lane charges, and on whose
            authority. The same catalog
            <.kbd>GET /api/v1/models</.kbd>
            publishes.
          </:subtitle>
        </.header>

        <.alert
          :if={!@billable?}
          id="model-catalog-not-billable"
          variant={:warning}
          label="Nothing here is billable"
        >
          No lane on this deployment carries rates read off a provider's price page, so
          every figure below is a working number and nothing bills from any of them.
          A lane marked <strong>Unpriced</strong>
          has no rates at all: its calls record no cost, and the sessions that touch it
          report no total rather than a zero.
        </.alert>

        <.table id="model-catalog-table" rows={@models} row_id={&"model-#{&1["id"]}"}>
          <:col :let={model} label="Model">
            <span class="font-mono text-xs">{model["id"]}</span>
            <.badge :if={model["id"] == @default_id} class="ml-2" variant={:dim}>default</.badge>
            <p class="mt-0.5 text-xs text-muted-foreground">{model["provider"]} lane</p>
          </:col>

          <:col :let={model} label="Availability">
            <.badge
              id={"model-availability-#{model["id"]}"}
              variant={availability_variant(model["availability"])}
            >
              {model["availability"]}
            </.badge>
          </:col>

          <:col :let={model} label="Input">
            <span
              id={"model-input-rate-#{model["id"]}"}
              class="tabular-nums"
            >{rate(model, "input_per_million_tokens")}</span>
          </:col>

          <:col :let={model} label="Cached input">
            <span class="tabular-nums">{cached_rate(model)}</span>
          </:col>

          <:col :let={model} label="Output">
            <span
              id={"model-output-rate-#{model["id"]}"}
              class="tabular-nums"
            >{rate(model, "output_per_million_tokens")}</span>
          </:col>

          <:col :let={model} label="Basis">
            <.badge
              id={"model-basis-#{model["id"]}"}
              variant={basis_variant(model["pricing_basis"])}
              data-basis={model["pricing_basis"]}
            >
              {model["pricing_basis"]}
            </.badge>
            <p
              id={"model-pricing-id-#{model["id"]}"}
              class="mt-0.5 font-mono text-xs text-muted-foreground"
            >
              {get_in(model, ["pricing", "id"]) || "unpriced"}
            </p>
          </:col>

          <:col :let={model} label="Context">
            <span class="tabular-nums">{tokens(model["context_window"])}</span>
            <p class="mt-0.5 text-xs text-muted-foreground">
              {tokens(model["max_output"])} out
            </p>
          </:col>
        </.table>

        <p class="text-xs text-muted-foreground">
          Rates are US dollars per million tokens. Cached reads are charged at the cached
          rate where a lane declares one and at the input rate where it does not, because
          a lane that has not said otherwise has not offered a discount.
        </p>

        <section id="model-catalog-bases" class="space-y-2">
          <h2 class="text-sm font-semibold">What the basis means</h2>
          <.list>
            <:item title="declared">
              The operator entered the provider's published rates. This is the only basis
              anything may bill from.
            </:item>
            <:item title="provisional">
              Rates this deployment wrote to make itself run, or rates whose table it can
              no longer resolve. A figure, not a bill.
            </:item>
            <:item title="unpriced">
              No rates. Calls on this lane record no cost, and every surface that reads
              them shows the word rather than a zero.
            </:item>
          </.list>
        </section>
      </main>
    </Layouts.app>
    """
  end

  defp availability_variant("available"), do: :success
  defp availability_variant("degraded"), do: :warning
  defp availability_variant(_unavailable), do: :danger

  defp basis_variant("declared"), do: :success
  defp basis_variant("provisional"), do: :warning
  defp basis_variant(_unpriced), do: :dim

  # A lane with no rates gets the word, never a figure. `$0.00` here would be
  # the most confident wrong number on the page.
  defp rate(%{"pricing" => %{} = pricing}, key), do: dollars(Map.get(pricing, key))
  defp rate(_model, _key), do: "Unpriced"

  # A priced lane that declares no cached rate is not offering free cache
  # reads: `Pricing.price/2` charges them at the input rate, so that is what
  # this cell says.
  defp cached_rate(%{"pricing" => %{} = pricing}) do
    case Map.fetch(pricing, "cached_input_per_million_tokens") do
      {:ok, value} -> dollars(value)
      :error -> "at input rate"
    end
  end

  defp cached_rate(_model), do: "Unpriced"

  defp dollars(nil), do: "Unpriced"

  defp dollars(microusd) when is_integer(microusd) do
    "$" <> :erlang.float_to_binary(microusd / 1_000_000, decimals: 2)
  end

  defp tokens(count) when is_integer(count) do
    count
    |> Integer.to_string()
    |> String.reverse()
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
    |> String.reverse()
  end

  defp tokens(_count), do: "—"
end