Add account API tokens and chat responses

55ef08b7c47f · AtlantisPleb · · parent 0cdcc8365381

Add account API tokens and chat responses

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

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

Changed files

  • modified docs/api-authentication.md
  • modified lib/openagents/api_tokens.ex
  • modified lib/openagents/api_tokens/api_token.ex
  • added lib/openagents/chat/runner.ex
  • modified lib/openagents_web/controllers/api_token_controller.ex
  • added lib/openagents_web/controllers/chat_response_controller.ex
  • modified lib/openagents_web/controllers/forge_user_controller.ex
  • modified lib/openagents_web/live/api_tokens_live.ex
  • modified lib/openagents_web/live/chat_placeholder_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/api-tokens.md
  • modified priv/docs/rest-api.md
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260822223012_allow_non_expiring_account_api_tokens.exs
  • modified test/openagents/api_tokens_test.exs
  • modified test/openagents_web/controllers/api_token_controller_test.exs
  • added test/openagents_web/controllers/chat_response_controller_test.exs
  • modified test/openagents_web/controllers/forge_user_controller_test.exs
  • added test/support/chat_provider_stub.ex
  • modified test/support/conn_case.ex

Diff

21 files changed, +603 -72

docs/api-authentication.md modified +24 -4

@@ -1,6 +1,28 @@

1 1
# API authentication
2 2
3
Date: 2026-08-20
3
Date: 2026-08-22
4
5
## Account API clients
6
7
Create a token at `/settings/api-tokens`. Manual tokens carry `account:write`
8
and `forge:write`, do not expire by default, and act with the issuing account's
9
authority. You can select a 7-, 30-, or 90-day lifetime when a client requires
10
rotation. The `oa_pat_…` plaintext appears once; OpenAgents stores only its
11
SHA-256 digest.
12
13
Use `account:write` to call the same chat runtime that serves `/chat`:
14
15
```sh
16
curl https://openagents.com/api/v1/chat/responses \
17
  --header "Authorization: Bearer $OPENAGENTS_API_TOKEN" \
18
  --header "Content-Type: application/json" \
19
  --data '{"input":"Read my repository README.","reasoning":{"effort":"high"}}'
20
```
21
22
Set `"stream":true` to receive server-sent events for reasoning, tool calls,
23
tool results, response text, and terminal completion. The server selects the
24
model and tools. The token supplies the account identity, so a caller cannot
25
claim another user's repository authority.
4 26
5 27
## Forge API clients
6 28

@@ -8,9 +30,7 @@ Date: 2026-08-20

8 30
Every `POST`, `PUT`, `PATCH`, and `DELETE` route under `/api/v3` requires an
9 31
OpenAgents personal API token with exact `forge:write` scope.
10 32
11
Create a token in the authenticated browser at `/settings/api-tokens`. Choose a
12
name and a lifetime from 1 through 90 days. The `oa_pat_…` plaintext appears
13
once; OpenAgents stores only its SHA-256 digest. Send it as a bearer:
33
Send a token with `forge:write` as a bearer:
14 34
15 35
```sh
16 36
curl \
lib/openagents/api_tokens.ex modified +30 -13

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgents.ApiTokens do
2
  @moduledoc "Scoped, expiring first-party credentials for non-browser API clients."
2
  @moduledoc "Scoped first-party credentials for non-browser API clients."
3 3
4 4
  import Ecto.Query
5 5

@@ -9,7 +9,7 @@ defmodule OpenAgents.ApiTokens do

9 9
  alias OpenAgents.Repo
10 10
11 11
  @prefix "oa_pat_"
12
  @allowed_scopes ["forge:write"]
12
  @allowed_scopes ["account:write", "forge:write"]
13 13
  @maximum_lifetime_days 90
14 14
15 15
  @spec create(User.t(), map()) ::

@@ -17,7 +17,7 @@ defmodule OpenAgents.ApiTokens do

17 17
  def create(%User{id: user_id}, attributes) when is_map(attributes) do
18 18
    with {:ok, name} <- name(attributes),
19 19
         {:ok, scopes} <- scopes(attributes),
20
         {:ok, lifetime_days} <- lifetime_days(attributes) do
20
         {:ok, expires_at} <- expires_at(attributes) do
21 21
      id = Ecto.UUID.generate()
22 22
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
23 23
      plaintext = @prefix <> id <> "." <> secret

@@ -28,7 +28,7 @@ defmodule OpenAgents.ApiTokens do

28 28
          |> ApiToken.create_changeset(%{
29 29
            name: name,
30 30
            scopes: scopes,
31
            expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
31
            expires_at: expires_at
32 32
          })
33 33
          |> Repo.insert!()
34 34

@@ -118,7 +118,7 @@ defmodule OpenAgents.ApiTokens do

118 118
119 119
  defp usable?(token, required_scope) do
120 120
    is_nil(token.revoked_at) and required_scope in token.scopes and
121
      DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt
121
      (is_nil(token.expires_at) or DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt)
122 122
  end
123 123
124 124
  defp name(attributes) do

@@ -145,18 +145,35 @@ defmodule OpenAgents.ApiTokens do

145 145
    end
146 146
  end
147 147
148
  defp lifetime_days(attributes) do
149
    case Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days) || 30 do
150
      days when is_integer(days) and days in 1..@maximum_lifetime_days -> {:ok, days}
151
      days when is_binary(days) -> parse_lifetime_days(days)
152
      _invalid -> {:error, :invalid_api_token}
148
  defp expires_at(attributes) do
149
    case Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days) do
150
      nil ->
151
        {:ok, nil}
152
153
      "" ->
154
        {:ok, nil}
155
156
      "never" ->
157
        {:ok, nil}
158
159
      days when is_integer(days) and days in 1..@maximum_lifetime_days ->
160
        {:ok, DateTime.add(DateTime.utc_now(), days, :day)}
161
162
      days when is_binary(days) ->
163
        parse_expiry(days)
164
165
      _invalid ->
166
        {:error, :invalid_api_token}
153 167
    end
154 168
  end
155 169
156
  defp parse_lifetime_days(value) do
170
  defp parse_expiry(value) do
157 171
    case Integer.parse(value) do
158
      {days, ""} when days in 1..@maximum_lifetime_days -> {:ok, days}
159
      _invalid -> {:error, :invalid_api_token}
172
      {days, ""} when days in 1..@maximum_lifetime_days ->
173
        {:ok, DateTime.add(DateTime.utc_now(), days, :day)}
174
175
      _invalid ->
176
        {:error, :invalid_api_token}
160 177
    end
161 178
  end
162 179
lib/openagents/api_tokens/api_token.ex modified +1 -1

@@ -25,7 +25,7 @@ defmodule OpenAgents.ApiTokens.ApiToken do

25 25
  def create_changeset(token, attributes) do
26 26
    token
27 27
    |> cast(attributes, [:name, :scopes, :expires_at])
28
    |> validate_required([:name, :scopes, :expires_at])
28
    |> validate_required([:name, :scopes])
29 29
    |> validate_length(:name, min: 1, max: 80)
30 30
    |> validate_length(:scopes, min: 1, max: 8)
31 31
    |> check_constraint(:scopes, name: :api_tokens_scopes_present)
lib/openagents/chat/runner.ex added +98

@@ -0,0 +1,98 @@

1
defmodule OpenAgents.Chat.Runner do
2
  @moduledoc "Runs account-scoped chat requests for both LiveView and API clients."
3
4
  alias OpenAgents.Chat.OpenRouter
5
6
  @fallback_models ["openrouter/free"]
7
  @maximum_messages 100
8
  @maximum_content_bytes 128_000
9
10
  @spec request([map()], String.t(), String.t()) :: map()
11
  def request(history, message, reasoning) do
12
    %{
13
      "model" => OpenRouter.default_model(),
14
      "models" => @fallback_models,
15
      "reasoning" => OpenRouter.reasoning_effort(reasoning),
16
      "messages" =>
17
        Enum.map(history, &provider_message/1) ++ [%{"role" => "user", "content" => message}]
18
    }
19
  end
20
21
  @spec request_from_params(map()) :: {:ok, map()} | {:error, atom()}
22
  def request_from_params(params) when is_map(params) do
23
    with {:ok, messages} <- messages(params),
24
         :ok <- validate_messages(messages) do
25
      reasoning = reasoning(params)
26
27
      {:ok,
28
       %{
29
         "model" => OpenRouter.default_model(),
30
         "models" => @fallback_models,
31
         "reasoning" => reasoning,
32
         "messages" => messages
33
       }}
34
    end
35
  end
36
37
  @spec stream(map(), OpenAgents.Accounts.User.t(), (tuple() -> any()), keyword()) ::
38
          {:ok, map()} | {:error, term()}
39
  def stream(request, user, on_event, options \\ []) do
40
    provider = Keyword.get(options, :provider, configured_provider())
41
    provider.stream(request, on_event, Keyword.put(options, :tool_context, %{user: user}))
42
  end
43
44
  defp configured_provider,
45
    do: Application.get_env(:openagents, :chat_provider, OpenRouter)
46
47
  defp messages(%{"messages" => messages}) when is_list(messages), do: {:ok, messages}
48
49
  defp messages(%{"input" => input}) when is_binary(input),
50
    do: {:ok, [%{"role" => "user", "content" => input}]}
51
52
  defp messages(%{"input" => input}) when is_list(input), do: {:ok, input}
53
  defp messages(_params), do: {:error, :invalid_input}
54
55
  defp validate_messages(messages) when length(messages) in 1..@maximum_messages do
56
    valid? =
57
      Enum.all?(messages, fn
58
        %{"role" => role, "content" => content}
59
        when role in ["system", "user", "assistant"] and is_binary(content) ->
60
          byte_size(content) <= @maximum_content_bytes
61
62
        _invalid ->
63
          false
64
      end)
65
66
    if valid?, do: :ok, else: {:error, :invalid_input}
67
  end
68
69
  defp validate_messages(_messages), do: {:error, :invalid_input}
70
71
  defp reasoning(%{"reasoning" => %{"effort" => effort}}),
72
    do: OpenRouter.reasoning_effort(effort)
73
74
  defp reasoning(%{"reasoning" => effort}) when is_binary(effort),
75
    do: OpenRouter.reasoning_effort(effort)
76
77
  defp reasoning(_params), do: "high"
78
79
  defp provider_message(%{role: :assistant} = message) do
80
    %{"role" => "assistant", "content" => message.content}
81
    |> maybe_put("id", Map.get(message, :provider_message_id))
82
    |> maybe_put("status", Map.get(message, :provider_status))
83
    |> maybe_put_list("reasoning_items", Map.get(message, :provider_reasoning_items))
84
  end
85
86
  defp provider_message(%{role: role, content: content}),
87
    do: %{"role" => Atom.to_string(role), "content" => content}
88
89
  defp provider_message(%{"role" => _role, "content" => _content} = message), do: message
90
91
  defp maybe_put(message, _key, nil), do: message
92
  defp maybe_put(message, key, value), do: Map.put(message, key, value)
93
94
  defp maybe_put_list(message, key, values) when is_list(values) and values != [],
95
    do: Map.put(message, key, values)
96
97
  defp maybe_put_list(message, _key, _values), do: message
98
end
lib/openagents_web/controllers/api_token_controller.ex modified +7

@@ -11,6 +11,13 @@ defmodule OpenAgentsWeb.ApiTokenController do

11 11
  end
12 12
13 13
  def create(conn, params) do
14
    params =
15
      if Map.has_key?(params, "scopes") or Map.has_key?(params, :scopes) do
16
        params
17
      else
18
        Map.put(params, "scopes", ["account:write", "forge:write"])
19
      end
20
14 21
    case ApiTokens.create(conn.assigns.current_user, params) do
15 22
      {:ok, token, plaintext} ->
16 23
        conn
lib/openagents_web/controllers/chat_response_controller.ex added +161

@@ -0,0 +1,161 @@

1
defmodule OpenAgentsWeb.ChatResponseController do
2
  @moduledoc "Provides account-authenticated programmatic access to the chat runtime."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.Chat.Runner
7
8
  def create(conn, params) do
9
    with {:ok, request} <- Runner.request_from_params(params) do
10
      if params["stream"] == true do
11
        stream(conn, request)
12
      else
13
        complete(conn, request)
14
      end
15
    else
16
      {:error, :invalid_input} ->
17
        conn
18
        |> put_status(:unprocessable_entity)
19
        |> json(%{
20
          "error" => %{"code" => "invalid_input", "message" => "Provide valid input or messages."}
21
        })
22
    end
23
  end
24
25
  defp complete(conn, request) do
26
    {result, events} = collect(request, conn.assigns.current_user)
27
28
    case result do
29
      {:ok, completion} ->
30
        json(conn, response_body(completion, events))
31
32
      {:error, reason} ->
33
        provider_error(conn, reason)
34
    end
35
  end
36
37
  defp stream(conn, request) do
38
    conn =
39
      conn
40
      |> put_resp_header("content-type", "text/event-stream; charset=utf-8")
41
      |> put_resp_header("cache-control", "no-store")
42
      |> put_resp_header("x-accel-buffering", "no")
43
      |> send_chunked(200)
44
45
    owner = self()
46
    stream_id = make_ref()
47
48
    task =
49
      Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
50
        Runner.stream(request, conn.assigns.current_user, fn event ->
51
          send(owner, {:account_chat_event, stream_id, event})
52
        end)
53
      end)
54
55
    stream_loop(conn, task, stream_id)
56
  end
57
58
  defp stream_loop(conn, task, stream_id) do
59
    receive do
60
      {:account_chat_event, ^stream_id, event} ->
61
        case chunk(conn, sse(event_payload(event))) do
62
          {:ok, conn} -> stream_loop(conn, task, stream_id)
63
          {:error, _closed} -> conn
64
        end
65
66
      {ref, {:ok, completion}} when ref == task.ref ->
67
        Process.demonitor(task.ref, [:flush])
68
69
        {:ok, conn} =
70
          chunk(
71
            conn,
72
            sse(%{"type" => "response.completed", "response" => response_body(completion, [])})
73
          )
74
75
        conn
76
77
      {ref, {:error, reason}} when ref == task.ref ->
78
        Process.demonitor(task.ref, [:flush])
79
80
        {:ok, conn} =
81
          chunk(conn, sse(%{"type" => "response.failed", "error" => error_body(reason)}))
82
83
        conn
84
85
      {:DOWN, ref, :process, _pid, reason} when ref == task.ref ->
86
        {:ok, conn} =
87
          chunk(conn, sse(%{"type" => "response.failed", "error" => error_body(reason)}))
88
89
        conn
90
    after
91
      130_000 ->
92
        Task.shutdown(task, :brutal_kill)
93
94
        {:ok, conn} =
95
          chunk(conn, sse(%{"type" => "response.failed", "error" => error_body(:timeout)}))
96
97
        conn
98
    end
99
  end
100
101
  defp collect(request, user) do
102
    key = {__MODULE__, make_ref()}
103
    Process.put(key, [])
104
105
    result =
106
      Runner.stream(request, user, fn event -> Process.put(key, [event | Process.get(key)]) end)
107
108
    events = key |> Process.get() |> Enum.reverse()
109
    Process.delete(key)
110
    {result, events}
111
  end
112
113
  defp response_body(completion, events) do
114
    %{
115
      "id" => completion["id"] || completion["assistant_message_id"],
116
      "object" => "response",
117
      "status" => "completed",
118
      "model" => completion["model"],
119
      "output_text" => completion["assistant_content"] || "",
120
      "reasoning" => completion["reasoning_summary"],
121
      "reasoning_items" => completion["reasoning_items"] || [],
122
      "tool_calls" => completion["tool_calls"] || [],
123
      "events" => Enum.map(events, &event_payload/1)
124
    }
125
  end
126
127
  defp event_payload({:text_delta, delta}),
128
    do: %{"type" => "response.output_text.delta", "delta" => delta}
129
130
  defp event_payload({:reasoning_delta, delta}),
131
    do: %{"type" => "response.reasoning.delta", "delta" => delta}
132
133
  defp event_payload({:tool_call_started, call}),
134
    do: %{"type" => "response.tool_call.started", "tool_call" => call}
135
136
  defp event_payload({:tool_call_completed, call}),
137
    do: %{"type" => "response.tool_call.completed", "tool_call" => call}
138
139
  defp event_payload({:tool_call_failed, call}),
140
    do: %{"type" => "response.tool_call.failed", "tool_call" => call}
141
142
  defp event_payload(event), do: %{"type" => "response.event", "data" => inspect(event)}
143
144
  defp sse(payload), do: "data: " <> Jason.encode!(payload) <> "\n\n"
145
146
  defp provider_error(conn, reason) do
147
    conn
148
    |> put_status(:bad_gateway)
149
    |> json(%{"error" => error_body(reason)})
150
  end
151
152
  defp error_body(reason),
153
    do: %{
154
      "code" => error_code(reason),
155
      "message" => "The chat provider could not complete the request."
156
    }
157
158
  defp error_code({:provider_error, code, _detail}), do: code
159
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
160
  defp error_code(_reason), do: "provider_error"
161
end
lib/openagents_web/controllers/forge_user_controller.ex modified +4 -1

@@ -13,7 +13,7 @@ defmodule OpenAgentsWeb.ForgeUserController do

13 13
        json(conn, %{
14 14
          "id" => user.github_id,
15 15
          "login" => user.github_login,
16
          "token_expires_at" => DateTime.to_iso8601(conn.assigns.api_token.expires_at),
16
          "token_expires_at" => serialized_expiration(conn.assigns.api_token.expires_at),
17 17
          "namespaces" =>
18 18
            Enum.map(namespaces, fn namespace ->
19 19
              %{

@@ -29,6 +29,9 @@ defmodule OpenAgentsWeb.ForgeUserController do

29 29
    end
30 30
  end
31 31
32
  defp serialized_expiration(nil), do: nil
33
  defp serialized_expiration(%DateTime{} = expires_at), do: DateTime.to_iso8601(expires_at)
34
32 35
  defp render_error(conn, :github_connection_required),
33 36
    do: error(conn, :forbidden, "github_connection_required", "Connect GitHub to continue")
34 37
lib/openagents_web/live/api_tokens_live.ex modified +17 -11

@@ -21,7 +21,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

21 21
  def handle_event("create", %{"api_token" => params}, socket) do
22 22
    case ApiTokens.create(socket.assigns.current_user, %{
23 23
           "name" => params["name"],
24
           "scopes" => ["forge:write"],
24
           "scopes" => ["account:write", "forge:write"],
25 25
           "lifetime_days" => params["lifetime_days"]
26 26
         }) do
27 27
      {:ok, token, plaintext} ->

@@ -32,7 +32,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

32 32
         |> stream_insert(:tokens, token, at: 0)}
33 33
34 34
      {:error, _invalid} ->
35
        {:noreply, put_flash(socket, :error, "Choose a name and a lifetime from 1 to 90 days.")}
35
        {:noreply, put_flash(socket, :error, "Choose a name and a valid lifetime.")}
36 36
    end
37 37
  end
38 38

@@ -55,7 +55,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

55 55
        <header class="space-y-2">
56 56
          <h1 class="text-3xl font-semibold tracking-tight">API tokens</h1>
57 57
          <p class="text-muted-foreground">
58
            Create an expiring credential for CLI forge writes. Tokens carry only <code>forge:write</code>, are stored as digests, and are shown once.
58
            Create a credential for API and CLI access to your account. Tokens are stored as digests and shown once. New tokens do not expire unless you choose a lifetime.
59 59
          </p>
60 60
        </header>
61 61

@@ -78,13 +78,16 @@ defmodule OpenAgentsWeb.ApiTokensLive do

78 78
              <.input field={@form[:name]} placeholder="Release CLI" required />
79 79
            </.field>
80 80
            <.field>
81
              <.label for={@form[:lifetime_days].id}>Lifetime in days</.label>
81
              <.label for={@form[:lifetime_days].id}>Lifetime</.label>
82 82
              <.input
83 83
                field={@form[:lifetime_days]}
84
                type="number"
85
                min="1"
86
                max="90"
87
                required
84
                type="select"
85
                options={[
86
                  {"Does not expire", "never"},
87
                  {"7 days", "7"},
88
                  {"30 days", "30"},
89
                  {"90 days", "90"}
90
                ]}
88 91
              />
89 92
            </.field>
90 93
            <.button id="create-api-token" type="submit" variant={:primary}>

@@ -97,14 +100,14 @@ defmodule OpenAgentsWeb.ApiTokensLive do

97 100
          <h2 id="api-token-list-heading" class="text-xl font-semibold">Credentials</h2>
98 101
          <div id="api-tokens" phx-update="stream" class="space-y-3">
99 102
            <.empty id="api-tokens-empty" title="No API tokens" class="hidden only:block">
100
              Create one when a CLI needs forge write access.
103
              Create one when an API client or CLI needs access to your account.
101 104
            </.empty>
102 105
            <.card :for={{id, token} <- @streams.tokens} id={id}>
103 106
              <div class="flex flex-wrap items-center justify-between gap-4">
104 107
                <div>
105 108
                  <strong>{token.name}</strong>
106 109
                  <p class="text-sm text-muted-foreground">
107
                    {Enum.join(token.scopes, ", ")} · expires {format_time(token.expires_at)}
110
                    {Enum.join(token.scopes, ", ")} · {expiration(token.expires_at)}
108 111
                  </p>
109 112
                  <.badge :if={token.revoked_at} variant={:danger}>REVOKED</.badge>
110 113
                </div>

@@ -128,9 +131,12 @@ defmodule OpenAgentsWeb.ApiTokensLive do

128 131
  end
129 132
130 133
  defp token_form do
131
    to_form(%{"name" => "", "lifetime_days" => "30"}, as: :api_token)
134
    to_form(%{"name" => "", "lifetime_days" => "never"}, as: :api_token)
132 135
  end
133 136
137
  defp expiration(nil), do: "does not expire"
138
  defp expiration(%DateTime{} = value), do: "expires #{format_time(value)}"
139
134 140
  defp format_time(%DateTime{} = value),
135 141
    do: value |> DateTime.truncate(:second) |> DateTime.to_iso8601()
136 142
end
lib/openagents_web/live/chat_placeholder_live.ex modified +5 -39

@@ -9,7 +9,7 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

9 9
10 10
  use OpenAgentsWeb, :live_view
11 11
12
  alias OpenAgents.Chat.OpenRouter
12
  alias OpenAgents.Chat.{OpenRouter, Runner}
13 13
14 14
  @reasoning_options [
15 15
    {"Reasoning off", "none"},

@@ -357,22 +357,12 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

357 357
    to_form(%{"message" => "", "reasoning" => reasoning}, as: :chat)
358 358
  end
359 359
360
  defp local_chat_request(messages, message, reasoning) do
361
    %{
362
      "model" => OpenRouter.default_model(),
363
      "models" => ["openrouter/free"],
364
      "reasoning" => reasoning,
365
      "messages" =>
366
        Enum.map(messages, &provider_message/1) ++ [%{"role" => "user", "content" => message}]
367
    }
368
  end
369
370 360
  defp submit_message(socket, message, reasoning) do
371 361
    stream_id = System.unique_integer([:positive, :monotonic])
372 362
    owner = self()
373 363
374 364
    request =
375
      local_chat_request(
365
      Runner.request(
376 366
        Enum.filter(socket.assigns.messages, fn message -> message.history? end),
377 367
        message,
378 368
        reasoning

@@ -380,10 +370,11 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

380 370
381 371
    task =
382 372
      Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
383
        OpenRouter.stream(
373
        Runner.stream(
384 374
          request,
375
          socket.assigns.current_user,
385 376
          fn event -> send(owner, {:openrouter_stream_event, stream_id, event}) end,
386
          tool_context: %{user: socket.assigns.current_user}
377
          []
387 378
        )
388 379
      end)
389 380

@@ -444,31 +435,6 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

444 435
    update(socket, :messages, &(&1 ++ [assistant]))
445 436
  end
446 437
447
  defp provider_message(%{role: :assistant} = message) do
448
    %{"role" => "assistant", "content" => message.content}
449
    |> maybe_put_provider_message_id(message.provider_message_id)
450
    |> maybe_put_provider_status(message.provider_status)
451
    |> maybe_put_provider_reasoning_items(message.provider_reasoning_items)
452
  end
453
454
  defp provider_message(%{role: role, content: content}),
455
    do: %{"role" => Atom.to_string(role), "content" => content}
456
457
  defp maybe_put_provider_message_id(message, id) when is_binary(id),
458
    do: Map.put(message, "id", id)
459
460
  defp maybe_put_provider_message_id(message, _id), do: message
461
462
  defp maybe_put_provider_status(message, status) when is_binary(status),
463
    do: Map.put(message, "status", status)
464
465
  defp maybe_put_provider_status(message, _status), do: message
466
467
  defp maybe_put_provider_reasoning_items(message, items) when is_list(items) and items != [],
468
    do: Map.put(message, "reasoning_items", items)
469
470
  defp maybe_put_provider_reasoning_items(message, _items), do: message
471
472 438
  defp error_message(:missing_api_key), do: "OpenRouter is not configured for this environment."
473 439
  defp error_message(:rate_limited), do: "OpenRouter is rate-limited. Try again later."
474 440
lib/openagents_web/route_authority.ex modified +3

@@ -187,6 +187,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

187 187
  defp policy(%{path: "/api/inference/proxy"}),
188 188
    do: declaration(:internal_service, "scoped inference grant", "inference:invoke", true)
189 189
190
  defp policy(%{path: "/api/v1/chat/responses", verb: :post}),
191
    do: declaration(:authenticated_api, "first-party account bearer token", "account:write", true)
192
190 193
  defp policy(%{path: "/api/v3/device/authorizations" <> _path, verb: :post}),
191 194
    do:
192 195
      declaration(
lib/openagents_web/router.ex modified +12

@@ -42,6 +42,12 @@ defmodule OpenAgentsWeb.Router do

42 42
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
43 43
  end
44 44
45
  pipeline :account_api do
46
    plug :accepts, ["json"]
47
    plug OpenAgentsWeb.Plugs.RequestOrigin
48
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "account:write"
49
  end
50
45 51
  pipeline :optional_forge_api do
46 52
    plug :accepts, ["json"]
47 53
    plug OpenAgentsWeb.Plugs.RequestOrigin

@@ -203,6 +209,12 @@ defmodule OpenAgentsWeb.Router do

203 209
    delete "/computer-agent-jobs/:id", ComputerAgentJobsController, :delete
204 210
  end
205 211
212
  scope "/api/v1", OpenAgentsWeb do
213
    pipe_through :account_api
214
215
    post "/chat/responses", ChatResponseController, :create
216
  end
217
206 218
  scope "/admin", OpenAgentsWeb do
207 219
    pipe_through [:browser, :authenticated, :operator]
208 220
priv/docs/api-tokens.md modified +20

@@ -17,6 +17,10 @@ not authenticate repository API or Git operations.

17 17
Create a token and copy it immediately. Only a hash is stored, so the value
18 18
cannot be shown again — if you lose it, revoke it and create another.
19 19
20
Manual tokens do not expire by default. You can choose a 7-, 30-, or 90-day
21
lifetime. A manual token carries `account:write` for account APIs and
22
`forge:write` for repository APIs.
23
20 24
## Using a token
21 25
22 26
Send it as a bearer token:

@@ -34,3 +38,19 @@ on its next call.

34 38
## Understand the scope
35 39
36 40
A token acts as you. It reaches what your account reaches and nothing more.
41
The server derives your identity from the token and ignores caller-supplied
42
identity claims.
43
44
## Call chat programmatically
45
46
`POST /api/v1/chat/responses` uses the same model, repository tools, reasoning
47
events, and tool-result events as `/chat`.
48
49
```
50
curl https://openagents.com/api/v1/chat/responses \
51
  -H "Authorization: Bearer $OPENAGENTS_TOKEN" \
52
  -H "Content-Type: application/json" \
53
  -d '{"input":"Read the README of my connected repository."}'
54
```
55
56
Add `"stream":true` to receive server-sent events.
priv/docs/rest-api.md modified +12

@@ -36,6 +36,18 @@ POST /api/v3/orgs/:org/repos/imports

36 36
GET    /api/v3/repository-imports/:id
37 37
```
38 38
39
## Chat responses
40
41
```text
42
POST /api/v1/chat/responses
43
```
44
45
This endpoint requires an `oa_pat_` token with `account:write`. Send `input` as
46
a string, or send `messages` as a list of `system`, `user`, and `assistant`
47
messages. Set `stream` to `true` for server-sent reasoning, tool-call,
48
tool-result, text, and completion events. The endpoint uses the same server
49
model and account-scoped repository tools as `/chat`.
50
39 51
Repository writes require an `Idempotency-Key` header. The published
40 52
[`openagents.repositories.v1` contract](/api/contracts/repositories-v1.json)
41 53
defines request authority, lifecycle states, pagination, and stable error
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -229,7 +229,8 @@

229 229
    20260822082657,
230 230
    20260822120326,
231 231
    20260822132511,
232
    20260822153929
232
    20260822153929,
233
    20260822223012
233 234
  ],
234 235
  "required_tables": [
235 236
    "users",
priv/repo/migrations/20260822223012_allow_non_expiring_account_api_tokens.exs added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgents.Repo.Migrations.AllowNonExpiringAccountApiTokens do
2
  use Ecto.Migration
3
4
  def up do
5
    drop constraint(:api_tokens, :api_tokens_expiry_after_creation)
6
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
7
8
    alter table(:api_tokens) do
9
      modify :expires_at, :utc_datetime_usec, null: true
10
    end
11
12
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
13
             check: "scopes <@ ARRAY['account:write', 'forge:write']::varchar[]"
14
           )
15
16
    create constraint(:api_tokens, :api_tokens_expiry_after_creation,
17
             check: "expires_at IS NULL OR expires_at > inserted_at"
18
           )
19
  end
20
21
  def down do
22
    drop constraint(:api_tokens, :api_tokens_expiry_after_creation)
23
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
24
25
    execute("DELETE FROM api_tokens WHERE expires_at IS NULL OR 'account:write' = ANY(scopes)")
26
27
    alter table(:api_tokens) do
28
      modify :expires_at, :utc_datetime_usec, null: false
29
    end
30
31
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
32
             check: "scopes <@ ARRAY['forge:write']::varchar[]"
33
           )
34
35
    create constraint(:api_tokens, :api_tokens_expiry_after_creation,
36
             check: "expires_at > inserted_at"
37
           )
38
  end
39
end
test/openagents/api_tokens_test.exs modified +15

@@ -61,6 +61,21 @@ defmodule OpenAgents.ApiTokensTest do

61 61
             })
62 62
  end
63 63
64
  test "account tokens do not expire by default and remain account-scoped" do
65
    {:ok, user} = Accounts.upsert_github_user(profile(803, "api-account"))
66
67
    assert {:ok, token, plaintext} =
68
             ApiTokens.create(user, %{
69
               name: "account automation",
70
               scopes: ["account:write", "forge:write"]
71
             })
72
73
    assert is_nil(token.expires_at)
74
    assert {:ok, ^user, _used} = ApiTokens.authenticate(plaintext, "account:write")
75
    assert {:ok, ^user, _used} = ApiTokens.authenticate(plaintext, "forge:write")
76
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "admin")
77
  end
78
64 79
  defp profile(id, login) do
65 80
    %{
66 81
      github_id: id,
test/openagents_web/controllers/api_token_controller_test.exs modified +17

@@ -29,6 +29,23 @@ defmodule OpenAgentsWeb.ApiTokenControllerTest do

29 29
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
30 30
  end
31 31
32
  test "browser issuance defaults to a non-expiring account credential", %{conn: conn} do
33
    user = github_user("default-account-token-owner")
34
    browser = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
35
36
    issued = post(browser, ~p"/api/tokens", %{name: "programmatic client"})
37
38
    assert %{
39
             "token" => plaintext,
40
             "credential" => %{
41
               "expires_at" => nil,
42
               "scopes" => ["account:write", "forge:write"]
43
             }
44
           } = json_response(issued, 201)
45
46
    assert {:ok, ^user, _token} = ApiTokens.authenticate(plaintext, "account:write")
47
  end
48
32 49
  test "forge mutations refuse missing, malformed, expired, and revoked credentials", %{
33 50
    conn: conn
34 51
  } do
test/openagents_web/controllers/chat_response_controller_test.exs added +95

@@ -0,0 +1,95 @@

1
defmodule OpenAgentsWeb.ChatResponseControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  setup do
5
    previous = Application.get_env(:openagents, :chat_provider)
6
    Application.put_env(:openagents, :chat_provider, OpenAgents.ChatProviderStub)
7
8
    on_exit(fn ->
9
      if previous do
10
        Application.put_env(:openagents, :chat_provider, previous)
11
      else
12
        Application.delete_env(:openagents, :chat_provider)
13
      end
14
    end)
15
  end
16
17
  test "an account token runs chat with the issuing user's authority", %{conn: conn} do
18
    user = github_user("account-chat-owner")
19
    conn = put_account_api_token_for_user(conn, user)
20
21
    response = post(conn, ~p"/api/v1/chat/responses", %{input: "Read my repository."})
22
23
    assert %{
24
             "status" => "completed",
25
             "model" => "test/provider",
26
             "output_text" => output,
27
             "events" => events
28
           } = json_response(response, 200)
29
30
    assert output == "#{user.github_login}: Read my repository."
31
32
    assert Enum.map(events, & &1["type"]) == [
33
             "response.reasoning.delta",
34
             "response.tool_call.started",
35
             "response.tool_call.completed",
36
             "response.output_text.delta"
37
           ]
38
  end
39
40
  test "the streaming response exposes reasoning, tools, text, and completion", %{conn: conn} do
41
    user = github_user("account-chat-stream-owner")
42
43
    response =
44
      conn
45
      |> put_account_api_token_for_user(user)
46
      |> post(~p"/api/v1/chat/responses", %{input: "Stream this.", stream: true})
47
48
    body = response.resp_body
49
    assert body =~ "response.reasoning.delta"
50
    assert body =~ "response.tool_call.started"
51
    assert body =~ "response.tool_call.completed"
52
    assert body =~ "response.output_text.delta"
53
    assert body =~ "response.completed"
54
  end
55
56
  test "missing and forge-only tokens cannot control an account", %{conn: conn} do
57
    path = ~p"/api/v1/chat/responses"
58
    assert conn |> post(path, %{input: "Denied"}) |> json_response(401)
59
60
    user = github_user("forge-only-chat-owner")
61
62
    {:ok, _credential, plaintext} =
63
      OpenAgents.ApiTokens.create(user, %{
64
        name: "forge only",
65
        scopes: ["forge:write"],
66
        lifetime_days: 1
67
      })
68
69
    denied =
70
      conn
71
      |> put_req_header("authorization", "Bearer " <> plaintext)
72
      |> post(path, %{input: "Denied"})
73
74
    assert json_response(denied, 401) == %{"error" => "invalid_api_token"}
75
  end
76
77
  test "revocation immediately removes account authority", %{conn: conn} do
78
    user = github_user("revoked-account-chat-owner")
79
80
    {:ok, credential, plaintext} =
81
      OpenAgents.ApiTokens.create(user, %{
82
        name: "revoked account client",
83
        scopes: ["account:write", "forge:write"]
84
      })
85
86
    assert {:ok, _credential} = OpenAgents.ApiTokens.revoke(user, credential.id)
87
88
    denied =
89
      conn
90
      |> put_req_header("authorization", "Bearer " <> plaintext)
91
      |> post(~p"/api/v1/chat/responses", %{input: "Denied"})
92
93
    assert json_response(denied, 401) == %{"error" => "invalid_api_token"}
94
  end
95
end
test/openagents_web/controllers/forge_user_controller_test.exs modified +1 -2

@@ -47,12 +47,11 @@ defmodule OpenAgentsWeb.ForgeUserControllerTest do

47 47
             "namespaces" => [
48 48
               %{"id" => namespace_id, "login" => "octavia", "type" => "user"}
49 49
             ],
50
             "token_expires_at" => token_expires_at
50
             "token_expires_at" => nil
51 51
           } = json_response(response, 200)
52 52
53 53
    assert github_id == user.github_id
54 54
    assert namespace_id == user.github_id
55
    assert {:ok, _date_time, 0} = DateTime.from_iso8601(token_expires_at)
56 55
    assert get_resp_header(response, "cache-control") == ["no-store"]
57 56
  end
58 57
test/support/chat_provider_stub.ex added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.ChatProviderStub do
2
  @moduledoc false
3
4
  def stream(request, on_event, options) do
5
    user = options |> Keyword.fetch!(:tool_context) |> Map.fetch!(:user)
6
    message = request["messages"] |> List.last() |> Map.fetch!("content")
7
    on_event.({:reasoning_delta, "Checking account context."})
8
9
    on_event.(
10
      {:tool_call_started, %{"id" => "call_1", "name" => "test_account", "arguments" => %{}}}
11
    )
12
13
    on_event.(
14
      {:tool_call_completed,
15
       %{"id" => "call_1", "name" => "test_account", "result" => %{"login" => user.github_login}}}
16
    )
17
18
    output = "#{user.github_login}: #{message}"
19
    on_event.({:text_delta, output})
20
21
    {:ok,
22
     %{
23
       "object" => "response",
24
       "model" => "test/provider",
25
       "assistant_message_id" => "msg_test",
26
       "assistant_content" => output,
27
       "reasoning_summary" => "Checking account context."
28
     }}
29
  end
30
end
test/support/conn_case.ex modified +10

@@ -122,6 +122,16 @@ defmodule OpenAgentsWeb.ConnCase do

122 122
    Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> plaintext)
123 123
  end
124 124
125
  def put_account_api_token_for_user(conn, user) do
126
    {:ok, _credential, plaintext} =
127
      OpenAgents.ApiTokens.create(user, %{
128
        name: "test account client",
129
        scopes: ["account:write", "forge:write"]
130
      })
131
132
    Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> plaintext)
133
  end
134
125 135
  @doc """
126 136
  Logs in an account and grants it operator access for the duration of the test.
127 137
  """

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