Answer as an OpenResponses provider, starting with a stub

bf115c0595a1 · AtlantisPleb · · parent 5bdf3bc06ce6

Answer as an OpenResponses provider, starting with a stub

POST /api/v1/responses takes an OpenResponses request and answers
"Acknowledged." — one completed assistant message, or with stream: true
the full semantic event sequence (created, item and part boundaries,
two text deltas, completed), each event numbered. No model is
consulted. The coder's dev lane speaks this surface first, so the
client turn loop is built against the event grammar before a provider
stands behind it. Anonymous while it is a stub; auth arrives with the
loop that makes it worth protecting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

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

pushed
by user · WAL seq 383 · 2026-08-25T14:42:20.087258Z

Changed files

  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/responses_controller.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents_web/controllers/responses_controller_test.exs

Diff

4 files changed, +223 -0

lib/openagents_web/api_route_authority.ex modified +4

@@ -118,6 +118,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

118 118
    %{
119 119
      # Anonymous by design: the extension index is a public API description.
120 120
      "get /api/v1" => {:anonymous, :meta, :legacy},
121
      # Anonymous while the stub answers; required_bearer when a real loop
122
      # stands behind it.
123
      "post /api/v1/responses" => {:anonymous, :response, :envelope},
121 124
      # Anonymous by design: device authorization bootstraps credentials.
122 125
      "post /api/v1/device/authorizations" => {:anonymous, :device, :legacy},
123 126
      "post /api/v1/device/authorizations/token" => {:anonymous, :device, :legacy},

@@ -195,6 +198,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

195 198
      "get /api/v1/chat/events" => {:required_bearer, :chat, :legacy},
196 199
      "post /api/v1/chat/turns" => {:required_bearer, :chat, :legacy},
197 200
      "get /api/v1/models" => {:required_bearer, :model, :envelope},
201
      
198 202
      "post /api/v1/threads" => {:required_bearer, :thread, :envelope},
199 203
      "get /api/v1/threads" => {:required_bearer, :thread, :envelope},
200 204
      "get /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
lib/openagents_web/controllers/responses_controller.ex added +127

@@ -0,0 +1,127 @@

1
defmodule OpenAgentsWeb.ResponsesController do
2
  @moduledoc """
3
  The OpenResponses surface, opening as a stub.
4
5
  `POST /api/v1/responses` takes an OpenResponses request — `input` as a
6
  string or a list of items — and answers with one completed assistant
7
  message reading `Acknowledged.` No model is consulted and nothing is
8
  recorded. The route exists so the coder's turn loop can move onto the
9
  OpenResponses shape before a provider stands behind it.
10
11
  Both of the specification's answer shapes are served. Without `stream`,
12
  the non-streaming response object. With `"stream": true`, server-sent
13
  events carrying the semantic sequence — `response.created`,
14
  `response.output_item.added`, `response.content_part.added`,
15
  `response.output_text.delta`, the matching `done` events, and
16
  `response.completed` — each numbered by `sequence_number`, so a client
17
  built against this stub is built against the real event grammar. The text
18
  arrives in more than one delta on purpose: a client that concatenates
19
  deltas is proven here, not on the first provider.
20
21
  This codebase already speaks OpenResponses as a client
22
  (`OpenAgents.Providers.OpenAI` at `/v1/responses` upstream); this is the
23
  first time it answers as one.
24
  """
25
26
  use OpenAgentsWeb, :controller
27
28
  alias OpenAgentsWeb.ApiError
29
30
  @answer "Acknowledged."
31
32
  @doc "Answers any OpenResponses request with one acknowledged message."
33
  def create(conn, params) do
34
    case params["input"] do
35
      input when is_binary(input) and input != "" -> respond(conn, params)
36
      [_ | _] -> respond(conn, params)
37
      _missing -> ApiError.validation_failed(conn, %{"input" => ["is required"]})
38
    end
39
  end
40
41
  defp respond(conn, params) do
42
    response = response_object(params)
43
44
    if params["stream"] == true do
45
      stream(conn, response)
46
    else
47
      json(conn, response)
48
    end
49
  end
50
51
  # The whole semantic sequence for one message, numbered and in order. Built
52
  # complete rather than emitted from a loop: the stub's answer is known, and
53
  # a list the whole of which is visible here is a list a reader can check
54
  # against the specification event by event.
55
  defp stream(conn, response) do
56
    [message] = response["output"]
57
    part = %{"type" => "output_text", "text" => "", "annotations" => []}
58
    added = %{message | "status" => "in_progress", "content" => []}
59
    base = %{"item_id" => message["id"], "output_index" => 0, "content_index" => 0}
60
61
    events =
62
      [
63
        {"response.created", %{"response" => %{response | "status" => "in_progress"}}},
64
        {"response.output_item.added", %{"output_index" => 0, "item" => added}},
65
        {"response.content_part.added", Map.put(base, "part", part)},
66
        {"response.output_text.delta", Map.put(base, "delta", "Acknow")},
67
        {"response.output_text.delta", Map.put(base, "delta", "ledged.")},
68
        {"response.output_text.done", Map.put(base, "text", @answer)},
69
        {"response.content_part.done", Map.put(base, "part", %{part | "text" => @answer})},
70
        {"response.output_item.done", %{"output_index" => 0, "item" => message}},
71
        {"response.completed", %{"response" => response}}
72
      ]
73
      |> Enum.with_index()
74
      |> Enum.map(fn {{type, payload}, sequence} ->
75
        data =
76
          payload
77
          |> Map.put("type", type)
78
          |> Map.put("sequence_number", sequence)
79
          |> Jason.encode!()
80
81
        "event: #{type}\ndata: #{data}\n\n"
82
      end)
83
84
    conn =
85
      conn
86
      |> put_resp_content_type("text/event-stream")
87
      |> put_resp_header("cache-control", "no-store")
88
      |> send_chunked(200)
89
90
    Enum.reduce_while(events, conn, fn frame, conn ->
91
      case chunk(conn, frame) do
92
        {:ok, conn} -> {:cont, conn}
93
        {:error, _closed} -> {:halt, conn}
94
      end
95
    end)
96
  end
97
98
  defp response_object(params) do
99
    %{
100
      "id" => "resp_" <> identifier(),
101
      "object" => "response",
102
      "created_at" => System.os_time(:second),
103
      "status" => "completed",
104
      "model" => model_of(params),
105
      "output" => [
106
        %{
107
          "type" => "message",
108
          "id" => "msg_" <> identifier(),
109
          "role" => "assistant",
110
          "status" => "completed",
111
          "content" => [
112
            %{"type" => "output_text", "text" => @answer, "annotations" => []}
113
          ]
114
        }
115
      ],
116
      "error" => nil,
117
      "usage" => %{"input_tokens" => 0, "output_tokens" => 0, "total_tokens" => 0}
118
    }
119
  end
120
121
  # Echoed when the caller named one, and the product name when not: no vendor
122
  # default leaks out of a route no vendor stands behind.
123
  defp model_of(%{"model" => model}) when is_binary(model) and model != "", do: model
124
  defp model_of(_params), do: "openagents-coder"
125
126
  defp identifier, do: Base.encode16(:crypto.strong_rand_bytes(12), case: :lower)
127
end
lib/openagents_web/router.ex modified +7

@@ -489,6 +489,13 @@ defmodule OpenAgentsWeb.Router do

489 489
    pipe_through :api
490 490
491 491
    post "/agents/register", AgentController, :register
492
493
    # The OpenResponses surface, currently a stub that acknowledges every
494
    # request: the coder's dev lane speaks it first, and a real loop stands
495
    # behind it later. Anonymous while it is a stub — a canned sentence
496
    # spends nothing and reads nothing — and the auth flips to a required
497
    # bearer with the loop that makes it worth protecting.
498
    post "/responses", ResponsesController, :create
492 499
  end
493 500
494 501
  scope "/api/v1", OpenAgentsWeb do
test/openagents_web/controllers/responses_controller_test.exs added +85

@@ -0,0 +1,85 @@

1
defmodule OpenAgentsWeb.ResponsesControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  test "answers an anonymous caller while it is a stub", %{conn: conn} do
5
    conn = post(conn, ~p"/api/v1/responses", %{input: "hello"})
6
7
    assert %{"status" => "completed"} = json_response(conn, 200)
8
  end
9
10
  test "acknowledges a string input in the OpenResponses shape", %{conn: conn} do
11
    conn =
12
      conn
13
      |> put_chat_api_token("responses-ack")
14
      |> post(~p"/api/v1/responses", %{input: "hello there"})
15
16
    assert %{
17
             "object" => "response",
18
             "status" => "completed",
19
             "model" => "openagents-coder",
20
             "output" => [message]
21
           } = json_response(conn, 200)
22
23
    assert %{
24
             "type" => "message",
25
             "role" => "assistant",
26
             "status" => "completed",
27
             "content" => [%{"type" => "output_text", "text" => "Acknowledged."}]
28
           } = message
29
  end
30
31
  test "acknowledges an item-list input and echoes the model", %{conn: conn} do
32
    conn =
33
      conn
34
      |> put_chat_api_token("responses-items")
35
      |> post(~p"/api/v1/responses", %{
36
        model: "anything",
37
        input: [%{role: "user", content: [%{type: "input_text", text: "hi"}]}]
38
      })
39
40
    assert %{"model" => "anything", "output" => [_]} = json_response(conn, 200)
41
  end
42
43
  test "refuses a request with no input, in the envelope", %{conn: conn} do
44
    conn =
45
      conn
46
      |> put_chat_api_token("responses-empty")
47
      |> post(~p"/api/v1/responses", %{})
48
49
    body = json_response(conn, 422)
50
    assert body["code"] == "validation_failed"
51
    assert body["errors"] == %{"input" => ["is required"]}
52
  end
53
54
  test "streams the semantic event sequence when asked to", %{conn: conn} do
55
    conn =
56
      conn
57
      |> put_chat_api_token("responses-stream")
58
      |> post(~p"/api/v1/responses", %{input: "hello", stream: true})
59
60
    assert [type] = get_resp_header(conn, "content-type")
61
    assert type =~ "text/event-stream"
62
    body = response(conn, 200)
63
64
    # The grammar, in order, each event numbered.
65
    for {event, at} <- Enum.with_index(~w(
66
          response.created
67
          response.output_item.added
68
          response.content_part.added
69
          response.output_text.delta
70
          response.output_text.delta
71
          response.output_text.done
72
          response.content_part.done
73
          response.output_item.done
74
          response.completed
75
        )) do
76
      assert body =~ "event: " <> event
77
      assert body =~ ~s("sequence_number":#{at})
78
    end
79
80
    # The text arrives in pieces a client must concatenate.
81
    assert body =~ ~s("delta":"Acknow")
82
    assert body =~ ~s("delta":"ledged.")
83
    refute body =~ ~s("delta":"Acknowledged.")
84
  end
85
end

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