Answer the responses surface with real inference, streamed

fa23f3047eca · AtlantisPleb · · parent eb48e65a444c

Answer the responses surface with real inference, streamed

POST /api/v1/responses now answers from the model's provider — default
gemini-3.7-flash — instead of the acknowledgement stub. The caller's
instructions, input items, max_output_tokens, and catalog model pass
through; the system prompt is one sentence when none is given. The
streaming shape flushes each provider delta as its own semantic event
the moment it arrives (this surface streams for real, where the
chat-completions proxy deliberately buffers), reasoning deltas ride
reasoning_summary_text events, usage lands on response.completed, and
a provider that dies mid-stream answers response.failed — the
specification's shape for exactly that.

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 398 · 2026-08-25T15:26:08.450499Z

Changed files

  • modified lib/openagents_web/controllers/responses_controller.ex
  • modified test/openagents_web/controllers/responses_controller_test.exs
  • added test/support/providers/failing_test_provider.ex

Diff

3 files changed, +476 -151

lib/openagents_web/controllers/responses_controller.ex modified +319 -82

@@ -1,85 +1,171 @@

1 1
defmodule OpenAgentsWeb.ResponsesController do
2 2
  @moduledoc """
3
  The OpenResponses surface, opening as a stub.
3
  The OpenResponses surface, answered by real inference.
4 4
5 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.
6
  string or a list of items, optional `instructions`, optional
7
  `max_output_tokens`, optional catalog `model` — and answers from the
8
  model's provider. The default model is `gemini-3.7-flash`.
10 9
11 10
  Both of the specification's answer shapes are served. Without `stream`,
12 11
  the non-streaming response object. With `"stream": true`, server-sent
13 12
  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.
13
  `response.output_item.added`, `response.content_part.added`, a
14
  `response.output_text.delta` per provider delta (and
15
  `response.reasoning_summary_text.delta` where the model thinks out loud),
16
  the matching `done` events, and `response.completed` — each numbered by
17
  `sequence_number` and flushed as it happens, so the client reads tokens
18
  while the provider is still writing them. A provider failure after the
19
  stream has opened arrives as `response.failed`, which is the
20
  specification's shape for exactly that.
21
22
  The system prompt is deliberately minimal: the caller's `instructions`
23
  when given, one sentence otherwise. This surface adds no context of its
24
  own — what the coder wants the model to know arrives in the request.
25
26
  This codebase has long spoken OpenResponses as a client
27
  (`OpenAgents.Providers.OpenAI` at `/v1/responses` upstream); this is where
28
  it answers as one.
24 29
  """
25 30
26 31
  use OpenAgentsWeb, :controller
27 32
33
  alias OpenAgents.Inference.Models
34
  alias OpenAgents.Providers.Request
28 35
  alias OpenAgentsWeb.ApiError
29 36
30
  @answer "Acknowledged."
37
  @default_model "gemini-3.7-flash"
38
  @default_instructions "You are OpenAgents Coder. Answer directly and concisely."
31 39
32
  @doc "Answers any OpenResponses request with one acknowledged message."
33 40
  def create(conn, params) do
41
    with {:ok, input} <- input_of(params),
42
         {:ok, model} <- model_of(params),
43
         :ok <- serving(model) do
44
      request = build_request(model, input, params)
45
46
      if params["stream"] == true do
47
        stream(conn, model, request)
48
      else
49
        collect(conn, model, request)
50
      end
51
    else
52
      {:error, :input_missing} ->
53
        ApiError.validation_failed(conn, %{"input" => ["is required"]})
54
55
      {:error, {:model_not_served, requested}} ->
56
        ApiError.validation_failed(conn, %{"model" => ["`#{requested}` is not in the catalog"]})
57
58
      {:error, :model_unavailable} ->
59
        ApiError.refuse(conn, "model_unavailable")
60
    end
61
  end
62
63
  # ── request shape ────────────────────────────────────────────────────────
64
65
  defp input_of(params) do
34 66
    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"]})
67
      input when is_binary(input) and input != "" ->
68
        {:ok, [%{role: "user", content: input}]}
69
70
      [_ | _] = items ->
71
        {:ok, Enum.flat_map(items, &item_message/1)}
72
73
      _missing ->
74
        {:error, :input_missing}
75
    end
76
  end
77
78
  # One OpenResponses input item as a provider message. Text rides in
79
  # `content` as a string or as `input_text`/`output_text` blocks; anything
80
  # else contributes nothing rather than failing the request.
81
  defp item_message(%{"role" => role} = item) when role in ["user", "assistant", "system"] do
82
    case item_text(item["content"]) do
83
      "" -> []
84
      text -> [%{role: role, content: text}]
38 85
    end
39 86
  end
40 87
41
  defp respond(conn, params) do
42
    response = response_object(params)
88
  defp item_message(_item), do: []
43 89
44
    if params["stream"] == true do
45
      stream(conn, response)
46
    else
47
      json(conn, response)
90
  defp item_text(content) when is_binary(content), do: content
91
92
  defp item_text(blocks) when is_list(blocks) do
93
    blocks
94
    |> Enum.map(fn
95
      %{"text" => text} when is_binary(text) -> text
96
      _other -> ""
97
    end)
98
    |> Enum.reject(&(&1 == ""))
99
    |> Enum.join("\n")
100
  end
101
102
  defp item_text(_other), do: ""
103
104
  defp model_of(params) do
105
    case params["model"] do
106
      absent when absent in [nil, ""] ->
107
        case Models.fetch(@default_model) do
108
          {:ok, model} -> {:ok, model}
109
          :error -> {:error, {:model_not_served, @default_model}}
110
        end
111
112
      named when is_binary(named) ->
113
        case Models.fetch(named) do
114
          {:ok, model} -> {:ok, model}
115
          :error -> {:error, {:model_not_served, named}}
116
        end
117
118
      _not_a_string ->
119
        {:error, {:model_not_served, "a non-string model"}}
48 120
    end
49 121
  end
50 122
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)
123
  defp serving(model) do
124
    if Models.available?(model), do: :ok, else: {:error, :model_unavailable}
125
  end
126
127
  defp build_request(model, input, params) do
128
    {system, turns} = Enum.split_with(input, &(&1.role == "system"))
129
130
    instructions =
131
      case params["instructions"] do
132
        text when is_binary(text) and text != "" -> text
133
        _absent -> joined_or_default(system)
134
      end
135
136
    max_output =
137
      case params["max_output_tokens"] do
138
        tokens when is_integer(tokens) and tokens > 0 -> min(tokens, model.max_output)
139
        _absent -> model.max_output
140
      end
141
142
    %Request{
143
      model_id: model.provider_model,
144
      instructions: instructions,
145
      input: turns,
146
      max_output: max_output
147
    }
148
  end
149
150
  defp joined_or_default([]), do: @default_instructions
151
  defp joined_or_default(system), do: Enum.map_join(system, "\n\n", & &1.content)
152
153
  # ── streaming ────────────────────────────────────────────────────────────
154
155
  # Each provider delta becomes one OpenResponses event, flushed as it
156
  # arrives. The adapter runs in this process and pushes through the
157
  # callback synchronously, so the chunk is on the wire before the provider
158
  # writes the next one — this surface streams for real, where the
159
  # chat-completions proxy deliberately buffers.
160
  #
161
  # The callback cannot rebind outer variables, so the small amount of turn
162
  # state — the sequence number, the accumulated text — lives in the process
163
  # dictionary of this request's own process, scoped to this function.
164
  defp stream(conn, model, request) do
165
    response_id = "resp_" <> identifier()
166
    message_id = "msg_" <> identifier()
167
    base = %{"item_id" => message_id, "output_index" => 0, "content_index" => 0}
168
    started = shell(response_id, model_name(model), "in_progress", [])
83 169
84 170
    conn =
85 171
      conn

@@ -87,41 +173,192 @@ defmodule OpenAgentsWeb.ResponsesController do

87 173
      |> put_resp_header("cache-control", "no-store")
88 174
      |> send_chunked(200)
89 175
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}
176
    Process.put(:responses_seq, 0)
177
    Process.put(:responses_text, [])
178
    Process.put(:responses_usage, %{})
179
    # The conn rides the process dictionary too: `chunk/2` returns the conn
180
    # that carries what has been sent — on the test adapter, literally the
181
    # accumulated body — and a closure cannot rebind the outer variable.
182
    Process.put(:responses_conn, conn)
183
184
    emit = fn type, payload ->
185
      sequence = Process.get(:responses_seq)
186
      Process.put(:responses_seq, sequence + 1)
187
188
      data =
189
        payload
190
        |> Map.put("type", type)
191
        |> Map.put("sequence_number", sequence)
192
        |> Jason.encode!()
193
194
      case chunk(Process.get(:responses_conn), "event: #{type}\ndata: #{data}\n\n") do
195
        {:ok, sent} -> Process.put(:responses_conn, sent)
196
        {:error, _closed} -> :ok
94 197
      end
95
    end)
198
199
      :ok
200
    end
201
202
    emit.("response.created", %{"response" => started})
203
204
    emit.("response.output_item.added", %{
205
      "output_index" => 0,
206
      "item" => message(message_id, "in_progress", [])
207
    })
208
209
    emit.("response.content_part.added", Map.put(base, "part", text_part("")))
210
211
    result =
212
      model.adapter.stream(request, fn
213
        {:text_delta, text} when is_binary(text) and text != "" ->
214
          Process.put(:responses_text, [Process.get(:responses_text), text])
215
          emit.("response.output_text.delta", Map.put(base, "delta", text))
216
217
        {:reasoning_delta, text} when is_binary(text) and text != "" ->
218
          emit.("response.reasoning_summary_text.delta", Map.put(base, "delta", text))
219
220
        {:usage, usage} when is_map(usage) ->
221
          Process.put(:responses_usage, usage)
222
          :ok
223
224
        _other ->
225
          :ok
226
      end)
227
228
    text = IO.iodata_to_binary(Process.get(:responses_text))
229
    usage = Process.get(:responses_usage)
230
231
    case result do
232
      :ok ->
233
        completed =
234
          shell(response_id, model_name(model), "completed", [
235
            message(message_id, "completed", [text_part(text)])
236
          ])
237
          |> Map.put("usage", usage_view(usage))
238
239
        emit.("response.output_text.done", Map.put(base, "text", text))
240
        emit.("response.content_part.done", Map.put(base, "part", text_part(text)))
241
242
        emit.("response.output_item.done", %{
243
          "output_index" => 0,
244
          "item" => message(message_id, "completed", [text_part(text)])
245
        })
246
247
        emit.("response.completed", %{"response" => completed})
248
249
      {:error, reason} ->
250
        failed =
251
          started
252
          |> Map.put("status", "failed")
253
          |> Map.put("error", %{
254
            "code" => "provider_failed",
255
            "message" => "the provider did not finish: #{inspect(reason)}"
256
          })
257
258
        emit.("response.failed", %{"response" => failed})
259
    end
260
261
    Process.get(:responses_conn)
96 262
  end
97 263
98
  defp response_object(params) do
264
  # ── non-streaming ────────────────────────────────────────────────────────
265
266
  defp collect(conn, model, request) do
267
    parent = self()
268
    result = model.adapter.stream(request, fn event -> send(parent, {:responses_event, event}) end)
269
    events = drain([])
270
271
    text =
272
      events
273
      |> Enum.map(fn
274
        {:text_delta, delta} -> delta
275
        _other -> ""
276
      end)
277
      |> IO.iodata_to_binary()
278
279
    usage =
280
      Enum.find_value(events, %{}, fn
281
        {:usage, map} when is_map(map) -> map
282
        _other -> nil
283
      end)
284
285
    case result do
286
      :ok ->
287
        response_id = "resp_" <> identifier()
288
        message_id = "msg_" <> identifier()
289
290
        json(
291
          conn,
292
          shell(response_id, model_name(model), "completed", [
293
            message(message_id, "completed", [text_part(text)])
294
          ])
295
          |> Map.put("usage", usage_view(usage))
296
        )
297
298
      {:error, reason} ->
299
        json(
300
          conn,
301
          shell("resp_" <> identifier(), model_name(model), "failed", [])
302
          |> Map.put("error", %{
303
            "code" => "provider_failed",
304
            "message" => "the provider did not answer: #{inspect(reason)}"
305
          })
306
        )
307
    end
308
  end
309
310
  defp drain(acc) do
311
    receive do
312
      {:responses_event, event} -> drain([event | acc])
313
    after
314
      0 -> Enum.reverse(acc)
315
    end
316
  end
317
318
  # ── the response object ──────────────────────────────────────────────────
319
320
  defp shell(id, model_name, status, output) do
99 321
    %{
100
      "id" => "resp_" <> identifier(),
322
      "id" => id,
101 323
      "object" => "response",
102 324
      "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
      ],
325
      "status" => status,
326
      "model" => model_name,
327
      "output" => output,
116 328
      "error" => nil,
117 329
      "usage" => %{"input_tokens" => 0, "output_tokens" => 0, "total_tokens" => 0}
118 330
    }
119 331
  end
120 332
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"
333
  defp message(id, status, content) do
334
    %{
335
      "type" => "message",
336
      "id" => id,
337
      "role" => "assistant",
338
      "status" => status,
339
      "content" => content
340
    }
341
  end
342
343
  defp usage_view(usage) do
344
    input = whole(usage["input_tokens"])
345
    output = whole(usage["output_tokens"])
346
347
    %{
348
      "input_tokens" => input,
349
      "output_tokens" => output,
350
      "total_tokens" => whole(usage["total_tokens"]) || (input || 0) + (output || 0)
351
    }
352
    |> Map.new(fn {key, value} -> {key, value || 0} end)
353
  end
354
355
  defp whole(value) when is_integer(value) and value >= 0, do: value
356
  defp whole(_value), do: nil
357
358
  defp text_part(text),
359
    do: %{"type" => "output_text", "text" => text, "annotations" => []}
360
361
  defp model_name(model), do: model.id
125 362
126 363
  defp identifier, do: Base.encode16(:crypto.strong_rand_bytes(12), case: :lower)
127 364
end
test/openagents_web/controllers/responses_controller_test.exs modified +132 -69

@@ -1,85 +1,148 @@

1 1
defmodule OpenAgentsWeb.ResponsesControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 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"})
4
  alias OpenAgents.Providers.{FailingTestProvider, RecordingTestProvider, UnconfiguredTestProvider}
6 5
7
    assert %{"status" => "completed"} = json_response(conn, 200)
6
  # The default model rides the Vercel gateway lane; swapping the lane's
7
  # adapter is how a test decides what "real inference" answers with.
8
  defp swap_lane(adapter) do
9
    previous = Application.get_env(:openagents, :vercel_gateway_provider)
10
    Application.put_env(:openagents, :vercel_gateway_provider, adapter)
11
    on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_provider, previous) end)
8 12
  end
9 13
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
14
  describe "the non-streaming response object" do
15
    setup do
16
      swap_lane(RecordingTestProvider)
17
      :ok
18
    end
19
20
    test "answers an anonymous caller from the provider", %{conn: conn} do
21
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello"})
22
23
      assert %{
24
               "object" => "response",
25
               "status" => "completed",
26
               "model" => "gemini-3.7-flash",
27
               "output" => [message],
28
               "usage" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12}
29
             } = json_response(conn, 200)
30
31
      assert %{
32
               "type" => "message",
33
               "role" => "assistant",
34
               "status" => "completed",
35
               "content" => [%{"type" => "output_text", "text" => "Recorded."}]
36
             } = message
37
    end
38
39
    test "carries the caller's instructions and input items to the provider", %{conn: conn} do
40
      Application.put_env(:openagents, :test_recording_provider_observer, self())
41
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)
30 42
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
      })
43
      conn =
44
        post(conn, ~p"/api/v1/responses", %{
45
          instructions: "Answer in French.",
46
          input: [
47
            %{role: "user", content: [%{type: "input_text", text: "bonjour"}]},
48
            %{role: "assistant", content: "salut"},
49
            %{role: "user", content: "encore"}
50
          ],
51
          max_output_tokens: 128
52
        })
39 53
40
    assert %{"model" => "anything", "output" => [_]} = json_response(conn, 200)
54
      assert json_response(conn, 200)
55
      assert_receive {:recorded_request, _id, request}
56
      assert request.instructions == "Answer in French."
57
      assert request.max_output == 128
58
59
      assert request.input == [
60
               %{role: "user", content: "bonjour"},
61
               %{role: "assistant", content: "salut"},
62
               %{role: "user", content: "encore"}
63
             ]
64
    end
65
66
    test "reports a provider failure as a failed response object", %{conn: conn} do
67
      swap_lane(FailingTestProvider)
68
69
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello"})
70
71
      assert %{"status" => "failed", "error" => %{"code" => "provider_failed"}} =
72
               json_response(conn, 200)
73
    end
41 74
  end
42 75
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", %{})
76
  describe "streaming" do
77
    setup do
78
      swap_lane(RecordingTestProvider)
79
      :ok
80
    end
81
82
    test "streams the semantic event sequence around the provider's deltas", %{conn: conn} do
83
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello", stream: true})
84
85
      assert [type] = get_resp_header(conn, "content-type")
86
      assert type =~ "text/event-stream"
87
      body = response(conn, 200)
88
89
      for {event, at} <- Enum.with_index(~w(
90
            response.created
91
            response.output_item.added
92
            response.content_part.added
93
            response.output_text.delta
94
            response.output_text.done
95
            response.content_part.done
96
            response.output_item.done
97
            response.completed
98
          )) do
99
        assert body =~ "event: " <> event
100
        assert body =~ ~s("sequence_number":#{at})
101
      end
102
103
      assert body =~ ~s("delta":"Recorded.")
104
      assert body =~ ~s("text":"Recorded.")
105
      assert body =~ ~s("input_tokens":4)
106
      refute body =~ "Acknowledged"
107
    end
108
109
    test "a provider failure mid-stream arrives as response.failed", %{conn: conn} do
110
      swap_lane(FailingTestProvider)
111
112
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello", stream: true})
113
      body = response(conn, 200)
48 114
49
    body = json_response(conn, 422)
50
    assert body["code"] == "validation_failed"
51
    assert body["errors"] == %{"input" => ["is required"]}
115
      assert body =~ ~s("delta":"half an ")
116
      assert body =~ "event: response.failed"
117
      assert body =~ ~s("code":"provider_failed")
118
      refute body =~ "response.completed"
119
    end
52 120
  end
53 121
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})
122
  describe "refusals, in the envelope" do
123
    test "a request with no input", %{conn: conn} do
124
      swap_lane(RecordingTestProvider)
125
      conn = post(conn, ~p"/api/v1/responses", %{})
126
127
      body = json_response(conn, 422)
128
      assert body["code"] == "validation_failed"
129
      assert body["errors"] == %{"input" => ["is required"]}
78 130
    end
79 131
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.")
132
    test "a model outside the catalog", %{conn: conn} do
133
      swap_lane(RecordingTestProvider)
134
      conn = post(conn, ~p"/api/v1/responses", %{input: "hi", model: "gpt-9-imaginary"})
135
136
      body = json_response(conn, 422)
137
      assert body["errors"]["model"] == ["`gpt-9-imaginary` is not in the catalog"]
138
    end
139
140
    test "a lane with no configured credential", %{conn: conn} do
141
      swap_lane(UnconfiguredTestProvider)
142
      conn = post(conn, ~p"/api/v1/responses", %{input: "hi"})
143
144
      body = json_response(conn, 503)
145
      assert body["code"] == "model_unavailable"
146
    end
84 147
  end
85 148
end
test/support/providers/failing_test_provider.ex added +25

@@ -0,0 +1,25 @@

1
defmodule OpenAgents.Providers.FailingTestProvider 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.failing_provider"
10
11
  @impl true
12
  def capabilities, do: [:text]
13
14
  @impl true
15
  def configured?, do: true
16
17
  # One delta lands, then the provider dies: the case a streaming surface
18
  # must answer with its failure shape rather than a silent half-answer.
19
  @impl true
20
  def stream(%Request{}, on_event) when is_function(on_event, 1) do
21
    on_event.({:response_started, "failing-response"})
22
    on_event.({:text_delta, "half an "})
23
    {:error, :upstream_5xx}
24
  end
25
end

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