Packages

AI SDK for Elixir - A toolkit for building AI-powered applications in Elixir, inspired by Vercel's AI SDK.

Current section

Files

Jump to
ai_sdk lib ai providers openai_compatible chat_language_model.ex
Raw

lib/ai/providers/openai_compatible/chat_language_model.ex

defmodule AI.Providers.OpenAICompatible.ChatLanguageModel do
@moduledoc """
OpenAI-compatible chat language model implementation.
This module implements the language model interface for OpenAI-compatible APIs.
"""
alias AI.Provider.Utils.EventSource
defstruct [
:provider,
:model_id,
:formatted_provider,
:supports_image_urls,
:supports_structured_outputs
]
alias AI.Providers.OpenAICompatible.MessageConversion
@doc """
Creates a new OpenAI-compatible chat language model.
## Options
* `:model_id` - The model ID to use (required)
* `:supports_image_urls` - Whether the model supports image URLs (default: false)
* `:supports_structured_outputs` - Whether the model supports structured outputs (default: false)
"""
def new(provider, opts) do
model_id = Map.fetch!(opts, :model_id)
supports_image_urls = Map.get(opts, :supports_image_urls, false)
supports_structured_outputs = Map.get(opts, :supports_structured_outputs, false)
# Format provider name
formatted_provider = format_provider_name(provider.name)
%__MODULE__{
provider: provider,
model_id: model_id,
formatted_provider: formatted_provider,
supports_image_urls: supports_image_urls,
supports_structured_outputs: supports_structured_outputs
}
end
@doc """
Generate text using the OpenAI-compatible API.
## Options
* `:messages` - List of messages to send to the API (required)
* `:temperature` - Temperature for sampling (default: 1.0)
* `:max_tokens` - Maximum number of tokens to generate (optional)
* `:tools` - List of tools available to the model (optional)
* `:tool_choice` - Tool choice configuration (optional)
* `:response_format` - Response format configuration (optional)
"""
def do_generate(model, opts) do
messages = Map.get(opts, :messages, [])
# Convert messages to OpenAI-compatible format
openai_messages = MessageConversion.convert_to_openai_compatible_chat_messages(messages)
# Build request body
request_body = %{
model: model.model_id,
messages: openai_messages
}
# Add optional parameters if provided
request_body = add_optional_params(request_body, opts)
# Make API request
case make_api_request(model.provider, "/v1/chat/completions", request_body) do
{:ok, response} ->
# Process API response and return result
process_response(response)
{:error, error} ->
{:error, error}
end
end
# Add optional parameters to the request body
defp add_optional_params(request_body, opts) do
request_body
|> add_param(:temperature, opts)
|> add_param(:max_tokens, opts)
|> add_param(:tools, opts)
|> add_param(:tool_choice, opts)
|> add_param(:response_format, opts)
end
# Helper to add a parameter if it exists in opts
defp add_param(request_body, key, opts) do
case Map.get(opts, key) do
nil -> request_body
value -> Map.put(request_body, key, value)
end
end
# Make an API request to the OpenAI-compatible API
defp make_api_request(provider, path, body) do
url = "#{provider.base_url}#{path}"
Tesla.client([
{Tesla.Middleware.Headers, ensure_headers_list(provider.headers)},
{Tesla.Middleware.JSON, []}
])
|> Tesla.post(url, body)
|> handle_response()
end
# Ensure headers are in the proper format (list of tuples)
defp ensure_headers_list(headers) when is_list(headers), do: headers
defp ensure_headers_list(headers) when is_map(headers) do
Enum.map(headers, fn {key, value} -> {key, value} end)
end
defp ensure_headers_list(_), do: []
# Handle the Tesla response
defp handle_response({:ok, %Tesla.Env{status: 200, body: body}}) do
{:ok, body}
end
defp handle_response({:ok, %Tesla.Env{status: status, body: body}}) do
{:error, %{status: status, body: body}}
end
defp handle_response({:error, reason}) do
{:error, reason}
end
# Process the API response to extract the relevant information
defp process_response(response) do
# Extract the first choice from the response
first_choice = get_in(response, ["choices", Access.at(0)])
# Extract the message content from the first choice
content = get_in(first_choice, ["message", "content"])
# Extract reasoning content if available
reasoning = extract_reasoning(first_choice)
# Extract usage statistics and finish reason
usage = response["usage"]
finish_reason = first_choice["finish_reason"]
# Extract tool calls if present
tool_calls = extract_tool_calls(first_choice)
# Build the result
result = %{
text: content || "",
reasoning: reasoning,
finish_reason: finish_reason || "unknown",
usage: %{
prompt_tokens: usage["prompt_tokens"] || 0,
completion_tokens: usage["completion_tokens"] || 0,
total_tokens: usage["total_tokens"] || 0
},
tool_calls: tool_calls,
provider_metadata: response
}
{:ok, result}
end
# Extract tool calls from the response
defp extract_tool_calls(%{"message" => %{"tool_calls" => tool_calls}})
when is_list(tool_calls) do
Enum.map(tool_calls, fn tool_call ->
%{
id: tool_call["id"],
type: tool_call["type"],
function: %{
name: get_in(tool_call, ["function", "name"]),
arguments: process_tool_arguments(get_in(tool_call, ["function", "arguments"]))
}
}
end)
end
defp extract_tool_calls(_) do
[]
end
# Process tool arguments from string to map
defp process_tool_arguments(arguments) when is_binary(arguments) do
case Jason.decode(arguments) do
{:ok, decoded} -> decoded
_ -> %{}
end
end
defp process_tool_arguments(_) do
%{}
end
# Extract reasoning content from the response
defp extract_reasoning(%{
"message" => %{"reasoning" => [%{"type" => "text", "text" => text} | _rest]}
}) do
text
end
defp extract_reasoning(_) do
nil
end
# Format provider name to handle dashes, underscores, etc.
defp format_provider_name(name) when is_binary(name) do
name
end
@doc """
Stream text generation using the OpenAI-compatible API.
This function enables streaming responses from the model, returning chunks
as they are generated rather than waiting for the complete response.
## Options
* `:messages` - List of messages to send to the API (required)
* `:temperature` - Temperature for sampling (default: 1.0)
* `:max_tokens` - Maximum number of tokens to generate (optional)
* `:tools` - List of tools available to the model (optional)
* `:tool_choice` - Tool choice configuration (optional)
* `:response_format` - Response format configuration (optional)
"""
def do_stream(model, opts) do
messages = Map.get(opts, :messages, [])
# Convert messages to OpenAI-compatible format
openai_messages = MessageConversion.convert_to_openai_compatible_chat_messages(messages)
# Build request body
request_body = %{
model: model.model_id,
messages: openai_messages,
stream: true
}
# Add optional parameters if provided
request_body = add_optional_params(request_body, opts)
# Create URL and headers
url = "#{model.provider.base_url}/v1/chat/completions"
headers = ensure_headers_map(model.provider.headers)
# Get the EventSource module to use (allows mocking)
event_source_module = Application.get_env(:ai_sdk, :event_source_module, EventSource)
# Make streaming API request
case event_source_module.post(url, request_body, headers, opts) do
{:ok, response} ->
stream = process_stream_events(response)
{:ok,
%{
stream: stream,
warnings: [],
raw_response: %{
request_body: request_body,
url: url
}
}}
{:error, error} ->
{:error, error}
end
end
# Process streaming events into a unified stream
defp process_stream_events(response) do
# Use the OpenAI Compatible transformer to convert the raw stream to standardized events
alias AI.Stream.OpenAICompatibleTransformer
alias AI.Stream.Event
# Transform the response stream using our dedicated transformer
transformed_stream =
OpenAICompatibleTransformer.transform(response.stream, %{
detect_end_of_stream: false
})
response.stream
|> Enum.group_by(&elem(&1, 0))
|> Enum.map(fn {k, v} -> {k, Enum.count(v)} end)
|> dbg()
# Convert the transformed stream (now containing Event structs) back to tuple format
Stream.map(transformed_stream, &Event.to_tuple/1)
end
# Convert headers to map format for EventSource
defp ensure_headers_map(headers) when is_list(headers) do
Enum.into(headers, %{})
end
defp ensure_headers_map(headers) when is_map(headers) do
headers
end
defp ensure_headers_map(_) do
%{}
end
end