Sycophant (sycophant v0.4.2)

Copy Markdown

Unified Elixir client for multiple LLM providers.

Sycophant abstracts the differences between OpenAI, Anthropic, Google Gemini, AWS Bedrock, Azure AI Foundry, and OpenRouter behind a single composable API. Provider-specific wire protocols, authentication, and parameter validation are handled automatically based on the model identifier.

Text Generation

messages = [Sycophant.Message.user("What is the capital of France?")]

{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages)
response.text
#=> "The capital of France is Paris."

Multi-turn Conversations

Use Context from a previous response to continue the conversation:

{:ok, response} = Sycophant.generate_text("anthropic:claude-haiku-4-5-20251001", messages)

ctx = response.context |> Context.add(Message.user("Tell me more"))
{:ok, follow_up} = Sycophant.generate_text("anthropic:claude-haiku-4-5-20251001", ctx)

Structured Output

Use generate_object/4 with a Zoi schema or a JSON Schema map to get validated structured data:

# With Zoi (returns atom keys)
schema = Zoi.object(%{name: Zoi.string(), age: Zoi.integer()})
{:ok, response} = Sycophant.generate_object("openai:gpt-4o-mini", messages, schema)
response.object
#=> %{name: "John", age: 30}

# With JSON Schema (returns string keys)
schema = %{"type" => "object", "properties" => %{"name" => %{"type" => "string"}}, "required" => ["name"]}
{:ok, response} = Sycophant.generate_object("openai:gpt-4o-mini", messages, schema)
response.object
#=> %{"name" => "John"}

Parameters

LLM parameters like temperature and max_tokens are passed as flat keyword options. Each wire protocol declares its own param schema composed from shared definitions plus wire-specific extras. Only parameters supported by the resolved wire protocol are sent to the provider; unsupported params are dropped with a warning log. LLMDB model constraints (e.g., temperature unsupported) are applied automatically.

Common shared params: :temperature, :max_tokens, :top_p, :top_k, :stop, :reasoning_effort, :reasoning_summary, :service_tier, :tool_choice, :parallel_tool_calls.

Wire-specific params are passed as flat keywords alongside shared ones. For example, OpenAI completions accept :logprobs, :seed, and :top_logprobs:

Sycophant.generate_text("openai:gpt-4o-mini", messages,
  temperature: 0.7,
  max_tokens: 500,
  logprobs: true,
  seed: 42
)

Embeddings

request = %Sycophant.EmbeddingRequest{
  inputs: ["Hello world"],
  model: "amazon_bedrock:cohere.embed-english-v3"
}
{:ok, response} = Sycophant.embed(request)
response.embeddings
#=> %{float: [[0.123, -0.456, ...]]}

Streaming

Pass a callback via the :stream option to receive chunks as they arrive:

Sycophant.generate_text("openai:gpt-4o-mini", messages,
  stream: fn chunk -> IO.write(chunk.data) end
)

Use the accumulator form to build up state across chunks:

Sycophant.generate_text("openai:gpt-4o-mini", messages,
  stream: {[], fn
    %Sycophant.StreamChunk{type: :text_delta, data: text}, acc ->
      IO.write(text)
      [text | acc]
    _chunk, acc ->
      acc
  end}
)

Tool Use

Define tools with Zoi schemas or JSON Schema maps. When using Zoi, tool functions receive atom keys. When using JSON Schema, functions receive string keys:

# Zoi-defined tool (atom keys in function args)
weather_tool = %Sycophant.Tool{
  name: "get_weather",
  description: "Gets current weather for a city",
  parameters: Zoi.object(%{city: Zoi.string()}),
  function: fn %{city: city} -> "72F sunny in #{city}" end
}

# JSON Schema-defined tool (string keys in function args)
weather_tool = %Sycophant.Tool{
  name: "get_weather",
  description: "Gets current weather for a city",
  parameters: %{"type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"]},
  function: fn %{"city" => city} -> "72F sunny in #{city}" end
}

Sycophant.generate_text("openai:gpt-4o-mini", messages, tools: [weather_tool])

Configuration

Credentials can be provided per-request, via application config, or through environment variables. See Sycophant.Credentials for the resolution order.

# Per-request
Sycophant.generate_text("openai:gpt-4o-mini", messages,
  credentials: %{api_key: "sk-..."}
)

# Application config (config/runtime.exs)
config :sycophant, :providers,
  openai: [api_key: System.get_env("OPENAI_API_KEY")]

Summary

Functions

Generates embeddings for the given inputs.

Generates a structured object validated against a schema.

Sends a text generation request to the configured LLM provider.

Types

model_ref()

@type model_ref() :: String.t() | LLMDB.Model.t()

Functions

embed(request, opts \\ [])

Generates embeddings for the given inputs.

Accepts an EmbeddingRequest struct containing inputs (text, images, or mixed), model specification, and optional parameters.

Examples

request = %Sycophant.EmbeddingRequest{
  inputs: ["Hello world", "Goodbye world"],
  model: "amazon_bedrock:cohere.embed-english-v3"
}
{:ok, response} = Sycophant.embed(request)
length(response.embeddings.float)
#=> 2

generate_object(model, messages_or_context, schema, opts \\ [])

@spec generate_object(
  model_ref(),
  [Sycophant.Message.t()] | Sycophant.Context.t(),
  Zoi.schema() | map(),
  keyword()
) :: {:ok, Sycophant.Response.t()} | {:error, Splode.Error.t()}

Generates a structured object validated against a schema.

Accepts either a Zoi schema or a JSON Schema map. The schema is sent to the provider to constrain output format, and the response is validated against it.

When using a Zoi schema, response.object has atom keys. When using a JSON Schema map, response.object has string keys.

Options

Accepts the same options as generate_text/3, plus:

  • :validate - Whether to validate the output against the schema (default: true). When false, the response is parsed as JSON but not validated, and string keys are always returned regardless of schema type.

Examples

# Zoi schema (atom keys)
schema = Zoi.object(%{name: Zoi.string(), age: Zoi.integer()})
{:ok, response} = Sycophant.generate_object("openai:gpt-4o-mini", messages, schema)
response.object
#=> %{name: "Alice", age: 25}

# JSON Schema (string keys)
schema = %{"type" => "object", "properties" => %{"name" => %{"type" => "string"}}, "required" => ["name"]}
{:ok, response} = Sycophant.generate_object("openai:gpt-4o-mini", messages, schema)
response.object
#=> %{"name" => "Alice"}

generate_text(model, messages_or_context, opts \\ [])

@spec generate_text(
  model_ref(),
  [Sycophant.Message.t()] | Sycophant.Context.t(),
  keyword()
) ::
  {:ok, Sycophant.Response.t()} | {:error, Splode.Error.t()}

Sends a text generation request to the configured LLM provider.

Model is the first argument, followed by either a list of messages or a Context struct for multi-turn conversations. When a Context is passed, tools, streaming, and parameter settings are extracted and merged with user-provided opts (user opts take precedence).

Options

  • :credentials - Per-request credentials map (optional)
  • :tools - List of Sycophant.Tool structs (optional)
  • :stream - Stream callback: either a function/1 receiving StreamChunk structs, or a {initial_acc, function/2} tuple for accumulator-style streaming (optional)
  • :max_steps - Maximum tool execution loop iterations (default: 10)
  • :auto_execute_tools - When true (default), tool calls are auto-executed via tool :function callbacks in a loop. Set to false to disable the loop and return the raw tool_calls in the response for manual handling.
  • :temperature, :max_tokens, :top_p, etc. - LLM parameters validated against the resolved wire protocol's param schema.

Examples

messages = [Sycophant.Message.user("Hello")]
{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages)

{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages,
  temperature: 0.5,
  max_tokens: 200
)

# Continue via Context
ctx = response.context |> Context.add(Message.user("Thanks!"))
{:ok, response2} = Sycophant.generate_text("openai:gpt-4o-mini", ctx)