Serve a typed model catalog and refuse silent model substitution

5125d3f0a0f4 · AtlantisPleb · · parent e874b6a1d570

Serve a typed model catalog and refuse silent model substitution

GET /api/v3/models publishes the models this deployment serves — id,
provider lane, context and output ceilings, availability — from one
config-driven catalog (config :openagents, :model_catalog) that thread
admission, grant minting, and the inference proxy all check, so the
offered set and the refused-against set cannot drift (#199).

A lane whose adapter reports no credential (the optional configured?/0
provider callback) is listed as unavailable rather than omitted, and
selecting it is refused with model_unavailable at thread admission and
at the proxy before any provider is called. A proxy body naming a model
the catalog does not serve, or a served model other than the grant's,
is a typed 422 (model_not_served / model_mismatch) naming the served
set — the request-body model field was silently ignored before, the
same defect shape as #160's server half. Every successful proxy
response attributes the effective model in the x-openagents-model
header and each SSE chunk's model field, so a client renders what
answered. PROVIDER-002 records the law.

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.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified lib/openagents/inference/models.ex
  • modified lib/openagents/providers/open_ai.ex
  • modified lib/openagents/providers/open_router.ex
  • modified lib/openagents/providers/provider.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • added lib/openagents_web/controllers/model_catalog_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents/inference/models_test.exs
  • modified test/openagents/providers/persona_boundary_test.exs
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • added test/openagents_web/controllers/model_catalog_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • modified test/support/providers/test.ex
  • added test/support/providers/unconfigured_test_provider.ex

Diff

20 files changed, +612 -60

INVARIANTS.md modified +40

@@ -1187,6 +1187,45 @@ Evidence: `OpenAgents.Providers.ProviderEvent`, `OpenAgents.Providers.OpenAI`,

1187 1187
`OpenAgents.Inference.ModelsTest`, `OpenAgents.Providers.Test`,
1188 1188
`OpenAgents.TurnProviderEventsTest`, and `OpenAgents.DependencyBoundaryTest`.
1189 1189
1190
### PROVIDER-002 — Model selection is honest: no silent substitution
1191
1192
Status: Current
1193
1194
A turn is never answered by a different model than the one requested without
1195
the caller being told. `OpenAgents.Inference.Models` is the one config-driven
1196
catalog (`config :openagents, :model_catalog`) of the models this deployment
1197
serves, `GET /api/v3/models` publishes it — id, provider lane, context and
1198
output ceilings, availability — and every admission checks the same list, so
1199
the offered set and the refused-against set cannot drift.
1200
1201
Concretely:
1202
1203
- A model outside the catalog is refused with a typed error naming the served
1204
  set — a field-level `422` at `POST /api/v3/threads`, `model_not_served` at
1205
  the inference proxy — never replaced by a default.
1206
- A model in the catalog whose provider credential is not configured is
1207
  **listed as unavailable rather than omitted**, and selecting it is refused
1208
  with `model_unavailable` at thread admission and at the proxy, before any
1209
  provider is called. Availability is the adapter's own report
1210
  (`configured?/0`); it says only that a credential is present, never what it
1211
  is.
1212
- A proxy body naming a served model other than the grant's is refused with
1213
  `model_mismatch` naming both, rather than silently answered by the grant's.
1214
- Every successful proxy response attributes the effective model — the
1215
  `x-openagents-model` header and each SSE chunk's `model` field — so a
1216
  client renders what answered rather than what it assumed. Because a
1217
  mismatch is refused, requested and effective agree on every `200`.
1218
1219
The chat lane keeps the same law through `OpenAgents.Chat.Backends`: an
1220
unsupported `model` on `POST /api/v3/chat/turns` is a typed `422`, and
1221
`GET /api/v3` publishes the supported enum from the same list.
1222
1223
Evidence: `OpenAgents.Inference.Models`, `OpenAgentsWeb.ModelCatalogController`,
1224
`OpenAgentsWeb.InferenceProxyController`, `OpenAgentsWeb.ThreadController`,
1225
`OpenAgents.Inference.ModelsTest`, `OpenAgentsWeb.ModelCatalogControllerTest`,
1226
`OpenAgentsWeb.InferenceProxyControllerTest`, and
1227
`OpenAgentsWeb.ThreadControllerTest`.
1228
1190 1229
## Tool authority and execution
1191 1230
1192 1231
### TOOL-001 — A turn uses one immutable tool catalog

@@ -4821,6 +4860,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4821 4860
| TURN-005 | `test/openagents/turn_tool_loop_test.exs` |
4822 4861
| PROVENANCE-001 | `test/openagents/turn_provenance_test.exs` |
4823 4862
| PROVIDER-001 | `test/openagents/providers/provider_contract_test.exs`, `test/openagents/turn_provider_events_test.exs`, `test/openagents/dependency_boundary_test.exs` |
4863
| 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` |
4824 4864
| TOOL-001 | `test/openagents/tools/registry_and_runner_test.exs` |
4825 4865
| COLLECTIVE-001 | `test/openagents/collective_test.exs` |
4826 4866
| COLLECTIVE-002 | `test/openagents/collective_generalizer_test.exs` |
config/config.exs modified +26

@@ -103,6 +103,32 @@ config :openagents,

103 103
  openai_api_key: nil,
104 104
  openrouter_api_key: nil,
105 105
  openrouter_model: "stealth/ox-alpha",
106
  # The typed model catalog (`OpenAgents.Inference.Models`, PROVIDER-002):
107
  # every model this deployment serves, in the order a client should offer
108
  # them; the first entry is the default. `id` is the public name a caller
109
  # asks for, `provider_model` the string the vendor is called with, and
110
  # either may be `{:config, key}` to follow a runtime-configurable value.
111
  # `provider` names a lane whose adapter module and credential are configured
112
  # separately, so no secret lives here; a lane whose credential is absent is
113
  # listed as unavailable, never silently substituted. `max_output` is the
114
  # per-call output cap the proxy's adapters actually send (4,096 tokens);
115
  # `context_window` is the ceiling this deployment publishes for the lane.
116
  model_catalog: [
117
    %{
118
      id: {:config, :openai_model},
119
      provider: :openai,
120
      provider_model: {:config, :openai_model},
121
      context_window: 272_000,
122
      max_output: 4_096
123
    },
124
    %{
125
      id: "ox-alpha",
126
      provider: :openrouter,
127
      provider_model: {:config, :openrouter_model},
128
      context_window: 256_000,
129
      max_output: 4_096
130
    }
131
  ],
106 132
  gemini_api_key: nil,
107 133
  gemini_model: "gemini-3.7-flash",
108 134
  box_api_key: nil,
lib/openagents/inference/models.ex modified +105 -33

@@ -1,32 +1,61 @@

1 1
defmodule OpenAgents.Inference.Models do
2 2
  @moduledoc """
3
  The models a grant may pin, and the provider that serves each.
3
  The typed model catalog: every model this deployment serves, and the
4
  provider lane that serves each.
4 5
5 6
  A grant carries one model and the proxy pins it, so the set of models a
6
  caller may be granted is the set the proxy can route. This module is that
7
  single list: `OpenAgents.Threads` admits a thread's model against it,
8
  `OpenAgents.Inference.mint/1` refuses a grant naming anything else, and
9
  `OpenAgentsWeb.InferenceProxyController` asks it which provider to call.
7
  caller may be granted is the set the proxy can route. This module reads that
8
  set from one config-driven list, `config :openagents, :model_catalog`:
9
  `OpenAgents.Threads` admits a thread's model against it, `OpenAgents.Inference.mint/1`
10
  refuses a grant naming anything else, `OpenAgentsWeb.InferenceProxyController`
11
  asks it which adapter to call, and `GET /api/v3/models` publishes it so a
12
  client selects from what is actually served instead of guessing.
10 13
11
  Two names appear per model and they are not the same name. The `id` is what a
12
  client asks for and what the grant publishes — `ox-alpha`. The
14
  Two names appear per model and they are not the same name. The `id` is what
15
  a client asks for and what the grant publishes — `ox-alpha`. The
13 16
  `provider_model` is what the provider is called with — `stealth/ox-alpha`.
14 17
  Keeping them apart is what lets the routed vendor string change without
15
  invalidating grants that already name the model.
18
  invalidating grants that already name the model. A catalog entry may write
19
  either name as `{:config, key}` to follow a runtime-configurable value.
16 20
17
  The provider module for each lane is read from configuration rather than
18
  compiled in, so `config/test.exs` substitutes `OpenAgents.Providers.Test` for
19
  both lanes and no test reaches a vendor.
21
  A catalog entry names a provider lane, not a module: the adapter module for
22
  each lane is read from configuration (`@provider_lanes`), so
23
  `config/test.exs` substitutes `OpenAgents.Providers.Test` and no test
24
  reaches a vendor. Adding a provider is one lane here, one adapter module,
25
  and one credential in runtime configuration — no catalog entry carries a
26
  secret.
27
28
  Availability is the adapter's own report: an adapter that exports
29
  `configured?/0` is asked whether its credential is configured, and a lane
30
  whose credential is absent is **listed as unavailable rather than omitted**,
31
  so a client can tell "not served here" from "served here, not currently
32
  configured". An unavailable model is refused at thread admission and at the
33
  proxy (`model_unavailable`), never silently substituted (PROVIDER-002).
20 34
  """
21 35
22
  @ox_alpha "ox-alpha"
36
  # Lane name → the application-config key that holds the lane's adapter
37
  # module. Modules stay out of `:model_catalog` so the catalog is pure data
38
  # and the test environment swaps adapters without touching it.
39
  @provider_lanes %{
40
    openai: :provider,
41
    openrouter: :openrouter_provider
42
  }
23 43
24
  @type t :: %{id: String.t(), provider: module(), provider_model: String.t()}
44
  @type t :: %{
45
          id: String.t(),
46
          provider: atom(),
47
          adapter: module(),
48
          provider_model: String.t(),
49
          context_window: pos_integer(),
50
          max_output: pos_integer()
51
        }
25 52
26
  @doc "Every model a grant may pin, in the order a client should offer them."
53
  @doc "Every model in the catalog, in the order a client should offer them."
27 54
  @spec all() :: [t()]
28 55
  def all do
29
    [default(), ox_alpha()]
56
    :openagents
57
    |> Application.fetch_env!(:model_catalog)
58
    |> Enum.map(&resolve/1)
30 59
    |> Enum.uniq_by(& &1.id)
31 60
  end
32 61

@@ -34,17 +63,9 @@ defmodule OpenAgents.Inference.Models do

34 63
  @spec ids() :: [String.t()]
35 64
  def ids, do: Enum.map(all(), & &1.id)
36 65
37
  @doc "The model a grant pins when its caller names none."
66
  @doc "The model a grant pins when its caller names none: the catalog's first entry."
38 67
  @spec default() :: t()
39
  def default do
40
    model = Application.fetch_env!(:openagents, :openai_model)
41
42
    %{
43
      id: model,
44
      provider: Application.fetch_env!(:openagents, :provider),
45
      provider_model: model
46
    }
47
  end
68
  def default, do: hd(all())
48 69
49 70
  @doc "The id of the model a grant pins when its caller names none."
50 71
  @spec default_id() :: String.t()

@@ -54,14 +75,14 @@ defmodule OpenAgents.Inference.Models do

54 75
  The model with this id, or `:error`.
55 76
56 77
  A thread opened before this list existed carries the vendor string
57
  `stealth/ox-alpha` in its `model` column, so that spelling resolves to the
58
  same model rather than leaving those threads unable to mint.
78
  (`stealth/ox-alpha`) in its `model` column, so a vendor spelling resolves to
79
  the model it routes rather than leaving those threads unable to mint.
59 80
  """
60 81
  @spec fetch(String.t() | nil) :: {:ok, t()} | :error
61 82
  def fetch(id) when is_binary(id) do
62
    normalized = if id == ox_alpha().provider_model, do: @ox_alpha, else: id
83
    models = all()
63 84
64
    case Enum.find(all(), &(&1.id == normalized)) do
85
    case Enum.find(models, &(&1.id == id)) || Enum.find(models, &(&1.provider_model == id)) do
65 86
      nil -> :error
66 87
      model -> {:ok, model}
67 88
    end

@@ -69,11 +90,62 @@ defmodule OpenAgents.Inference.Models do

69 90
70 91
  def fetch(_id), do: :error
71 92
72
  defp ox_alpha do
93
  @doc """
94
  Whether this model's adapter reports its credential configured.
95
96
  An adapter that does not export `configured?/0` is taken as configured: the
97
  test adapters need no credential, and an adapter that cannot say is refused
98
  at call time by its own `missing_api_key` rather than guessed at here.
99
  """
100
  @spec available?(t()) :: boolean()
101
  def available?(%{adapter: adapter}) do
102
    if Code.ensure_loaded?(adapter) and function_exported?(adapter, :configured?, 0) do
103
      adapter.configured?()
104
    else
105
      true
106
    end
107
  end
108
109
  @doc """
110
  The public projection of the catalog, for `GET /api/v3/models`.
111
112
  No adapter module and no credential state beyond the availability word: a
113
  client learns what it can select and what each selection can carry, nothing
114
  about how the server is wired.
115
  """
116
  @spec catalog() :: [map()]
117
  def catalog do
118
    default_id = default_id()
119
120
    Enum.map(all(), fn model ->
121
      %{
122
        "id" => model.id,
123
        "provider" => Atom.to_string(model.provider),
124
        "context_window" => model.context_window,
125
        "max_output" => model.max_output,
126
        "availability" => if(available?(model), do: "available", else: "unavailable"),
127
        "default" => model.id == default_id
128
      }
129
    end)
130
  end
131
132
  @doc "The ids currently available to serve, for a refusal that names what is."
133
  @spec available_ids() :: [String.t()]
134
  def available_ids do
135
    all() |> Enum.filter(&available?/1) |> Enum.map(& &1.id)
136
  end
137
138
  defp resolve(entry) do
73 139
    %{
74
      id: @ox_alpha,
75
      provider: Application.fetch_env!(:openagents, :openrouter_provider),
76
      provider_model: OpenAgents.Chat.OpenRouter.default_model()
140
      id: value(entry.id),
141
      provider: entry.provider,
142
      adapter: Application.fetch_env!(:openagents, Map.fetch!(@provider_lanes, entry.provider)),
143
      provider_model: value(entry.provider_model),
144
      context_window: entry.context_window,
145
      max_output: entry.max_output
77 146
    }
78 147
  end
148
149
  defp value({:config, key}) when is_atom(key), do: Application.fetch_env!(:openagents, key)
150
  defp value(literal) when is_binary(literal), do: literal
79 151
end
lib/openagents/providers/open_ai.ex modified +5

@@ -19,6 +19,11 @@ defmodule OpenAgents.Providers.OpenAI do

19 19
  @impl true
20 20
  def capabilities, do: [:text, :tool_calls, :usage]
21 21
22
  @impl true
23
  def configured? do
24
    match?({:ok, _key}, OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key))
25
  end
26
22 27
  @impl true
23 28
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
24 29
    stream(request, on_event, [])
lib/openagents/providers/open_router.ex modified +5

@@ -31,6 +31,11 @@ defmodule OpenAgents.Providers.OpenRouter do

31 31
  @impl true
32 32
  def capabilities, do: [:text, :tool_calls, :usage]
33 33
34
  @impl true
35
  def configured? do
36
    match?({:ok, _key}, OpenAgents.RuntimeConfig.fetch_secret(:openrouter_api_key))
37
  end
38
34 39
  @impl true
35 40
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
36 41
    stream(request, on_event, [])
lib/openagents/providers/provider.ex modified +13

@@ -12,6 +12,19 @@ defmodule OpenAgents.Providers.Provider do

12 12
  @type capability :: :text | :tool_calls | :usage
13 13
  @callback capabilities() :: [capability()]
14 14
15
  @doc """
16
  Whether this adapter's credential is configured on this deployment.
17
18
  Optional. `OpenAgents.Inference.Models` asks it to publish availability in
19
  the model catalog and to refuse an unavailable model before a call is made
20
  (PROVIDER-002). An adapter that does not export it is taken as configured —
21
  the test adapters need no credential. The answer says only that a secret is
22
  present, never what it is.
23
  """
24
  @callback configured?() :: boolean()
25
26
  @optional_callbacks configured?: 0
27
15 28
  @callback stream(
16 29
              OpenAgents.Providers.Request.t(),
17 30
              (OpenAgents.Providers.ProviderEvent.t() -> any())
lib/openagents_web/api_error.ex modified +6

@@ -59,6 +59,12 @@ defmodule OpenAgentsWeb.ApiError do

59 59
    # malformed request and not a forbidden one: the same call succeeds once
60 60
    # the caller revokes one, so it is the rate-limit status and its own code.
61 61
    "thread_quota_reached" => {429, "This account holds the maximum number of open threads"},
62
    # Model availability (PROVIDER-002). The model is in the catalog but its
63
    # provider credential is not configured on this deployment, which is the
64
    # server's condition and not the caller's mistake: the same call succeeds
65
    # once the operator configures the lane, so it is the unavailable status
66
    # and its own code — never a silent substitution.
67
    "model_unavailable" => {503, "The model's provider is not configured on this deployment"},
62 68
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
63 69
    # Spending the account's inference credit is not a rate limit: no amount of
64 70
    # waiting or revoking makes the same call succeed, so it is the payment
lib/openagents_web/api_route_authority.ex modified +1

@@ -189,6 +189,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

189 189
      # Scoped bearer pipelines require the route-specific token authority.
190 190
      "get /api/v3/chat/events" => {:required_bearer, :chat, :legacy},
191 191
      "post /api/v3/chat/turns" => {:required_bearer, :chat, :legacy},
192
      "get /api/v3/models" => {:required_bearer, :model, :envelope},
192 193
      "post /api/v3/threads" => {:required_bearer, :thread, :envelope},
193 194
      "get /api/v3/threads" => {:required_bearer, :thread, :envelope},
194 195
      "get /api/v3/threads/:thread_id" => {:required_bearer, :thread, :envelope},
lib/openagents_web/controllers/api_extension_controller.ex modified +8 -3

@@ -423,6 +423,7 @@ defmodule OpenAgentsWeb.ApiExtensionController do

423 423
          "conversation, and a grant names one or the other, never both " <>
424 424
          "(THREAD-001).",
425 425
      "endpoints" => [
426
        "GET /api/v3/models",
426 427
        "POST /api/v3/threads",
427 428
        "GET /api/v3/threads/{thread_id}",
428 429
        "DELETE /api/v3/threads/{thread_id}"

@@ -442,9 +443,13 @@ defmodule OpenAgentsWeb.ApiExtensionController do

442 443
          "default" => OpenAgents.Inference.Models.default_id(),
443 444
          "description" =>
444 445
            "The model the thread's grant pins, and therefore the model every " <>
445
              "call at the inference proxy reaches. A value outside this enum " <>
446
              "is refused with a field-level 422 naming `model`. Open a second " <>
447
              "thread to run other work on another model."
446
              "call at the inference proxy reaches. GET /api/v3/models is the " <>
447
              "typed catalog behind this enum, with each model's provider, " <>
448
              "ceilings, and availability. A value outside this enum is " <>
449
              "refused with a field-level 422 naming `model`; a listed model " <>
450
              "whose provider is not configured is refused with " <>
451
              "`model_unavailable`. It is never replaced silently. Open a " <>
452
              "second thread to run other work on another model."
448 453
        },
449 454
        "reasoning" => %{
450 455
          "endpoint" => "POST /api/v3/threads",
lib/openagents_web/controllers/inference_proxy_controller.ex modified +85 -16

@@ -12,6 +12,12 @@ defmodule OpenAgentsWeb.InferenceProxyController do

12 12
  back as chat-completions SSE that probe's parser consumes. Provider JSON,
13 13
  credentials, and raw errors never cross this boundary.
14 14
15
  Model selection is honest (PROVIDER-002): a body that names a model the
16
  catalog does not serve, or a served model other than the grant's, is refused
17
  with a typed error naming the served set, never answered by another model.
18
  Every 200 attributes the effective model — the `x-openagents-model` header
19
  and each chunk's `model` field — so a client renders what answered.
20
15 21
  The probe→proxy hop is buffered (the provider still streams from the vendor
16 22
  internally); probe's transport reads the whole body before parsing, so this
17 23
  matches its consumer and keeps failure handling honest.

@@ -31,8 +37,10 @@ defmodule OpenAgentsWeb.InferenceProxyController do

31 37
    with {:ok, token} <- bearer(conn),
32 38
         {:ok, grant} <- resolve(token),
33 39
         {:ok, model} <- route(grant),
40
         :ok <- serving(model),
41
         :ok <- requested_model(model, conn.body_params),
34 42
         {:ok, request} <- build_request(model, conn.body_params) do
35
      run(conn, grant, model.provider, request)
43
      run(conn, grant, model, request)
36 44
    else
37 45
      {:error, reason} -> refuse(conn, reason)
38 46
    end

@@ -51,6 +59,35 @@ defmodule OpenAgentsWeb.InferenceProxyController do

51 59
    end
52 60
  end
53 61
62
  # A routed model whose adapter reports no credential is refused before the
63
  # call, not answered by another lane (PROVIDER-002).
64
  defp serving(model) do
65
    if Models.available?(model), do: :ok, else: {:error, :model_unavailable}
66
  end
67
68
  # The grant pins the model, and a body that names one must name the same
69
  # model. Ignoring the field would answer a request that asked for one model
70
  # with another and say nothing — the silent substitution of issue #160 —
71
  # so a disagreement is a typed refusal that names the served set
72
  # (PROVIDER-002). Either spelling of the granted model (public id or vendor
73
  # string) is the same name.
74
  defp requested_model(model, body) do
75
    case Map.get(body, "model") do
76
      absent when absent in [nil, ""] ->
77
        :ok
78
79
      requested when is_binary(requested) ->
80
        case Models.fetch(requested) do
81
          {:ok, %{id: id}} when id == model.id -> :ok
82
          {:ok, %{id: other}} -> {:error, {:model_mismatch, other, model.id}}
83
          :error -> {:error, {:model_not_served, requested}}
84
        end
85
86
      _not_a_string ->
87
        {:error, :invalid_request}
88
    end
89
  end
90
54 91
  defp build_request(model, %{"messages" => messages} = body) when is_list(messages) do
55 92
    {system, turns} = Enum.split_with(messages, &(role(&1) == "system"))
56 93

@@ -136,12 +173,12 @@ defmodule OpenAgentsWeb.InferenceProxyController do

136 173
137 174
  # ── run + translate ─────────────────────────────────────────────────────
138 175
139
  defp run(conn, grant, provider, request) do
176
  defp run(conn, grant, model, request) do
140 177
    parent = self()
141 178
142 179
    # The provider pushes events synchronously; capture them to this process's
143 180
    # mailbox and drain in order once the call returns.
144
    result = provider.stream(request, fn event -> send(parent, {:proxy_event, event}) end)
181
    result = model.adapter.stream(request, fn event -> send(parent, {:proxy_event, event}) end)
145 182
    events = drain_events([])
146 183
147 184
    case result do

@@ -149,10 +186,16 @@ defmodule OpenAgentsWeb.InferenceProxyController do

149 186
        usage = usage_of(events)
150 187
        _ = meter(grant, usage)
151 188
189
        # The effective model is attributed on the response itself — the
190
        # header and every chunk's `model` field — so a client renders what
191
        # answered, not what it assumed (PROVIDER-002). Because a mismatched
192
        # request was refused above, requested and effective are the same
193
        # name on every 200.
152 194
        conn
153 195
        |> put_resp_content_type("text/event-stream")
154 196
        |> put_resp_header("cache-control", "no-store")
155
        |> send_resp(200, sse_body(events))
197
        |> put_resp_header("x-openagents-model", model.id)
198
        |> send_resp(200, sse_body(events, model.id))
156 199
157 200
      {:error, reason} ->
158 201
        # A failure that produced partial usage is still metered; the probe

@@ -183,7 +226,9 @@ defmodule OpenAgentsWeb.InferenceProxyController do

183 226
  end
184 227
185 228
  # Translate the ordered provider events into a chat-completions SSE body.
186
  defp sse_body(events) do
229
  # Every chunk carries the effective model id, the field an OpenAI-compatible
230
  # parser already reads as "the model that answered".
231
  defp sse_body(events, model_id) do
187 232
    saw_tool_call = Enum.any?(events, &match?({:tool_call, _}, &1))
188 233
    finish_reason = if saw_tool_call, do: "tool_calls", else: "stop"
189 234

@@ -192,33 +237,36 @@ defmodule OpenAgentsWeb.InferenceProxyController do

192 237
      |> Enum.with_index()
193 238
      |> Enum.flat_map(fn {event, index} -> event_chunks(event, index) end)
194 239
195
    finish = [
196
      data(%{"choices" => [%{"index" => 0, "delta" => %{}, "finish_reason" => finish_reason}]})
197
    ]
240
    finish = [%{"choices" => [%{"index" => 0, "delta" => %{}, "finish_reason" => finish_reason}]}]
198 241
199 242
    usage_chunk =
200 243
      case usage_of(events) do
201 244
        usage when usage == %{} -> []
202
        usage -> [data(%{"choices" => [], "usage" => wire_usage(usage)})]
245
        usage -> [%{"choices" => [], "usage" => wire_usage(usage)}]
203 246
      end
204 247
205
    IO.iodata_to_binary([chunks, finish, usage_chunk, "data: [DONE]\n\n"])
248
    frames =
249
      Enum.map(chunks ++ finish ++ usage_chunk, fn payload ->
250
        data(Map.put(payload, "model", model_id))
251
      end)
252
253
    IO.iodata_to_binary([frames, "data: [DONE]\n\n"])
206 254
  end
207 255
208 256
  defp event_chunks({:text_delta, text}, _index) when text != "" do
209
    [data(%{"choices" => [%{"index" => 0, "delta" => %{"content" => text}}]})]
257
    [%{"choices" => [%{"index" => 0, "delta" => %{"content" => text}}]}]
210 258
  end
211 259
212 260
  # Reasoning rides the OpenRouter chat-completions extension field —
213 261
  # `delta.reasoning` alongside `delta.content` — the shape the CLI's
214 262
  # OpenAI-compatible parser already expects from that vendor surface.
215 263
  defp event_chunks({:reasoning_delta, text}, _index) when text != "" do
216
    [data(%{"choices" => [%{"index" => 0, "delta" => %{"reasoning" => text}}]})]
264
    [%{"choices" => [%{"index" => 0, "delta" => %{"reasoning" => text}}]}]
217 265
  end
218 266
219 267
  defp event_chunks({:tool_call, tool_call}, index) do
220 268
    [
221
      data(%{
269
      %{
222 270
        "choices" => [
223 271
          %{
224 272
            "index" => 0,

@@ -237,7 +285,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

237 285
            }
238 286
          }
239 287
        ]
240
      })
288
      }
241 289
    ]
242 290
  end
243 291

@@ -274,12 +322,33 @@ defmodule OpenAgentsWeb.InferenceProxyController do

274 322
  end
275 323
276 324
  defp refuse(conn, reason) do
277
    {status, code} = status_for(reason)
325
    {status, error} = error_for(reason)
278 326
279 327
    conn
280 328
    |> put_resp_content_type("application/json")
281 329
    |> put_resp_header("cache-control", "no-store")
282
    |> send_resp(status, Jason.encode!(%{"error" => %{"code" => code}}))
330
    |> send_resp(status, Jason.encode!(%{"error" => error}))
331
  end
332
333
  # The model refusals name the served set, because the fix for either is to
334
  # ask for a model on that list.
335
  defp error_for({:model_not_served, requested}) do
336
    {422, %{"code" => "model_not_served", "requested" => requested, "served" => Models.ids()}}
337
  end
338
339
  defp error_for({:model_mismatch, requested, granted}) do
340
    {422,
341
     %{
342
       "code" => "model_mismatch",
343
       "requested" => requested,
344
       "granted" => granted,
345
       "served" => Models.ids()
346
     }}
347
  end
348
349
  defp error_for(reason) do
350
    {status, code} = status_for(reason)
351
    {status, %{"code" => code}}
283 352
  end
284 353
285 354
  defp status_for(:missing_grant), do: {401, "missing_grant"}
lib/openagents_web/controllers/model_catalog_controller.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgentsWeb.ModelCatalogController do
2
  @moduledoc """
3
  `GET /api/v3/models`: the typed model catalog this deployment serves.
4
5
  The CLI renders model selection from this list instead of guessing, so it is
6
  the same list every admission checks against — `OpenAgents.Inference.Models`
7
  admits a thread's model, refuses a grant, and routes the proxy from the one
8
  catalog this endpoint publishes (PROVIDER-002). A model whose provider
9
  credential is not configured is listed with availability `unavailable`
10
  rather than omitted, so a client can tell "not served here" from "served
11
  here, not currently configured"; selecting it is refused with
12
  `model_unavailable`, never answered by another model.
13
14
  Behind the same bearer scope as threads: the catalog names what a thread
15
  grant can be minted for, so the caller who can open a thread is the caller
16
  who reads it.
17
  """
18
19
  use OpenAgentsWeb, :controller
20
21
  alias OpenAgents.Inference.Models
22
23
  def index(conn, _params) do
24
    json(conn, %{
25
      "models" => Models.catalog(),
26
      "default" => Models.default_id()
27
    })
28
  end
29
end
lib/openagents_web/controllers/thread_controller.ex modified +29

@@ -44,6 +44,7 @@ defmodule OpenAgentsWeb.ThreadController do

44 44
      open(conn, objective, options)
45 45
    else
46 46
      {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
47
      {:unavailable, model_id} -> unavailable_model(conn, model_id)
47 48
    end
48 49
  end
49 50

@@ -293,6 +294,7 @@ defmodule OpenAgentsWeb.ThreadController do

293 294
294 295
  defp execution_shape(params) do
295 296
    with {:ok, model} <- admitted(params, "model", Models.ids(), Models.default_id()),
297
         :ok <- serving(model),
296 298
         {:ok, reasoning} <-
297 299
           admitted(params, "reasoning", Thread.reasoning_efforts(), Threads.default_reasoning()),
298 300
         {:ok, profile} <-

@@ -306,6 +308,33 @@ defmodule OpenAgentsWeb.ThreadController do

306 308
    end
307 309
  end
308 310
311
  # An admitted model whose provider credential is not configured is refused
312
  # here rather than minted into a grant that can only fail at its first call
313
  # (PROVIDER-002): the catalog lists it as unavailable, and opening a thread
314
  # on it would be authority for work the deployment cannot do.
315
  defp serving(model_id) do
316
    case Models.fetch(model_id) do
317
      {:ok, model} ->
318
        if Models.available?(model), do: :ok, else: {:unavailable, model_id}
319
320
      # `admitted/4` has already bound the id to the catalog.
321
      :error ->
322
        {:unavailable, model_id}
323
    end
324
  end
325
326
  defp unavailable_model(conn, model_id) do
327
    sentence =
328
      "#{inspect(model_id)} is in the catalog but its provider is not configured " <>
329
        "on this deployment. Currently available: " <>
330
        "#{Enum.join(Models.available_ids(), ", ")}. See GET /api/v3/models."
331
332
    ApiError.refuse(conn, "model_unavailable",
333
      message: sentence,
334
      errors: %{"model" => [sentence]}
335
    )
336
  end
337
309 338
  # A value outside the enum is refused rather than replaced by the default: a
310 339
  # caller that asked for one execution shape and was given another has no way
311 340
  # to tell.
lib/openagents_web/router.ex modified +4

@@ -560,6 +560,10 @@ defmodule OpenAgentsWeb.Router do

560 560
  scope "/api/v3", OpenAgentsWeb do
561 561
    pipe_through :chat_account_api
562 562
563
    # The typed model catalog the thread admission checks against. It sits in
564
    # the thread scope because it names what a thread grant can be minted for.
565
    get "/models", ModelCatalogController, :index
566
563 567
    post "/threads", ThreadController, :create
564 568
    get "/threads", ThreadController, :index
565 569
    get "/threads/:thread_id", ThreadController, :show
test/openagents/inference/models_test.exs modified +43 -2

@@ -4,12 +4,12 @@ defmodule OpenAgents.Inference.ModelsTest do

4 4
  alias OpenAgents.Chat.OpenRouter
5 5
  alias OpenAgents.Inference.Models
6 6
7
  test "the default model is the configured one, served by the configured provider" do
7
  test "the default model is the configured one, served by the configured adapter" do
8 8
    default = Models.default()
9 9
10 10
    assert default.id == Application.fetch_env!(:openagents, :openai_model)
11 11
    assert default.provider_model == default.id
12
    assert default.provider == Application.fetch_env!(:openagents, :provider)
12
    assert default.adapter == Application.fetch_env!(:openagents, :provider)
13 13
    assert Models.default_id() == default.id
14 14
  end
15 15

@@ -18,6 +18,7 @@ defmodule OpenAgents.Inference.ModelsTest do

18 18
    assert model.id == "ox-alpha"
19 19
    assert model.provider_model == OpenRouter.default_model()
20 20
    refute model.id == model.provider_model
21
    assert model.adapter == Application.fetch_env!(:openagents, :openrouter_provider)
21 22
  end
22 23
23 24
  test "the vendor spelling resolves to the same model" do

@@ -35,4 +36,44 @@ defmodule OpenAgents.Inference.ModelsTest do

35 36
    assert Models.fetch("attacker/gpt-9-ultra") == :error
36 37
    assert Models.fetch(nil) == :error
37 38
  end
39
40
  test "every catalog entry carries the typed ceilings and a provider lane" do
41
    for model <- Models.all() do
42
      assert is_binary(model.id) and model.id != ""
43
      assert is_atom(model.provider)
44
      assert is_atom(model.adapter)
45
      assert is_binary(model.provider_model) and model.provider_model != ""
46
      assert is_integer(model.context_window) and model.context_window > 0
47
      assert is_integer(model.max_output) and model.max_output > 0
48
    end
49
  end
50
51
  test "the public catalog projects each model without its adapter module" do
52
    catalog = Models.catalog()
53
54
    assert Enum.map(catalog, & &1["id"]) == Models.ids()
55
56
    for entry <- catalog do
57
      assert Enum.sort(Map.keys(entry)) ==
58
               ~w(availability context_window default id max_output provider)
59
60
      assert entry["availability"] in ["available", "unavailable"]
61
      refute entry["provider"] =~ "Elixir."
62
    end
63
64
    assert Enum.count(catalog, & &1["default"]) == 1
65
    assert Enum.find(catalog, & &1["default"])["id"] == Models.default_id()
66
  end
67
68
  # The test adapters export `configured?/0` returning true, so in this
69
  # environment every lane is available; PROVIDER-002's unavailable branch is
70
  # driven by the controller tests, which swap in an adapter that reports
71
  # false.
72
  test "a lane whose adapter reports a configured credential is available" do
73
    for model <- Models.all() do
74
      assert Models.available?(model)
75
    end
76
77
    assert Models.available_ids() == Models.ids()
78
  end
38 79
end
test/openagents/providers/persona_boundary_test.exs modified +1

@@ -67,6 +67,7 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

67 67
    OpenAgents.Providers.OpenRouter => :outbound_http,
68 68
    OpenAgents.Providers.RecordingTestProvider => :in_process,
69 69
    OpenAgents.Providers.Test => :in_process,
70
    OpenAgents.Providers.UnconfiguredTestProvider => :in_process,
70 71
    OpenAgents.Voice.OpenAI.CallClient => :outbound_http,
71 72
    OpenAgents.Voice.OpenAI.Sideband => :outbound_socket,
72 73
    OpenAgents.Voice.TestCallProvider => :in_process,
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +78 -5

@@ -57,7 +57,7 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

57 57
58 58
    conn =
59 59
      post_chat(conn, token, %{
60
        "model" => "ignored-by-proxy",
60
        "model" => OpenAgents.Inference.Models.default_id(),
61 61
        "messages" => [
62 62
          %{"role" => "system", "content" => "You are helpful."},
63 63
          %{"role" => "user", "content" => "hello there"}

@@ -68,9 +68,18 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

68 68
    assert conn.status == 200
69 69
    assert get_resp_header(conn, "content-type") |> hd() =~ "text/event-stream"
70 70
71
    # The effective model is attributed on the response itself (PROVIDER-002):
72
    # the header and every chunk name the model that answered.
73
    assert get_resp_header(conn, "x-openagents-model") ==
74
             [OpenAgents.Inference.Models.default_id()]
75
71 76
    events = sse_events(conn.resp_body)
72 77
    assert List.last(events) == "[DONE]"
73 78
79
    for chunk <- events, chunk != "[DONE]" do
80
      assert Jason.decode!(chunk)["model"] == OpenAgents.Inference.Models.default_id()
81
    end
82
74 83
    # The Test provider's default path streams "I hear you. You said: <prompt>".
75 84
    text =
76 85
      events

@@ -175,19 +184,83 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

175 184
           )
176 185
  end
177 186
178
  test "the model is pinned by the grant, not the request body", %{conn: conn} do
179
    %{grant: grant, token: token} = grant("model-pin")
187
  test "a body naming a model outside the catalog is refused, naming the served set",
188
       %{conn: conn} do
189
    %{grant: grant, token: token} = grant("model-not-served")
180 190
181
    _conn =
191
    conn =
182 192
      post_chat(conn, token, %{
183 193
        "model" => "attacker/gpt-9-ultra",
184 194
        "messages" => [%{"role" => "user", "content" => "hello"}]
185 195
      })
186 196
187
    # The grant's model_id is Sarah's configured model, never the body's.
197
    # Never a 202-and-answer-from-another-model (#160): the refusal is typed,
198
    # echoes what was asked, and names what is served.
199
    assert conn.status == 422
200
    error = Jason.decode!(conn.resp_body)["error"]
201
    assert error["code"] == "model_not_served"
202
    assert error["requested"] == "attacker/gpt-9-ultra"
203
    assert error["served"] == OpenAgents.Inference.Models.ids()
204
205
    # The grant's model_id stays Sarah's configured model, never the body's.
188 206
    assert grant.model_id == Application.fetch_env!(:openagents, :openai_model)
189 207
  end
190 208
209
  test "a body naming a served model other than the grant's is refused, never substituted",
210
       %{conn: conn} do
211
    %{token: token} = grant("model-mismatch")
212
213
    conn =
214
      post_chat(conn, token, %{
215
        "model" => "ox-alpha",
216
        "messages" => [%{"role" => "user", "content" => "hello"}]
217
      })
218
219
    assert conn.status == 422
220
    error = Jason.decode!(conn.resp_body)["error"]
221
    assert error["code"] == "model_mismatch"
222
    assert error["requested"] == "ox-alpha"
223
    assert error["granted"] == OpenAgents.Inference.Models.default_id()
224
    assert error["served"] == OpenAgents.Inference.Models.ids()
225
  end
226
227
  test "the vendor spelling of the grant's model is the same name, not a mismatch",
228
       %{conn: conn} do
229
    %{token: token} = grant("model-vendor-spelling", model_id: "ox-alpha")
230
231
    conn =
232
      post_chat(conn, token, %{
233
        "model" => OpenAgents.Chat.OpenRouter.default_model(),
234
        "messages" => [%{"role" => "user", "content" => "hi"}]
235
      })
236
237
    assert conn.status == 200
238
    assert get_resp_header(conn, "x-openagents-model") == ["ox-alpha"]
239
  end
240
241
  test "a grant on a lane without a credential is refused before any provider call",
242
       %{conn: conn} do
243
    # Minted while the lane was configured; the credential goes away under a
244
    # live grant. The UnconfiguredTestProvider raises from `stream/2`, so a
245
    # 503 here also proves no provider was called.
246
    %{token: token} = grant("model-lane-unavailable", model_id: "ox-alpha")
247
248
    previous = Application.get_env(:openagents, :openrouter_provider)
249
250
    Application.put_env(
251
      :openagents,
252
      :openrouter_provider,
253
      OpenAgents.Providers.UnconfiguredTestProvider
254
    )
255
256
    on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
257
258
    conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
259
260
    assert conn.status == 503
261
    assert Jason.decode!(conn.resp_body)["error"]["code"] == "model_unavailable"
262
  end
263
191 264
  test "missing bearer is rejected", %{conn: conn} do
192 265
    conn =
193 266
      conn
test/openagents_web/controllers/model_catalog_controller_test.exs added +76

@@ -0,0 +1,76 @@

1
defmodule OpenAgentsWeb.ModelCatalogControllerTest do
2
  @moduledoc """
3
  `GET /api/v3/models`: the typed catalog a client selects from (PROVIDER-002).
4
5
  The catalog is what makes model selection honest: the CLI renders this list,
6
  the thread admission refuses against the same list, and a lane whose
7
  credential is not configured is listed as unavailable rather than omitted or
8
  silently substituted.
9
  """
10
  use OpenAgentsWeb.ConnCase, async: false
11
12
  alias OpenAgents.Inference.Models
13
14
  test "the catalog lists every served model in its typed shape", %{conn: conn} do
15
    body =
16
      conn
17
      |> put_chat_api_token("model-catalog")
18
      |> get(~p"/api/v3/models")
19
      |> json_response(200)
20
21
    assert body["default"] == Models.default_id()
22
    assert Enum.map(body["models"], & &1["id"]) == Models.ids()
23
24
    for entry <- body["models"] do
25
      assert Enum.sort(Map.keys(entry)) ==
26
               ~w(availability context_window default id max_output provider)
27
28
      assert is_binary(entry["provider"]) and entry["provider"] != ""
29
      assert is_integer(entry["context_window"]) and entry["context_window"] > 0
30
      assert is_integer(entry["max_output"]) and entry["max_output"] > 0
31
      # Every test lane's adapter reports a configured credential.
32
      assert entry["availability"] == "available"
33
    end
34
35
    assert [default_entry] = Enum.filter(body["models"], & &1["default"])
36
    assert default_entry["id"] == body["default"]
37
  end
38
39
  test "a lane without a configured credential is listed unavailable, not omitted", %{conn: conn} do
40
    previous = Application.get_env(:openagents, :openrouter_provider)
41
42
    Application.put_env(
43
      :openagents,
44
      :openrouter_provider,
45
      OpenAgents.Providers.UnconfiguredTestProvider
46
    )
47
48
    on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
49
50
    body =
51
      conn
52
      |> put_chat_api_token("model-catalog-unavailable")
53
      |> get(~p"/api/v3/models")
54
      |> json_response(200)
55
56
    # The model stays in the list — "served here, not currently configured"
57
    # is different information from "not served here" — and only its
58
    # availability changes.
59
    assert Enum.map(body["models"], & &1["id"]) == Models.ids()
60
61
    ox_alpha = Enum.find(body["models"], &(&1["id"] == "ox-alpha"))
62
    assert ox_alpha["availability"] == "unavailable"
63
64
    default_entry = Enum.find(body["models"], &(&1["id"] == body["default"]))
65
    assert default_entry["availability"] == "available"
66
  end
67
68
  test "the catalog requires the same bearer as threads", %{conn: conn} do
69
    body =
70
      conn
71
      |> get(~p"/api/v3/models")
72
      |> json_response(401)
73
74
    assert body["code"] == "unauthenticated"
75
  end
76
end
test/openagents_web/controllers/thread_controller_test.exs modified +28 -1

@@ -99,6 +99,33 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

99 99
      assert body["grant"]["model"] == OpenAgents.Inference.Models.default_id()
100 100
    end
101 101
102
    test "a catalog model whose provider is not configured is refused, never substituted",
103
         %{conn: conn} do
104
      previous = Application.get_env(:openagents, :openrouter_provider)
105
106
      Application.put_env(
107
        :openagents,
108
        :openrouter_provider,
109
        OpenAgents.Providers.UnconfiguredTestProvider
110
      )
111
112
      on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
113
114
      body =
115
        conn
116
        |> put_chat_api_token("thread-unavailable-model")
117
        |> post(~p"/api/v3/threads", %{
118
          "objective" => "Ask for the unconfigured lane.",
119
          "model" => "ox-alpha"
120
        })
121
        |> json_response(503)
122
123
      assert body["code"] == "model_unavailable"
124
      assert Map.has_key?(body["errors"], "model")
125
      # The refusal names what is currently available.
126
      assert body["message"] =~ OpenAgents.Inference.Models.default_id()
127
    end
128
102 129
    test "a model the proxy cannot route is refused, naming the field", %{conn: conn} do
103 130
      body =
104 131
        conn

@@ -434,7 +461,7 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

434 461
        |> post(
435 462
          ~p"/api/inference/proxy",
436 463
          Jason.encode!(%{
437
            "model" => "ignored-by-proxy",
464
            "model" => created["grant"]["model"],
438 465
            "messages" => [%{"role" => "user", "content" => "hello there"}],
439 466
            "stream" => true
440 467
          })
test/support/providers/test.ex modified +4

@@ -11,6 +11,10 @@ defmodule OpenAgents.Providers.Test do

11 11
  @impl true
12 12
  def capabilities, do: [:text, :tool_calls, :usage]
13 13
14
  # The test lane needs no credential, so it is always configured.
15
  @impl true
16
  def configured?, do: true
17
14 18
  @impl true
15 19
  def stream(%Request{} = request, on_event) do
16 20
    if request.previous_response_id do
test/support/providers/unconfigured_test_provider.ex added +26

@@ -0,0 +1,26 @@

1
defmodule OpenAgents.Providers.UnconfiguredTestProvider do
2
  @moduledoc """
3
  A provider whose credential is not configured, for PROVIDER-002 tests.
4
5
  Swapped into a lane's configuration to drive the unavailable branch: the
6
  catalog must list the lane's models as `unavailable`, and admission and the
7
  proxy must refuse them with `model_unavailable` before any call is made —
8
  which is why `stream/2` raises rather than answers.
9
  """
10
11
  @behaviour OpenAgents.Providers.Provider
12
13
  @impl true
14
  def id, do: "test.unconfigured_provider"
15
16
  @impl true
17
  def capabilities, do: [:text]
18
19
  @impl true
20
  def configured?, do: false
21
22
  @impl true
23
  def stream(_request, _on_event) do
24
    raise "an unavailable model must be refused before its provider is called"
25
  end
26
end

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