Packages
phoenix_kit
1.7.76
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/ai/completion.ex
defmodule PhoenixKit.Modules.AI.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 `PhoenixKit.Modules.AI` public functions.
## Supported Endpoints
- `/chat/completions` - Text and vision completions
- `/embeddings` - Text embeddings
- `/images/generations` - Image generation (planned)
"""
require Logger
alias PhoenixKit.Modules.AI.OpenRouterClient
@base_url "https://openrouter.ai/api/v1"
@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 with reason string
## 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}} ->
end_time = System.monotonic_time(:millisecond)
latency_ms = end_time - start_time
case Jason.decode(response_body) do
{:ok, response} ->
{:ok, Map.put(response, "latency_ms", latency_ms)}
{:error, _} ->
{:error, "Invalid JSON response"}
end
{:ok, %{status_code: 401}} ->
{:error, "Invalid API key"}
{:ok, %{status_code: 402}} ->
{:error, "Insufficient credits"}
{:ok, %{status_code: 429}} ->
{:error, "Rate limited"}
{:ok, %{status_code: status, body: response_body}} ->
error_msg = extract_error_message(response_body) || "API error: #{status}"
Logger.warning("OpenRouter completion failed: #{status} - #{response_body}")
{:error, error_msg}
{:error, :timeout} ->
{:error, "Request timeout"}
{:error, reason} ->
Logger.warning("OpenRouter completion error: #{inspect(reason)}")
{:error, "Connection error: #{inspect(reason)}"}
end
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 with reason
"""
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}} ->
end_time = System.monotonic_time(:millisecond)
latency_ms = end_time - start_time
case Jason.decode(response_body) do
{:ok, response} ->
{:ok, Map.put(response, "latency_ms", latency_ms)}
{:error, _} ->
{:error, "Invalid JSON response"}
end
{:ok, %{status_code: status, body: response_body}} ->
error_msg = extract_error_message(response_body) || "API error: #{status}"
{:error, error_msg}
{:error, reason} ->
{:error, "Connection error: #{inspect(reason)}"}
end
end
@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 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(response) do
case response do
%{"usage" => usage} when is_map(usage) ->
# OpenRouter returns cost in dollars (as "cost" field)
# Store in nanodollars (1/1000000 of a dollar) for precision
# e.g., $0.00003 becomes 30 nanodollars
cost_cents =
case usage["cost"] || usage["total_cost"] do
nil -> nil
cost when is_number(cost) -> round(cost * 1_000_000)
_ -> nil
end
%{
prompt_tokens: usage["prompt_tokens"] || 0,
completion_tokens: usage["completion_tokens"] || 0,
total_tokens: usage["total_tokens"] || 0,
cost_cents: cost_cents
}
_ ->
%{prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, cost_cents: nil}
end
end
# 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)
case Req.post(url,
json: body,
headers: headers_map,
receive_timeout: @timeout,
connect_options: [timeout: @timeout]
) 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.error("HTTP POST failed: #{inspect(reason)}")
{:error, reason}
{:error, reason} ->
Logger.error("HTTP POST failed: #{inspect(reason)}")
{:error, reason}
end
end
defp 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
base = endpoint.base_url || @base_url
# Remove trailing slash from base if present
base = String.trim_trailing(base, "/")
"#{base}#{path}"
end
end