Step 7: port /api/inference/proxy and /controller/pairings.

126737fd61d6 · AtlantisPleb · · parent 1905e683088b

Step 7: port /api/inference/proxy and /controller/pairings.

- Adds InferenceProxyController and ControllerPairingController from Sarah.

- Wires /api/inference/proxy, /controller/pairings, and /controller/pairings/:id.

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

  • added lib/openagents_web/controllers/controller_pairing_controller.ex
  • added lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified lib/openagents_web/router.ex

Diff

3 files changed, +383 -0

lib/openagents_web/controllers/controller_pairing_controller.ex added +92

@@ -0,0 +1,92 @@

1
defmodule OpenAgentsWeb.ControllerPairingController do
2
  @moduledoc """
3
  Device-style pairing API for the sarah-computer-controller CLI.
4
5
  `create` is unauthenticated: it registers a pending pairing and returns a
6
  short code the signed-in owner approves in the browser. `show` is polled by
7
  the CLI with the poll secret and hands the machine token over exactly once.
8
  """
9
10
  use OpenAgentsWeb, :controller
11
12
  alias OpenAgents.Computer
13
  alias OpenAgents.Machines
14
15
  plug :verify_enabled
16
17
  def create(conn, params) do
18
    attributes = %{
19
      "name" => params["name"],
20
      "tier" => params["tier"] || "probe",
21
      "platform" => bounded(params["platform"]),
22
      "agent_version" => bounded(params["agent_version"]),
23
      "roots" => bounded_roots(params["roots"])
24
    }
25
26
    case Machines.start_pairing(attributes) do
27
      {:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} ->
28
        json(conn, %{
29
          "pairing_id" => pairing.id,
30
          "code" => format_code(code),
31
          "poll_secret" => poll_secret,
32
          "verify_url" => url(~p"/computers"),
33
          "expires_at" => DateTime.to_iso8601(pairing.expires_at),
34
          "interval_seconds" => 3
35
        })
36
37
      {:error, _changeset} ->
38
        conn |> put_status(:unprocessable_entity) |> json(%{"error" => "invalid_pairing"})
39
    end
40
  end
41
42
  def show(conn, %{"id" => pairing_id}) do
43
    poll_secret = get_req_header(conn, "x-pairing-secret") |> List.first("")
44
45
    case Machines.claim_pairing(pairing_id, poll_secret) do
46
      {:ok, %{token: token, machine_id: machine_id, name: name}} ->
47
        json(conn, %{
48
          "status" => "approved",
49
          "machine_id" => machine_id,
50
          "name" => name,
51
          "token" => token
52
        })
53
54
      {:error, :pairing_pending} ->
55
        json(conn, %{"status" => "pending"})
56
57
      {:error, :pairing_expired} ->
58
        conn |> put_status(:gone) |> json(%{"status" => "expired"})
59
60
      {:error, _reason} ->
61
        conn |> put_status(:not_found) |> json(%{"error" => "pairing_not_found"})
62
    end
63
  end
64
65
  defp verify_enabled(conn, _options) do
66
    if Computer.enabled?() do
67
      conn
68
    else
69
      conn
70
      |> put_status(:not_found)
71
      |> json(%{"error" => "computer_controller_disabled"})
72
      |> halt()
73
    end
74
  end
75
76
  defp bounded(value) when is_binary(value), do: String.slice(value, 0, 40)
77
  defp bounded(_value), do: nil
78
79
  defp bounded_roots(roots) when is_list(roots) do
80
    roots
81
    |> Enum.filter(&is_binary/1)
82
    |> Enum.take(16)
83
    |> Enum.map(&String.slice(&1, 0, 512))
84
  end
85
86
  defp bounded_roots(_roots), do: []
87
88
  defp format_code(<<first::binary-size(4), second::binary-size(4)>>),
89
    do: first <> "-" <> second
90
91
  defp format_code(code), do: code
92
end
lib/openagents_web/controllers/inference_proxy_controller.ex added +283

@@ -0,0 +1,283 @@

1
defmodule OpenAgentsWeb.InferenceProxyController do
2
  @moduledoc """
3
  The Sarah inference proxy: an OpenAI-compatible `/chat/completions` surface
4
  a delegated probe calls with its delegation-scoped grant as the bearer.
5
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
9
  never leaves the server (RELEASE-002) — meters token usage against the
10
  grant's budget (VOICE-010 pattern), and streams the typed provider events
11
  back as chat-completions SSE that probe's parser consumes. Provider JSON,
12
  credentials, and raw errors never cross this boundary.
13
14
  The probe→proxy hop is buffered (the provider still streams from the vendor
15
  internally); probe's transport reads the whole body before parsing, so this
16
  matches its consumer and keeps failure handling honest.
17
  """
18
19
  use OpenAgentsWeb, :controller
20
21
  require Logger
22
23
  alias OpenAgents.Inference
24
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
25
26
  def create(conn, _params) do
27
    # The :api pipeline already parsed the JSON body into body_params; the
28
    # proxy never re-reads or re-parses it.
29
    with {:ok, token} <- bearer(conn),
30
         {:ok, grant} <- resolve(token),
31
         {:ok, request} <- build_request(grant, conn.body_params) do
32
      run(conn, grant, request)
33
    else
34
      {:error, reason} -> refuse(conn, reason)
35
    end
36
  end
37
38
  # ── request assembly ────────────────────────────────────────────────────
39
40
  defp build_request(grant, %{"messages" => messages} = body) when is_list(messages) do
41
    {system, turns} = Enum.split_with(messages, &(role(&1) == "system"))
42
43
    request = %Request{
44
      # The grant pins the model; a request body cannot select another.
45
      model_id: grant.model_id,
46
      instructions: join_text(system),
47
      input: Enum.flat_map(turns, &input_message/1),
48
      tool_definitions: tool_definitions(body["tools"]),
49
      tool_outputs: tool_outputs(turns)
50
    }
51
52
    if request.input == [] do
53
      {:error, :empty_input}
54
    else
55
      {:ok, request}
56
    end
57
  end
58
59
  defp build_request(_grant, _body), do: {:error, :invalid_request}
60
61
  defp input_message(%{"role" => "tool"}), do: []
62
63
  defp input_message(message) do
64
    case content_text(message) do
65
      "" -> []
66
      text -> [%{role: role(message), content: text}]
67
    end
68
  end
69
70
  defp tool_outputs(turns) do
71
    turns
72
    |> Enum.filter(&(role(&1) == "tool"))
73
    |> Enum.map(fn message ->
74
      %ToolOutput{
75
        call_id: text(message["tool_call_id"]),
76
        output: %{"content" => content_text(message)}
77
      }
78
    end)
79
    |> Enum.reject(&(&1.call_id == ""))
80
  end
81
82
  defp tool_definitions(tools) when is_list(tools) do
83
    Enum.flat_map(tools, fn
84
      %{"function" => %{"name" => name} = function} when is_binary(name) ->
85
        [
86
          %ToolDefinition{
87
            name: name,
88
            description: text(function["description"]),
89
            input_schema: Map.get(function, "parameters", %{}),
90
            strict: false
91
          }
92
        ]
93
94
      _ ->
95
        []
96
    end)
97
  end
98
99
  defp tool_definitions(_), do: []
100
101
  # ── run + translate ─────────────────────────────────────────────────────
102
103
  defp run(conn, grant, request) do
104
    provider = Application.fetch_env!(:sarah, :provider)
105
    parent = self()
106
107
    # The provider pushes events synchronously; capture them to this process's
108
    # mailbox and drain in order once the call returns.
109
    result = provider.stream(request, fn event -> send(parent, {:proxy_event, event}) end)
110
    events = drain_events([])
111
112
    case result do
113
      :ok ->
114
        usage = usage_of(events)
115
        _ = meter(grant, usage)
116
117
        conn
118
        |> put_resp_content_type("text/event-stream")
119
        |> put_resp_header("cache-control", "no-store")
120
        |> send_resp(200, sse_body(events))
121
122
      {:error, reason} ->
123
        # A failure that produced partial usage is still metered; the probe
124
        # sees a provider error, never raw provider detail.
125
        usage = usage_of(events)
126
        if usage != %{}, do: meter(grant, usage)
127
        Logger.warning("inference proxy provider failure: #{inspect(reason)}")
128
        refuse(conn, :provider_failed)
129
    end
130
  end
131
132
  defp drain_events(acc) do
133
    receive do
134
      {:proxy_event, event} -> drain_events([event | acc])
135
    after
136
      0 -> Enum.reverse(acc)
137
    end
138
  end
139
140
  defp meter(grant, usage) when usage == %{}, do: {:ok, grant}
141
  defp meter(grant, usage), do: Inference.record_usage(grant, usage)
142
143
  defp usage_of(events) do
144
    Enum.reduce(events, %{}, fn
145
      {:usage, usage}, _acc -> usage
146
      _event, acc -> acc
147
    end)
148
  end
149
150
  # Translate the ordered provider events into a chat-completions SSE body.
151
  defp sse_body(events) do
152
    saw_tool_call = Enum.any?(events, &match?({:tool_call, _}, &1))
153
    finish_reason = if saw_tool_call, do: "tool_calls", else: "stop"
154
155
    chunks =
156
      events
157
      |> Enum.with_index()
158
      |> Enum.flat_map(fn {event, index} -> event_chunks(event, index) end)
159
160
    finish = [
161
      data(%{"choices" => [%{"index" => 0, "delta" => %{}, "finish_reason" => finish_reason}]})
162
    ]
163
164
    usage_chunk =
165
      case usage_of(events) do
166
        usage when usage == %{} -> []
167
        usage -> [data(%{"choices" => [], "usage" => wire_usage(usage)})]
168
      end
169
170
    IO.iodata_to_binary([chunks, finish, usage_chunk, "data: [DONE]\n\n"])
171
  end
172
173
  defp event_chunks({:text_delta, text}, _index) when text != "" do
174
    [data(%{"choices" => [%{"index" => 0, "delta" => %{"content" => text}}]})]
175
  end
176
177
  defp event_chunks({:tool_call, tool_call}, index) do
178
    [
179
      data(%{
180
        "choices" => [
181
          %{
182
            "index" => 0,
183
            "delta" => %{
184
              "tool_calls" => [
185
                %{
186
                  "index" => index,
187
                  "id" => tool_call.call_id,
188
                  "type" => "function",
189
                  "function" => %{
190
                    "name" => tool_call.name,
191
                    "arguments" => tool_call.raw_arguments
192
                  }
193
                }
194
              ]
195
            }
196
          }
197
        ]
198
      })
199
    ]
200
  end
201
202
  defp event_chunks(_event, _index), do: []
203
204
  defp wire_usage(usage) do
205
    input = integer(usage["input_tokens"] || usage[:input_tokens])
206
    output = integer(usage["output_tokens"] || usage[:output_tokens])
207
    total = integer(usage["total_tokens"] || usage[:total_tokens])
208
209
    %{
210
      "prompt_tokens" => input,
211
      "completion_tokens" => output,
212
      "total_tokens" => if(total > 0, do: total, else: input + output)
213
    }
214
  end
215
216
  defp data(payload), do: ["data: ", Jason.encode!(payload), "\n\n"]
217
218
  # ── auth + errors ───────────────────────────────────────────────────────
219
220
  defp bearer(conn) do
221
    case get_req_header(conn, "authorization") do
222
      ["Bearer " <> token | _] when token != "" -> {:ok, token}
223
      _ -> {:error, :missing_grant}
224
    end
225
  end
226
227
  defp resolve(token) do
228
    case Inference.resolve(token) do
229
      {:ok, grant} -> {:ok, grant}
230
      {:error, reason} -> {:error, reason}
231
    end
232
  end
233
234
  defp refuse(conn, reason) do
235
    {status, code} = status_for(reason)
236
237
    conn
238
    |> put_resp_content_type("application/json")
239
    |> send_resp(status, Jason.encode!(%{"error" => %{"code" => code}}))
240
  end
241
242
  defp status_for(:missing_grant), do: {401, "missing_grant"}
243
  defp status_for(:grant_not_found), do: {401, "invalid_grant"}
244
  defp status_for(:grant_revoked), do: {403, "grant_revoked"}
245
  defp status_for(:grant_expired), do: {403, "grant_expired"}
246
  defp status_for(:grant_exhausted), do: {429, "grant_exhausted"}
247
  defp status_for(:grant_budget_reached), do: {429, "grant_budget_reached"}
248
  defp status_for(:empty_input), do: {400, "empty_input"}
249
  defp status_for(:invalid_request), do: {400, "invalid_request"}
250
  defp status_for(:body_too_large), do: {413, "body_too_large"}
251
  defp status_for(:invalid_json), do: {400, "invalid_json"}
252
  defp status_for(:provider_failed), do: {502, "provider_failed"}
253
  defp status_for(_), do: {400, "bad_request"}
254
255
  # ── small helpers ───────────────────────────────────────────────────────
256
257
  defp role(%{"role" => role}) when is_binary(role), do: role
258
  defp role(_), do: "user"
259
260
  defp content_text(%{"content" => content}) when is_binary(content), do: content
261
262
  defp content_text(%{"content" => parts}) when is_list(parts) do
263
    parts
264
    |> Enum.map(fn
265
      %{"text" => text} when is_binary(text) -> text
266
      _ -> ""
267
    end)
268
    |> Enum.join("")
269
  end
270
271
  defp content_text(_), do: ""
272
273
  defp join_text(messages) do
274
    messages |> Enum.map(&content_text/1) |> Enum.reject(&(&1 == "")) |> Enum.join("\n")
275
  end
276
277
  defp text(value) when is_binary(value), do: value
278
  defp text(_), do: ""
279
280
  defp integer(value) when is_integer(value), do: value
281
  defp integer(value) when is_float(value), do: trunc(value)
282
  defp integer(_), do: 0
283
end
lib/openagents_web/router.ex modified +8

@@ -106,6 +106,14 @@ defmodule OpenAgentsWeb.Router do

106 106
107 107
  forward "/git", OpenAgents.Forge.GitHTTP
108 108
109
  scope "/", OpenAgentsWeb do
110
    pipe_through :api
111
112
    post "/controller/pairings", ControllerPairingController, :create
113
    get "/controller/pairings/:id", ControllerPairingController, :show
114
    post "/api/inference/proxy", InferenceProxyController, :create
115
  end
116
109 117
  scope "/api/v3", OpenAgentsWeb do
110 118
    pipe_through :api
111 119

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