Current section

Files

Jump to
phoenix_kit_ai lib phoenix_kit_ai completion.ex
Raw

lib/phoenix_kit_ai/completion.ex

defmodule PhoenixKitAI.Completion do
@moduledoc """
OpenRouter completion client for making AI API calls.
This module handles the actual HTTP requests to OpenRouter's chat completions
and other endpoints. It's used internally by `PhoenixKitAI` public functions.
## Supported Endpoints
- `/chat/completions` - Text and vision completions
- `/embeddings` - Text embeddings
- `/audio/speech` / xAI's `/tts` - Text-to-speech synthesis
- `/images/generations` - Image generation
## Logging conventions
- `Logger.warning` — expected/recoverable external failures (non-2xx HTTP
responses, transport errors, rate limits). Callers see a user-facing error.
- `Logger.error` — unexpected internal failures (unknown error shapes,
parse failures).
"""
require Logger
alias PhoenixKitAI.Endpoint
alias PhoenixKitAI.OpenRouterClient
@timeout 120_000
@doc """
Makes a chat completion request to OpenRouter.
## Parameters
- `endpoint` - The AI endpoint struct with API key and model
- `messages` - List of message maps with `:role` and `:content`
- `opts` - Additional options (temperature, max_tokens, etc.)
## Options
- `:temperature` - Sampling temperature (0-2)
- `:max_tokens` - Maximum tokens in response
- `:top_p` - Nucleus sampling parameter
- `:top_k` - Top-k sampling parameter
- `:frequency_penalty` - Frequency penalty (-2 to 2)
- `:presence_penalty` - Presence penalty (-2 to 2)
- `:repetition_penalty` - Repetition penalty (0 to 2)
- `:stop` - Stop sequences (list of strings)
- `:seed` - Random seed for reproducibility
- `:stream` - Enable streaming (default: false)
## Returns
- `{:ok, response}` - Successful response with completion
- `{:error, reason}` - Error atom or tagged tuple. See
`PhoenixKitAI.Errors` for the full reason vocabulary and translation.
## Response Structure
```elixir
%{
"id" => "gen-...",
"model" => "anthropic/claude-3-haiku",
"choices" => [
%{
"message" => %{
"role" => "assistant",
"content" => "Hello! How can I help you today?"
},
"finish_reason" => "stop"
}
],
"usage" => %{
"prompt_tokens" => 10,
"completion_tokens" => 15,
"total_tokens" => 25
}
}
```
"""
def chat_completion(endpoint, messages, opts \\ []) do
url = build_url(endpoint, "/chat/completions")
headers = OpenRouterClient.build_headers_from_endpoint(endpoint)
body = build_chat_body(endpoint.model, messages, opts)
start_time = System.monotonic_time(:millisecond)
case http_post(url, headers, body) do
{:ok, %{status_code: 200, body: response_body}} ->
parse_success_response(response_body, start_time)
{:ok, %{status_code: status, body: response_body}} ->
handle_error_status(status, response_body)
{:error, :timeout} ->
{:error, :request_timeout}
{:error, reason} ->
Logger.warning("OpenRouter completion transport error: #{inspect(reason)}")
{:error, {:connection_error, reason}}
end
end
defp parse_success_response(response_body, start_time) do
latency_ms = System.monotonic_time(:millisecond) - start_time
case Jason.decode(response_body) do
{:ok, response} -> {:ok, Map.put(response, "latency_ms", latency_ms)}
{:error, _} -> {:error, :invalid_json_response}
end
end
@doc false
# Public for testability. Maps an HTTP status + response body to a
# `{:error, reason}` tuple where `reason` is an atom or tagged tuple.
def handle_error_status(401, _body), do: {:error, :invalid_api_key}
def handle_error_status(402, _body), do: {:error, :insufficient_credits}
def handle_error_status(429, _body), do: {:error, :rate_limited}
def handle_error_status(status, response_body) do
# Log only the parsed error message (not the raw body) to avoid
# persisting potentially sensitive provider data to application logs.
case extract_error_message(response_body) do
nil ->
Logger.warning("OpenRouter completion failed: #{status} (no parsable error body)")
msg ->
Logger.warning("OpenRouter completion failed: #{status} - #{msg}")
end
{:error, {:api_error, status}}
end
@doc """
Makes an embeddings request to OpenRouter.
## Parameters
- `endpoint` - The AI endpoint struct with API key and model
- `input` - Text or list of texts to embed
- `opts` - Additional options
## Options
- `:dimensions` - Output dimensions (model-specific)
## Returns
- `{:ok, response}` - Response with embeddings
- `{:error, reason}` - Error atom or tagged tuple. See
`PhoenixKitAI.Errors` for the full reason vocabulary and translation.
"""
def embeddings(endpoint, input, opts \\ []) do
url = build_url(endpoint, "/embeddings")
headers = OpenRouterClient.build_headers_from_endpoint(endpoint)
body =
%{
"model" => endpoint.model,
"input" => input
}
|> maybe_add("dimensions", Keyword.get(opts, :dimensions))
start_time = System.monotonic_time(:millisecond)
case http_post(url, headers, body) do
{:ok, %{status_code: 200, body: response_body}} ->
parse_success_response(response_body, start_time)
{:ok, %{status_code: status, body: response_body}} ->
handle_error_status(status, response_body)
{:error, :timeout} ->
{:error, :request_timeout}
{:error, reason} ->
Logger.warning("OpenRouter embeddings transport error: #{inspect(reason)}")
{:error, {:connection_error, reason}}
end
end
@doc """
Synthesizes speech from text via the provider's `/audio/speech` endpoint
(OpenAI-compatible providers), or xAI's own `POST /v1/tts` (a completely
different request/response shape — see `xai_text_to_speech/3`).
This is xAI's **batch** REST endpoint — a single request, a complete
audio file back in the response, no persistent connection. Suited to
"generate once, cache, serve from your own storage" use cases (e.g. a
language-learning app pre-rendering vocabulary audio). For live,
low-latency streaming playback, use `Xai.Realtime` directly (what the
Playground's "Streaming Voice (xAI)" panel does) — that path returns no
cacheable file, just a stream of chunks meant for one immediate playback.
## Parameters
- `endpoint` - The AI endpoint struct with API key and model
- `text` - The text to synthesize
- `opts` - Additional options
## Options
- `:response_format` - Audio container/codec (default `"mp3"`)
- `:voice` - Preset voice identifier (OpenRouter / OSS vLLM shape)
- `:voice_id` - Saved/cloned voice id (Mistral hosted shape; also xAI's
voice field — see `fetch_xai_voices/2` in `OpenRouterClient` for the
list, e.g. `"eve"`)
- `:instructions` - Steering text for models that support it (e.g.
`gpt-4o-mini-tts`'s accent/tone/pacing/language control). Only sent
to models known to support it (the `gpt-4o*-tts` family); silently
dropped otherwise. This is a real gate, not just documentation —
some providers (Mistral's `voxtral-*-tts`) hard-fail the whole
request with HTTP 422 on an unrecognized `instructions` field
rather than ignoring it, so the module must not send it blindly.
- `:language` - **xAI only**, ignored by other providers. BCP-47 code
(`"en"`, `"pt-BR"`, ...) or `"auto"` for automatic detection.
Defaults to `"auto"`.
- `:sample_rate`, `:bit_rate`, `:speed` - **xAI only**, passed through
to its `output_format` / `speed` fields when present.
- `:with_timestamps` - **xAI only**, ignored by other providers (neither
Mistral's Voxtral TTS nor OpenAI's `/audio/speech` offer anything
equivalent). When `true`, xAI returns character-level timing data
alongside the audio (see `Returns` below) — no cost difference, per
xAI's pricing page; adds latency for the post-synthesis alignment
pass on xAI's side.
Only the voice option that is present is sent; the module stays
provider-neutral and does not assume a field name.
## Returns
- `{:ok, %{audio: binary(), format: String.t(), latency_ms: integer(),
timestamps: [%{char:, start:, end:}] | nil}}` - `timestamps` is `nil`
unless `:with_timestamps` was passed to an xAI endpoint and the
response actually included them; `char` is a single input character,
`start`/`end` its position in seconds as floats. Word boundaries
aren't provided directly — derive them by splitting on whitespace.
- `{:error, reason}` - Error atom or tagged tuple. See
`PhoenixKitAI.Errors` for the full reason vocabulary and translation.
"""
def text_to_speech(endpoint, text, opts \\ []) do
if xai_provider?(endpoint.provider) do
xai_text_to_speech(endpoint, text, opts)
else
generic_text_to_speech(endpoint, text, opts)
end
end
defp xai_provider?(provider) when is_binary(provider) do
Endpoint.base_provider(provider) == "xai"
end
defp xai_provider?(_provider), do: false
defp generic_text_to_speech(endpoint, text, opts) do
url = build_url(endpoint, "/audio/speech")
headers = OpenRouterClient.build_headers_from_endpoint(endpoint)
format = Keyword.get(opts, :response_format, "mp3")
body =
%{"model" => endpoint.model, "input" => text, "response_format" => format}
|> maybe_add("voice", Keyword.get(opts, :voice))
|> maybe_add("voice_id", Keyword.get(opts, :voice_id))
|> maybe_add(
"instructions",
instructions_for(endpoint.model, Keyword.get(opts, :instructions))
)
start_time = System.monotonic_time(:millisecond)
case http_post(url, headers, body) do
{:ok, %{status_code: 200, body: response_body}} ->
decode_audio(response_body, format, start_time)
{:ok, %{status_code: status, body: response_body}} ->
handle_error_status(status, response_body)
{:error, :timeout} ->
{:error, :request_timeout}
{:error, reason} ->
Logger.warning("TTS transport error: #{inspect(reason)}")
{:error, {:connection_error, reason}}
end
end
# Synthesizes speech via xAI's synchronous POST /v1/tts — a single
# request, a complete audio file back, no WebSocket. Not the same
# endpoint Xai.Realtime.connect_tts/1 streams from, and not a variant
# of generic_text_to_speech/2 — text/voice_id/language fields
# (language required by xAI, defaulted to "auto" here) and a nested
# output_format request body, matching neither the OpenAI-compatible
# request shape. The response, despite xAI's own docs describing a
# `{"audio": <base64>, "content_type", "duration"}` envelope, comes
# back on the live API as the raw audio bytes directly (confirmed
# against a real endpoint) — see `decode_xai_audio/3`.
defp xai_text_to_speech(endpoint, text, opts) do
base = endpoint.base_url || Endpoint.default_base_url("xai")
url = "#{String.trim_trailing(base, "/")}/tts"
headers = OpenRouterClient.build_headers_from_endpoint(endpoint)
format = Keyword.get(opts, :response_format, "mp3")
body =
%{"text" => text, "language" => Keyword.get(opts, :language, "auto")}
|> maybe_add("voice_id", Keyword.get(opts, :voice_id) || Keyword.get(opts, :voice))
|> maybe_add("output_format", xai_output_format(format, opts))
|> maybe_add("speed", Keyword.get(opts, :speed))
|> maybe_add("with_timestamps", Keyword.get(opts, :with_timestamps))
start_time = System.monotonic_time(:millisecond)
case http_post(url, headers, body) do
{:ok, %{status_code: 200, body: response_body}} ->
decode_xai_audio(response_body, format, start_time)
{:ok, %{status_code: status, body: response_body}} ->
handle_error_status(status, response_body)
{:error, :timeout} ->
{:error, :request_timeout}
{:error, reason} ->
Logger.warning("xAI TTS transport error: #{inspect(reason)}")
{:error, {:connection_error, reason}}
end
end
defp xai_output_format(codec, opts) do
%{}
|> maybe_add("codec", codec)
|> maybe_add("sample_rate", Keyword.get(opts, :sample_rate))
|> maybe_add("bit_rate", Keyword.get(opts, :bit_rate))
end
# xAI's docs describe `{"audio": <base64>, "content_type", "duration"}`,
# but the live API returns the raw audio bytes directly (verified against
# a real endpoint: status 200, body starting with an MP3 frame-sync byte,
# not JSON) — *unless* `with_timestamps: true` was requested, in which case
# it genuinely does return that JSON envelope (now also carrying
# "audio_timestamps"), since there'd be nowhere else to put the timing data.
# Fall back to raw-binary only when the body fails to parse as JSON at all —
# unlike `decode_audio/3`'s broader "any non-matching JSON shape" fallback,
# this stays narrow on purpose: a body that *is* valid JSON but missing/null
# `"audio"` is a real error response, not an audio file, and must still
# surface as `:invalid_audio_response` rather than being handed back as
# bogus "audio" bytes (the JSON text itself).
defp decode_xai_audio(response_body, format, start_time) do
latency_ms = System.monotonic_time(:millisecond) - start_time
{audio, timestamps} =
case Jason.decode(response_body) do
{:ok, %{"audio" => b64} = json} when is_binary(b64) ->
bytes =
case Base.decode64(b64) do
{:ok, bytes} -> bytes
:error -> nil
end
{bytes, parse_xai_timestamps(json["audio_timestamps"])}
{:ok, _other_json} ->
{nil, nil}
{:error, _} ->
# Not JSON at all → assume raw binary audio body (never has
# timestamps — that shape only exists when with_timestamps forced
# the JSON envelope in the first place).
{response_body, nil}
end
case audio do
bytes when is_binary(bytes) and byte_size(bytes) > 0 ->
{:ok, %{audio: bytes, format: format, latency_ms: latency_ms, timestamps: timestamps}}
_ ->
{:error, :invalid_audio_response}
end
end
# "graph_chars"/"graph_times" are parallel arrays (every input character, in
# order, paired with a [start, end] in seconds) — zipped once here into a flat
# list of maps so every caller doesn't have to redo it. `nil` when
# with_timestamps wasn't requested (no "audio_timestamps" key at all) or the
# response doesn't match the documented shape.
defp parse_xai_timestamps(%{"graph_chars" => chars, "graph_times" => times})
when is_list(chars) and is_list(times) and length(chars) == length(times) do
Enum.zip(chars, times)
|> Enum.map(fn
{char, [start_t, end_t]} -> %{char: char, start: start_t, end: end_t}
{char, _other} -> %{char: char, start: nil, end: nil}
end)
end
defp parse_xai_timestamps(_), do: nil
# Gate `instructions` on the model, not the provider: OpenAI's own
# `tts-1` / `tts-1-hd` predate steering and don't accept the field
# either, so a provider-level allowlist would still send it to those.
# Default to "unsupported" for anything else — sending the field to a
# model that rejects it is a hard 422 (Mistral's `voxtral-*-tts`),
# while dropping it for a model that would have accepted it just loses
# a pronunciation/tone hint. The safer failure mode wins.
defp instructions_for(model, instructions)
when is_binary(instructions) and instructions != "" do
if supports_instructions?(model) do
instructions
else
Logger.debug(
"TTS instructions dropped: model #{inspect(model)} is not known to support steering"
)
nil
end
end
defp instructions_for(_model, _instructions), do: nil
# OpenAI publishes dated snapshots of this family too (e.g.
# `gpt-4o-mini-tts-2025-12-15`) — the trailing `-YYYY-MM-DD` is optional so a
# pinned snapshot still matches, not just the bare model name.
defp supports_instructions?(model) when is_binary(model) do
model
|> String.split("/")
|> List.last()
|> then(&Regex.match?(~r/^gpt-4o.*-tts(-\d{4}-\d{2}-\d{2})?$/, &1))
end
defp supports_instructions?(_model), do: false
# The provider may return either base64 JSON (`{"audio_data": "..."}`,
# Mistral hosted) or a raw binary audio body (OpenRouter / OSS vLLM).
# `http_post/3` re-encodes a decoded JSON map back to a string and
# passes a binary body through `to_string/1` unchanged, so try the
# JSON-with-base64 shape first and fall back to raw bytes.
defp decode_audio(response_body, format, start_time) do
latency_ms = System.monotonic_time(:millisecond) - start_time
audio =
case Jason.decode(response_body) do
{:ok, %{"audio_data" => b64}} when is_binary(b64) ->
case Base.decode64(b64) do
{:ok, bytes} -> bytes
:error -> nil
end
_ ->
# Not JSON (or no audio_data) → assume raw binary audio body.
response_body
end
case audio do
bytes when is_binary(bytes) and byte_size(bytes) > 0 ->
# Neither Mistral's nor OpenAI's /audio/speech has any equivalent to xAI's
# with_timestamps — always nil here, never omitted, so a caller can safely
# read result.timestamps regardless of which provider answered.
{:ok, %{audio: bytes, format: format, latency_ms: latency_ms, timestamps: nil}}
_ ->
{:error, :invalid_audio_response}
end
end
@doc """
Generates image(s) from a text prompt via the provider's
`POST /images/generations` endpoint.
Unlike TTS, this one genuinely is the same path and response envelope
across OpenAI, OpenRouter, and xAI — `{"data": [{"url" | "b64_json",
...}]}` — so a single implementation covers all three; only the
optional request fields differ per provider/model family.
## Options
- `:n` - Number of images to generate (default 1; provider-limited —
e.g. DALL-E 3 only supports 1)
- `:response_format` - `"url"` or `"b64_json"` (DALL-E 2/3 only — GPT
image models always return `b64_json` regardless; xAI honors it)
- `:size` - Pixel dimensions, e.g. `"1024x1024"` (OpenAI / DALL-E / GPT
image models)
- `:quality` - `"standard"`/`"hd"` (DALL-E 3) or `"low"`/`"medium"`/`"high"`
(GPT image models)
- `:style` - `"vivid"`/`"natural"` (DALL-E 3 only)
- `:background`, `:output_format` - GPT image models only
- `:aspect_ratio` - e.g. `"16:9"` (**xAI only**)
- `:resolution` - `"1k"` or `"2k"` (**xAI only**)
Only options the caller passes are sent — the module stays
provider-neutral and does not assume which fields a given model
supports.
## Returns
- `{:ok, %{images: [%{url: String.t() | nil, data: binary() | nil}], latency_ms: integer()}}`
`data` is the decoded bytes when the provider returned `b64_json`;
`url` is the provider's URL when it returned one instead (DALL-E's
default, valid ~60 minutes). Never both nil for a successfully
decoded entry.
- `{:error, reason}` - Error atom or tagged tuple. See
`PhoenixKitAI.Errors` for the full reason vocabulary and translation.
"""
def generate_image(endpoint, prompt, opts \\ []) do
url = build_url(endpoint, "/images/generations")
headers = OpenRouterClient.build_headers_from_endpoint(endpoint)
body =
%{"model" => endpoint.model, "prompt" => prompt}
|> maybe_add("n", Keyword.get(opts, :n))
|> maybe_add("response_format", Keyword.get(opts, :response_format))
|> maybe_add("size", Keyword.get(opts, :size))
|> maybe_add("quality", Keyword.get(opts, :quality))
|> maybe_add("style", Keyword.get(opts, :style))
|> maybe_add("background", Keyword.get(opts, :background))
|> maybe_add("output_format", Keyword.get(opts, :output_format))
|> maybe_add("aspect_ratio", Keyword.get(opts, :aspect_ratio))
|> maybe_add("resolution", Keyword.get(opts, :resolution))
start_time = System.monotonic_time(:millisecond)
case http_post(url, headers, body) do
{:ok, %{status_code: 200, body: response_body}} ->
decode_images(response_body, start_time)
{:ok, %{status_code: status, body: response_body}} ->
handle_error_status(status, response_body)
{:error, :timeout} ->
{:error, :request_timeout}
{:error, reason} ->
Logger.warning("Image generation transport error: #{inspect(reason)}")
{:error, {:connection_error, reason}}
end
end
defp decode_images(response_body, start_time) do
latency_ms = System.monotonic_time(:millisecond) - start_time
case Jason.decode(response_body) do
{:ok, %{"data" => images}} when is_list(images) and images != [] ->
{:ok, %{images: Enum.map(images, &decode_image_entry/1), latency_ms: latency_ms}}
{:ok, _} ->
{:error, :invalid_response_format}
{:error, _} ->
{:error, :invalid_json_response}
end
end
defp decode_image_entry(%{"b64_json" => b64}) when is_binary(b64) do
case Base.decode64(b64) do
{:ok, bytes} -> %{url: nil, data: bytes}
:error -> %{url: nil, data: nil}
end
end
defp decode_image_entry(%{"url" => url}) when is_binary(url), do: %{url: url, data: nil}
defp decode_image_entry(_entry), do: %{url: nil, data: nil}
@doc """
Extracts the text content from a chat completion response.
"""
def extract_content(response) do
case response do
%{"choices" => [%{"message" => %{"content" => content}} | _]} ->
{:ok, content}
%{"choices" => []} ->
{:error, :no_choices_in_response}
_ ->
{:error, :invalid_response_format}
end
end
@doc """
Extracts the reasoning / chain-of-thought from a chat completion response,
for reasoning models (DeepSeek-R1, Mistral Magistral, OpenAI o-series, etc.).
Different providers put the chain-of-thought in different fields:
- OpenRouter (and most providers it proxies): `message.reasoning`
- DeepSeek native API: `message.reasoning_content`
- Some providers may use `message.thinking`
Returns the first non-empty string found, or `nil` if no reasoning is
present (i.e. for non-reasoning models or when the operator opted out
of returning reasoning via `reasoning_exclude: true`).
## `reasoning_exclude: true` and buggy providers
The endpoint's `reasoning_exclude` flag controls the REQUEST payload —
it tells the provider not to send reasoning back. A correctly-behaving
provider then returns a response without any of the three reasoning
fields and `extract_reasoning/1` returns `nil`.
A buggy provider (or one that doesn't honour the flag) might still
include reasoning. We deliberately extract it anyway rather than
gating the response-side capture on `reasoning_exclude` — the
reasoning in `metadata.response_reasoning` then doubles as a
breadcrumb that lets operators correlate "request asked for no
reasoning but provider sent it anyway" against a specific provider
+ model + request id (PR #6 review finding #9). PII / data-retention
concerns are covered by the `capture_request_content?/0`
application-config gate — when content capture is off, the
`response_reasoning` metadata is dropped too.
If you specifically want "discard reasoning when `reasoning_exclude:
true` regardless of what the provider sent", that's a separate
faithfulness-mode opt the caller would have to set on top of this
helper. The helper itself stays transparent.
"""
@spec extract_reasoning(map()) :: String.t() | nil
def extract_reasoning(response) do
case response do
%{"choices" => [%{"message" => message} | _]} when is_map(message) ->
first_present_string(message, ["reasoning", "reasoning_content", "thinking"])
_ ->
nil
end
end
defp first_present_string(map, keys) do
Enum.find_value(keys, fn key ->
case Map.get(map, key) do
value when is_binary(value) and value != "" -> value
_ -> nil
end
end)
end
@doc """
Extracts usage information from a response.
Returns a map with token counts and cost (if available from OpenRouter).
Cost is stored in nanodollars (1/1,000,000 of a dollar) to preserve precision
for cheap API calls. Stored in the cost_cents field for backward compatibility.
"""
def extract_usage(%{"usage" => usage}) when is_map(usage) do
%{
prompt_tokens: usage["prompt_tokens"] || 0,
completion_tokens: usage["completion_tokens"] || 0,
total_tokens: usage["total_tokens"] || 0,
cost_cents: parse_cost(usage["cost"] || usage["total_cost"])
}
end
def extract_usage(_response) do
%{prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, cost_cents: nil}
end
# Store in nanodollars (1/1000000 of a dollar) for precision
# e.g., $0.00003 becomes 30 nanodollars
defp parse_cost(cost) when is_number(cost), do: round(cost * 1_000_000)
defp parse_cost(_), do: nil
# Private functions
defp build_chat_body(model, messages, opts) do
# Normalize messages to ensure string keys
normalized_messages =
Enum.map(messages, fn msg ->
%{
"role" => to_string(msg[:role] || msg["role"]),
"content" => msg[:content] || msg["content"]
}
end)
%{
"model" => model,
"messages" => normalized_messages
}
|> maybe_add("temperature", Keyword.get(opts, :temperature))
|> maybe_add("max_tokens", Keyword.get(opts, :max_tokens))
|> maybe_add("top_p", Keyword.get(opts, :top_p))
|> maybe_add("top_k", Keyword.get(opts, :top_k))
|> maybe_add("frequency_penalty", Keyword.get(opts, :frequency_penalty))
|> maybe_add("presence_penalty", Keyword.get(opts, :presence_penalty))
|> maybe_add("repetition_penalty", Keyword.get(opts, :repetition_penalty))
|> maybe_add("stop", Keyword.get(opts, :stop))
|> maybe_add("seed", Keyword.get(opts, :seed))
|> maybe_add("stream", Keyword.get(opts, :stream))
|> maybe_add_reasoning(opts)
end
# Build reasoning object for OpenRouter API
# See: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
defp maybe_add_reasoning(body, opts) do
reasoning_enabled = Keyword.get(opts, :reasoning_enabled)
reasoning_effort = Keyword.get(opts, :reasoning_effort)
reasoning_max_tokens = Keyword.get(opts, :reasoning_max_tokens)
reasoning_exclude = Keyword.get(opts, :reasoning_exclude)
# Build reasoning object only if any reasoning option is set
reasoning =
%{}
|> maybe_add("enabled", reasoning_enabled)
|> maybe_add("effort", reasoning_effort)
|> maybe_add("max_tokens", reasoning_max_tokens)
|> maybe_add("exclude", reasoning_exclude)
if map_size(reasoning) > 0 do
Map.put(body, "reasoning", reasoning)
else
body
end
end
defp maybe_add(map, _key, nil), do: map
defp maybe_add(map, _key, []), do: map
defp maybe_add(map, key, value), do: Map.put(map, key, value)
defp http_post(url, headers, body) do
# Convert headers list to map format for Req
headers_map = Map.new(headers)
base_opts = [
json: body,
headers: headers_map,
receive_timeout: @timeout,
connect_options: [timeout: @timeout]
]
# `:req_options` is empty in production. Tests opt in via
# `Application.put_env(:phoenix_kit_ai, :req_options, plug: {Req.Test, Stub})`.
opts = base_opts ++ Application.get_env(:phoenix_kit_ai, :req_options, [])
case Req.post(url, opts) do
{:ok, %Req.Response{status: status, body: response_body}} ->
# Req automatically decodes JSON, so encode it back to string for consistency
body_string =
if is_map(response_body) or is_list(response_body) do
Jason.encode!(response_body)
else
to_string(response_body)
end
{:ok, %{status_code: status, body: body_string}}
{:error, %Req.TransportError{reason: :timeout}} ->
{:error, :timeout}
{:error, %Req.TransportError{reason: reason}} ->
Logger.warning("HTTP POST transport error: #{inspect(reason)}")
{:error, reason}
{:error, reason} ->
Logger.error("HTTP POST failed with unexpected error: #{inspect(reason)}")
{:error, reason}
end
end
@doc false
# Public for testability. Parses an OpenRouter error body and returns
# the human-readable message, or nil if the shape is unrecognised.
def extract_error_message(body) do
case Jason.decode(body) do
{:ok, %{"error" => %{"message" => message}}} -> message
{:ok, %{"error" => error}} when is_binary(error) -> error
_ -> nil
end
end
defp build_url(endpoint, path) do
# Falls back to the provider's canonical default base url when the
# endpoint row has none — covers legacy rows persisted before the
# changeset gained `maybe_set_default_base_url`. Hardcoding
# OpenRouter's URL here would silently misroute Mistral / DeepSeek
# traffic.
base =
cond do
is_binary(endpoint.base_url) and endpoint.base_url != "" -> endpoint.base_url
is_binary(endpoint.provider) -> Endpoint.default_base_url(endpoint.provider)
true -> nil
end
case base do
nil ->
raise ArgumentError,
"endpoint #{inspect(endpoint.uuid)} has no base_url and " <>
"provider #{inspect(endpoint.provider)} has no default — " <>
"edit the endpoint to set a base_url"
base ->
"#{String.trim_trailing(base, "/")}#{path}"
end
end
end