fix: implement TD-02.

This commit is contained in:
2026-06-11 16:18:09 +02:00
parent 284637970f
commit a5391e8e25
7 changed files with 315 additions and 57 deletions

View File

@@ -82,10 +82,22 @@ for `BDS.Desktop.Endpoint`.
---
### TD-02: Replace the hand-rolled `:httpc` client with Req
### TD-02: Replace the hand-rolled `:httpc` client with Req ✅ DONE (2026-06-11)
**Severity: High (reliability), enables TD-06 (streaming).**
**Status: implemented.** `BDS.AI.HttpClient` is a Req wrapper with explicit
connect/receive timeouts and constant-delay transient retries for GETs only
(POST completions are never retried), configurable under
`config :bds, BDS.AI.HttpClient` (`:connect_timeout_ms`,
`:receive_timeout_ms`, `:get_max_retries`, `:retry_delay_ms`). The legacy
`{:ok, %{status, headers, body}}` contract is preserved (raw binary body,
downcased single-valued headers), so runtime/catalog callers are unchanged.
The automation driver's health check also moved to Req, `:inets` left
`extra_applications`, and no `:httpc` remains under `lib/`
(test files still use `:httpc` as a client against local servers — they start
`:inets` themselves). TD-06 (SSE streaming via Req `into:`) is now unblocked.
**Context.** `BDS.AI.HttpClient` wraps `:httpc` with empty http options — the
default timeout is **infinity**, so a hung LLM endpoint (Ollama, LM Studio,
any OpenAI-compatible server) blocks the chat task forever (see TD-03 for the

View File

@@ -1,58 +1,86 @@
defmodule BDS.AI.HttpClient do
@moduledoc false
@moduledoc """
Req-based HTTP client for AI endpoints.
Replaces the previous `:httpc` wrapper with explicit connect/receive
timeouts, TLS verification via Req's defaults, and transient retries for
idempotent GETs only — POSTs (chat completions) are never retried.
The response contract is unchanged: `{:ok, %{status, headers, body}}` with
downcased single-valued header names and the body as a raw binary (callers
decode JSON themselves), or `{:error, reason}` where transport failures
surface as plain reason atoms such as `:timeout` or `:econnrefused`.
Config (`config :bds, BDS.AI.HttpClient`):
* `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
* `:receive_timeout_ms` — response budget (default 120_000; generous
because local LLM completions are slow)
* `:get_max_retries` — transient retries for GETs (default 2)
* `:retry_delay_ms` — constant delay between retries (default 500)
"""
@default_connect_timeout_ms 5_000
@default_receive_timeout_ms 120_000
@default_get_max_retries 2
@default_retry_delay_ms 500
@spec get(String.t(), %{String.t() => String.t()}) ::
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
def get(url, headers) when is_binary(url) and is_map(headers) do
request =
{String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end)}
:inets.start()
:ssl.start()
case :httpc.request(:get, request, [], body_format: :binary) do
{:ok, {{_version, status, _reason}, response_headers, body}} ->
{:ok,
%{
status: status,
headers: normalize_headers(response_headers),
body: body
}}
{:error, reason} ->
{:error, reason}
end
[
method: :get,
url: url,
headers: headers,
retry: :transient,
max_retries: config(:get_max_retries, @default_get_max_retries),
retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end,
retry_log_level: false
]
|> Keyword.merge(base_options())
|> Req.request()
|> normalize_result()
end
@spec post(String.t(), %{String.t() => String.t()}, binary()) ::
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do
request =
{String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end), ~c"application/json", body}
:inets.start()
:ssl.start()
case :httpc.request(:post, request, [], body_format: :binary) do
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
{:ok,
%{
status: status,
headers: normalize_headers(response_headers),
body: response_body
}}
{:error, reason} ->
{:error, reason}
end
[
method: :post,
url: url,
headers: headers,
body: body,
# Completions are not idempotent; a retry could bill or generate twice.
retry: false
]
|> Keyword.merge(base_options())
|> Req.request()
|> normalize_result()
end
defp base_options do
[
connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)],
receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms),
# Callers parse the body themselves; keep it a raw binary.
decode_body: false
]
end
defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do
{:ok, %{status: status, headers: normalize_headers(headers), body: body}}
end
defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason}
defp normalize_result({:error, reason}), do: {:error, reason}
# Req header names are already downcased; values arrive as lists.
defp normalize_headers(headers) do
Enum.into(headers, %{}, fn {key, value} ->
{key |> to_string() |> String.downcase(), to_string(value)}
end)
Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
end
defp config(key, default) do
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
end
end

View File

@@ -58,7 +58,6 @@ defmodule BDS.Desktop.Automation do
base_url = BDS.Desktop.url(port)
File.mkdir_p!(screenshot_dir)
ensure_http_client_started()
app_port = start_app_process(project_root, port)
:ok = wait_for_server(base_url)
@@ -319,8 +318,14 @@ defmodule BDS.Desktop.Automation do
end
defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} ->
case Req.request(
method: :get,
url: base_url <> "health",
retry: false,
connect_options: [timeout: 1_000],
receive_timeout: 1_000
) do
{:ok, %Req.Response{status: 200}} ->
:ok
_other ->
@@ -340,12 +345,6 @@ defmodule BDS.Desktop.Automation do
port
end
defp ensure_http_client_started do
_ = Application.ensure_all_started(:inets)
_ = Application.ensure_all_started(:ssl)
:ok
end
defp await_port_exit(nil, _timeout), do: :ok
defp await_port_exit(port, timeout) do

View File

@@ -17,7 +17,7 @@ defmodule BDS.MixProject do
def application do
[
extra_applications: [:logger, :wx, :inets, :ssl],
extra_applications: [:logger, :wx, :ssl],
mod: {BDS.Application, []}
]
end
@@ -32,6 +32,7 @@ defmodule BDS.MixProject do
{:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"},
{:bandit, "~> 1.5"},
{:req, "~> 0.5"},
{:desktop, "~> 1.5"},
{:image, "~> 0.67"},
{:nx, "~> 0.10"},

View File

@@ -27,6 +27,7 @@
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hnswlib": {:hex, :hnswlib, "0.1.7", "784afdbfbc9af53e64d4b6da3f685c07039e472636a98fa954ffae5292ad6cc4", [:make, :mix], [{:cc_precompiler, "~> 0.1.0", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fb43bb675facc8bb1ef0f4f8fec92479fc23317ed0f35c7160b2f95aff3e4742"},
@@ -40,7 +41,9 @@
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nx": {:hex, :nx, "0.10.0", "128e4a094cb790f663e20e1334b127c1f2a4df54edfb8b13c22757ec33133b4f", [:mix], [{:complex, "~> 0.6", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3db8892c124aeee091df0e6fbf8e5bf1b81f502eb0d4f5ba63e6378ebcae7da4"},
@@ -56,6 +59,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
"progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"},
"req": {:hex, :req, "0.6.1", "7b904c8b42d0e08136a5c6aba024fd12fc79a1ed8856e7a3522b0917f7e75113", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "aaf11c9c80f2df2364630b3594e1857fe610d8ea7cb994e1ce3dcb55f204ff1c"},
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"},

View File

@@ -0,0 +1,144 @@
defmodule BDS.AI.HttpClientTest do
use ExUnit.Case, async: false
alias BDS.AI.HttpClient
defmodule TestPlug do
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{path_info: ["ok"]} = conn, _opts) do
conn
|> put_resp_header("x-test-header", "Value-1")
|> put_resp_content_type("application/json")
|> send_resp(200, ~s({"hello":"world"}))
end
def call(%Plug.Conn{path_info: ["echo"], method: "POST"} = conn, _opts) do
{:ok, body, conn} = read_body(conn)
send(test_pid(), {:echo_request, conn.req_headers, body})
send_resp(conn, 200, body)
end
def call(%Plug.Conn{path_info: ["slow"]} = conn, _opts) do
Process.sleep(1_000)
send_resp(conn, 200, "late")
end
def call(%Plug.Conn{path_info: ["flaky-get"]} = conn, _opts) do
send(test_pid(), :flaky_hit)
if :ets.update_counter(:bds_http_client_test, :flaky_get, 1) == 1 do
send_resp(conn, 500, "boom")
else
send_resp(conn, 200, "recovered")
end
end
def call(%Plug.Conn{path_info: ["fail-post"], method: "POST"} = conn, _opts) do
send(test_pid(), :post_hit)
send_resp(conn, 500, "boom")
end
defp test_pid, do: Application.get_env(:bds, :http_client_test_pid)
end
setup do
if :ets.whereis(:bds_http_client_test) == :undefined do
:ets.new(:bds_http_client_test, [:named_table, :public, :set])
end
:ets.insert(:bds_http_client_test, {:flaky_get, 0})
Application.put_env(:bds, :http_client_test_pid, self())
original_config = Application.fetch_env(:bds, HttpClient)
# Fast retries so retry tests don't slow the suite down.
Application.put_env(:bds, HttpClient, retry_delay_ms: 10)
on_exit(fn ->
case original_config do
{:ok, value} -> Application.put_env(:bds, HttpClient, value)
:error -> Application.delete_env(:bds, HttpClient)
end
end)
server = start_supervised!({Bandit, plug: TestPlug, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
{:ok, base_url: "http://127.0.0.1:#{port}"}
end
defp put_config(extra) do
Application.put_env(:bds, HttpClient, Keyword.merge([retry_delay_ms: 10], extra))
end
test "GET returns status, body, and downcased single-valued headers", %{base_url: base_url} do
assert {:ok, response} = HttpClient.get(base_url <> "/ok", %{"accept" => "application/json"})
assert response.status == 200
assert response.body == ~s({"hello":"world"})
assert response.headers["x-test-header"] == "Value-1"
assert response.headers["content-type"] =~ "application/json"
refute Map.has_key?(response.headers, "X-Test-Header")
end
test "POST sends the raw body and headers and returns the raw response body", %{
base_url: base_url
} do
payload = ~s({"model":"gpt-test"})
assert {:ok, response} =
HttpClient.post(
base_url <> "/echo",
%{"content-type" => "application/json", "authorization" => "Bearer sk-123"},
payload
)
assert response.status == 200
assert response.body == payload
assert_received {:echo_request, req_headers, ^payload}
assert {"authorization", "Bearer sk-123"} in req_headers
assert {"content-type", "application/json"} in req_headers
end
test "a hung endpoint times out within the configured budget", %{base_url: base_url} do
put_config(receive_timeout_ms: 100, get_max_retries: 0)
started_at = System.monotonic_time(:millisecond)
assert {:error, :timeout} = HttpClient.post(base_url <> "/slow", %{}, "{}")
assert {:error, :timeout} = HttpClient.get(base_url <> "/slow", %{})
elapsed = System.monotonic_time(:millisecond) - started_at
assert elapsed < 2_000
end
test "a refused connection returns an error instead of raising" do
put_config(get_max_retries: 0)
# An ephemeral port nothing listens on.
{:ok, socket} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(socket)
:ok = :gen_tcp.close(socket)
assert {:error, :econnrefused} = HttpClient.get("http://127.0.0.1:#{port}/ok", %{})
end
test "GET retries transient server errors", %{base_url: base_url} do
assert {:ok, %{status: 200, body: "recovered"}} =
HttpClient.get(base_url <> "/flaky-get", %{})
assert_received :flaky_hit
assert_received :flaky_hit
end
test "POST is never retried, even on transient server errors", %{base_url: base_url} do
assert {:ok, %{status: 500, body: "boom"}} =
HttpClient.post(base_url <> "/fail-post", %{}, "{}")
assert_received :post_hit
refute_received :post_hit
end
end

View File

@@ -0,0 +1,70 @@
defmodule BDS.AI.OpenAICompatibleRuntimeTest do
use ExUnit.Case, async: false
alias BDS.AI.HttpClient
alias BDS.AI.OpenAICompatibleRuntime
defmodule SlowPlug do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
Process.sleep(1_000)
send_resp(conn, 200, ~s({"choices":[],"data":[]}))
end
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
original_config = Application.fetch_env(:bds, HttpClient)
Application.put_env(:bds, HttpClient,
receive_timeout_ms: 100,
get_max_retries: 0,
retry_delay_ms: 10
)
on_exit(fn ->
case original_config do
{:ok, value} -> Application.put_env(:bds, HttpClient, value)
:error -> Application.delete_env(:bds, HttpClient)
end
end)
server = start_supervised!({Bandit, plug: SlowPlug, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
{:ok, url: "http://127.0.0.1:#{port}/v1"}
end
test "generate returns a structured timeout error within the configured budget", %{url: url} do
endpoint = %{url: url, api_key: "sk-test"}
request = %{
operation: :chat,
model: "gpt-test",
max_output_tokens: 16,
messages: [%{"role" => "user", "content" => "hello"}]
}
started_at = System.monotonic_time(:millisecond)
assert {:error, %{kind: :http_error, reason: :timeout}} =
OpenAICompatibleRuntime.generate(endpoint, request, [])
assert System.monotonic_time(:millisecond) - started_at < 2_000
end
test "list_models returns a structured timeout error within the configured budget", %{url: url} do
endpoint = %{url: url, api_key: "sk-test"}
started_at = System.monotonic_time(:millisecond)
assert {:error, %{kind: :http_error, reason: :timeout}} =
OpenAICompatibleRuntime.list_models(endpoint)
assert System.monotonic_time(:millisecond) - started_at < 2_000
end
end