Give a signed-in account credit, a chat scope, and a second model

26a7e605e9c3 · Devin AI · · parent 2858e1051f77

Give a signed-in account credit, a chat scope, and a second model

Three refusals a reader met in one sitting, which are one problem: the
authority a login hands out did not match the authority the product
promises.

Signing in granted no chat. `openagents auth login` minted
`["forge:write"]`, so the token it stored was refused at
`POST /api/v3/threads` with an instruction to sign in again naming a
scope the client never printed. The default is now
`ApiTokens.default_scopes/0` — `["chat:account", "forge:write"]` — in the
schema, the controller, and the service, so the three cannot drift, and
an explicit `--scope` still narrows it.

Credit was per thread and the same for everyone. A grant carried a small
cost ceiling and nothing added those ceilings up: an anonymous browser
could open thread after thread, each with a fresh allowance, and signing
in bought nothing. `OpenAgents.Inference.Credit` makes the money the
account's — `account_credit_microusd` for an account with a user behind
it, `visitor_credit_microusd` for a browser — read as the allowance minus
what every grant the account has ever held has metered, so a revoked or
expired grant still counts. A thread is minted for the remainder, so one
thread may spend the whole balance and an exhausted account is refused
`credit_exhausted` (402) naming its allowance rather than handed a
ceiling it cannot spend. `GET /api/v3` publishes both allowances instead
of a per-thread cost cap, because that number no longer describes
anything a caller is given.

A thread could only run the default model. `OpenAgents.Inference.Models`
is now the one place that maps a public model id to a provider module and
that provider's own spelling, so `ox-alpha` admits and routes to
`OpenAgents.Providers.OpenRouter` as `stealth/ox-alpha` while the default
stays on OpenAI. `POST /api/v3/threads` takes `model`, refuses anything
outside the enum with a field-level 422, and the proxy resolves the
provider from the grant rather than from the request body — so a client
picks a model by opening a thread, never by naming one in a call it
makes. That is what lets a coder session run its children on another
model than the conversation it delegates from.

Amends THREAD-001 in INVARIANTS.md: a thread's budget is still its own,
but its money is the account's.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.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 284 · 2026-08-24T06:32:48.411112Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/test.exs
  • modified lib/openagents/api_tokens.ex
  • modified lib/openagents/device_authorizations.ex
  • modified lib/openagents/device_authorizations/device_authorization.ex
  • modified lib/openagents/inference.ex
  • added lib/openagents/inference/credit.ex
  • added lib/openagents/inference/models.ex
  • added lib/openagents/providers/open_router.ex
  • added lib/openagents/providers/open_router/stream_decoder.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/device_authorization_controller.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • added test/openagents/inference/credit_test.exs
  • added test/openagents/inference/models_test.exs
  • modified test/openagents/inference_test.exs
  • added test/openagents/providers/open_router/request_payload_test.exs
  • added test/openagents/providers/open_router/stream_decoder_test.exs
  • added test/openagents/providers/open_router_test.exs
  • modified test/openagents/providers/persona_boundary_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents_web/controllers/api_extension_governance_test.exs
  • modified test/openagents_web/controllers/device_authorization_controller_test.exs
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • added test/support/providers/recording_test_provider.ex

Diff

31 files changed, +1527 -60

INVARIANTS.md modified +23 -7

@@ -1173,8 +1173,18 @@ module's import table and fails when one gains that dependency, which is what

1173 1173
the earlier adapter-behavior tests could not do: they exercised the adapter
1174 1174
rather than the code that must not know about it.
1175 1175
1176
A grant names one model and the proxy routes it. `OpenAgents.Inference.Models`
1177
is the list of models a grant may name, and it keeps the id a client asks for
1178
(`ox-alpha`) apart from the string the provider is called with
1179
(`stealth/ox-alpha`), so a routed vendor string can change without invalidating
1180
grants that already name the model. The adapter for each model is read from
1181
configuration, so no caller holds a compile-time dependency on one.
1182
1176 1183
Evidence: `OpenAgents.Providers.ProviderEvent`, `OpenAgents.Providers.OpenAI`,
1177
`OpenAgents.Providers.OpenAI.StreamDecoderTest`, `OpenAgents.Providers.Test`,
1184
`OpenAgents.Providers.OpenAI.StreamDecoderTest`,
1185
`OpenAgents.Providers.OpenRouter`,
1186
`OpenAgents.Providers.OpenRouter.StreamDecoderTest`,
1187
`OpenAgents.Inference.ModelsTest`, `OpenAgents.Providers.Test`,
1178 1188
`OpenAgents.TurnProviderEventsTest`, and `OpenAgents.DependencyBoundaryTest`.
1179 1189
1180 1190
## Tool authority and execution

@@ -1971,12 +1981,18 @@ conversation, and a thread is not one.

1971 1981
  caller leaves nothing behind. Because a thread has at most one live grant,
1972 1982
  capping open threads caps the account's concurrent thread-scoped authority by
1973 1983
  the same number.
1974
- **A thread's budget is its own.** `OpenAgents.Threads.ceilings/0` reads the
1975
  `thread_grant_*` settings and passes them to `OpenAgents.Inference.mint/1`,
1976
  which otherwise applies the delegation ceilings. A delegation is one probe
1977
  run the server admitted before minting anything; a thread is authority a
1978
  caller asked for. The two budgets are stated separately, and neither moves
1979
  the other.
1984
- **A thread's budget is its own, and its money is the account's.**
1985
  `OpenAgents.Threads.ceilings/0` reads the `thread_grant_*` settings and
1986
  passes them to `OpenAgents.Inference.mint/1`, which otherwise applies the
1987
  delegation ceilings. A delegation is one probe run the server admitted before
1988
  minting anything; a thread is authority a caller asked for. The two budgets
1989
  are stated separately, and neither moves the other. The cost figure is the
1990
  exception: `OpenAgents.Threads.ceilings/1` lowers it to what
1991
  `OpenAgents.Inference.Credit.remaining/1` says the account has left, so
1992
  opening a second thread hands out no second allowance and an account with
1993
  nothing left is refused `:credit_exhausted` rather than minted a grant it
1994
  cannot spend. `GET /api/v3` publishes both allowances, because a client that
1995
  read a fixed per-thread cost cap would be reading a budget nobody is given.
1980 1996
- **Expiry revokes without being asked.** `OpenAgents.Threads.reap_expired/1`
1981 1997
  runs at admission and on every read of a thread: an active grant past
1982 1998
  `expires_at` becomes `expired`, and an open thread that has minted authority
config/config.exs modified +7

@@ -98,6 +98,7 @@ config :openagents,

98 98
  voice_operational_retention_days: 90,
99 99
  voice_compaction_input_token_threshold: 16_000,
100 100
  provider: OpenAgents.Providers.OpenAI,
101
  openrouter_provider: OpenAgents.Providers.OpenRouter,
101 102
  openai_model: "gpt-5.6-luna",
102 103
  openai_api_key: nil,
103 104
  openrouter_api_key: nil,

@@ -273,6 +274,12 @@ config :openagents,

273 274
  thread_grant_max_calls: 256,
274 275
  thread_grant_max_cost_microusd: 2_000_000,
275 276
  thread_grant_ttl_seconds: 3_600,
277
  # The inference credit an account draws its threads against. Signing in is
278
  # what raises it: a visitor holding only a browser key gets the same figure a
279
  # single thread used to get, and an account with a user behind it gets $100 to
280
  # spend across every thread it opens.
281
  account_credit_microusd: 100_000_000,
282
  visitor_credit_microusd: 2_000_000,
276 283
  inference_input_price_microusd_per_ktoken: 1_250,
277 284
  inference_output_price_microusd_per_ktoken: 10_000,
278 285
  forge_enabled: false,
config/test.exs modified +1

@@ -45,6 +45,7 @@ config :openagents, :github_token_decryption_keys, %{}

45 45
46 46
# Test fakes for providers and voice sideband so the suite never reaches the network.
47 47
config :openagents, :provider, OpenAgents.Providers.Test
48
config :openagents, :openrouter_provider, OpenAgents.Providers.Test
48 49
config :openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider
49 50
config :openagents, :voice_sideband_provider, OpenAgents.Voice.TestSidebandProvider
50 51
lib/openagents/api_tokens.ex modified +11

@@ -27,6 +27,13 @@ defmodule OpenAgents.ApiTokens do

27 27
    "computer:control"
28 28
  ]
29 29
  @privileged_scopes ["deployments:promote"]
30
  # What signing in gets you. Both scopes are what a person who signs in from
31
  # the CLI came for: `forge:write` to push, `chat:account` to talk to a model
32
  # and to open a coder thread. Leaving chat out made a plain `openagents auth
33
  # login` produce a credential that could not open a thread, and the refusal
34
  # arrived one command later, which reads as the product being broken rather
35
  # than as a scope not asked for.
36
  @default_scopes ["chat:account", "forge:write"]
30 37
  @default_lifetime_days 30
31 38
  @maximum_lifetime_days 90
32 39
  @privileged_maximum_lifetime_days 7

@@ -35,6 +42,10 @@ defmodule OpenAgents.ApiTokens do

35 42
  @spec allowed_scopes() :: [String.t()]
36 43
  def allowed_scopes, do: @allowed_scopes
37 44
45
  @doc "The scopes a credential carries when its requester names none."
46
  @spec default_scopes() :: [String.t()]
47
  def default_scopes, do: @default_scopes
48
38 49
  @doc """
39 50
  Scopes that only a current operator may be issued.
40 51
lib/openagents/device_authorizations.ex modified +1 -1

@@ -22,7 +22,7 @@ defmodule OpenAgents.DeviceAuthorizations do

22 22
  @interval_seconds 5
23 23
  @maximum_create_attempts 3
24 24
25
  def create(scopes \\ ["forge:write"])
25
  def create(scopes \\ ApiTokens.default_scopes())
26 26
27 27
  def create(scopes) when is_list(scopes), do: create(scopes, @maximum_create_attempts)
28 28
lib/openagents/device_authorizations/device_authorization.ex modified +2 -2

@@ -12,7 +12,7 @@ defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do

12 12
    field :device_code_digest, :binary
13 13
    field :user_code_digest, :binary
14 14
    field :state, :string, default: "pending"
15
    field :scopes, {:array, :string}, default: ["forge:write"]
15
    field :scopes, {:array, :string}, default: ["chat:account", "forge:write"]
16 16
    field :interval_seconds, :integer, default: 5
17 17
    field :poll_count, :integer, default: 0
18 18
    field :last_polled_at, :utc_datetime_usec

@@ -66,7 +66,7 @@ defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do

66 66
          else: add_error(changeset, :scopes, "is not an allowed scope set")
67 67
68 68
      _empty ->
69
        put_change(changeset, :scopes, ["forge:write"])
69
        put_change(changeset, :scopes, OpenAgents.ApiTokens.default_scopes())
70 70
    end
71 71
  end
72 72
end
lib/openagents/inference.ex modified +39 -3

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

16 16
  """
17 17
18 18
  import Ecto.Query
19
  alias OpenAgents.Inference.Grant
19
  alias OpenAgents.Inference.{Grant, Models}
20 20
  alias OpenAgents.Machines.Machine
21 21
  alias OpenAgents.Repo
22 22

@@ -34,6 +34,7 @@ defmodule OpenAgents.Inference do

34 34
          optional(:conversation_id) => String.t() | nil,
35 35
          optional(:thread_id) => String.t() | nil,
36 36
          optional(:machine_id) => String.t() | nil,
37
          optional(:model_id) => String.t() | nil,
37 38
          optional(:ceilings) => ceilings()
38 39
        }
39 40

@@ -52,6 +53,10 @@ defmodule OpenAgents.Inference do

52 53
  lives as long as someone is working — so `OpenAgents.Threads` passes
53 54
  `OpenAgents.Threads.ceilings/0` rather than borrowing these numbers.
54 55
56
  The input may name its own `:model_id`. Without one a grant takes
57
  `OpenAgents.Inference.Models.default_id/0`, and a name the proxy cannot route
58
  is refused here rather than at the first call.
59
55 60
  A grant that names a computer is minted only while that computer is active,
56 61
  and only inside the transaction that established it (IDENTITY-008). A revoked
57 62
  computer answers `{:error, :machine_revoked}`.

@@ -59,6 +64,13 @@ defmodule OpenAgents.Inference do

59 64
  @spec mint(mint_input()) ::
60 65
          {:ok, Grant.t(), String.t()} | {:error, Ecto.Changeset.t() | :machine_revoked}
61 66
  def mint(%{} = input) do
67
    case model_id(Map.get(input, :model_id)) do
68
      {:ok, model_id} -> mint(input, model_id)
69
      :error -> {:error, unadmitted_model(Map.get(input, :model_id))}
70
    end
71
  end
72
73
  defp mint(%{} = input, model_id) do
62 74
    token = @token_prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
63 75
    ceilings = Map.get(input, :ceilings) || delegation_ceilings()
64 76

@@ -67,7 +79,7 @@ defmodule OpenAgents.Inference do

67 79
      conversation_id: Map.get(input, :conversation_id),
68 80
      thread_id: Map.get(input, :thread_id),
69 81
      machine_id: Map.get(input, :machine_id),
70
      model_id: model_id(),
82
      model_id: model_id,
71 83
      token_digest: digest(token),
72 84
      max_total_tokens: ceilings.max_total_tokens,
73 85
      max_calls: ceilings.max_calls,

@@ -404,7 +416,31 @@ defmodule OpenAgents.Inference do

404 416
405 417
  defp now, do: DateTime.utc_now()
406 418
407
  defp model_id, do: Application.fetch_env!(:openagents, :openai_model)
419
  # A grant may pin only a model the proxy can route, so an unadmitted name is
420
  # refused at the mint rather than at the first call: a token that cannot be
421
  # spent is worse than no token, because its holder learns that only after
422
  # believing it had authority.
423
  defp model_id(nil), do: {:ok, Models.default_id()}
424
425
  defp model_id(requested) do
426
    case Models.fetch(requested) do
427
      {:ok, model} -> {:ok, model.id}
428
      :error -> :error
429
    end
430
  end
431
432
  defp unadmitted_model(requested) do
433
    sentence =
434
      "#{inspect(requested)} is not a model this proxy routes. " <>
435
        "Admitted: #{Enum.join(Models.ids(), ", ")}."
436
437
    # Only the model is reported. Running the full mint changeset here would
438
    # answer a wrong model name with a list of every field the caller never
439
    # sent, burying the one thing it can fix.
440
    %Grant{}
441
    |> Ecto.Changeset.change()
442
    |> Ecto.Changeset.add_error(:model_id, sentence)
443
  end
408 444
409 445
  defp max_total_tokens,
410 446
    do: Application.get_env(:openagents, :inference_grant_max_total_tokens, 2_000_000)
lib/openagents/inference/credit.ex added +83

@@ -0,0 +1,83 @@

1
defmodule OpenAgents.Inference.Credit do
2
  @moduledoc """
3
  The inference money an account holds, and what it has spent.
4
5
  A grant carries a cost ceiling, so before this module a thread's budget was
6
  the same small figure every time and nothing added those figures up: an
7
  account could open thread after thread, each with its own ceiling, and no
8
  question anywhere was "how much has this account spent". That is the wrong
9
  shape for both readers of the product. A signed-in account was given no more
10
  than an anonymous one, and an anonymous one was given an unbounded number of
11
  bounded threads.
12
13
  So the credit is the account's, and a thread draws against it. Signing in is
14
  what buys the difference: `account_credit_microusd` for an account with a
15
  user behind it, `visitor_credit_microusd` for a browser that has not signed
16
  in. `remaining/1` is the allowance minus everything the account's grants have
17
  metered, and a thread is minted for exactly that, so one thread may spend the
18
  whole balance and the next is refused rather than handed a fresh ceiling.
19
20
  Spend is read from the grants themselves rather than kept in a second
21
  counter. `OpenAgents.Inference.record_usage/2` is the one writer of
22
  `usage`, the proxy calls it for every call it buys, and a revoked or expired
23
  grant keeps what it metered — so summing that column is the same number a
24
  ledger would hold, without a ledger that can disagree with it.
25
  """
26
27
  import Ecto.Query
28
29
  alias OpenAgents.Conversations.Visitor
30
  alias OpenAgents.Inference.Grant
31
  alias OpenAgents.Repo
32
33
  @doc """
34
  What this account may spend in total, in microUSD.
35
36
  Signing in raises it, which is the whole point: an account with a user behind
37
  it holds `account_credit_microusd`, and a visitor that has only a browser key
38
  holds `visitor_credit_microusd`.
39
  """
40
  @spec allowance(String.t()) :: non_neg_integer()
41
  def allowance(visitor_id) when is_binary(visitor_id) do
42
    if signed_in?(visitor_id), do: account_allowance(), else: visitor_allowance()
43
  end
44
45
  @doc "What a signed-in account may spend in total, in microUSD."
46
  @spec account_allowance() :: non_neg_integer()
47
  def account_allowance, do: setting(:account_credit_microusd, 100_000_000)
48
49
  @doc "What a browser that has not signed in may spend in total, in microUSD."
50
  @spec visitor_allowance() :: non_neg_integer()
51
  def visitor_allowance, do: setting(:visitor_credit_microusd, 2_000_000)
52
53
  @doc "What every grant this account has held has metered, in microUSD."
54
  @spec spent(String.t()) :: non_neg_integer()
55
  def spent(visitor_id) when is_binary(visitor_id) do
56
    Repo.one(
57
      from grant in Grant,
58
        where: grant.owner_visitor_id == ^visitor_id,
59
        select:
60
          type(
61
            coalesce(
62
              sum(
63
                fragment("COALESCE((? ->> 'estimated_cost_microusd')::bigint, 0)", grant.usage)
64
              ),
65
              0
66
            ),
67
            :integer
68
          )
69
    )
70
  end
71
72
  @doc "What is left of this account's credit, in microUSD. Never negative."
73
  @spec remaining(String.t()) :: non_neg_integer()
74
  def remaining(visitor_id) when is_binary(visitor_id) do
75
    max(allowance(visitor_id) - spent(visitor_id), 0)
76
  end
77
78
  defp signed_in?(visitor_id) do
79
    Repo.exists?(from v in Visitor, where: v.id == ^visitor_id and not is_nil(v.user_id))
80
  end
81
82
  defp setting(key, default), do: Application.get_env(:openagents, key, default)
83
end
lib/openagents/inference/models.ex added +76

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

1
defmodule OpenAgents.Inference.Models do
2
  @moduledoc """
3
  The models a grant may pin, and the provider that serves each.
4
5
  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.
10
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
13
  `provider_model` is what the provider is called with — `stealth/ox-alpha`.
14
  Keeping them apart is what lets the routed vendor string change without
15
  invalidating grants that already name the model.
16
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.
20
  """
21
22
  @ox_alpha "ox-alpha"
23
24
  @type t :: %{id: String.t(), provider: module(), provider_model: String.t()}
25
26
  @doc "Every model a grant may pin, in the order a client should offer them."
27
  @spec all() :: [t()]
28
  def all, do: [default(), ox_alpha()]
29
30
  @doc "The model ids a grant may pin."
31
  @spec ids() :: [String.t()]
32
  def ids, do: Enum.map(all(), & &1.id)
33
34
  @doc "The model a grant pins when its caller names none."
35
  @spec default() :: t()
36
  def default do
37
    model = Application.fetch_env!(:openagents, :openai_model)
38
39
    %{
40
      id: model,
41
      provider: Application.fetch_env!(:openagents, :provider),
42
      provider_model: model
43
    }
44
  end
45
46
  @doc "The id of the model a grant pins when its caller names none."
47
  @spec default_id() :: String.t()
48
  def default_id, do: default().id
49
50
  @doc """
51
  The model with this id, or `:error`.
52
53
  A thread opened before this list existed carries the vendor string
54
  `stealth/ox-alpha` in its `model` column, so that spelling resolves to the
55
  same model rather than leaving those threads unable to mint.
56
  """
57
  @spec fetch(String.t() | nil) :: {:ok, t()} | :error
58
  def fetch(id) when is_binary(id) do
59
    normalized = if id == ox_alpha().provider_model, do: @ox_alpha, else: id
60
61
    case Enum.find(all(), &(&1.id == normalized)) do
62
      nil -> :error
63
      model -> {:ok, model}
64
    end
65
  end
66
67
  def fetch(_id), do: :error
68
69
  defp ox_alpha do
70
    %{
71
      id: @ox_alpha,
72
      provider: Application.fetch_env!(:openagents, :openrouter_provider),
73
      provider_model: OpenAgents.Chat.OpenRouter.default_model()
74
    }
75
  end
76
end
lib/openagents/providers/open_router.ex added +175

@@ -0,0 +1,175 @@

1
defmodule OpenAgents.Providers.OpenRouter do
2
  @moduledoc """
3
  OpenRouter chat-completions adapter for the inference proxy.
4
5
  This is the second provider the proxy can reach, and it exists so a grant can
6
  pin Ox Alpha: `OpenAgents.Inference.Models` names which model each provider
7
  serves, and the proxy dispatches on the grant's model rather than on one
8
  compiled-in module.
9
10
  OpenRouter HTTP, SSE framing, and chat-completions shapes terminate here.
11
  Callers receive only `OpenAgents.Providers.ProviderEvent` values and
12
  normalized failure reasons, and the OpenRouter credential never leaves the
13
  server (RELEASE-002).
14
15
  The chat-completions surface is used rather than the Responses surface the
16
  `/chat` console prefers, because a proxy request arrives as OpenAI-style
17
  messages from a harness and chat completions is the shape that maps to it
18
  without a second translation.
19
  """
20
21
  @behaviour OpenAgents.Providers.Provider
22
23
  alias OpenAgents.Providers.OpenRouter.StreamDecoder
24
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
25
26
  @endpoint "https://openrouter.ai/api/v1/chat/completions"
27
28
  @impl true
29
  def id, do: "openrouter.chat_completions"
30
31
  @impl true
32
  def capabilities, do: [:text, :tool_calls, :usage]
33
34
  @impl true
35
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
36
    stream(request, on_event, [])
37
  end
38
39
  @doc false
40
  def stream(%Request{} = request, on_event, options)
41
      when is_function(on_event, 1) and is_list(options) do
42
    with {:ok, api_key} <- fetch_api_key(options),
43
         {:ok, response} <- request(api_key, request, options) do
44
      consume_response(response, on_event)
45
    end
46
  end
47
48
  defp fetch_api_key(options) do
49
    case Keyword.fetch(options, :api_key) do
50
      {:ok, key} when is_binary(key) and byte_size(key) > 0 ->
51
        {:ok, key}
52
53
      _not_supplied ->
54
        case OpenAgents.RuntimeConfig.fetch_secret(:openrouter_api_key) do
55
          {:ok, key} -> {:ok, key}
56
          {:error, :not_configured} -> {:error, :missing_api_key}
57
        end
58
    end
59
  end
60
61
  defp request(api_key, %Request{} = request, options) do
62
    request_options = Keyword.get(options, :request_options, [])
63
64
    base_options = [
65
      auth: {:bearer, api_key},
66
      headers: [{"accept", "text/event-stream"}],
67
      json: request_payload(request),
68
      into: :self,
69
      receive_timeout: 120_000,
70
      retry: false
71
    ]
72
73
    case Req.post(@endpoint, Keyword.merge(base_options, request_options)) do
74
      {:ok, response} ->
75
        {:ok, response}
76
77
      {:error, %Req.TransportError{reason: reason}} when is_atom(reason) ->
78
        {:error, {:transport, reason}}
79
80
      {:error, _error} ->
81
        {:error, {:transport, :request_failed}}
82
    end
83
  end
84
85
  @doc false
86
  def request_payload(%Request{} = request) do
87
    %{
88
      model: request.model_id,
89
      messages: messages(request),
90
      stream: true,
91
      stream_options: %{include_usage: true},
92
      max_tokens: 4_096
93
    }
94
    |> maybe_put_tools(request.tool_definitions)
95
  end
96
97
  # The proxy hands over the system text separately from the turns, and a tool
98
  # output arrives without the assistant call that asked for it, because the
99
  # calling harness flattens its own tool loop before it sends. So an output is
100
  # carried as a labelled user message: it keeps the result in the transcript
101
  # without claiming a call OpenRouter never saw.
102
  defp messages(%Request{} = request) do
103
    instructions =
104
      case String.trim(request.instructions || "") do
105
        "" -> []
106
        text -> [%{role: "system", content: text}]
107
      end
108
109
    turns = Enum.map(request.input, &%{role: role(&1.role), content: &1.content})
110
    instructions ++ turns ++ Enum.map(request.tool_outputs, &tool_output/1)
111
  end
112
113
  defp role(role) when role in ["system", "user", "assistant"], do: role
114
  defp role(_role), do: "user"
115
116
  defp tool_output(%ToolOutput{} = output) do
117
    %{
118
      role: "user",
119
      content: "Tool result for #{output.call_id}: #{Jason.encode!(output.output)}"
120
    }
121
  end
122
123
  defp maybe_put_tools(payload, []), do: payload
124
125
  defp maybe_put_tools(payload, definitions) do
126
    Map.put(payload, :tools, Enum.map(definitions, &tool_definition/1))
127
  end
128
129
  defp tool_definition(%ToolDefinition{} = definition) do
130
    %{
131
      type: "function",
132
      function: %{
133
        name: definition.name,
134
        description: definition.description,
135
        parameters: definition.input_schema
136
      }
137
    }
138
  end
139
140
  defp consume_response(%Req.Response{status: status, body: body}, on_event)
141
       when status in 200..299 do
142
    body
143
    |> Enum.reduce_while({:ok, StreamDecoder.new()}, fn chunk, {:ok, decoder} ->
144
      case StreamDecoder.feed(decoder, chunk) do
145
        {:ok, next_decoder, events} ->
146
          emit(events, on_event)
147
          {:cont, {:ok, next_decoder}}
148
149
        {:error, reason} ->
150
          {:halt, {:error, reason}}
151
      end
152
    end)
153
    |> finish(on_event)
154
  rescue
155
    _exception -> {:error, {:transport, :stream_failed}}
156
  end
157
158
  defp consume_response(%Req.Response{status: status}, _on_event),
159
    do: {:error, {:http_status, status}}
160
161
  defp finish({:ok, decoder}, on_event) do
162
    case StreamDecoder.finish(decoder) do
163
      {:ok, _decoder, events} ->
164
        emit(events, on_event)
165
        :ok
166
167
      {:error, reason} ->
168
        {:error, reason}
169
    end
170
  end
171
172
  defp finish({:error, reason}, _on_event), do: {:error, reason}
173
174
  defp emit(events, on_event), do: Enum.each(events, on_event)
175
end
lib/openagents/providers/open_router/stream_decoder.ex added +280

@@ -0,0 +1,280 @@

1
defmodule OpenAgents.Providers.OpenRouter.StreamDecoder do
2
  @moduledoc false
3
4
  alias OpenAgents.Providers.ProviderEvent.ToolCall
5
6
  @maximum_buffer_bytes 262_144
7
  @maximum_delta_bytes 65_536
8
  @maximum_arguments_bytes 65_536
9
  @identifier_regex ~r/\A[a-zA-Z0-9_.:\/-]+\z/
10
  @tool_name_regex ~r/\A[a-zA-Z0-9_-]+\z/
11
12
  defstruct buffer: "", response_id: nil, terminal?: false, failed?: false, calls: %{}
13
14
  @type t :: %__MODULE__{
15
          buffer: String.t(),
16
          response_id: String.t() | nil,
17
          terminal?: boolean(),
18
          failed?: boolean(),
19
          calls: %{optional(integer()) => map()}
20
        }
21
22
  @spec new() :: t()
23
  def new, do: %__MODULE__{}
24
25
  @spec feed(t(), binary()) ::
26
          {:ok, t(), [OpenAgents.Providers.ProviderEvent.t()]} | {:error, atom()}
27
  def feed(%__MODULE__{} = state, chunk) when is_binary(chunk) do
28
    buffer = String.replace(state.buffer <> chunk, "\r\n", "\n")
29
30
    if byte_size(buffer) > @maximum_buffer_bytes do
31
      {:error, :invalid_provider_event}
32
    else
33
      parts = String.split(buffer, "\n\n")
34
      {frames, [remainder]} = Enum.split(parts, -1)
35
      decode_frames(%{state | buffer: remainder}, frames)
36
    end
37
  end
38
39
  @doc """
40
  Close the stream.
41
42
  A chat-completions stream reports its end with a `finish_reason`, and the
43
  accumulated tool calls are only whole once that arrives, so they are emitted
44
  here rather than mid-stream. A stream that ends without one is truncated: the
45
  reply it carried is a fragment, and reporting it as complete would hand a
46
  caller half an answer as a whole one.
47
48
  A stream that already reported a failure is closed without a completion and
49
  without its part-built calls: the failure is the outcome, and a completion
50
  after it would say the reply arrived.
51
  """
52
  @spec finish(t()) :: {:ok, t(), [OpenAgents.Providers.ProviderEvent.t()]} | {:error, atom()}
53
  def finish(%__MODULE__{} = state) do
54
    with {:ok, state, events} <- decode_final_buffer(state, state.buffer) do
55
      cond do
56
        state.failed? -> {:ok, state, events}
57
        state.terminal? -> {:ok, state, events ++ completion_events(state)}
58
        true -> {:error, :truncated_stream}
59
      end
60
    end
61
  end
62
63
  defp completion_events(state) do
64
    tool_call_events(state) ++ [{:response_completed, id(state)}]
65
  end
66
67
  defp decode_final_buffer(state, buffer) do
68
    if String.trim(buffer) == "" do
69
      {:ok, %{state | buffer: ""}, []}
70
    else
71
      decode_frames(%{state | buffer: ""}, [buffer])
72
    end
73
  end
74
75
  defp decode_frames(state, frames) do
76
    Enum.reduce_while(frames, {:ok, state, []}, fn frame, {:ok, next_state, events} ->
77
      case decode_frame(next_state, frame) do
78
        {:ok, decoded_state, decoded_events} ->
79
          {:cont, {:ok, decoded_state, events ++ decoded_events}}
80
81
        {:error, reason} ->
82
          {:halt, {:error, reason}}
83
      end
84
    end)
85
  end
86
87
  defp decode_frame(state, frame) do
88
    data =
89
      frame
90
      |> String.split("\n")
91
      |> Enum.filter(&String.starts_with?(&1, "data:"))
92
      |> Enum.map_join("\n", fn line ->
93
        line |> String.replace_prefix("data:", "") |> String.trim_leading()
94
      end)
95
96
    case data do
97
      "" -> {:ok, state, []}
98
      "[DONE]" -> {:ok, %{state | terminal?: true}, []}
99
      json -> decode_json(state, Jason.decode(json))
100
    end
101
  end
102
103
  defp decode_json(_state, {:error, _error}), do: {:error, :invalid_provider_event}
104
105
  defp decode_json(state, {:ok, %{"error" => error}}) when is_map(error) do
106
    {:ok, %{state | terminal?: true, failed?: true},
107
     [{:failed, {:provider_failed, error_code(error)}}]}
108
  end
109
110
  defp decode_json(state, {:ok, %{} = chunk}) do
111
    with {:ok, state, start_events} <- start_response(state, chunk["id"]),
112
         {:ok, state, choice_events} <- choices(state, chunk["choices"]),
113
         {:ok, usage_events} <- usage(chunk["usage"]) do
114
      {:ok, state, start_events ++ choice_events ++ usage_events}
115
    end
116
  end
117
118
  defp decode_json(_state, {:ok, _invalid}), do: {:error, :invalid_provider_event}
119
120
  defp choices(state, nil), do: {:ok, state, []}
121
122
  defp choices(state, choices) when is_list(choices) do
123
    Enum.reduce_while(choices, {:ok, state, []}, fn choice, {:ok, next_state, events} ->
124
      case choice(next_state, choice) do
125
        {:ok, decoded_state, decoded_events} ->
126
          {:cont, {:ok, decoded_state, events ++ decoded_events}}
127
128
        {:error, reason} ->
129
          {:halt, {:error, reason}}
130
      end
131
    end)
132
  end
133
134
  defp choices(_state, _invalid), do: {:error, :invalid_provider_event}
135
136
  defp choice(state, %{} = choice) do
137
    delta = if is_map(choice["delta"]), do: choice["delta"], else: %{}
138
    state = if is_binary(choice["finish_reason"]), do: %{state | terminal?: true}, else: state
139
140
    with {:ok, text_events} <- text(delta["content"]),
141
         {:ok, state} <- tool_calls(state, delta["tool_calls"]) do
142
      {:ok, state, text_events}
143
    end
144
  end
145
146
  defp choice(_state, _invalid), do: {:error, :invalid_provider_event}
147
148
  defp text(nil), do: {:ok, []}
149
  defp text(""), do: {:ok, []}
150
151
  defp text(content) when is_binary(content) and byte_size(content) <= @maximum_delta_bytes,
152
    do: {:ok, [{:text_delta, content}]}
153
154
  defp text(_content), do: {:error, :invalid_provider_event}
155
156
  # A chat-completions tool call streams as fragments keyed by index: the name
157
  # arrives once and the arguments arrive as a string built up over chunks, so
158
  # the fragments are accumulated and emitted whole at the end of the stream.
159
  defp tool_calls(state, nil), do: {:ok, state}
160
161
  defp tool_calls(state, calls) when is_list(calls) do
162
    Enum.reduce_while(calls, {:ok, state}, fn call, {:ok, next_state} ->
163
      case tool_call(next_state, call) do
164
        {:ok, decoded} -> {:cont, {:ok, decoded}}
165
        {:error, reason} -> {:halt, {:error, reason}}
166
      end
167
    end)
168
  end
169
170
  defp tool_calls(_state, _invalid), do: {:error, :invalid_provider_event}
171
172
  defp tool_call(state, %{} = call) do
173
    index = if is_integer(call["index"]), do: call["index"], else: 0
174
    function = if is_map(call["function"]), do: call["function"], else: %{}
175
    held = Map.get(state.calls, index, %{id: nil, name: nil, arguments: ""})
176
177
    arguments =
178
      case function["arguments"] do
179
        fragment when is_binary(fragment) -> held.arguments <> fragment
180
        _absent -> held.arguments
181
      end
182
183
    if byte_size(arguments) > @maximum_arguments_bytes do
184
      {:error, :invalid_provider_event}
185
    else
186
      merged = %{
187
        id: text_or(call["id"], held.id),
188
        name: text_or(function["name"], held.name),
189
        arguments: arguments
190
      }
191
192
      {:ok, %{state | calls: Map.put(state.calls, index, merged)}}
193
    end
194
  end
195
196
  defp tool_call(_state, _invalid), do: {:error, :invalid_provider_event}
197
198
  defp text_or(value, _fallback) when is_binary(value) and value != "", do: value
199
  defp text_or(_value, fallback), do: fallback
200
201
  defp tool_call_events(%__MODULE__{calls: calls} = state) do
202
    calls
203
    |> Enum.sort_by(fn {index, _call} -> index end)
204
    |> Enum.flat_map(fn {index, call} -> tool_call_event(state, index, call) end)
205
  end
206
207
  defp tool_call_event(state, index, %{name: name} = call) when is_binary(name) do
208
    if Regex.match?(@tool_name_regex, name) and byte_size(name) <= 128 do
209
      call_id = call.id || "#{id(state)}-#{index}"
210
211
      [
212
        {:tool_call,
213
         %ToolCall{
214
           item_id: call_id,
215
           call_id: call_id,
216
           name: name,
217
           raw_arguments: if(call.arguments == "", do: "{}", else: call.arguments)
218
         }}
219
      ]
220
    else
221
      []
222
    end
223
  end
224
225
  defp tool_call_event(_state, _index, _call), do: []
226
227
  defp start_response(%__MODULE__{response_id: nil} = state, response_id) do
228
    if valid_identifier?(response_id) do
229
      {:ok, %{state | response_id: response_id}, [{:response_started, response_id}]}
230
    else
231
      {:ok, %{state | response_id: nil}, []}
232
    end
233
  end
234
235
  defp start_response(%__MODULE__{} = state, _response_id), do: {:ok, state, []}
236
237
  defp usage(nil), do: {:ok, []}
238
239
  defp usage(usage) when is_map(usage) do
240
    input = integer(usage["prompt_tokens"])
241
    output = integer(usage["completion_tokens"])
242
    total = integer(usage["total_tokens"])
243
244
    if is_nil(input) and is_nil(output) and is_nil(total) do
245
      {:ok, []}
246
    else
247
      normalized =
248
        %{
249
          "input_tokens" => input || 0,
250
          "output_tokens" => output || 0,
251
          "total_tokens" => total || (input || 0) + (output || 0)
252
        }
253
254
      {:ok, [{:usage, normalized}]}
255
    end
256
  end
257
258
  defp usage(_usage), do: {:error, :invalid_provider_event}
259
260
  defp integer(value) when is_integer(value) and value >= 0, do: value
261
  defp integer(_value), do: nil
262
263
  defp id(%__MODULE__{response_id: nil}), do: "openrouter-response"
264
  defp id(%__MODULE__{response_id: response_id}), do: response_id
265
266
  defp valid_identifier?(value) when is_binary(value) and byte_size(value) in 1..256,
267
    do: Regex.match?(@identifier_regex, value)
268
269
  defp valid_identifier?(_value), do: false
270
271
  defp error_code(error) do
272
    code = error["code"] || error["type"]
273
274
    cond do
275
      is_binary(code) and byte_size(code) <= 128 and Regex.match?(@tool_name_regex, code) -> code
276
      is_integer(code) -> Integer.to_string(code)
277
      true -> nil
278
    end
279
  end
280
end
lib/openagents/threads.ex modified +40 -8

@@ -58,7 +58,7 @@ defmodule OpenAgents.Threads do

58 58
  alias OpenAgents.Conversations
59 59
  alias OpenAgents.Conversations.Visitor
60 60
  alias OpenAgents.Inference
61
  alias OpenAgents.Inference.Grant
61
  alias OpenAgents.Inference.{Credit, Grant, Models}
62 62
  alias OpenAgents.Repo
63 63
  alias OpenAgents.Threads.Event
64 64
  alias OpenAgents.Threads.Thread

@@ -71,9 +71,11 @@ defmodule OpenAgents.Threads do

71 71
  Open a thread for an account.
72 72
73 73
  The owner visitor is resolved (and created if absent) without touching the
74
  account's conversation. The admitted execution shape defaults to the chat
75
  lane's configured model, `high` reasoning, and the `read_only` permission
76
  profile; a caller may narrow or widen only within the admitted vocabulary.
74
  account's conversation. The admitted execution shape defaults to
75
  `OpenAgents.Inference.Models.default_id/0`, `high` reasoning, and the
76
  `read_only` permission profile; a caller may narrow or widen only within the
77
  admitted vocabulary. The thread's model is the model its grant pins, so a
78
  caller that opens a thread on `ox-alpha` gets authority for `ox-alpha`.
77 79
78 80
  Admission is where the ceiling lives. Elapsed authority is reaped first, so a
79 81
  slot held by an abandoned thread is released before the count is taken, and

@@ -107,7 +109,8 @@ defmodule OpenAgents.Threads do

107 109
  """
108 110
  @spec open_and_mint(User.t() | Visitor.t(), String.t(), keyword()) ::
109 111
          {:ok, Thread.t(), Grant.t(), String.t()}
110
          | {:error, :thread_quota_reached | :thread_terminal | Ecto.Changeset.t()}
112
          | {:error,
113
             :thread_quota_reached | :thread_terminal | :credit_exhausted | Ecto.Changeset.t()}
111 114
  def open_and_mint(owner, objective, options \\ []) do
112 115
    with {:ok, thread} <- open(owner, objective, options) do
113 116
      case mint_grant(thread) do

@@ -134,7 +137,7 @@ defmodule OpenAgents.Threads do

134 137
135 138
    attributes = %{
136 139
      objective: objective,
137
      model: Keyword.get(options, :model) || OpenRouter.default_model(),
140
      model: Keyword.get(options, :model) || Models.default_id(),
138 141
      reasoning_effort:
139 142
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),
140 143
      permission_profile: Keyword.get(options, :permission_profile, @default_permission_profile)

@@ -234,6 +237,12 @@ defmodule OpenAgents.Threads do

234 237
  @doc """
235 238
  Mint model authority for a thread.
236 239
240
  The grant pins the thread's own model, so the model a caller was admitted to
241
  at `open/3` is the model every call on the thread reaches. A thread opened
242
  before models were admitted carries a vendor string rather than an admitted
243
  id; `OpenAgents.Inference.Models.fetch/1` resolves that spelling, and
244
  anything else it cannot route is refused rather than quietly replaced.
245
237 246
  This is the fence. In one transaction: the thread is locked and refused
238 247
  unless it is open, every active grant naming it is revoked, `generation` is
239 248
  bumped, and a fresh grant is minted against the thread — never against a

@@ -241,7 +250,7 @@ defmodule OpenAgents.Threads do

241 250
  """
242 251
  @spec mint_grant(Thread.t()) ::
243 252
          {:ok, Thread.t(), Grant.t(), String.t()}
244
          | {:error, :thread_terminal | Ecto.Changeset.t()}
253
          | {:error, :thread_terminal | :credit_exhausted | Ecto.Changeset.t()}
245 254
  def mint_grant(%Thread{} = thread) do
246 255
    Repo.transaction(fn ->
247 256
      case locked(thread.id) do

@@ -249,12 +258,14 @@ defmodule OpenAgents.Threads do

249 258
          _revoked = Inference.revoke_active_for_thread(current.id)
250 259
251 260
          with {:ok, fenced} <- current |> Thread.generation_changeset() |> Repo.update(),
261
               {:ok, ceilings} <- ceilings(fenced.owner_visitor_id),
252 262
               {:ok, grant, token} <-
253 263
                 Inference.mint(%{
254 264
                   owner_visitor_id: fenced.owner_visitor_id,
255 265
                   thread_id: fenced.id,
256 266
                   machine_id: nil,
257
                   ceilings: ceilings()
267
                   model_id: fenced.model,
268
                   ceilings: ceilings
258 269
                 }) do
259 270
            {fenced, grant, token}
260 271
          else

@@ -312,6 +323,10 @@ defmodule OpenAgents.Threads do

312 323
  ceilings in `OpenAgents.Inference`, because a thread's budget is not a
313 324
  delegation's budget. `GET /api/v3` publishes this map, so a client reads the
314 325
  budget it was given rather than discovering it by exhausting it.
326
327
  The cost figure here is the configured per-thread cap. What a particular
328
  thread is minted for is `ceilings/1`, which is this map with the cost lowered
329
  to what the account's credit has left.
315 330
  """
316 331
  @spec ceilings() :: Inference.ceilings()
317 332
  def ceilings do

@@ -323,6 +338,23 @@ defmodule OpenAgents.Threads do

323 338
    }
324 339
  end
325 340
341
  @doc """
342
  The ceilings this account's next thread is minted with.
343
344
  A thread spends the account's credit rather than a fresh allowance of its
345
  own, so the cost ceiling is what `OpenAgents.Inference.Credit.remaining/1`
346
  says is left — a signed-in account's whole balance is available to one thread
347
  if that is what the work needs. An account with nothing left is refused
348
  `:credit_exhausted` instead of being minted a grant it cannot spend.
349
  """
350
  @spec ceilings(String.t()) :: {:ok, Inference.ceilings()} | {:error, :credit_exhausted}
351
  def ceilings(visitor_id) when is_binary(visitor_id) do
352
    case Credit.remaining(visitor_id) do
353
      0 -> {:error, :credit_exhausted}
354
      remaining -> {:ok, %{ceilings() | max_cost_microusd: remaining}}
355
    end
356
  end
357
326 358
  @doc "How many threads one account may hold open at once."
327 359
  @spec maximum_open_per_account() :: pos_integer()
328 360
  def maximum_open_per_account, do: setting(:maximum_open_threads_per_account, 8)
lib/openagents_web/api_error.ex modified +4

@@ -59,6 +59,10 @@ 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
    # Spending the account's inference credit is not a rate limit: no amount of
63
    # waiting or revoking makes the same call succeed, so it is the payment
64
    # status and its own code.
65
    "credit_exhausted" => {402, "This account has spent its inference credit"},
62 66
    # Push receipts are read from the WAL, not from PostgreSQL, so a storage
63 67
    # that will not answer is a temporary unreadability rather than an absence.
64 68
    # Reporting it as `not_found` would tell a pusher their push is not on
lib/openagents_web/controllers/api_extension_controller.ex modified +35 -2

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

38 38
39 39
  use OpenAgentsWeb, :controller
40 40
41
  alias OpenAgents.Inference.Credit
41 42
  alias OpenAgentsWeb.ApiError
42 43
  alias OpenAgentsWeb.ApiRouteAuthority
43 44
  alias OpenAgentsWeb.ContributionContract

@@ -434,6 +435,17 @@ defmodule OpenAgentsWeb.ApiExtensionController do

434 435
            "What this body of work is for. Required, non-blank, and capped " <>
435 436
              "at 32 KB."
436 437
        },
438
        "model" => %{
439
          "endpoint" => "POST /api/v3/threads",
440
          "type" => "string",
441
          "enum" => OpenAgents.Inference.Models.ids(),
442
          "default" => OpenAgents.Inference.Models.default_id(),
443
          "description" =>
444
            "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."
448
        },
437 449
        "reasoning" => %{
438 450
          "endpoint" => "POST /api/v3/threads",
439 451
          "type" => "string",

@@ -458,12 +470,17 @@ defmodule OpenAgentsWeb.ApiExtensionController do

458 470
      "limits" => %{
459 471
        "maximum_open_threads_per_account" => OpenAgents.Threads.maximum_open_per_account(),
460 472
        "grant" => thread_grant_ceilings(),
473
        "credit" => credit_allowances(),
461 474
        "description" =>
462 475
          "Admission is capped: an account already holding " <>
463 476
            "`maximum_open_threads_per_account` open threads is refused with " <>
464 477
            "`thread_quota_reached` until it revokes one. The grant ceilings " <>
465 478
            "are the thread's own and are not the delegation ceilings a probe " <>
466
            "run is minted with. Authority that passes `expires_at` stops " <>
479
            "run is minted with. The cost ceiling is not among them: a " <>
480
            "thread's grant is minted for what the credit under `credit` has " <>
481
            "left, every thread of one account draws against that same " <>
482
            "balance, and an account with nothing left is refused " <>
483
            "`credit_exhausted`. Authority that passes `expires_at` stops " <>
467 484
            "being live and stops holding a slot, with or without a request."
468 485
      },
469 486
      "grant" => %{

@@ -489,11 +506,27 @@ defmodule OpenAgentsWeb.ApiExtensionController do

489 506
    %{
490 507
      "max_total_tokens" => ceilings.max_total_tokens,
491 508
      "max_calls" => ceilings.max_calls,
492
      "max_cost_microusd" => ceilings.max_cost_microusd,
493 509
      "ttl_seconds" => ceilings.ttl_seconds
494 510
    }
495 511
  end
496 512
513
  # The two allowances a caller can be minted against. A thread's
514
  # `max_cost_microusd` is whichever of these applies minus what the account
515
  # has already spent, so publishing the allowance describes the balance while
516
  # publishing a per-thread number would describe nothing.
517
  defp credit_allowances do
518
    %{
519
      "account_microusd" => Credit.account_allowance(),
520
      "visitor_microusd" => Credit.visitor_allowance(),
521
      "description" =>
522
        "A signed-in account draws against `account_microusd` and an " <>
523
          "anonymous visitor against `visitor_microusd`, for the life of the " <>
524
          "account rather than per thread. A thread's grant is minted for the " <>
525
          "remainder, so `grant.max_cost_microusd` in the mint response is " <>
526
          "what is left rather than a fixed cap."
527
    }
528
  end
529
497 530
  # One description of one envelope. A client reads the codes it must branch on
498 531
  # here rather than collecting them from whatever refusals it happened to hit.
499 532
  defp errors_contract do
lib/openagents_web/controllers/device_authorization_controller.ex modified +1 -1

@@ -42,7 +42,7 @@ defmodule OpenAgentsWeb.DeviceAuthorizationController do

42 42
    case params["scope"] || params["scopes"] do
43 43
      scope when is_binary(scope) -> String.split(scope, " ", trim: true)
44 44
      scopes when is_list(scopes) -> Enum.filter(scopes, &(&1 in ApiTokens.allowed_scopes()))
45
      _absent -> ["forge:write"]
45
      _absent -> ApiTokens.default_scopes()
46 46
    end
47 47
  end
48 48
lib/openagents_web/controllers/inference_proxy_controller.ex modified +23 -9

@@ -4,8 +4,9 @@ defmodule OpenAgentsWeb.InferenceProxyController do

4 4
  a delegated probe calls with its delegation-scoped grant as the bearer.
5 5
6 6
  It authenticates the grant (never a provider credential), translates the
7
  request into a provider-neutral `OpenAgents.Providers.Request`, fans it into the
8
  configured `OpenAgents.Providers.Provider` (PROVIDER-001) — so the OpenAI key
7
  request into a provider-neutral `OpenAgents.Providers.Request`, fans it into
8
  the `OpenAgents.Providers.Provider` that serves the grant's model
9
  (`OpenAgents.Inference.Models`, PROVIDER-001) — so the provider credential
9 10
  never leaves the server (RELEASE-002) — meters token usage against the
10 11
  grant's budget (VOICE-010 pattern), and streams the typed provider events
11 12
  back as chat-completions SSE that probe's parser consumes. Provider JSON,

@@ -21,6 +22,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

21 22
  require Logger
22 23
23 24
  alias OpenAgents.Inference
25
  alias OpenAgents.Inference.Models
24 26
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
25 27
26 28
  def create(conn, _params) do

@@ -28,8 +30,9 @@ defmodule OpenAgentsWeb.InferenceProxyController do

28 30
    # proxy never re-reads or re-parses it.
29 31
    with {:ok, token} <- bearer(conn),
30 32
         {:ok, grant} <- resolve(token),
31
         {:ok, request} <- build_request(grant, conn.body_params) do
32
      run(conn, grant, request)
33
         {:ok, model} <- route(grant),
34
         {:ok, request} <- build_request(model, conn.body_params) do
35
      run(conn, grant, model.provider, request)
33 36
    else
34 37
      {:error, reason} -> refuse(conn, reason)
35 38
    end

@@ -37,12 +40,23 @@ defmodule OpenAgentsWeb.InferenceProxyController do

37 40
38 41
  # ── request assembly ────────────────────────────────────────────────────
39 42
40
  defp build_request(grant, %{"messages" => messages} = body) when is_list(messages) do
43
  # The grant's model names the provider and the string that provider is called
44
  # with. A grant minted before the model was routable — or one whose model has
45
  # since been withdrawn — is refused here rather than sent to a provider that
46
  # does not serve it.
47
  defp route(grant) do
48
    case Models.fetch(grant.model_id) do
49
      {:ok, model} -> {:ok, model}
50
      :error -> {:error, :model_unavailable}
51
    end
52
  end
53
54
  defp build_request(model, %{"messages" => messages} = body) when is_list(messages) do
41 55
    {system, turns} = Enum.split_with(messages, &(role(&1) == "system"))
42 56
43 57
    request = %Request{
44 58
      # The grant pins the model; a request body cannot select another.
45
      model_id: grant.model_id,
59
      model_id: model.provider_model,
46 60
      instructions: join_text(system),
47 61
      input: Enum.flat_map(turns, &input_message/1),
48 62
      tool_definitions: tool_definitions(body["tools"]),

@@ -56,7 +70,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

56 70
    end
57 71
  end
58 72
59
  defp build_request(_grant, _body), do: {:error, :invalid_request}
73
  defp build_request(_model, _body), do: {:error, :invalid_request}
60 74
61 75
  defp input_message(%{"role" => "tool"}), do: []
62 76

@@ -100,8 +114,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

100 114
101 115
  # ── run + translate ─────────────────────────────────────────────────────
102 116
103
  defp run(conn, grant, request) do
104
    provider = Application.fetch_env!(:openagents, :provider)
117
  defp run(conn, grant, provider, request) do
105 118
    parent = self()
106 119
107 120
    # The provider pushes events synchronously; capture them to this process's

@@ -251,6 +264,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

251 264
  defp status_for(:body_too_large), do: {413, "body_too_large"}
252 265
  defp status_for(:invalid_json), do: {400, "invalid_json"}
253 266
  defp status_for(:provider_failed), do: {502, "provider_failed"}
267
  defp status_for(:model_unavailable), do: {503, "model_unavailable"}
254 268
  defp status_for(_), do: {400, "bad_request"}
255 269
256 270
  # ── small helpers ───────────────────────────────────────────────────────
lib/openagents_web/controllers/thread_controller.ex modified +37 -11

@@ -18,16 +18,20 @@ defmodule OpenAgentsWeb.ThreadController do

18 18
    every request first retires the account's elapsed authority, so a grant
19 19
    past its expiry stops being live whether or not anyone presents it.
20 20
21
  The model is not a parameter. The proxy pins the model from the grant so a
22
  request body cannot select another, and offering a choice the grant would
23
  overrule would tell a caller it got a model it did not get. `GET /api/v3`
24
  publishes the model the grant carries instead.
21
  The model is admitted here and nowhere else. A request body sent to the proxy
22
  still cannot select a model — the proxy pins the grant's — so the one place a
23
  caller states which model it wants is the thread it opens, and the response
24
  publishes the model the grant carries. Admitting it at the door is what lets
25
  a coding session run its own turns on one model and its delegated children on
26
  another: it opens a second thread on `ox-alpha` and gets authority for
27
  `ox-alpha`, with its own budget, rather than borrowing the first thread's.
25 28
  """
26 29
27 30
  use OpenAgentsWeb, :controller
28 31
32
  alias OpenAgents.Conversations
29 33
  alias OpenAgents.Inference
30
  alias OpenAgents.Inference.Grant
34
  alias OpenAgents.Inference.{Credit, Grant, Models}
31 35
  alias OpenAgents.Threads
32 36
  alias OpenAgents.Threads.Thread
33 37
  alias OpenAgentsWeb.ApiError

@@ -73,6 +77,9 @@ defmodule OpenAgentsWeb.ThreadController do

73 77
      {:error, :thread_quota_reached} ->
74 78
        quota_reached(conn)
75 79
80
      {:error, :credit_exhausted} ->
81
        credit_exhausted(conn)
82
76 83
      {:error, %Ecto.Changeset{} = changeset} ->
77 84
        ApiError.changeset(conn, changeset)
78 85

@@ -99,6 +106,25 @@ defmodule OpenAgentsWeb.ThreadController do

99 106
    )
100 107
  end
101 108
109
  # A thread spends the account's credit, so an exhausted balance is not a
110
  # thing to retry. The refusal names the allowance that was spent, because
111
  # that is the fact a reader acts on.
112
  defp credit_exhausted(conn) do
113
    visitor = Conversations.ensure_owner_visitor(conn.assigns.current_user)
114
115
    sentence =
116
      "This account has spent its inference credit of " <>
117
        "#{dollars(Credit.allowance(visitor.id))}. " <>
118
        "Nothing is left to mint a thread against."
119
120
    ApiError.refuse(conn, "credit_exhausted",
121
      message: sentence,
122
      errors: %{"credit" => [sentence]}
123
    )
124
  end
125
126
  defp dollars(microusd), do: "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
127
102 128
  # ── reading ─────────────────────────────────────────────────────────────
103 129
104 130
  # Expiry is retired before the lookup, so a read reports what is true now

@@ -138,7 +164,8 @@ defmodule OpenAgentsWeb.ThreadController do

138 164
  end
139 165
140 166
  defp execution_shape(params) do
141
    with {:ok, reasoning} <-
167
    with {:ok, model} <- admitted(params, "model", Models.ids(), Models.default_id()),
168
         {:ok, reasoning} <-
142 169
           admitted(params, "reasoning", Thread.reasoning_efforts(), Threads.default_reasoning()),
143 170
         {:ok, profile} <-
144 171
           admitted(

@@ -147,7 +174,7 @@ defmodule OpenAgentsWeb.ThreadController do

147 174
             Thread.permission_profiles(),
148 175
             Threads.default_permission_profile()
149 176
           ) do
150
      {:ok, [reasoning: reasoning, permission_profile: profile]}
177
      {:ok, [model: model, reasoning: reasoning, permission_profile: profile]}
151 178
    end
152 179
  end
153 180

@@ -175,10 +202,9 @@ defmodule OpenAgentsWeb.ThreadController do

175 202
176 203
  # ── views ───────────────────────────────────────────────────────────────
177 204
178
  # The thread carries a `model` column for an executor that runs it, and the
179
  # proxy pins a different one from the grant. Publishing both would put two
180
  # model names in one response with nothing saying which the caller gets, so
181
  # only the grant's is served: it is the one the request will actually use.
205
  # The thread's `model` and its grant's are now the same admitted id, so only
206
  # the grant's is published: it is the one the request will actually use, and
207
  # printing the same name twice invites a reader to think they can differ.
182 208
183 209
  defp thread_view(%Thread{} = thread) do
184 210
    %{
test/openagents/inference/credit_test.exs added +106

@@ -0,0 +1,106 @@

1
defmodule OpenAgents.Inference.CreditTest do
2
  @moduledoc """
3
  The account's inference money.
4
5
  Two facts are proven here because both were false before: that signing in
6
  raises the allowance, and that what a thread spends comes out of the
7
  account's credit rather than out of a per-thread figure nothing adds up.
8
  """
9
10
  use OpenAgents.DataCase, async: false
11
12
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
13
14
  alias OpenAgents.Conversations
15
  alias OpenAgents.Conversations.Visitor
16
  alias OpenAgents.Inference
17
  alias OpenAgents.Inference.Credit
18
  alias OpenAgents.Threads
19
20
  defp account(key) do
21
    key |> github_user() |> Conversations.ensure_owner_visitor()
22
  end
23
24
  defp visitor(key) do
25
    {:ok, conversation} = Conversations.ensure_conversation("credit-#{key}")
26
    conversation.visitor_id
27
  end
28
29
  # Cost is priced from tokens by `OpenAgents.Inference`, never taken from a
30
  # caller, so spend is stated here in the output tokens that price to it.
31
  defp output_tokens_costing(microusd) do
32
    div(
33
      microusd * 1_000,
34
      Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
35
    )
36
  end
37
38
  # A grant names exactly one fence, so spend is recorded through a real
39
  # thread's grant rather than a fenceless one the changeset would refuse.
40
  defp minted(visitor_id) do
41
    {:ok, thread} = Threads.open(%Visitor{id: visitor_id}, "spend some credit")
42
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
43
    grant
44
  end
45
46
  test "signing in raises the allowance to the account credit" do
47
    assert Credit.allowance(account("credit-signed-in").id) ==
48
             Application.fetch_env!(:openagents, :account_credit_microusd)
49
  end
50
51
  test "a visitor that has not signed in holds the visitor credit" do
52
    assert Credit.allowance(visitor("anonymous")) ==
53
             Application.fetch_env!(:openagents, :visitor_credit_microusd)
54
  end
55
56
  test "an account that has spent nothing has its whole allowance left" do
57
    owner = account("credit-unspent")
58
59
    assert Credit.spent(owner.id) == 0
60
    assert Credit.remaining(owner.id) == Credit.allowance(owner.id)
61
  end
62
63
  test "what a grant metered comes out of the account's remaining credit" do
64
    owner = account("credit-metered")
65
66
    {:ok, _metered} =
67
      Inference.record_usage(minted(owner.id), %{
68
        "output_tokens" => output_tokens_costing(250_000)
69
      })
70
71
    assert Credit.spent(owner.id) == 250_000
72
    assert Credit.remaining(owner.id) == Credit.allowance(owner.id) - 250_000
73
  end
74
75
  test "remaining credit never reads as negative" do
76
    visitor_id = visitor("overspent")
77
    allowance = Credit.allowance(visitor_id)
78
79
    {:ok, _metered} =
80
      Inference.record_usage(minted(visitor_id), %{
81
        "output_tokens" => output_tokens_costing(allowance * 2)
82
      })
83
84
    assert Credit.remaining(visitor_id) == 0
85
  end
86
87
  test "a thread is minted for what the account has left, not a fresh ceiling" do
88
    owner = account("credit-thread")
89
90
    {:ok, remaining} = Threads.ceilings(owner.id)
91
92
    assert remaining.max_cost_microusd == Credit.remaining(owner.id)
93
    assert remaining.max_cost_microusd > Threads.ceilings().max_cost_microusd
94
  end
95
96
  test "an account with nothing left is refused rather than minted a grant" do
97
    visitor_id = visitor("exhausted")
98
99
    {:ok, _metered} =
100
      Inference.record_usage(minted(visitor_id), %{
101
        "output_tokens" => output_tokens_costing(Credit.allowance(visitor_id))
102
      })
103
104
    assert Threads.ceilings(visitor_id) == {:error, :credit_exhausted}
105
  end
106
end
test/openagents/inference/models_test.exs added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Inference.ModelsTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Chat.OpenRouter
5
  alias OpenAgents.Inference.Models
6
7
  test "the default model is the configured one, served by the configured provider" do
8
    default = Models.default()
9
10
    assert default.id == Application.fetch_env!(:openagents, :openai_model)
11
    assert default.provider_model == default.id
12
    assert default.provider == Application.fetch_env!(:openagents, :provider)
13
    assert Models.default_id() == default.id
14
  end
15
16
  test "ox-alpha publishes a public id and routes the vendor string" do
17
    assert {:ok, model} = Models.fetch("ox-alpha")
18
    assert model.id == "ox-alpha"
19
    assert model.provider_model == OpenRouter.default_model()
20
    refute model.id == model.provider_model
21
  end
22
23
  test "the vendor spelling resolves to the same model" do
24
    assert {:ok, model} = Models.fetch(OpenRouter.default_model())
25
    assert model.id == "ox-alpha"
26
  end
27
28
  test "every routed model is listed once, and only routed models are" do
29
    ids = Models.ids()
30
31
    assert ids == Enum.uniq(ids)
32
    assert Models.default_id() in ids
33
    assert "ox-alpha" in ids
34
    assert Enum.map(Models.all(), & &1.id) == ids
35
    assert Models.fetch("attacker/gpt-9-ultra") == :error
36
    assert Models.fetch(nil) == :error
37
  end
38
end
test/openagents/inference_test.exs modified +14

@@ -42,6 +42,20 @@ defmodule OpenAgents.InferenceTest do

42 42
      refute grant.token_digest == token
43 43
      assert grant.token_digest == :crypto.hash(:sha256, token)
44 44
    end
45
46
    test "pins a named model, publishing the public id rather than the vendor one" do
47
      {:ok, grant, _token} = Inference.mint(Map.put(scope("mint-ox"), :model_id, "ox-alpha"))
48
49
      assert grant.model_id == "ox-alpha"
50
    end
51
52
    test "refuses a model the proxy cannot route, naming only the model" do
53
      input = Map.put(scope("mint-unrouted"), :model_id, "attacker/gpt-9-ultra")
54
55
      assert {:error, changeset} = Inference.mint(input)
56
      assert [model_id: {sentence, _opts}] = changeset.errors
57
      assert sentence =~ "is not a model this proxy routes"
58
    end
45 59
  end
46 60
47 61
  describe "resolve/1" do
test/openagents/providers/open_router/request_payload_test.exs added +91

@@ -0,0 +1,91 @@

1
defmodule OpenAgents.Providers.OpenRouter.RequestPayloadTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Providers.{OpenRouter, Request, ToolDefinition, ToolOutput}
5
6
  test "carries the provider model, the system text, and the turns in order" do
7
    request = %Request{
8
      model_id: "stealth/ox-alpha",
9
      instructions: "  Remain OpenAgents.  ",
10
      input: [
11
        %{role: "user", content: "Write a file."},
12
        %{role: "assistant", content: "Which one?"},
13
        %{role: "developer", content: "Any of them."}
14
      ]
15
    }
16
17
    payload = OpenRouter.request_payload(request)
18
19
    assert payload.model == "stealth/ox-alpha"
20
    assert payload.stream == true
21
    assert payload.stream_options == %{include_usage: true}
22
    refute Map.has_key?(payload, :tools)
23
24
    assert payload.messages == [
25
             %{role: "system", content: "Remain OpenAgents."},
26
             %{role: "user", content: "Write a file."},
27
             %{role: "assistant", content: "Which one?"},
28
             # `developer` is not a chat-completions role; it is carried as a
29
             # user turn rather than dropped or sent as-is.
30
             %{role: "user", content: "Any of them."}
31
           ]
32
  end
33
34
  test "sends no system message when the request has no instructions" do
35
    request = %Request{
36
      model_id: "stealth/ox-alpha",
37
      instructions: "",
38
      input: [%{role: "user", content: "Hello."}]
39
    }
40
41
    assert OpenRouter.request_payload(request).messages == [
42
             %{role: "user", content: "Hello."}
43
           ]
44
  end
45
46
  test "maps tool definitions to chat-completions functions" do
47
    request = %Request{
48
      model_id: "stealth/ox-alpha",
49
      instructions: "",
50
      input: [%{role: "user", content: "Search."}],
51
      tool_definitions: [
52
        %ToolDefinition{
53
          name: "conversation_search",
54
          description: "Search the transcript",
55
          strict: true,
56
          input_schema: %{"type" => "object", "properties" => %{}}
57
        }
58
      ]
59
    }
60
61
    assert OpenRouter.request_payload(request).tools == [
62
             %{
63
               type: "function",
64
               function: %{
65
                 name: "conversation_search",
66
                 description: "Search the transcript",
67
                 parameters: %{"type" => "object", "properties" => %{}}
68
               }
69
             }
70
           ]
71
  end
72
73
  test "carries a tool output as a labelled user turn" do
74
    request = %Request{
75
      model_id: "stealth/ox-alpha",
76
      instructions: "",
77
      input: [%{role: "user", content: "Search."}],
78
      tool_outputs: [
79
        %ToolOutput{call_id: "call_1", output: %{"status" => "succeeded"}}
80
      ]
81
    }
82
83
    assert OpenRouter.request_payload(request).messages == [
84
             %{role: "user", content: "Search."},
85
             %{
86
               role: "user",
87
               content: ~s(Tool result for call_1: {"status":"succeeded"})
88
             }
89
           ]
90
  end
91
end
test/openagents/providers/open_router/stream_decoder_test.exs added +124

@@ -0,0 +1,124 @@

1
defmodule OpenAgents.Providers.OpenRouter.StreamDecoderTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Providers.OpenRouter.StreamDecoder
5
  alias OpenAgents.Providers.ProviderEvent.ToolCall
6
7
  test "decodes a fragmented text stream into lifecycle, text, usage, and completion" do
8
    stream =
9
      frame(%{"id" => "gen-1", "choices" => [%{"delta" => %{"content" => "Hel"}}]}) <>
10
        ": OPENROUTER PROCESSING\n\n" <>
11
        frame(%{"id" => "gen-1", "choices" => [%{"delta" => %{"content" => "lo"}}]}) <>
12
        frame(%{
13
          "id" => "gen-1",
14
          "choices" => [%{"delta" => %{}, "finish_reason" => "stop"}]
15
        }) <>
16
        frame(%{
17
          "id" => "gen-1",
18
          "choices" => [],
19
          "usage" => %{"prompt_tokens" => 9, "completion_tokens" => 2, "total_tokens" => 11}
20
        }) <> "data: [DONE]\n\n"
21
22
    assert {:ok, decoder, events} = feed_in_pieces(stream, 5)
23
    assert {:ok, _decoder, final} = StreamDecoder.finish(decoder)
24
25
    assert events ++ final == [
26
             {:response_started, "gen-1"},
27
             {:text_delta, "Hel"},
28
             {:text_delta, "lo"},
29
             {:usage, %{"input_tokens" => 9, "output_tokens" => 2, "total_tokens" => 11}},
30
             {:response_completed, "gen-1"}
31
           ]
32
  end
33
34
  test "accumulates tool-call fragments and emits them whole at the end" do
35
    stream =
36
      frame(%{
37
        "id" => "gen-2",
38
        "choices" => [
39
          %{
40
            "delta" => %{
41
              "tool_calls" => [
42
                %{
43
                  "index" => 0,
44
                  "id" => "call_1",
45
                  "function" => %{"name" => "delegate", "arguments" => "{\"pro"}
46
                }
47
              ]
48
            }
49
          }
50
        ]
51
      }) <>
52
        frame(%{
53
          "id" => "gen-2",
54
          "choices" => [
55
            %{
56
              "delta" => %{
57
                "tool_calls" => [%{"index" => 0, "function" => %{"arguments" => "mpt\":\"go\"}"}}]
58
              },
59
              "finish_reason" => "tool_calls"
60
            }
61
          ]
62
        })
63
64
    assert {:ok, decoder, events} = feed_in_pieces(stream, 11)
65
    assert {:ok, _decoder, final} = StreamDecoder.finish(decoder)
66
67
    assert events == [{:response_started, "gen-2"}]
68
69
    assert final == [
70
             {:tool_call,
71
              %ToolCall{
72
                item_id: "call_1",
73
                call_id: "call_1",
74
                name: "delegate",
75
                raw_arguments: "{\"prompt\":\"go\"}"
76
              }},
77
             {:response_completed, "gen-2"}
78
           ]
79
  end
80
81
  test "reports a provider error and closes without a completion" do
82
    stream = frame(%{"error" => %{"code" => "model_not_found", "message" => "no such model"}})
83
84
    assert {:ok, decoder, events} = StreamDecoder.feed(StreamDecoder.new(), stream)
85
    assert events == [{:failed, {:provider_failed, "model_not_found"}}]
86
    assert {:ok, _decoder, []} = StreamDecoder.finish(decoder)
87
  end
88
89
  test "refuses a stream that ends without a finish reason" do
90
    stream = frame(%{"id" => "gen-3", "choices" => [%{"delta" => %{"content" => "Half"}}]})
91
92
    assert {:ok, decoder, [{:response_started, "gen-3"}, {:text_delta, "Half"}]} =
93
             StreamDecoder.feed(StreamDecoder.new(), stream)
94
95
    assert StreamDecoder.finish(decoder) == {:error, :truncated_stream}
96
  end
97
98
  test "refuses a frame that is not JSON" do
99
    assert StreamDecoder.feed(StreamDecoder.new(), "data: {not json\n\n") ==
100
             {:error, :invalid_provider_event}
101
  end
102
103
  defp feed_in_pieces(stream, size) do
104
    stream
105
    |> pieces(size)
106
    |> Enum.reduce({:ok, StreamDecoder.new(), []}, fn piece, {:ok, decoder, events} ->
107
      assert {:ok, next, more} = StreamDecoder.feed(decoder, piece)
108
      {:ok, next, events ++ more}
109
    end)
110
  end
111
112
  defp pieces(stream, size) do
113
    Stream.unfold(stream, fn
114
      "" -> nil
115
      rest -> {binary_part(rest, 0, min(size, byte_size(rest))), cut(rest, size)}
116
    end)
117
    |> Enum.to_list()
118
  end
119
120
  defp cut(rest, size) when byte_size(rest) <= size, do: ""
121
  defp cut(rest, size), do: binary_part(rest, size, byte_size(rest) - size)
122
123
  defp frame(payload), do: "data: " <> Jason.encode!(payload) <> "\n\n"
124
end
test/openagents/providers/open_router_test.exs added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgents.Providers.OpenRouterTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Providers.{OpenRouter, Request}
5
6
  setup {Req.Test, :verify_on_exit!}
7
8
  defp request do
9
    %Request{
10
      model_id: "stealth/ox-alpha",
11
      instructions: "Remain OpenAgents.",
12
      input: [%{role: "user", content: "Say hello."}]
13
    }
14
  end
15
16
  defp collect(options) do
17
    parent = self()
18
    result = OpenRouter.stream(request(), &send(parent, {:event, &1}), options)
19
    {result, drain([])}
20
  end
21
22
  defp drain(events) do
23
    receive do
24
      {:event, event} -> drain([event | events])
25
    after
26
      0 -> Enum.reverse(events)
27
    end
28
  end
29
30
  test "sends the payload with the server's credential and emits the decoded stream" do
31
    test_process = self()
32
33
    Req.Test.expect(__MODULE__, fn conn ->
34
      {:ok, body, conn} = Plug.Conn.read_body(conn)
35
      send(test_process, {:outbound, Plug.Conn.get_req_header(conn, "authorization"), body})
36
37
      conn
38
      |> Plug.Conn.put_resp_content_type("text/event-stream")
39
      |> Plug.Conn.send_resp(200, sse_stream())
40
    end)
41
42
    assert {:ok, events} =
43
             collect(
44
               api_key: "sentinel-openrouter-key",
45
               request_options: [plug: {Req.Test, __MODULE__}]
46
             )
47
48
    assert_received {:outbound, ["Bearer sentinel-openrouter-key"], body}
49
    assert Jason.decode!(body)["model"] == "stealth/ox-alpha"
50
51
    assert events == [
52
             {:response_started, "gen-1"},
53
             {:text_delta, "Hello."},
54
             {:usage, %{"input_tokens" => 3, "output_tokens" => 1, "total_tokens" => 4}},
55
             {:response_completed, "gen-1"}
56
           ]
57
  end
58
59
  test "an HTTP failure is a bounded reason, not a stream" do
60
    Req.Test.expect(__MODULE__, fn conn -> Plug.Conn.send_resp(conn, 429, "slow down") end)
61
62
    assert {{:error, {:http_status, 429}}, []} =
63
             collect(
64
               api_key: "sentinel-openrouter-key",
65
               request_options: [plug: {Req.Test, __MODULE__}]
66
             )
67
  end
68
69
  test "a missing credential is refused before any request is made" do
70
    assert {{:error, :missing_api_key}, []} = collect([])
71
  end
72
73
  defp sse_stream do
74
    frames = [
75
      %{"id" => "gen-1", "choices" => [%{"delta" => %{"content" => "Hello."}}]},
76
      %{"id" => "gen-1", "choices" => [%{"delta" => %{}, "finish_reason" => "stop"}]},
77
      %{
78
        "id" => "gen-1",
79
        "choices" => [],
80
        "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 1, "total_tokens" => 4}
81
      }
82
    ]
83
84
    Enum.map_join(frames, "", &("data: " <> Jason.encode!(&1) <> "\n\n")) <> "data: [DONE]\n\n"
85
  end
86
end
test/openagents/providers/persona_boundary_test.exs modified +10 -1

@@ -48,7 +48,12 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

48 48
    OpenAgents.Voice.CallProvider,
49 49
    OpenAgents.Voice.SidebandProvider
50 50
  ]
51
  @configured_provider_keys [:provider, :voice_call_provider, :voice_sideband_provider]
51
  @configured_provider_keys [
52
    :provider,
53
    :openrouter_provider,
54
    :voice_call_provider,
55
    :voice_sideband_provider
56
  ]
52 57
53 58
  # Every provider adapter, classified by what it can put in front of a model.
54 59
  #

@@ -59,6 +64,8 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

59 64
  #   * `:in_process` — it never leaves the VM, so it has no model to instruct.
60 65
  @adapters %{
61 66
    OpenAgents.Providers.OpenAI => :outbound_http,
67
    OpenAgents.Providers.OpenRouter => :outbound_http,
68
    OpenAgents.Providers.RecordingTestProvider => :in_process,
62 69
    OpenAgents.Providers.Test => :in_process,
63 70
    OpenAgents.Voice.OpenAI.CallClient => :outbound_http,
64 71
    OpenAgents.Voice.OpenAI.Sideband => :outbound_socket,

@@ -90,6 +97,8 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

90 97
    OpenAgentsWeb.InferenceProxyController => :relays_caller_instructions,
91 98
    OpenAgents.Conversations => :pins_a_composed_request,
92 99
    OpenAgents.Providers.OpenAI => :adapter,
100
    OpenAgents.Providers.OpenRouter => :adapter,
101
    OpenAgents.Providers.RecordingTestProvider => :adapter,
93 102
    OpenAgents.Providers.Test => :adapter,
94 103
    OpenAgents.Providers.Request => :the_struct_itself
95 104
  }
test/openagents/threads/grant_token_reach_test.exs modified +1

@@ -52,6 +52,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

52 52
    {:cancel, 1} => :thread_struct,
53 53
    {:cancel, 2} => :thread_struct,
54 54
    {:ceilings, 0} => :no_thread,
55
    {:ceilings, 1} => :no_thread,
55 56
    {:default_permission_profile, 0} => :no_thread,
56 57
    {:default_reasoning, 0} => :no_thread,
57 58
    {:finish, 2} => :thread_struct,
test/openagents/threads_test.exs modified +6 -1

@@ -5,6 +5,7 @@ defmodule OpenAgents.ThreadsTest do

5 5
6 6
  alias OpenAgents.Conversations
7 7
  alias OpenAgents.Inference
8
  alias OpenAgents.Inference.Credit
8 9
  alias OpenAgents.Inference.Grant
9 10
  alias OpenAgents.Repo
10 11
  alias OpenAgents.Threads

@@ -271,7 +272,11 @@ defmodule OpenAgents.ThreadsTest do

271 272
272 273
      assert grant.max_total_tokens == ceilings.max_total_tokens
273 274
      assert grant.max_calls == ceilings.max_calls
274
      assert grant.max_cost_microusd == ceilings.max_cost_microusd
275
276
      # Money is the account's, not the thread's: the cost ceiling is what the
277
      # account has left of its credit, so a second thread cannot mint itself a
278
      # fresh allowance (`OpenAgents.Inference.Credit`).
279
      assert grant.max_cost_microusd == Credit.remaining(grant.owner_visitor_id)
275 280
276 281
      delegation = Inference.delegation_ceilings()
277 282
test/openagents_web/controllers/api_extension_governance_test.exs modified +12 -4

@@ -11,6 +11,7 @@ defmodule OpenAgentsWeb.ApiExtensionGovernanceTest do

11 11
  """
12 12
  use OpenAgentsWeb.ConnCase
13 13
14
  alias OpenAgents.Inference.{Credit, Grant}
14 15
  alias OpenAgents.Issues
15 16
  alias OpenAgents.ProjectItems
16 17
  alias OpenAgents.Projects

@@ -179,19 +180,26 @@ defmodule OpenAgentsWeb.ApiExtensionGovernanceTest do

179 180
180 181
    assert limits["grant"]["max_total_tokens"] == ceilings.max_total_tokens
181 182
    assert limits["grant"]["max_calls"] == ceilings.max_calls
182
    assert limits["grant"]["max_cost_microusd"] == ceilings.max_cost_microusd
183 183
    assert limits["grant"]["ttl_seconds"] == ceilings.ttl_seconds
184 184
185
    granted =
185
    # The cost figure is the account's credit rather than a per-thread cap, so
186
    # the document publishes the allowances and the mint reports the remainder.
187
    refute Map.has_key?(limits["grant"], "max_cost_microusd")
188
    assert limits["credit"]["account_microusd"] == Credit.account_allowance()
189
    assert limits["credit"]["visitor_microusd"] == Credit.visitor_allowance()
190
191
    created =
186 192
      conn
187 193
      |> put_chat_api_token("governance-thread-budget")
188 194
      |> post(~p"/api/v3/threads", %{"objective" => "Measure the published budget."})
189 195
      |> json_response(201)
190
      |> get_in(["grant", "limits"])
196
197
    granted = created["grant"]["limits"]
198
    owner = OpenAgents.Repo.get_by!(Grant, thread_id: created["thread"]["id"]).owner_visitor_id
191 199
192 200
    assert granted["max_total_tokens"] == limits["grant"]["max_total_tokens"]
193 201
    assert granted["max_calls"] == limits["grant"]["max_calls"]
194
    assert granted["max_cost_microusd"] == limits["grant"]["max_cost_microusd"]
202
    assert granted["max_cost_microusd"] == Credit.remaining(owner)
195 203
  end
196 204
197 205
  test "every published thread parameter value is one the route actually accepts", %{conn: conn} do
test/openagents_web/controllers/device_authorization_controller_test.exs modified +37 -1

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

57 57
    assert %{
58 58
             "access_token" => "oa_pat_" <> _secret,
59 59
             "token_type" => "Bearer",
60
             "scope" => "forge:write",
60
             "scope" => "chat:account forge:write",
61 61
             "expires_in" => expires_in
62 62
           } = json_response(claimed, 200)
63 63

@@ -145,6 +145,42 @@ defmodule OpenAgentsWeb.DeviceAuthorizationControllerTest do

145 145
    assert expires_in <= 7 * 24 * 60 * 60
146 146
  end
147 147
148
  # Signing in is what a person does before they use the product, so the token
149
  # it mints has to reach the product. `openagents coder` opens a thread, and a
150
  # login that names no scope used to mint a repository-only token that the
151
  # thread route refused, which read as "my login cannot open a chat".
152
  test "a login that names no scope can open a thread", %{conn: conn} do
153
    %{"device_code" => device_code, "user_code" => user_code} =
154
      conn
155
      |> post(~p"/api/v3/device/authorizations", %{})
156
      |> json_response(201)
157
158
    user = github_user("device-chat", "device-chat-owner")
159
    assert {:ok, _authorization} = OpenAgents.DeviceAuthorizations.approve(user_code, user)
160
161
    %{"access_token" => token, "scope" => scope} =
162
      conn
163
      |> recycle()
164
      |> post(~p"/api/v3/device/authorizations/token", %{device_code: device_code})
165
      |> json_response(200)
166
167
    assert scope == "chat:account forge:write"
168
169
    opened =
170
      conn
171
      |> recycle()
172
      |> put_req_header("authorization", "Bearer " <> token)
173
      |> post(~p"/api/v3/threads", %{"objective" => "run the coder"})
174
175
    assert %{"thread" => %{"id" => _id}, "grant" => %{"token" => _grant, "limits" => limits}} =
176
             json_response(opened, 201)
177
178
    # Signing in is also what raises the money: the thread is granted the
179
    # account credit, not the visitor's.
180
    assert limits["max_cost_microusd"] ==
181
             Application.fetch_env!(:openagents, :account_credit_microusd)
182
  end
183
148 184
  test "an unknown scope is refused rather than silently narrowed", %{conn: conn} do
149 185
    refused =
150 186
      post(conn, ~p"/api/v3/device/authorizations", %{"scope" => "deployments:everything"})
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +64 -7

@@ -1,11 +1,13 @@

1 1
defmodule OpenAgentsWeb.InferenceProxyControllerTest do
2 2
  use OpenAgentsWeb.ConnCase, async: false
3
3 4
  alias OpenAgents.Inference
4 5
  alias OpenAgents.Inference.Grant
5 6
  alias OpenAgents.Machines
7
  alias OpenAgents.Providers.RecordingTestProvider
6 8
  alias OpenAgents.Repo
7 9
8
  defp grant(key) do
10
  defp grant(key, options \\ []) do
9 11
    owner = github_user("proxy-#{key}")
10 12
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
11 13

@@ -20,12 +22,19 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

20 22
21 23
    {:ok, machine} = Machines.approve_pairing(owner, code)
22 24
23
    {:ok, grant, token} =
24
      Inference.mint(%{
25
        owner_visitor_id: conversation.visitor_id,
26
        conversation_id: conversation.id,
27
        machine_id: machine.id
28
      })
25
    mint_input = %{
26
      owner_visitor_id: conversation.visitor_id,
27
      conversation_id: conversation.id,
28
      machine_id: machine.id
29
    }
30
31
    mint_input =
32
      case Keyword.fetch(options, :model_id) do
33
        {:ok, model_id} -> Map.put(mint_input, :model_id, model_id)
34
        :error -> mint_input
35
      end
36
37
    {:ok, grant, token} = Inference.mint(mint_input)
29 38
30 39
    %{grant: grant, token: token}
31 40
  end

@@ -144,4 +153,52 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

144 153
    assert conn.status == 400
145 154
    assert Jason.decode!(conn.resp_body)["error"]["code"] == "empty_input"
146 155
  end
156
157
  describe "routing the grant's model" do
158
    setup do
159
      previous = Application.get_env(:openagents, :openrouter_provider)
160
      Application.put_env(:openagents, :openrouter_provider, RecordingTestProvider)
161
      Application.put_env(:openagents, :test_recording_provider_observer, self())
162
163
      on_exit(fn ->
164
        Application.put_env(:openagents, :openrouter_provider, previous)
165
        Application.delete_env(:openagents, :test_recording_provider_observer)
166
      end)
167
    end
168
169
    test "an ox-alpha grant reaches the OpenRouter lane with the vendor model", %{conn: conn} do
170
      %{token: token} = grant("ox-alpha", model_id: "ox-alpha")
171
172
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
173
174
      assert conn.status == 200
175
      assert_received {:recorded_request, "test.recording_provider", request}
176
      assert request.model_id == OpenAgents.Chat.OpenRouter.default_model()
177
    end
178
179
    test "a default grant stays on the default lane", %{conn: conn} do
180
      %{token: token} = grant("default-lane")
181
182
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
183
184
      assert conn.status == 200
185
      refute_received {:recorded_request, _id, _request}
186
    end
187
188
    test "a grant naming a model the proxy cannot route is refused", %{conn: conn} do
189
      %{token: token} = grant("withdrawn")
190
191
      # A grant's model column is immutable and the mint refuses an unroutable
192
      # name, so the only way here is the routed set changing underneath a live
193
      # grant — a model withdrawn after it was issued.
194
      configured = Application.fetch_env!(:openagents, :openai_model)
195
      Application.put_env(:openagents, :openai_model, "#{configured}-withdrawn")
196
      on_exit(fn -> Application.put_env(:openagents, :openai_model, configured) end)
197
198
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
199
200
      assert conn.status == 503
201
      assert Jason.decode!(conn.resp_body)["error"]["code"] == "model_unavailable"
202
    end
203
  end
147 204
end
test/openagents_web/controllers/thread_controller_test.exs modified +73 -2

@@ -9,6 +9,7 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

9 9
  use OpenAgentsWeb.ConnCase, async: false
10 10
11 11
  alias OpenAgents.Inference
12
  alias OpenAgents.Inference.Credit
12 13
  alias OpenAgents.Inference.Grant
13 14
  alias OpenAgents.Repo
14 15
  alias OpenAgents.Threads

@@ -31,9 +32,11 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

31 32
      assert grant["url"] =~ "/api/inference/proxy"
32 33
      assert grant["limits"]["max_calls"] == Threads.ceilings().max_calls
33 34
      assert grant["limits"]["max_total_tokens"] == Threads.ceilings().max_total_tokens
34
      assert grant["limits"]["max_cost_microusd"] == Threads.ceilings().max_cost_microusd
35
35
      # The cost ceiling is what this account has left of its credit, so
36
      # opening another thread does not mint another allowance.
36 37
      minted = Repo.get_by!(Grant, thread_id: thread["id"])
38
      assert grant["limits"]["max_cost_microusd"] == Credit.remaining(minted.owner_visitor_id)
39
37 40
      assert minted.conversation_id == nil
38 41
      assert minted.status == "active"
39 42
    end

@@ -73,6 +76,43 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

73 76
      assert thread["permission_profile"] == "workspace_write"
74 77
    end
75 78
79
    test "a caller may open a thread on another routed model", %{conn: conn} do
80
      body =
81
        conn
82
        |> put_chat_api_token("thread-ox-alpha")
83
        |> post(~p"/api/v3/threads", %{
84
          "objective" => "Delegate the edit.",
85
          "model" => "ox-alpha"
86
        })
87
        |> json_response(201)
88
89
      assert body["grant"]["model"] == "ox-alpha"
90
    end
91
92
    test "a thread names the default model when its caller names none", %{conn: conn} do
93
      body =
94
        conn
95
        |> put_chat_api_token("thread-default-model")
96
        |> post(~p"/api/v3/threads", %{"objective" => "Take the default."})
97
        |> json_response(201)
98
99
      assert body["grant"]["model"] == OpenAgents.Inference.Models.default_id()
100
    end
101
102
    test "a model the proxy cannot route is refused, naming the field", %{conn: conn} do
103
      body =
104
        conn
105
        |> put_chat_api_token("thread-bad-model")
106
        |> post(~p"/api/v3/threads", %{
107
          "objective" => "Ask for the impossible.",
108
          "model" => "attacker/gpt-9-ultra"
109
        })
110
        |> json_response(422)
111
112
      assert body["code"] == "validation_failed"
113
      assert Map.has_key?(body["errors"], "model")
114
    end
115
76 116
    test "an objective is required", %{conn: conn} do
77 117
      body =
78 118
        conn

@@ -139,6 +179,37 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

139 179
      assert message =~ "#{limit}"
140 180
    end
141 181
182
    # An account that has spent its credit has nothing to mint a grant against,
183
    # and a thread without authority is not a thread anyone can work, so the
184
    # refusal names the money rather than reading as a transient failure.
185
    test "an account that has spent its credit is refused with what it spent", %{conn: conn} do
186
      authenticated = put_chat_api_token(conn, "thread-credit")
187
188
      opened =
189
        authenticated
190
        |> post(~p"/api/v3/threads", %{"objective" => "Spend it all."})
191
        |> json_response(201)
192
193
      grant = Repo.get_by!(Grant, thread_id: opened["thread"]["id"])
194
      allowance = Credit.allowance(grant.owner_visitor_id)
195
      price = Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
196
197
      {:ok, _metered} =
198
        Inference.record_usage(grant, %{"output_tokens" => div(allowance, price) * 1_000})
199
200
      assert Credit.remaining(grant.owner_visitor_id) == 0
201
202
      body =
203
        authenticated
204
        |> post(~p"/api/v3/threads", %{"objective" => "One more, on empty."})
205
        |> json_response(402)
206
207
      assert body["code"] == "credit_exhausted"
208
      assert body["message"] =~ "$100.00"
209
      assert [message] = body["errors"]["credit"]
210
      assert message =~ "$100.00"
211
    end
212
142 213
    test "the cap counts one account's threads, never another's", %{conn: conn} do
143 214
      previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
144 215
      Application.put_env(:openagents, :maximum_open_threads_per_account, 1)
test/support/providers/recording_test_provider.ex added +27

@@ -0,0 +1,27 @@

1
defmodule OpenAgents.Providers.RecordingTestProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Providers.Provider
5
6
  alias OpenAgents.Providers.Request
7
8
  @impl true
9
  def id, do: "test.recording_provider"
10
11
  @impl true
12
  def capabilities, do: [:text, :usage]
13
14
  @impl true
15
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
16
    case Application.fetch_env(:openagents, :test_recording_provider_observer) do
17
      {:ok, observer} -> send(observer, {:recorded_request, id(), request})
18
      :error -> :ok
19
    end
20
21
    on_event.({:response_started, "recording-response"})
22
    on_event.({:text_delta, "Recorded."})
23
    on_event.({:usage, %{"input_tokens" => 4, "output_tokens" => 8}})
24
    on_event.({:response_completed, "recording-response"})
25
    :ok
26
  end
27
end

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