Current section

Files

Jump to
gemini_ex lib gemini apis coordinator.ex
Raw

lib/gemini/apis/coordinator.ex

defmodule Gemini.APIs.Coordinator do
@moduledoc """
Coordinates API calls across different authentication strategies and endpoints.
Provides a unified interface that can route requests to either Gemini API or Vertex AI
based on configuration, while maintaining the same interface.
This module acts as the main entry point for all Gemini API operations,
automatically handling authentication strategy selection and request routing.
## Features
- Unified API for content generation across auth strategies
- Automatic auth strategy selection based on configuration
- Per-request auth strategy override capability
- Consistent error handling and response format
- Support for both streaming and non-streaming operations
- Model listing and token counting functionality
## Usage
# Use default auth strategy
{:ok, response} = Coordinator.generate_content("Hello world")
# Override auth strategy for specific request
{:ok, response} = Coordinator.generate_content("Hello world", auth: :vertex_ai)
# Start streaming with specific auth
{:ok, stream_id} = Coordinator.stream_generate_content("Tell me a story", auth: :gemini)
See `t:Gemini.options/0` in `Gemini` for the canonical list of options.
"""
alias Gemini.Client.HTTP
alias Gemini.Streaming.UnifiedManager
alias Gemini.Types.Request.GenerateContentRequest
alias Gemini.Types.Response.{GenerateContentResponse, ListModelsResponse}
alias Gemini.Types.Content
alias Gemini.Types.ToolSerialization
@type auth_strategy :: :gemini | :vertex_ai
@type request_opts :: keyword()
@type api_result(t) :: {:ok, t} | {:error, term()}
# Content Generation API
@doc """
Generate content using the specified model and input.
See `t:Gemini.options/0` for available options.
## Parameters
- `input`: String prompt or GenerateContentRequest struct
- `opts`: Options including model, auth strategy, and generation config
## Examples
# Simple text generation
{:ok, response} = Coordinator.generate_content("What is AI?")
# With specific model and auth
{:ok, response} = Coordinator.generate_content(
"Explain quantum computing",
model: Gemini.Config.get_model(:flash_2_0_lite),
auth: :vertex_ai,
temperature: 0.7
)
# Using request struct
request = %GenerateContentRequest{...}
{:ok, response} = Coordinator.generate_content(request)
"""
@spec generate_content(
String.t() | [Content.t()] | GenerateContentRequest.t(),
Gemini.options()
) ::
api_result(GenerateContentResponse.t())
def generate_content(input, opts \\ []) do
model = Keyword.get(opts, :model, Gemini.Config.get_model(:default))
path = "models/#{model}:generateContent"
with {:ok, request} <- build_generate_request(input, opts),
{:ok, response} <- HTTP.post(path, request, opts) do
parse_generate_response(response)
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Stream content generation with real-time response chunks.
See `t:Gemini.options/0` for available options.
## Parameters
- `input`: String prompt or GenerateContentRequest struct
- `opts`: Options including model, auth strategy, and generation config
## Returns
- `{:ok, stream_id}`: Stream started successfully
- `{:error, reason}`: Failed to start stream
After starting the stream, subscribe to receive events:
{:ok, stream_id} = Coordinator.stream_generate_content("Tell me a story")
:ok = Coordinator.subscribe_stream(stream_id)
# Handle incoming messages
receive do
{:stream_event, ^stream_id, event} ->
IO.inspect(event, label: "Stream Event")
{:stream_complete, ^stream_id} ->
IO.puts("Stream completed")
{:stream_error, ^stream_id, stream_error} ->
IO.puts("Stream error: \#{inspect(stream_error)}")
end
## Examples
# Basic streaming
{:ok, stream_id} = Coordinator.stream_generate_content("Write a poem")
# With specific configuration
{:ok, stream_id} = Coordinator.stream_generate_content(
"Explain machine learning",
model: Gemini.Config.get_model(:flash_2_0_lite),
auth: :gemini,
temperature: 0.8,
max_output_tokens: 1000
)
"""
@spec stream_generate_content(String.t() | GenerateContentRequest.t(), Gemini.options()) ::
api_result(String.t())
def stream_generate_content(input, opts \\ []) do
model = Keyword.get(opts, :model, Gemini.Config.get_model(:default))
with {:ok, request_body} <- build_generate_request(input, opts) do
# Pass through the auto_execute_tools option to the UnifiedManager
UnifiedManager.start_stream(model, request_body, opts)
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Subscribe to a streaming content generation.
## Parameters
- `stream_id`: ID of the stream to subscribe to
- `subscriber_pid`: Process to receive stream events (defaults to current process)
## Examples
{:ok, stream_id} = Coordinator.stream_generate_content("Hello")
:ok = Coordinator.subscribe_stream(stream_id)
# In a different process
:ok = Coordinator.subscribe_stream(stream_id, target_pid)
"""
@spec subscribe_stream(String.t(), pid()) :: :ok | {:error, term()}
def subscribe_stream(stream_id, subscriber_pid \\ self()) do
UnifiedManager.subscribe(stream_id, subscriber_pid)
end
@doc """
Unsubscribe from a streaming content generation.
"""
@spec unsubscribe_stream(String.t(), pid()) :: :ok | {:error, term()}
def unsubscribe_stream(stream_id, subscriber_pid \\ self()) do
UnifiedManager.unsubscribe(stream_id, subscriber_pid)
end
@doc """
Stop a streaming content generation.
"""
@spec stop_stream(String.t()) :: :ok | {:error, term()}
def stop_stream(stream_id) do
UnifiedManager.stop_stream(stream_id)
end
@doc """
Get the status of a streaming content generation.
"""
@spec stream_status(String.t()) :: {:ok, atom()} | {:error, term()}
def stream_status(stream_id) do
UnifiedManager.stream_status(stream_id)
end
# Model Management API
@doc """
List available models for the specified authentication strategy.
See `t:Gemini.options/0` for available options.
## Parameters
- `opts`: Options including auth strategy and pagination
## Options
- `:auth`: Authentication strategy (`:gemini` or `:vertex_ai`)
- `:page_size`: Number of models per page
- `:page_token`: Pagination token for next page
## Examples
# List models with default auth
{:ok, models_response} = Coordinator.list_models()
# List models with specific auth strategy
{:ok, models_response} = Coordinator.list_models(auth: :vertex_ai)
# With pagination
{:ok, models_response} = Coordinator.list_models(
auth: :gemini,
page_size: 50,
page_token: "next_page_token"
)
"""
@spec list_models(Gemini.options()) :: api_result(ListModelsResponse.t())
def list_models(opts \\ []) do
path = "models"
with {:ok, response} <- HTTP.get(path, opts) do
parse_models_response(response)
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Get information about a specific model.
See `t:Gemini.options/0` for available options.
## Parameters
- `model_name`: Name of the model to retrieve
- `opts`: Options including auth strategy
## Examples
{:ok, model} = Coordinator.get_model(Gemini.Config.get_model(:flash_2_0_lite))
{:ok, model} = Coordinator.get_model("gemini-2.5-pro", auth: :vertex_ai)
"""
@spec get_model(String.t(), Gemini.options()) :: api_result(map())
def get_model(model_name, opts \\ []) do
path = "models/#{model_name}"
case HTTP.get(path, opts) do
{:ok, response} ->
# Normalize response to use atom keys for common fields
normalized_response = normalize_model_response(response)
{:ok, normalized_response}
{:error, reason} ->
{:error, reason}
end
end
# Token Counting API
@doc """
Count tokens in the given input.
See `t:Gemini.options/0` for available options.
## Parameters
- `input`: String or GenerateContentRequest to count tokens for
- `opts`: Options including model and auth strategy
## Options
- `:model`: Model to use for token counting (defaults to configured default model)
- `:auth`: Authentication strategy (`:gemini` or `:vertex_ai`)
## Examples
{:ok, count} = Coordinator.count_tokens("Hello world")
{:ok, count} = Coordinator.count_tokens("Complex text", model: "gemini-2.5-pro", auth: :vertex_ai)
"""
@spec count_tokens(String.t() | GenerateContentRequest.t(), Gemini.options()) ::
api_result(%{total_tokens: integer()})
def count_tokens(input, opts \\ []) do
model = Keyword.get(opts, :model, Gemini.Config.get_model(:default))
path = "models/#{model}:countTokens"
with {:ok, request} <- build_count_tokens_request(input, opts),
{:ok, response} <- HTTP.post(path, request, opts) do
# Convert raw response to structured format
total_tokens = Map.get(response, "totalTokens", 0)
formatted_response = %{total_tokens: total_tokens}
{:ok, formatted_response}
else
{:error, reason} -> {:error, reason}
end
end
# Utility Functions
@doc """
Extract text content from a GenerateContentResponse.
## Examples
{:ok, response} = Coordinator.generate_content("Hello")
{:ok, text} = Coordinator.extract_text(response)
"""
@spec extract_text(GenerateContentResponse.t()) :: {:ok, String.t()} | {:error, term()}
def extract_text(%GenerateContentResponse{candidates: [first_candidate | _]}) do
case first_candidate do
%{content: %{parts: [_ | _] = parts}} ->
text =
parts
|> Enum.filter(&Map.has_key?(&1, :text))
|> Enum.map_join("", & &1.text)
{:ok, text}
_ ->
{:error, "No text content found in response"}
end
end
def extract_text(_), do: {:error, "No candidates found in response"}
# Private Helper Functions
@doc false
@spec convert_to_camel_case(atom()) :: String.t()
defp convert_to_camel_case(atom_key) when is_atom(atom_key) do
atom_key
|> Atom.to_string()
|> String.split("_")
|> case do
[first | rest] ->
first <> Enum.map_join(rest, "", &String.capitalize/1)
[] ->
""
end
end
@doc false
@spec struct_to_api_map(Gemini.Types.GenerationConfig.t()) :: map()
defp struct_to_api_map(%Gemini.Types.GenerationConfig{} = config) do
config
|> Map.from_struct()
|> Enum.reduce(%{}, fn {key, value}, acc ->
camel_key = convert_to_camel_case(key)
Map.put(acc, camel_key, value)
end)
|> filter_nil_values()
end
@doc false
@spec filter_nil_values(map()) :: map()
defp filter_nil_values(map) when is_map(map) do
map
|> Enum.reject(fn {_key, value} ->
is_nil(value) or (is_list(value) and value == [])
end)
|> Enum.into(%{})
end
@spec build_generate_request(
String.t() | [Content.t()] | GenerateContentRequest.t(),
request_opts()
) ::
{:ok, map()} | {:error, term()}
defp build_generate_request(%GenerateContentRequest{} = request, _opts) do
{:ok, request}
end
defp build_generate_request(text, opts) when is_binary(text) do
# Build a basic content request from text
content = %{
contents: [
%{
parts: [%{text: text}]
}
]
}
# Add generation config if provided
# Check for :generation_config option first, then fall back to individual options
config =
case Keyword.get(opts, :generation_config) do
%Gemini.Types.GenerationConfig{} = generation_config ->
# Convert GenerationConfig struct directly to API format
struct_to_api_map(generation_config)
nil ->
# Build from individual options for backward compatibility
build_generation_config(opts)
end
final_content =
if map_size(config) > 0 do
Map.put(content, :generationConfig, config)
else
content
end
# Inject tools and toolConfig if provided
final_content = maybe_put_tools(final_content, opts)
final_content = maybe_put_tool_config(final_content, opts)
{:ok, final_content}
end
defp build_generate_request(contents, opts) when is_list(contents) do
# Build content request from Content structs or normalize from flexible input
formatted_contents =
contents
|> normalize_content_list()
|> Enum.map(&format_content/1)
content = %{
contents: formatted_contents
}
# Add generation config if provided
# Check for :generation_config option first, then fall back to individual options
config =
case Keyword.get(opts, :generation_config) do
%Gemini.Types.GenerationConfig{} = generation_config ->
# Convert GenerationConfig struct directly to API format
struct_to_api_map(generation_config)
nil ->
# Build from individual options for backward compatibility
build_generation_config(opts)
end
final_content =
if map_size(config) > 0 do
Map.put(content, :generationConfig, config)
else
content
end
# Inject tools and toolConfig if provided
final_content = maybe_put_tools(final_content, opts)
final_content = maybe_put_tool_config(final_content, opts)
{:ok, final_content}
end
defp build_generate_request(_, _), do: {:error, "Invalid input type"}
# Normalize flexible content input formats to Content structs
defp normalize_content_list(contents) when is_list(contents) do
Enum.map(contents, &normalize_single_content/1)
end
# Test helper - expose normalization for testing
@doc false
def __test_normalize_content__(input), do: normalize_single_content(input)
@doc false
def __test_detect_mime__(data), do: detect_mime_type(data)
# Already a Content struct - pass through
defp normalize_single_content(%Content{} = content), do: content
# Map with explicit role and parts (Gemini SDK style)
defp normalize_single_content(%{role: role, parts: parts}) when is_list(parts) do
normalized_parts = Enum.map(parts, &normalize_part/1)
%Content{role: role || "user", parts: normalized_parts}
end
# Anthropic-style: list of mixed text/image maps with type field
defp normalize_single_content(%{type: "text", text: text}) do
%Content{role: "user", parts: [Gemini.Types.Part.text(text)]}
end
defp normalize_single_content(%{type: "image", source: %{type: "base64", data: data} = source}) do
# Extract MIME type or detect from data
mime_type =
Map.get(source, :mime_type) || Map.get(source, "mime_type") || detect_mime_type(data)
%Content{
role: "user",
parts: [Gemini.Types.Part.inline_data(data, mime_type)]
}
end
# Simple string - convert to text content
defp normalize_single_content(text) when is_binary(text) do
%Content{role: "user", parts: [Gemini.Types.Part.text(text)]}
end
# Fallback for unrecognized format
defp normalize_single_content(other) do
raise ArgumentError, """
Invalid content format: #{inspect(other)}
Expected one of:
- Content struct: %Content{role: "user", parts: [...]}
- String: "text message"
- Map with role and parts: %{role: "user", parts: [...]}
- Map with type: %{type: "text", text: "..."}
- Map with image: %{type: "image", source: %{type: "base64", data: "..."}}
"""
end
# Normalize part formats
defp normalize_part(%Gemini.Types.Part{} = part), do: part
defp normalize_part(%{text: text}) when is_binary(text), do: Gemini.Types.Part.text(text)
defp normalize_part(%{inline_data: %{mime_type: mime_type, data: data}}) do
Gemini.Types.Part.inline_data(data, mime_type)
end
defp normalize_part(text) when is_binary(text), do: Gemini.Types.Part.text(text)
defp normalize_part(part), do: part
# Detect MIME type from base64 data using magic bytes
defp detect_mime_type(base64_data) when is_binary(base64_data) do
# Decode first 12 bytes to check magic bytes
case Base.decode64(String.slice(base64_data, 0, 16)) do
{:ok, header} -> check_magic_bytes(header)
# Default fallback
:error -> "image/jpeg"
end
end
defp detect_mime_type(_), do: "image/jpeg"
# Check magic bytes to determine image format
defp check_magic_bytes(<<0x89, 0x50, 0x4E, 0x47, _::binary>>), do: "image/png"
defp check_magic_bytes(<<0xFF, 0xD8, 0xFF, _::binary>>), do: "image/jpeg"
defp check_magic_bytes(<<0x47, 0x49, 0x46, 0x38, _::binary>>), do: "image/gif"
defp check_magic_bytes(<<0x52, 0x49, 0x46, 0x46, _::binary>>), do: "image/webp"
# Default fallback
defp check_magic_bytes(_), do: "image/jpeg"
# Helper function to format Content structs for API requests
defp format_content(%Content{role: role, parts: parts}) do
%{
role: role,
parts: Enum.map(parts, &format_part/1)
}
end
# Helper function to format Part structs for API requests
defp format_part(%{text: text}) when is_binary(text) do
%{text: text}
end
defp format_part(%{inline_data: %{mime_type: mime_type, data: data}}) do
%{inline_data: %{mime_type: mime_type, data: data}}
end
defp format_part(part), do: part
# Tools serialization helpers
defp maybe_put_tools(map, opts) do
case Keyword.get(opts, :tools) do
tools when is_list(tools) and length(tools) > 0 ->
api_tools = ToolSerialization.to_api_tool_list(tools)
Map.put(map, :tools, api_tools)
_ ->
map
end
end
defp maybe_put_tool_config(map, opts) do
case Keyword.get(opts, :tool_config) do
%Altar.ADM.ToolConfig{} = tool_config ->
api_tool_config = ToolSerialization.to_api_tool_config(tool_config)
Map.put(map, :toolConfig, api_tool_config)
_ ->
map
end
end
# Convert ThinkingConfig to API format with camelCase keys
@doc false
defp convert_thinking_config_to_api(%Gemini.Types.GenerationConfig.ThinkingConfig{} = config) do
%{}
|> maybe_put_if_not_nil("thinkingBudget", config.thinking_budget)
|> maybe_put_if_not_nil("includeThoughts", config.include_thoughts)
end
defp convert_thinking_config_to_api(config) when is_map(config) do
# Support plain maps for backward compatibility
config
|> Enum.reduce(%{}, fn
{:thinking_budget, budget}, acc when is_integer(budget) ->
Map.put(acc, "thinkingBudget", budget)
{:include_thoughts, include}, acc when is_boolean(include) ->
Map.put(acc, "includeThoughts", include)
# Support both snake_case and camelCase input
{"thinkingBudget", budget}, acc ->
Map.put(acc, "thinkingBudget", budget)
{"includeThoughts", include}, acc ->
Map.put(acc, "includeThoughts", include)
_, acc ->
acc
end)
end
defp convert_thinking_config_to_api(nil), do: %{}
# Helper to put value only if not nil
@doc false
defp maybe_put_if_not_nil(map, _key, nil), do: map
defp maybe_put_if_not_nil(map, key, value), do: Map.put(map, key, value)
# Helper function to normalize model response keys
defp normalize_model_response(response) when is_map(response) do
response
|> Map.new(fn {key, value} ->
atom_key =
case key do
"displayName" -> :display_name
"name" -> :name
"description" -> :description
"inputTokenLimit" -> :input_token_limit
"outputTokenLimit" -> :output_token_limit
"supportedGenerationMethods" -> :supported_generation_methods
_ -> key
end
{atom_key, value}
end)
end
@spec build_generation_config(request_opts()) :: map()
defp build_generation_config(opts) do
opts
|> Enum.reduce(%{}, fn
# Basic generation parameters
{:temperature, temp}, acc when is_number(temp) ->
Map.put(acc, :temperature, temp)
{:max_output_tokens, max}, acc when is_integer(max) ->
Map.put(acc, :maxOutputTokens, max)
{:top_p, top_p}, acc when is_number(top_p) ->
Map.put(acc, :topP, top_p)
{:top_k, top_k}, acc when is_integer(top_k) ->
Map.put(acc, :topK, top_k)
# Advanced generation parameters
{:response_schema, schema}, acc when is_map(schema) ->
Map.put(acc, :responseSchema, schema)
{:response_mime_type, mime_type}, acc when is_binary(mime_type) ->
Map.put(acc, :responseMimeType, mime_type)
{:stop_sequences, sequences}, acc when is_list(sequences) ->
Map.put(acc, :stopSequences, sequences)
{:candidate_count, count}, acc when is_integer(count) and count > 0 ->
Map.put(acc, :candidateCount, count)
{:presence_penalty, penalty}, acc when is_number(penalty) ->
Map.put(acc, :presencePenalty, penalty)
{:frequency_penalty, penalty}, acc when is_number(penalty) ->
Map.put(acc, :frequencyPenalty, penalty)
{:response_logprobs, logprobs}, acc when is_boolean(logprobs) ->
Map.put(acc, :responseLogprobs, logprobs)
{:logprobs, logprobs}, acc when is_integer(logprobs) ->
Map.put(acc, :logprobs, logprobs)
# Thinking config support - FIXED: Now converts field names properly
{:thinking_config, thinking_config}, acc when not is_nil(thinking_config) ->
api_format = convert_thinking_config_to_api(thinking_config)
if map_size(api_format) > 0 do
Map.put(acc, "thinkingConfig", api_format)
else
acc
end
# Ignore unknown options
_, acc ->
acc
end)
end
@spec build_count_tokens_request(String.t() | GenerateContentRequest.t(), request_opts()) ::
{:ok, map()} | {:error, term()}
defp build_count_tokens_request(%GenerateContentRequest{} = request, _opts) do
{:ok, %{generateContentRequest: request}}
end
defp build_count_tokens_request(text, _opts) when is_binary(text) do
{:ok,
%{
contents: [
%{
parts: [%{text: text}]
}
]
}}
end
defp build_count_tokens_request(_, _), do: {:error, "Invalid input type"}
@spec parse_generate_response(map()) :: {:ok, GenerateContentResponse.t()} | {:error, term()}
defp parse_generate_response(response) when is_map(response) do
# Convert string keys to atom keys for struct creation
atomized_response = atomize_keys(response)
{:ok, struct(GenerateContentResponse, atomized_response)}
end
@spec parse_models_response(map()) :: {:ok, ListModelsResponse.t()} | {:error, term()}
defp parse_models_response(response) when is_map(response) do
atomized_response = atomize_keys(response)
{:ok, struct(ListModelsResponse, atomized_response)}
end
# Helper function to recursively convert string keys to atom keys
@spec atomize_keys(term()) :: term()
defp atomize_keys(map) when is_map(map) do
map
|> Enum.map(fn {k, v} -> {atomize_key(k), atomize_keys(v)} end)
|> Enum.into(%{})
end
defp atomize_keys(list) when is_list(list) do
Enum.map(list, &atomize_keys/1)
end
defp atomize_keys(value), do: value
@spec atomize_key(String.t() | atom()) :: atom()
defp atomize_key(key) when is_binary(key) do
# Convert camelCase to snake_case
key
|> String.replace(~r/([A-Z])/, "_\\1")
|> String.downcase()
|> String.trim_leading("_")
|> String.to_atom()
end
defp atomize_key(key) when is_atom(key), do: key
end