Packages

A layered agent framework for Elixir: structured output, tool-calling, streaming, stateful supervised agents, multi-agent sessions, durable persistence, compaction/cost/permissions and MCP — powered by the BEAM.

Current section

Files

Jump to
exagent lib exagent model.ex
Raw

lib/exagent/model.ex

defmodule ExAgent.ModelRequestParameters do
@moduledoc """
The per-request contract handed to a model. It bundles the tools the agent
has prepared (function tools + output tools), the negotiated output mode, and
the structured-output schema (if any).
The struct is deliberately minimal for now and grows as we add tool/output
features.
"""
alias ExAgent.{Tool, Message}
@type output_mode :: :text | :tool | :native | :prompted | :auto
@type output_object :: %{json_schema: map(), module: module() | nil}
defstruct function_tools: [],
output_tools: [],
output_mode: :text,
allow_text_output: true,
output_object: nil,
instructions: []
@type t :: %__MODULE__{
function_tools: [Tool.t()],
output_tools: [Tool.t()],
output_mode: output_mode(),
allow_text_output: boolean(),
output_object: output_object() | nil,
instructions: [Message.Part.System.t()]
}
end
defmodule ExAgent.Model do
@moduledoc """
Behaviour that every provider model implements.
A "model" is any struct whose module `@behaviour`s `ExAgent.Model`. The
struct carries the model's own state (base URL, API key, configured
options…). Callers never invoke a provider module directly — they go through
the dispatch functions in this module (`request/4`, `request_stream/4`, …),
which resolve the implementer from the struct's `__struct__`.
This uses Elixir behaviours plus struct dispatch so providers can stay small
and explicit.
"""
alias ExAgent.{Message, ModelSettings, ModelRequestParameters}
@type model :: struct()
@type messages :: [Message.t()]
@doc """
Make a non-streaming request and return a full `Message.Response`.
Implementations return the (possibly updated) model struct alongside the
response. Real providers return the model unchanged; stateful models (e.g.
the script-driven `Test`) thread their internal state through the run this
way, avoiding globals.
"""
@callback request(
model :: model(),
messages,
ModelSettings.t() | nil,
ModelRequestParameters.t()
) :: {:ok, Message.Response.t(), model()} | {:error, term()}
@doc """
Make a streaming request. Returns an enumerable of events. Implementations
may instead return `{:error, _}` if streaming is unsupported.
"""
@callback request_stream(
model :: model(),
messages,
ModelSettings.t() | nil,
ModelRequestParameters.t()
) :: Enumerable.t() | {:error, term()}
@callback model_name(model()) :: String.t()
@callback system(model()) :: String.t()
@doc "Declares this model's capabilities (optional; defaults are permissive)."
@callback profile(model()) :: ExAgent.ModelProfile.t()
@optional_callbacks [request_stream: 4, profile: 1]
# --- dispatch ------------------------------------------------------------
@spec request(model(), messages, ModelSettings.t() | nil, ModelRequestParameters.t()) ::
{:ok, Message.Response.t(), model()} | {:error, term()}
def request(%mod{} = model, messages, settings, params) do
mod.request(model, messages, settings, params)
end
@spec request_stream(model(), messages, ModelSettings.t() | nil, ModelRequestParameters.t()) ::
Enumerable.t()
def request_stream(%mod{} = model, messages, settings, params) do
if function_exported?(mod, :request_stream, 4) do
case mod.request_stream(model, messages, settings, params) do
{:error, _} = e -> Stream.concat([[], [e]])
stream -> stream
end
else
Stream.concat([[], [{:error, {:unsupported, :streaming}}]])
end
end
@spec model_name(model()) :: String.t()
def model_name(%mod{} = model), do: mod.model_name(model)
@spec system(model()) :: String.t()
def system(%mod{} = model), do: mod.system(model)
@spec profile(model()) :: ExAgent.ModelProfile.t()
def profile(%mod{} = model) do
if function_exported?(mod, :profile, 1) do
mod.profile(model)
else
%ExAgent.ModelProfile{}
end
end
@doc """
Resolve a model from a spec. A struct is returned as-is; a string like
`"openai:gpt-4o"` is resolved via the provider registry (only a few providers
are wired up for now).
"""
@spec resolve(model() | String.t()) :: {:ok, model()} | {:error, term()}
def resolve(%_{} = model), do: {:ok, model}
def resolve("test:" <> rest), do: {:ok, %ExAgent.Models.Test{label: rest}}
def resolve("test"), do: {:ok, %ExAgent.Models.Test{}}
def resolve("openai:" <> name),
do: {:ok, ExAgent.Models.OpenAI.new(model: name)}
def resolve("openrouter:" <> name),
do: {:ok, ExAgent.Models.OpenRouter.new(model: name)}
def resolve("anthropic:" <> name),
do: {:ok, ExAgent.Models.Anthropic.new(model: name)}
# Z.AI (Zhipu) GLM models via their Anthropic-compatible endpoint.
# Cheap pick: "glm-4.5-air". Needs ZAI_API_KEY (or ANTHROPIC_AUTH_TOKEN).
def resolve("zai:" <> name) do
token = System.get_env("ZAI_API_KEY") || System.get_env("ANTHROPIC_AUTH_TOKEN")
{:ok,
ExAgent.Models.Anthropic.new(
model: name,
auth_token: token,
base_url: "https://api.z.ai/api/anthropic"
)}
end
def resolve(other) do
{:error, {:unknown_model, other}}
end
end