ExAgent.Providers.OpenAICompatible (ExAgent v0.4.1)

Copy Markdown View Source

Provider for any endpoint speaking the OpenAI chat-completions dialect.

Covers self-hosted vLLM (including behind Modal), OpenRouter, Together, Groq, and anything else exposing POST {base_url}/chat/completions.

Chat only. Embeddings need a task vocabulary, and "any OpenAI-compatible endpoint" cannot have one - see ExAgent.Providers.JinaV5 for the shape an embeddings provider takes.

Modal (vLLM behind Modal's proxy auth)

provider = ExAgent.Providers.OpenAICompatible.new(
  base_url: System.fetch_env!("MODAL_QWEN_URL") <> "/v1",
  model: "Qwen/Qwen3-VL-8B-Instruct",
  headers: [
    {"Modal-Key", System.fetch_env!("MODAL_KEY")},
    {"Modal-Secret", System.fetch_env!("MODAL_SECRET")}
  ],
  modalities: [:text, :image, :video]
)

{:ok, agent} = ExAgent.start_agent(provider: provider)

OpenRouter / Together / Groq (bearer auth)

provider = ExAgent.Providers.OpenAICompatible.new(
  base_url: "https://openrouter.ai/api/v1",
  model: "meta-llama/llama-3.3-70b-instruct",
  api_key: System.fetch_env!("OPENROUTER_API_KEY"),
  modalities: [:text, :image]
)

:api_key is sugar for an Authorization: Bearer header. :headers are merged afterwards, so an explicit header always wins.

:base_url includes the version prefix

As with ExAgent.Providers.OpenAI, :base_url is the full API root - pass https://host/v1, not https://host. Requests are issued against #{base_url}/chat/completions.

Declaring modalities

:modalities describes this deployment, not the module: one vLLM container serves one model, and Qwen3-VL handles video where a text-only Llama container does not. It defaults to [:text], so a misconfigured deployment fails loudly rather than firing video at a model that cannot read it.

All four media modalities are declarable - :image, :video, :audio, and :document. Documents are shaped as the dialect's file content part (file_data for bytes, file_url for a URL), which is what a gateway fronting a document-reading model expects:

OpenAICompatible.new(
  base_url: "https://openrouter.ai/api/v1",
  model: "anthropic/claude-sonnet-4.5",
  api_key: key,
  modalities: [:text, :image, :document]
)

Declare only what the served model actually reads: nothing here is validated against the endpoint until the request is made.

No Files API

Compatible endpoints have no upload endpoint, so bytes are always sent as data: URIs. Past :max_inline_bytes (32 MB by default) the call returns {:error, %ExAgent.Error{type: :unsupported}} telling you to host the asset at a URL the container can reach - it is never silently truncated or retried.

Timeouts

:receive_timeout (5 minutes by default) is the ceiling for a single call, and a call site can lower it per request:

ExAgent.chat(agent, "Describe this clip",
  files: [%{path: "clip.mp4"}],
  receive_timeout: :timer.seconds(150))

Set it below any supervising timeout you wrap the call in - a Task killed at 180 s around a request still willing to wait 300 s reports a hung call as a dropped result. The connect timeout follows the same value.

Verifying the served model

Container swaps where config still names the old model are a common self-host failure. probe/1 checks:

case ExAgent.Providers.OpenAICompatible.probe(provider) do
  :ok -> :ready
  {:error, error} -> Logger.warning(Exception.message(error))
end

Summary

Functions

Creates a provider with validated options and an initialized Req client.

Checks that the endpoint serves the configured model.

Types

t()

@type t() :: %ExAgent.Providers.OpenAICompatible{
  api_key: String.t() | nil,
  base_url: String.t(),
  headers: [{String.t(), String.t()}],
  max_inline_bytes: pos_integer(),
  max_tokens: pos_integer() | nil,
  modalities: [ExAgent.Source.modality()],
  model: String.t(),
  receive_timeout: pos_integer(),
  req: Req.Request.t() | nil,
  system_prompt: String.t() | nil,
  temperature: float() | nil,
  tools: [ExAgent.Tool.t()]
}

Functions

new(opts)

@spec new(keyword()) :: t()

Creates a provider with validated options and an initialized Req client.

Performs no network I/O - use probe/1 to verify the served model.

Options

  • :base_url (String.t/0) - Required. API root, including any /v1 prefix

  • :model (String.t/0) - Required. Model name as the endpoint serves it

  • :api_key - Sugar for an Authorization: Bearer header The default value is nil.

  • :headers (list of term/0) - Extra request headers, merged after :api_key so they win The default value is [].

  • :modalities (list of atom/0) - Attachment modalities this deployment accepts The default value is [:text].

  • :max_inline_bytes (pos_integer/0) - Largest attachment sent as a data URI; there is no upload fallback The default value is 33554432.

  • :receive_timeout (pos_integer/0) - Milliseconds to wait for the response, per call; override per request with receive_timeout: on chat/3 The default value is 300000.

  • :temperature - Sampling temperature; omitted from the request when nil The default value is nil.

  • :max_tokens - Output token ceiling; omitted when nil The default value is nil.

  • :system_prompt - System prompt The default value is nil.

  • :tools (list of term/0) - Available tools The default value is [].

probe(provider)

@spec probe(t()) :: :ok | {:error, ExAgent.Error.t()}

Checks that the endpoint serves the configured model.

Issues GET {base_url}/models and compares the result against :model. Returns :ok when the model is listed, and an ExAgent.Error otherwise - including when the endpoint is unreachable.

A mismatch is reported rather than raised: some gateways do not enumerate every model they will route.