lib/openagents/inference/models.ex

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.Inference.Models do
  alias OpenAgents.Inference.Health
  alias OpenAgents.Inference.Pricing

  @moduledoc """
  The typed model catalog: every model this deployment serves, and the
  provider lane that serves each.

  A grant carries one model and the proxy pins it, so the set of models a
  caller may be granted is the set the proxy can route. This module reads that
  set from one config-driven list, `config :openagents, :model_catalog`:
  `OpenAgents.Threads` admits a thread's model against it, `OpenAgents.Inference.mint/1`
  refuses a grant naming anything else, `OpenAgentsWeb.InferenceProxyController`
  asks it which adapter to call, and `GET /api/v1/models` publishes it so a
  client selects from what is actually served instead of guessing.

  Two names appear per model and they are not the same name. The `id` is what
  a client asks for and what the grant publishes — `glm-5.3-flash`. The
  `provider_model` is what the provider is called with — `zai/glm-5.3-flash`.
  Keeping them apart is what lets the routed vendor string change without
  invalidating grants that already name the model. A catalog entry may write
  either name as `{:config, key}` to follow a runtime-configurable value.

  A catalog entry names a provider lane, not a module: the adapter module for
  each lane is read from configuration (`@provider_lanes`), so
  `config/test.exs` substitutes `OpenAgents.Providers.Test` and no test
  reaches a vendor. Adding a provider is one lane here, one adapter module,
  and one credential in runtime configuration — no catalog entry carries a
  secret.

  Availability is the adapter's own report: an adapter that exports
  `configured?/0` is asked whether its credential is configured, and a lane
  whose credential is absent is **listed as unavailable rather than omitted**,
  so a client can tell "not served here" from "served here, not currently
  configured". An unavailable model is refused at thread admission and at the
  proxy (`model_unavailable`), never silently substituted (PROVIDER-002).
  """

  # Lane name → the application-config key that holds the lane's adapter
  # module. Modules stay out of `:model_catalog` so the catalog is pure data
  # and the test environment swaps adapters without touching it.
  @provider_lanes %{
    openai: :provider,
    openrouter: :openrouter_provider,
    vercel_gateway: :vercel_gateway_provider
  }

  @type pricing :: %{
          required(:input_per_million_tokens) => non_neg_integer(),
          required(:output_per_million_tokens) => non_neg_integer(),
          optional(:cached_input_per_million_tokens) => non_neg_integer()
        }

  @type t :: %{
          id: String.t(),
          provider: atom(),
          adapter: module(),
          provider_model: String.t(),
          context_window: pos_integer(),
          max_output: pos_integer(),
          pricing: pricing() | nil
        }

  @doc "Every model in the catalog, in the order a client should offer them."
  @spec all() :: [t()]
  def all do
    :openagents
    |> Application.fetch_env!(:model_catalog)
    |> Enum.map(&resolve/1)
    |> Enum.uniq_by(& &1.id)
  end

  @doc "The model ids a grant may pin."
  @spec ids() :: [String.t()]
  def ids, do: Enum.map(all(), & &1.id)

  @doc "The model a grant pins when its caller names none: the catalog's first entry."
  @spec default() :: t()
  def default, do: hd(all())

  @doc "The id of the model a grant pins when its caller names none."
  @spec default_id() :: String.t()
  def default_id, do: default().id

  @doc """
  The model the server selects for a caller that names none, by policy.

  A lane that is configured and not degraded is preferred, in catalog order.
  If no such lane exists because every configured lane is degraded, or
  because no lane is configured, the catalog default is returned. The proxy
  still uses `Models.available?/1` to refuse the call when the default is
  unavailable, so a degraded default is used but an unavailable one is not.
  """
  @spec select() :: t()
  def select do
    models = all()

    case Enum.find(models, &healthy?/1) do
      nil -> default()
      model -> model
    end
  end

  @doc "The id of the model `select/0` returns."
  @spec select_id() :: String.t()
  def select_id, do: select().id

  @doc """
  The model with this id, or `:error`.

  A thread opened before this list existed carries a vendor string rather than
  a public id in its `model` column, so a vendor spelling (`zai/glm-5.3-flash`)
  resolves to the model it routes rather than leaving those threads unable to
  mint. A vendor string this catalog no longer routes does not resolve: a
  withdrawn model is withdrawn, not quietly replaced with a survivor.
  """
  @spec fetch(String.t() | nil) :: {:ok, t()} | :error
  def fetch(id) when is_binary(id) do
    models = all()

    case Enum.find(models, &(&1.id == id)) || Enum.find(models, &(&1.provider_model == id)) do
      nil -> :error
      model -> {:ok, model}
    end
  end

  def fetch(_id), do: :error

  @doc """
  What a client should believe about a lane, as one word.

  `unavailable` means the deployment cannot call it at all — no credential.
  `degraded` means it is configured and its recent calls have failed, which is
  the case the old two-word answer could not express: a lane whose wiring is
  right and whose every call fails used to publish `available` and mislead the
  caller that trusted it (#238).

  A lane nothing has called since boot is `available`, not `degraded`. Silence
  is not evidence of failure, and refusing to offer an untried lane would make
  every restart look like an outage.
  """
  @spec availability(t()) :: String.t()
  def availability(%{id: id} = model) do
    cond do
      not available?(model) -> "unavailable"
      match?({:degraded, _}, Health.status(id)) -> "degraded"
      true -> "available"
    end
  end

  @doc """
  Whether this model's adapter reports its credential configured.

  An adapter that does not export `configured?/0` is taken as configured: the
  test adapters need no credential, and an adapter that cannot say is refused
  at call time by its own `missing_api_key` rather than guessed at here.
  """
  @spec available?(t()) :: boolean()
  def available?(%{adapter: adapter}) do
    if Code.ensure_loaded?(adapter) and function_exported?(adapter, :configured?, 0) do
      adapter.configured?()
    else
      true
    end
  end

  defp healthy?(%{id: id} = model) do
    available?(model) and not match?({:degraded, _}, Health.status(id))
  end

  @doc """
  The public projection of the catalog, for `GET /api/v1/models`.

  No adapter module and no credential state beyond the availability word: a
  client learns what it can select and what each selection can carry, nothing
  about how the server is wired. Pricing is exposed only when the deployment
  has declared rates for a model; an unpriced model has no `pricing` key so it
  is not read as zero before spend.

  Absence is a weak signal, though — a client that forgets to check for the key
  reads a missing price as no price rather than as an unknown one. So every
  entry also carries `pricing_basis`, one word alongside `availability`:
  `declared` where the operator entered the provider's published rates,
  `provisional` where the rates are a working figure nothing may bill from, and
  `unpriced` where there are none. A caller can read what a lane will cost, and
  whether that figure can be trusted, before it spends anything (METER-001).
  """
  @spec catalog() :: [map()]
  def catalog do
    default_id = default_id()

    Enum.map(all(), fn model ->
      pricing = Pricing.effective_pricing(model)

      base = %{
        "id" => model.id,
        "provider" => Atom.to_string(model.provider),
        "context_window" => model.context_window,
        "max_output" => model.max_output,
        "availability" => availability(model),
        "pricing_basis" => Pricing.basis_of(pricing),
        "default" => model.id == default_id
      }

      base
      |> maybe_put_pricing(pricing)
      |> maybe_put_promotion(model, pricing)
    end)
  end

  defp maybe_put_pricing(base, nil), do: base
  defp maybe_put_pricing(base, pricing), do: Map.put(base, "pricing", public_pricing(pricing))

  defp maybe_put_promotion(base, model, pricing) do
    case Pricing.promotion_ends_at(model) do
      %DateTime{} = ends_at ->
        Map.put(base, "pricing_promotion", %{
          "active" => Pricing.pricing_id(pricing) != Pricing.pricing_id(model.pricing),
          "ends_at" => DateTime.to_iso8601(ends_at)
        })

      nil ->
        base
    end
  end

  defp public_pricing(pricing) do
    base = %{
      "id" => Pricing.pricing_id(pricing),
      "basis" => Pricing.basis_of(pricing),
      "input_per_million_tokens" => pricing.input_per_million_tokens,
      "output_per_million_tokens" => pricing.output_per_million_tokens
    }

    case Map.fetch(pricing, :cached_input_per_million_tokens) do
      {:ok, value} -> Map.put(base, "cached_input_per_million_tokens", value)
      :error -> base
    end
  end

  @doc "The ids currently available to serve, for a refusal that names what is."
  @spec available_ids() :: [String.t()]
  def available_ids do
    all() |> Enum.filter(&available?/1) |> Enum.map(& &1.id)
  end

  defp resolve(entry) do
    %{
      id: value(entry.id),
      provider: entry.provider,
      adapter: Application.fetch_env!(:openagents, Map.fetch!(@provider_lanes, entry.provider)),
      provider_model: value(entry.provider_model),
      context_window: entry.context_window,
      max_output: entry.max_output,
      pricing: Map.get(entry, :pricing)
    }
  end

  defp value({:config, key}) when is_atom(key), do: Application.fetch_env!(:openagents, key)
  defp value(literal) when is_binary(literal), do: literal
end