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": , "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": , "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