{"repo": "alloy-ex/alloy", "files": {"README.md": {"6250ec6cdab0d66a44e54a4a431cc8cacaa63d4b": {"t": 1785674533.839498, "n": 26716, "h": "c81da0e3808183ca", "c": "# Alloy\n\n[![Hex.pm](https://img.shields.io/hexpm/v/alloy.svg)](https://hex.pm/packages/alloy)\n[![CI](https://github.com/alloy-ex/alloy/actions/workflows/ci.yml/badge.svg)](https://github.com/alloy-ex/alloy/actions/workflows/ci.yml)\n[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/alloy)\n[![License](https://img.shields.io/hexpm/l/alloy.svg)](LICENSE)\n\n**Minimal, OTP-native agent loop for Elixir.**\n\nAlloy is the completion-tool-call loop and nothing else. Send messages to any LLM, execute tool calls, loop until done. Swap providers with one line. No opinions on sessions, persistence, memory, scheduling, or UI — those belong in your application, where OTP already gives you the runtime.\n\nAlloy is a harness, not a framework. Three runtime dependencies, ~9,000 lines — small enough to read in an afternoon, and everything beyond the loop is a [recipe](https://hexdocs.pm/alloy/sub-agents.html) built on the primitives, not a subsystem.\n\n```elixir\n{:ok, result} = Alloy.run(\"Read mix.exs and tell me the version\",\n provider: {Alloy.Provider.OpenAI, api_key: System.get_env(\"OPENAI_API_KEY\"), model: \"gpt-5.4\"},\n tools: [Alloy.Tool.Core.Read]\n)\n\nresult.text #=> \"The version is 0.12.0\"\n```\n\n## Why Alloy?\n\nMost agent frameworks try to be everything — sessions, memory, RAG, multi-agent orchestration, scheduling, UI. Alloy does one thing well: the agent loop. The BEAM makes the rest cheaper than a framework can: sub-agents are supervised function calls ([recipe](https://hexdocs.pm/alloy/sub-agents.html)), MCP servers mount as tools ([recipe](https://hexdocs.pm/alloy/mcp-tools.html)), parallel tool execution is `Task.Supervisor`, and crash isolation is just processes.\n\n## Design Boundary\n\nAlloy stays minimal by owning protocol and loop concerns, not application\nworkflows.\n\nWhat belongs in Alloy:\n- Provider wire-format translation\n- Tool-call / completion loop mechanics\n- Normalized message blocks\n- Opaque provider-owned state such as stored response IDs\n- Provider response metadata such as citations or server-side tool telemetry\n\nWhat does not belong in Alloy:\n- Sessions and persistence policy\n- File storage, indexing, or retrieval workflows\n- UI rendering for citations, search, or artifacts\n- Scheduling, background job orchestration, or dashboards\n- Tenant plans, quotas, billing, or hosted infrastructure policy\n\nRule of thumb: if the feature is required to speak a provider API correctly,\nand could help any Alloy consumer, it likely belongs here. If it needs a\ndatabase table, product defaults, UI decisions, or tenancy logic, it belongs in\nyour application layer.\n\n## Non-goals\n\nAlloy does not own multi-agent orchestration; use the\n[sub-agents recipe](https://hexdocs.pm/alloy/sub-agents.html), where an agent\nspawns itself via a function call. Alloy does not sandbox your machine; use\n`:bash_executor` with containers or another isolated runner. Sessions,\npersistence, scheduling, hosted queues, dashboards, and UI stay in your\napplication layer. The library stays small so those choices remain yours.\n\n## What's in the box\n\n- **6 providers** — Anthropic, Gemini, OpenAI, Codex, xAI, and OpenAICompat (works with any OpenAI-compatible API: Ollama, OpenRouter, DeepSeek, Mistral, Groq, Together, etc.)\n- **4 built-in tools** — read, write, edit, bash — plus inline tools defined as data with `Alloy.Tool.inline/1`\n- **GenServer agents** — supervised, stateful, message-passing (moving to the optional `alloy_agent` runtime package in 0.13)\n- **Streaming** — token-by-token from any provider, unified interface\n- **Async dispatch** — `send_message/2` fires non-blocking, result arrives via PubSub\n- **Middleware** — custom hooks, tool blocking, argument editing\n- **Context compaction** — tool-result clearing plus summary-based compaction when approaching token limits, with configurable reserve and fallback to truncation\n- **Memory primitive** — `Alloy.Memory` behaviour for Anthropic's `memory_20250818` tool. Alloy owns the wire format and path validation; you own the store (in-memory, disk, Postgres — whatever fits)\n- **Prompt caching** — Anthropic `cache: true` adds cache breakpoints for 60-90% input token savings\n- **Reasoning blocks** — DeepSeek/xAI `reasoning_content` parsed as first-class thinking blocks\n- **Tool guardrails** — `concurrent?/0` controls parallel execution, `max_result_chars/0` caps output, prompt-too-long auto-recovery. Note: the bash tool's restricted mode is a guardrail, not a sandbox — see [Built-in tools](#built-in-tools)\n- **Structured output** — `until_tool` forces the loop to continue until a specific tool is called\n- **Provider passthrough** — `extra_body` injects arbitrary provider-specific params (response_format, temperature, reasoning_effort)\n- **Telemetry** — run, turn, provider, and compaction lifecycle events for OTEL/logging/metrics\n- **Cost guard** — `max_budget_cents` halts the loop before overspending\n- **Pluggable model catalog** — `Alloy.ModelCatalog` behaviour; bring your own context-window source (e.g., an `llm_db` adapter)\n- **~9,000 lines** — small enough to read, understand, and extend\n\n## Installation\n\nAdd `alloy` to your dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n [\n {:alloy, \"~> 0.12\"}\n ]\nend\n```\n\n> **Note:** the optional `alloy_agent` runtime wrapper (sessions, async\n> dispatch, memory stores) referenced by the 0.12.x deprecation notices is\n> **not yet published** to Hex. Until it ships, keep using\n> `Alloy.Agent.Server` and `Alloy.Session` from this package — the 0.13\n> removals will not happen before `alloy_agent` is available.\n\n## Quick Start\n\nPrefer a runnable version? [![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Falloy-ex%2Falloy%2Fmain%2Flivebooks%2Fquickstart.livemd)\n\n### Simple completion\n\n```elixir\n{:ok, result} = Alloy.run(\"What is 2+2?\",\n provider: {Alloy.Provider.Anthropic, api_key: \"sk-ant-...\", model: \"claude-sonnet-4-6\"}\n)\n\nresult.text #=> \"4\"\n```\n\n### Agent with tools\n\n```elixir\n{:ok, result} = Alloy.run(\"Read mix.exs and summarize the dependencies\",\n provider: {Alloy.Provider.Gemini,\n api_key: \"...\", model: \"gemini-2.5-flash-lite\"},\n tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],\n max_turns: 10\n)\n```\n\nGemini model IDs Alloy now budgets for include `gemini-2.5-pro`,\n`gemini-2.5-flash`, `gemini-2.5-flash-lite`, `gemini-3-pro-preview`, and\n`gemini-3-flash-preview`.\n\n### Swap providers in one line\n\n```elixir\n# The same tools and conversation work with any provider\nopts = [tools: [Alloy.Tool.Core.Read], max_turns: 10]\n\n# Anthropic\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.Anthropic, api_key: \"...\", model: \"claude-sonnet-4-6\"}} | opts])\n\n# OpenAI\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4\"}} | opts])\n\n# Gemini\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.Gemini, api_key: \"...\", model: \"gemini-2.5-flash\"}} | opts])\n\n# xAI via Responses-compatible API\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.OpenAI, api_key: \"...\", api_url: \"https://api.x.ai\", model: \"grok-4.20-0309-reasoning\"}} | opts])\n\n# xAI via chat completions (reasoning models, extra_body)\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.OpenAICompat, api_key: \"...\", api_url: \"https://api.x.ai\", model: \"grok-4.1-fast-reasoning\"}} | opts])\n\n# Any OpenAI-compatible API (Ollama, OpenRouter, DeepSeek, Mistral, Groq, etc.)\nAlloy.run(\"Read mix.exs\", [{:provider, {Alloy.Provider.OpenAICompat, api_url: \"http://localhost:11434\", model: \"llama4\"}} | opts])\n```\n\n### Streaming\n\nFor a one-shot run, use `Alloy.stream/3`:\n\n```elixir\n{:ok, result} =\n Alloy.stream(\"Explain OTP\", fn chunk ->\n IO.write(chunk)\n end,\n provider: {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4\"}\n )\n```\n\nFor a persistent agent process with conversation state, use `Alloy.Agent.Server.stream_chat/4`:\n\n```elixir\n{:ok, agent} = Alloy.Agent.Server.start_link(\n provider: {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4\"},\n tools: [Alloy.Tool.Core.Read]\n)\n\n{:ok, result} = Alloy.Agent.Server.stream_chat(agent, \"Explain OTP\", fn chunk ->\n IO.write(chunk) # Print each token as it arrives\nend)\n```\n\nAll providers support streaming. If a custom provider doesn't implement\n`stream/4`, the turn loop falls back to `complete/3` automatically.\n\n`Alloy.run/2` remains the buffered convenience API. Use `Alloy.stream/3`\nwhen you want the same one-shot flow with token streaming.\n\n### Provider-owned state\n\nSome provider APIs expose server-side state such as stored response IDs.\nThat transport concern lives in Alloy; your app decides whether and how to\npersist it.\n\nResults expose provider-owned state in `result.metadata.provider_state`:\n\n```elixir\n{:ok, result} =\n Alloy.run(\"Read the repo\",\n provider: {Alloy.Provider.OpenAI,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-4.20-0309-reasoning\",\n store: true\n }\n )\n\nprovider_state = result.metadata.provider_state\n```\n\nPass that state back to the same provider on the next turn to continue a\nprovider-native conversation:\n\n```elixir\n{:ok, next_result} =\n Alloy.run(\"Keep going\",\n messages: result.messages,\n provider: {Alloy.Provider.OpenAI,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-4.20-0309-reasoning\",\n provider_state: provider_state\n }\n )\n```\n\nFor native OpenAI Responses, Alloy automatically preserves opaque reasoning\nitems across stateless tool-call turns. When `store` is not `true` and no\n`previous_response_id` / `provider_state.response_id` is present, the request\nadds `include: [\"reasoning.encrypted_content\"]` and echoes returned reasoning\nitems back verbatim before their related function calls; `result.text` ignores\nthose opaque blocks.\n\n### Provider-native tools and citations\n\nResponses-compatible providers can expose built-in server-side tools without\nleaking those wire details into your app layer.\n\nFor xAI search tools:\n\n```elixir\n{:ok, result} =\n Alloy.run(\"Summarise the latest xAI docs updates\",\n provider: {Alloy.Provider.OpenAI,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-4.20-0309-reasoning\",\n web_search: %{allowed_domains: [\"docs.x.ai\"]},\n include: [\"inline_citations\"]\n }\n )\n```\n\nCitation metadata is exposed in two places:\n- `result.metadata.provider_response.citations` for provider-level citation data\n- assistant text blocks may include `:annotations` for inline citation spans\n\n### Overriding model metadata\n\nAlloy derives the compaction budget from the configured provider model when it\nknows that model's context window. If you need to support a just-released model\nbefore Alloy ships a catalog update, override it in config:\n\n```elixir\n{:ok, result} = Alloy.run(\"Summarise this repository\",\n provider: {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4-2026-03-05\"},\n model_metadata_overrides: %{\n \"gpt-5.4\" => 900_000,\n \"acme-reasoner\" => %{limit: 640_000, suffix_patterns: [\"\", ~r/^-\\d{4}\\.\\d{2}$/]}\n }\n)\n```\n\nSet `max_tokens` explicitly when you want a fixed compaction budget. Otherwise\nAlloy derives it from the current model, including after\n`Alloy.Agent.Server.set_model/2` switches to a different provider model.\n\n### Pluggable model catalog\n\nThe built-in catalog is deliberately small — Alloy is a harness, not a model\ndatabase, and a hand-curated list will always lag the release treadmill. For a\nricher source of truth, implement the `Alloy.ModelCatalog` behaviour and pass\nit via `model_catalog:`. For example, backed by\n[`llm_db`](https://hex.pm/packages/llm_db) (45+ providers, maintained against\nmodels.dev):\n\n```elixir\ndefmodule MyApp.LlmDbCatalog do\n @behaviour Alloy.ModelCatalog\n\n @impl true\n def context_window(model) do\n case LLMDb.model(model) do\n %{context_window: limit} when is_integer(limit) -> limit\n _ -> nil\n end\n end\nend\n\n{:ok, result} = Alloy.run(\"Summarise this repository\",\n provider: {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4\"},\n model_catalog: MyApp.LlmDbCatalog\n)\n```\n\nResolution order: explicit `max_tokens` → `model_metadata_overrides` →\n`model_catalog` → default window. Returning `nil` for an unknown model is\nfine — Alloy falls back to the default.\n\nUse `compaction:` when you want to tune how much room Alloy reserves before it\ncompacts older context:\n\n```elixir\n{:ok, result} = Alloy.run(\"Summarise this repository\",\n provider: {Alloy.Provider.OpenAI, api_key: \"...\", model: \"gpt-5.4\"},\n compaction: [\n reserve_tokens: 12_000,\n keep_recent_tokens: 8_000,\n clear_tool_results: true,\n keep_recent_tool_results: 3,\n fallback: :truncate\n ]\n)\n```\n\nAlloy first clears old `tool_result` and `server_tool_result` content in one\nbatch, preserving the newest `keep_recent_tool_results` results, then only\ncalls the summarizer if the run is still over budget. Batched clearing matters\nwith prompt caching: clearing invalidates cached prefixes, so Alloy amortizes\nthat cost instead of dripping changes across turns.\n\nSet `summary_system_prompt:` and `summary_prompt:` inside `compaction:` when\nyour application needs to own the handoff format. Both values must be strings;\nomitting them uses Alloy's default summary prompts.\n\n### Cost guard\n\nCap how much an agent run can spend:\n\n```elixir\n{:ok, result} = Alloy.run(\"Research this codebase thoroughly\",\n provider: {Alloy.Provider.Anthropic, api_key: \"...\", model: \"claude-sonnet-4-6\"},\n tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],\n max_budget_cents: 50\n)\n\ncase result.status do\n :completed -> IO.puts(result.text)\n :budget_exceeded -> IO.puts(\"Stopped: spent #{result.usage.estimated_cost_cents}¢\")\nend\n```\n\nSet `max_budget_cents: nil` (default) for no limit.\n\n### Anthropic prompt caching\n\nEnable prompt caching to save 60-90% on input tokens. Alloy automatically adds\n`cache_control` breakpoints to the system prompt and last tool definition:\n\n```elixir\n{:ok, result} = Alloy.run(\"Explain this codebase\",\n provider: {Alloy.Provider.Anthropic,\n api_key: \"...\", model: \"claude-sonnet-4-6\",\n cache: true\n },\n tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],\n system_prompt: \"You are a senior Elixir developer.\"\n)\n\n# Cache usage is reported in result.usage\nresult.usage.cache_creation_input_tokens #=> 1500\nresult.usage.cache_read_input_tokens #=> 1500 (on subsequent calls)\n```\n\n### Memory (Anthropic `memory_20250818`)\n\nAlloy exposes memory as a behaviour — `Alloy.Memory` — matching the split\nAnthropic uses in their own Python SDK: Alloy owns the protocol (six\ncommands on a `/memories/` tree, return-string formats, path validation);\nyour code owns the backing store. No bytes touch Anthropic's servers.\n\n```elixir\ndefmodule MyApp.Memory.Disk do\n @behaviour Alloy.Memory\n\n @impl true\n def view(store, path), do: # read from disk\n @impl true\n def create(store, path, text), do: # write\n @impl true\n def str_replace(store, path, old, new), do: # ...\n @impl true\n def insert(store, path, line, text), do: # ...\n @impl true\n def delete(store, path), do: # ...\n @impl true\n def rename(store, old_path, new_path), do: # ...\nend\n\n{:ok, result} = Alloy.run(\"Remember the user prefers SI units\",\n provider: {Alloy.Provider.Anthropic, api_key: \"sk-ant-...\", model: \"claude-sonnet-4-6\"},\n memory: {MyApp.Memory.Disk, root: \"/var/agent/memories\"}\n)\n```\n\nWhen `:memory` is set, Alloy injects the `memory_20250818` tool into the\nAnthropic request and adds the `context-management-2025-06-27` beta\nheader. Memory tool calls are routed through `Alloy.Memory.Router`\n(not the general tool executor) so the typed-tool contract stays clean.\n\nThe store term (second element of `{module, opts}`) is opaque — pass a\nkeyword list, a map, a `pid()`, or a struct, whichever your store needs.\nAlloy does not bake session scoping into the contract; if you want\nper-session memory trees, thread `session_id: \"...\"` through your store\nopts and namespace inside your implementation.\n\nAs of 0.12.0, memory is Anthropic-only — configuring `:memory` with any\nother provider raises at `Alloy.run/2` entry. Other providers will be\nwired as they ship their own memory primitives.\n\n### Reasoning model support (DeepSeek, xAI)\n\nOpenAI-compatible reasoning models that return `reasoning_content` (DeepSeek-R1,\nxAI Grok reasoning variants) are automatically parsed into thinking blocks:\n\n```elixir\n{:ok, result} = Alloy.run(\"Solve this step by step\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"https://api.x.ai\",\n api_key: \"...\", model: \"grok-4.1-fast-reasoning\"\n }\n)\n\n# Thinking blocks are preserved in message content\n[thinking, text] = hd(result.messages).content\nthinking.type #=> \"thinking\"\nthinking.thinking #=> \"Step 1: Let me consider...\"\ntext.type #=> \"text\"\ntext.text #=> \"The answer is 42.\"\n```\n\n### Provider-specific parameters (extra_body)\n\nPass arbitrary provider-specific parameters via `extra_body`. It merges last,\nso it can override any default field:\n\n```elixir\n{:ok, result} = Alloy.run(\"Return JSON\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"https://api.deepseek.com\",\n api_key: \"...\", model: \"deepseek-chat\",\n extra_body: %{\n \"response_format\" => %{\"type\" => \"json_object\"},\n \"temperature\" => 0.3\n }\n }\n)\n```\n\nWorks for any provider param: `reasoning_effort`, `max_completion_tokens`,\n`presence_penalty`, etc.\n\n### Telemetry\n\nAlloy emits telemetry events for observability. Attach handlers for OTEL,\nlogging, or custom metrics:\n\n```elixir\n:telemetry.attach_many(\"my-handler\", [\n [:alloy, :run, :start],\n [:alloy, :run, :stop],\n [:alloy, :turn, :start],\n [:alloy, :turn, :stop],\n [:alloy, :provider, :request],\n [:alloy, :compaction, :cleared],\n [:alloy, :compaction, :done],\n [:alloy, :tool, :start],\n [:alloy, :tool, :stop],\n [:alloy, :event]\n], &MyApp.Telemetry.handle_event/4, nil)\n```\n\n| Event | Measurements | Metadata |\n|-------|-------------|----------|\n| `[:alloy, :run, :start]` | `system_time` | `model` |\n| `[:alloy, :run, :stop]` | `duration_ms` | `status`, `turns`, `model` |\n| `[:alloy, :turn, :start]` | `system_time` | `turn` |\n| `[:alloy, :turn, :stop]` | — | `turn`, `status` |\n| `[:alloy, :provider, :request]` | `duration_ms` | `provider`, `model`, `streaming`, `attempt`, `result` |\n| `[:alloy, :compaction, :cleared]` | `results_cleared`, `bytes_cleared` | `turn` |\n| `[:alloy, :compaction, :done]` | `messages_before`, `messages_after` | `turn` |\n| `[:alloy, :tool, :start]` | — | tool identity, correlation |\n| `[:alloy, :tool, :stop]` | `duration_ms` | tool identity, result |\n\n### Structured output with `until_tool`\n\nForce the model to call a specific tool before the loop completes. This is more\nreliable than response format instructions because the tool schema is validated\nat the API level:\n\n```elixir\ndefmodule SubmitAnswer do\n @behaviour Alloy.Tool\n def name, do: \"submit_answer\"\n def description, do: \"Submit your final answer as structured data.\"\n def input_schema do\n %{type: \"object\", properties: %{\n answer: %{type: \"string\"},\n confidence: %{type: \"number\", minimum: 0, maximum: 1}\n }, required: [\"answer\", \"confidence\"]}\n end\n def execute(input, _ctx), do: {:ok, \"Received: #{input[\"answer\"]}\"}\nend\n\n{:ok, result} = Alloy.run(\"What is the capital of France?\",\n provider: {Alloy.Provider.Anthropic, api_key: \"...\", model: \"claude-sonnet-4-6\"},\n tools: [SubmitAnswer],\n until_tool: \"submit_answer\"\n)\n```\n\n### Middleware: editing tool arguments\n\nMiddleware can return `{:edit, modified_call}` from `:before_tool_call` to rewrite\ntool arguments before execution (e.g., policy enforcement, input sanitization):\n\n```elixir\ndefmodule SanitizeBash do\n @behaviour Alloy.Middleware\n\n def call(:before_tool_call, state) do\n call = state.config.context[:current_tool_call]\n\n if call[:name] == \"bash\" && String.contains?(call[:input][\"command\"], \"rm \") do\n {:edit, %{call | input: %{\"command\" => \"echo 'rm commands are blocked'\"}}}\n else\n state\n end\n end\n\n def call(_hook, state), do: state\nend\n```\n\n### Supervised GenServer agent\n\n```elixir\n{:ok, agent} = Alloy.Agent.Server.start_link(\n provider: {Alloy.Provider.Anthropic, api_key: \"...\", model: \"claude-sonnet-4-6\"},\n tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Edit, Alloy.Tool.Core.Bash],\n system_prompt: \"You are a senior Elixir developer.\"\n)\n\n{:ok, response} = Alloy.Agent.Server.chat(agent, \"What does this project do?\")\n{:ok, response} = Alloy.Agent.Server.chat(agent, \"Now refactor the main module\")\n```\n\n### Async dispatch (Phoenix LiveView)\n\nFire a message without blocking the caller — ideal for LiveView and background jobs:\n\n```elixir\n# Subscribe to receive the result\nPhoenix.PubSub.subscribe(MyApp.PubSub, \"agent:#{session_id}:responses\")\n\n# Returns {:ok, request_id} immediately — agent works in the background\n{:ok, req_id} = Alloy.Agent.Server.send_message(agent, \"Summarise this report\",\n request_id: \"req-123\"\n)\n\n# Handle the result whenever it arrives\ndef handle_info({:agent_response, %{text: text, request_id: \"req-123\"}}, socket) do\n {:noreply, assign(socket, :response, text)}\nend\n```\n\n## Providers\n\n| Vendor | Recommended Module | Example Models |\n|--------|---------------------|----------------|\n| Anthropic | `Alloy.Provider.Anthropic` | `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5` |\n| Gemini | `Alloy.Provider.Gemini` | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3-pro-preview`, `gemma-4-26b-a4b-it` (open-weight) |\n| OpenAI | `Alloy.Provider.OpenAI` | `gpt-5.4` |\n| xAI | `Alloy.Provider.OpenAI` with `api_url: \"https://api.x.ai\"` | `grok-4.20-0309-reasoning`, `grok-4.20-multi-agent-0309`, `grok-4.1-fast-reasoning`, `grok-code-fast-1` |\n| Other OpenAI-compatible APIs | `Alloy.Provider.OpenAICompat` | `kimi-k2.6` (Moonshot), `qwen3-coder-plus` (1M ctx), `glm-4.6`, `mistral-large-2512`, plus Ollama, OpenRouter, DeepSeek, Groq, Together |\n\nUse `Alloy.Provider.OpenAI` for native Responses APIs like OpenAI and xAI.\nUse `Alloy.Provider.Gemini` for Gemini's native GenerateContent API.\nUse `Alloy.Provider.OpenAICompat` for chat-completions compatible APIs and local runtimes.\n\n`OpenAICompat` works with any API that implements the OpenAI chat completions format.\nJust set `api_url`, `model`, and optionally `api_key` and `chat_path`.\n\n## Built-in Tools\n\n| Tool | Module | Description |\n|------|--------|-------------|\n| **read** | `Alloy.Tool.Core.Read` | Read files from disk |\n| **write** | `Alloy.Tool.Core.Write` | Write files to disk |\n| **edit** | `Alloy.Tool.Core.Edit` | Search-and-replace editing |\n| **bash** | `Alloy.Tool.Core.Bash` | Execute shell commands (restricted shell by default) |\n\n> **Bash is a guardrail, not a sandbox.** Restricted mode (`bash -r`) blocks\n> `cd`, `PATH` changes, and redirection — but any interpreter on `PATH`\n> (`python3 -c`, `perl -e`) gives full capability, so treat it as a speed\n> bump against accidents, not an isolation boundary. For real isolation,\n> supply your own `:bash_executor` in context (container, jail, firejail, or\n> a remote runner) and restrict file tools with `:allowed_paths`. Treat the\n> agent's shell access as you would a contractor's laptop on your network.\n\n### Custom tools\n\n```elixir\ndefmodule MyApp.Tools.WebSearch do\n @behaviour Alloy.Tool\n\n @impl true\n def name, do: \"web_search\"\n\n @impl true\n def description, do: \"Search the web for information\"\n\n @impl true\n def input_schema do\n %{\n type: \"object\",\n properties: %{query: %{type: \"string\", description: \"Search query\"}},\n required: [\"query\"],\n additionalProperties: false\n }\n end\n\n @impl true\n def strict?, do: true\n\n @impl true\n def execute(%{\"query\" => query}, _context) do\n # Your implementation here\n {:ok, \"Results for: #{query}\"}\n end\nend\n```\n\nPrefer strict mode for tools that have stable schemas. Set `strict?/0` on a\nmodule tool, or `strict: true` on an inline tool, and include\n`additionalProperties: false` in the top-level JSON Schema. Alloy validates\nthat requirement instead of silently rewriting your schema. OpenAI strict mode\nalso requires every property to be listed in `required`.\n\nFor Anthropic advanced tool use, add `input_examples/0` or `input_examples:`\nwith representative argument maps to improve parameter selection. Set\n`defer_loading?/0` or `defer_loading: true` for tools that should be exposed\nthrough Anthropic's deferred-loading path; other providers ignore these fields.\n\n### Inline tools\n\nFor one-off tools, or tools discovered at runtime (an MCP server's tool\nlist, user-defined actions from a database), define a tool as data with\n`Alloy.Tool.inline/1` — no module needed. Inline tools and tool modules\nmix freely in `tools:`:\n\n```elixir\nweather =\n Alloy.Tool.inline(\n name: \"get_weather\",\n description: \"Get current weather for a location\",\n input_schema: %{\n type: \"object\",\n properties: %{location: %{type: \"string\"}},\n required: [\"location\"]\n },\n execute: fn %{\"location\" => loc}, _context ->\n {:ok, MyApp.Weather.current(loc)}\n end\n )\n\n{:ok, result} = Alloy.run(\"What's the weather in Sydney?\",\n provider: provider,\n tools: [weather, Alloy.Tool.Core.Read]\n)\n```\n\nOptional fields mirror the behaviour's optional callbacks: `concurrent?:`,\n`max_result_chars:`, `allowed_callers:`, `result_type:`. See\n`Alloy.Tool.Inline` for details.\n\n### Code execution (Anthropic)\n\nEnable Anthropic's server-side code execution sandbox:\n\n```elixir\n{:ok, result} = Alloy.run(\"Calculate the first 20 Fibonacci numbers\",\n provider: {Alloy.Provider.Anthropic, api_key: \"...\", model: \"claude-sonnet-4-6\"},\n code_execution: true\n)\n```\n\n## Architecture\n\n```\nAlloy.run/2 One-shot agent loop (pure function)\nAlloy.Agent.Server GenServer wrapper (stateful, supervisable)\nAlloy.Agent.Turn Single turn: call provider → execute tools → return\nAlloy.Provider Behaviour: translate wire format ↔ Alloy.Message\nAlloy.Tool Behaviour: name, description, input_schema, execute\nAlloy.Middleware Pipeline: custom hooks, tool blocking\nAlloy.Context.Compactor Automatic conversation summarization\n```\n\nSessions, persistence, multi-agent coordination, scheduling, skills, and UI\nbelong in your application layer. See [Anvil](https://github.com/alloy-ex/anvil)\nfor a reference Phoenix application built on Alloy.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n## Releases\n\nHex.pm publishing is handled by GitHub Actions on `v*` tags.\nSuccessful publishes also dispatch the landing-site version sync workflow.\n"}}, "lib/alloy/provider/openai_compat.ex": {"6250ec6cdab0d66a44e54a4a431cc8cacaa63d4b": {"t": 1785745928.432868, "n": 11279, "h": "bf218a809d538db2", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n\n # Mistral (mistral-large-latest and -2512 are in the built-in\n # model catalog, so context budgeting works out of the box)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"MISTRAL_API_KEY\"),\n api_url: \"https://api.mistral.ai\",\n model: \"mistral-large-latest\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @typedoc \"\"\"\n Configuration for the OpenAI-compatible provider. `:api_key` is optional\n (omit for local providers like Ollama). See the module doc for field\n semantics.\n \"\"\"\n @type config :: %{\n required(:api_url) => String.t(),\n required(:model) => String.t(),\n optional(:api_key) => String.t(),\n optional(:max_tokens) => pos_integer(),\n optional(:system_prompt) => String.t(),\n optional(:chat_path) => String.t(),\n optional(:extra_headers) => [{String.t(), String.t()}],\n optional(:req_options) => keyword()\n }\n\n @impl true\n @spec complete([Message.t()], [Alloy.Provider.tool_def()], config()) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n @spec stream([Message.t()], [Alloy.Provider.tool_def()], config(), (String.t() -> :ok)) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n tc = %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n\n # Echo Gemini 3.x thought signatures back for multi-turn tool calls.\n # Required or the API returns 400 (PR #24).\n case Map.get(call, :thought_signature) do\n nil -> tc\n sig -> Map.put(tc, \"extra_content\", %{\"google\" => %{\"thought_signature\" => sig}})\n end\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema} = def_map) do\n function = %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n\n function =\n if Map.get(def_map, :strict) == true,\n do: Map.put(function, \"strict\", true),\n else: function\n\n %{\"type\" => \"function\", \"function\" => function}\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case tc[\"function\"] do\n %{\"arguments\" => args, \"name\" => name} ->\n case Jason.decode(args) do\n {:ok, input} ->\n block =\n %{type: \"tool_use\", id: tc[\"id\"], name: name, input: input}\n |> maybe_put_thought_signature(tc)\n\n {:cont, acc ++ [block]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{name}\"}}\n end\n\n _ ->\n {:halt, {:error, \"Malformed tool call: missing function definition\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n # Preserve Gemini 3.x thought signatures for multi-turn tool calls (PR #24).\n defp maybe_put_thought_signature(block, tc) do\n case get_in(tc, [\"extra_content\", \"google\", \"thought_signature\"]) do\n nil -> block\n sig -> Map.put(block, :thought_signature, sig)\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n # Gemini 3.x wraps errors in a list (PR #24)\n defp parse_error(status, [item | _]) when is_map(item), do: parse_error(status, item)\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\n\n defp parse_error(status, body), do: \"HTTP #{status}: #{inspect(body)}\"\nend\n"}, "8b05b3da3c100b55355358281f819e316ac02d48": {"t": 1785757976.8922863, "n": 11279, "h": "bf218a809d538db2", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n\n # Mistral (mistral-large-latest and -2512 are in the built-in\n # model catalog, so context budgeting works out of the box)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"MISTRAL_API_KEY\"),\n api_url: \"https://api.mistral.ai\",\n model: \"mistral-large-latest\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @typedoc \"\"\"\n Configuration for the OpenAI-compatible provider. `:api_key` is optional\n (omit for local providers like Ollama). See the module doc for field\n semantics.\n \"\"\"\n @type config :: %{\n required(:api_url) => String.t(),\n required(:model) => String.t(),\n optional(:api_key) => String.t(),\n optional(:max_tokens) => pos_integer(),\n optional(:system_prompt) => String.t(),\n optional(:chat_path) => String.t(),\n optional(:extra_headers) => [{String.t(), String.t()}],\n optional(:req_options) => keyword()\n }\n\n @impl true\n @spec complete([Message.t()], [Alloy.Provider.tool_def()], config()) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n @spec stream([Message.t()], [Alloy.Provider.tool_def()], config(), (String.t() -> :ok)) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n tc = %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n\n # Echo Gemini 3.x thought signatures back for multi-turn tool calls.\n # Required or the API returns 400 (PR #24).\n case Map.get(call, :thought_signature) do\n nil -> tc\n sig -> Map.put(tc, \"extra_content\", %{\"google\" => %{\"thought_signature\" => sig}})\n end\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema} = def_map) do\n function = %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n\n function =\n if Map.get(def_map, :strict) == true,\n do: Map.put(function, \"strict\", true),\n else: function\n\n %{\"type\" => \"function\", \"function\" => function}\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case tc[\"function\"] do\n %{\"arguments\" => args, \"name\" => name} ->\n case Jason.decode(args) do\n {:ok, input} ->\n block =\n %{type: \"tool_use\", id: tc[\"id\"], name: name, input: input}\n |> maybe_put_thought_signature(tc)\n\n {:cont, acc ++ [block]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{name}\"}}\n end\n\n _ ->\n {:halt, {:error, \"Malformed tool call: missing function definition\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n # Preserve Gemini 3.x thought signatures for multi-turn tool calls (PR #24).\n defp maybe_put_thought_signature(block, tc) do\n case get_in(tc, [\"extra_content\", \"google\", \"thought_signature\"]) do\n nil -> block\n sig -> Map.put(block, :thought_signature, sig)\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n # Gemini 3.x wraps errors in a list (PR #24)\n defp parse_error(status, [item | _]) when is_map(item), do: parse_error(status, item)\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\n\n defp parse_error(status, body), do: \"HTTP #{status}: #{inspect(body)}\"\nend\n"}, "88d123831135f7d05c3ae90cf68bdbbaf19fbd24": {"t": 1785757978.5289965, "n": 11140, "h": "e5d93d3923553182", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n\n # Mistral (mistral-large-latest and -2512 are in the built-in\n # model catalog, so context budgeting works out of the box)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"MISTRAL_API_KEY\"),\n api_url: \"https://api.mistral.ai\",\n model: \"mistral-large-latest\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @typedoc \"\"\"\n Configuration for the OpenAI-compatible provider. `:api_key` is optional\n (omit for local providers like Ollama). See the module doc for field\n semantics.\n \"\"\"\n @type config :: %{\n required(:api_url) => String.t(),\n required(:model) => String.t(),\n optional(:api_key) => String.t(),\n optional(:max_tokens) => pos_integer(),\n optional(:system_prompt) => String.t(),\n optional(:chat_path) => String.t(),\n optional(:extra_headers) => [{String.t(), String.t()}],\n optional(:req_options) => keyword()\n }\n\n @impl true\n @spec complete([Message.t()], [Alloy.Provider.tool_def()], config()) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n @spec stream([Message.t()], [Alloy.Provider.tool_def()], config(), (String.t() -> :ok)) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n tc = %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n\n # Echo Gemini 3.x thought signatures back for multi-turn tool calls.\n # Required or the API returns 400 (PR #24).\n case Map.get(call, :thought_signature) do\n nil -> tc\n sig -> Map.put(tc, \"extra_content\", %{\"google\" => %{\"thought_signature\" => sig}})\n end\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case tc[\"function\"] do\n %{\"arguments\" => args, \"name\" => name} ->\n case Jason.decode(args) do\n {:ok, input} ->\n block =\n %{type: \"tool_use\", id: tc[\"id\"], name: name, input: input}\n |> maybe_put_thought_signature(tc)\n\n {:cont, acc ++ [block]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{name}\"}}\n end\n\n _ ->\n {:halt, {:error, \"Malformed tool call: missing function definition\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n # Preserve Gemini 3.x thought signatures for multi-turn tool calls (PR #24).\n defp maybe_put_thought_signature(block, tc) do\n case get_in(tc, [\"extra_content\", \"google\", \"thought_signature\"]) do\n nil -> block\n sig -> Map.put(block, :thought_signature, sig)\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n # Gemini 3.x wraps errors in a list (PR #24)\n defp parse_error(status, [item | _]) when is_map(item), do: parse_error(status, item)\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\n\n defp parse_error(status, body), do: \"HTTP #{status}: #{inspect(body)}\"\nend\n"}, "d62debc3cef71c25a1de408b4a4a617e94e01d7d": {"t": 1785757980.0139844, "n": 9866, "h": "3afc63c49c902d16", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @typedoc \"\"\"\n Configuration for the OpenAI-compatible provider. `:api_key` is optional\n (omit for local providers like Ollama). See the module doc for field\n semantics.\n \"\"\"\n @type config :: %{\n required(:api_url) => String.t(),\n required(:model) => String.t(),\n optional(:api_key) => String.t(),\n optional(:max_tokens) => pos_integer(),\n optional(:system_prompt) => String.t(),\n optional(:chat_path) => String.t(),\n optional(:extra_headers) => [{String.t(), String.t()}],\n optional(:req_options) => keyword()\n }\n\n @impl true\n @spec complete([Message.t()], [Alloy.Provider.tool_def()], config()) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n @spec stream([Message.t()], [Alloy.Provider.tool_def()], config(), (String.t() -> :ok)) ::\n {:ok, Alloy.Provider.completion_response()} | {:error, term()}\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case tc[\"function\"] do\n %{\"arguments\" => args, \"name\" => name} ->\n case Jason.decode(args) do\n {:ok, input} ->\n {:cont, acc ++ [%{type: \"tool_use\", id: tc[\"id\"], name: name, input: input}]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{name}\"}}\n end\n\n _ ->\n {:halt, {:error, \"Malformed tool call: missing function definition\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\nend\n"}, "ed2c5670585ca3054c76eb813ba20cfabffbf935": {"t": 1785757981.769959, "n": 8953, "h": "194838fbaa88c13e", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @impl true\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case tc[\"function\"] do\n %{\"arguments\" => args, \"name\" => name} ->\n case Jason.decode(args) do\n {:ok, input} ->\n {:cont, acc ++ [%{type: \"tool_use\", id: tc[\"id\"], name: name, input: input}]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{name}\"}}\n end\n\n _ ->\n {:halt, {:error, \"Malformed tool call: missing function definition\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\nend\n"}, "d75843da30ddfb4239d8295d4d34fa585b90dce8": {"t": 1785757984.4675455, "n": 8823, "h": "196cedecef2ab990", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @impl true\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n body =\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n\n # Merge extra_body LAST so caller can override any field\n Map.merge(body, Map.get(config, :extra_body, %{}))\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n reasoning_blocks =\n case message[\"reasoning_content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"thinking\", thinking: text}]\n end\n\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case Jason.decode(tc[\"function\"][\"arguments\"]) do\n {:ok, input} ->\n {:cont,\n acc ++\n [%{type: \"tool_use\", id: tc[\"id\"], name: tc[\"function\"][\"name\"], input: input}]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{tc[\"function\"][\"name\"]}\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, reasoning_blocks ++ text_blocks ++ blocks}\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} when is_map(error) -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n %{\"error\" => error} when is_binary(error) -> \"HTTP #{status}: #{error}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\nend\n"}, "920a628e2bf2e2eea4541e93fb2bec5e8243b4b0": {"t": 1785757986.5389087, "n": 8404, "h": "4342534604d83b76", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"https://api.x.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # xAI chat completions compatibility\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"XAI_API_KEY\"),\n api_url: \"https://api.x.ai\",\n model: \"grok-code-fast-1\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @impl true\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case Jason.decode(tc[\"function\"][\"arguments\"]) do\n {:ok, input} ->\n {:cont,\n acc ++\n [%{type: \"tool_use\", id: tc[\"id\"], name: tc[\"function\"][\"name\"], input: input}]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{tc[\"function\"][\"name\"]}\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, text_blocks ++ blocks}\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\nend\n"}, "3d95b9bf93e2db8c6955c890cd8b4d7e806fca61": {"t": 1785757988.5342143, "n": 8432, "h": "7aec49ac228cccef", "c": "defmodule Alloy.Provider.OpenAICompat do\n @moduledoc \"\"\"\n Generic OpenAI-compatible provider.\n\n Works with any API that implements the OpenAI chat completions format:\n DeepSeek, Mistral, xAI/Grok, Ollama, OpenRouter, Together, Groq, etc.\n\n ## Config\n\n Required:\n - `:api_url` - Base URL (e.g., \"https://api.deepseek.com\",\n \"https://api.mistral.ai\", \"http://localhost:11434\")\n - `:model` - Model name\n\n Optional:\n - `:api_key` - API key (omit for local providers like Ollama)\n - `:max_tokens` - Max output tokens (default: 4096)\n - `:system_prompt` - System prompt string\n - `:chat_path` - Path to completions endpoint (default: \"/v1/chat/completions\")\n - `:extra_headers` - Additional headers as `[{name, value}]`\n - `:req_options` - Additional options passed to Req\n\n ## Examples\n\n # DeepSeek\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"DEEPSEEK_API_KEY\"),\n api_url: \"https://api.deepseek.com\",\n model: \"deepseek-chat\"\n }\n )\n\n # Ollama (no API key)\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_url: \"http://localhost:11434\",\n model: \"llama4\"\n }\n )\n\n # OpenRouter\n Alloy.run(\"Hello\",\n provider: {Alloy.Provider.OpenAICompat,\n api_key: System.get_env(\"OPENROUTER_API_KEY\"),\n api_url: \"https://openrouter.ai\",\n chat_path: \"/api/v1/chat/completions\",\n model: \"anthropic/claude-sonnet-4-6\"\n }\n )\n \"\"\"\n\n @behaviour Alloy.Provider\n\n alias Alloy.Message\n alias Alloy.Provider.OpenAIStream\n\n @default_max_tokens 4096\n @default_chat_path \"/v1/chat/completions\"\n\n @impl true\n def complete(messages, tool_defs, config) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n req_opts =\n ([\n url: url,\n method: :post,\n headers: build_headers(config),\n body: Jason.encode!(body)\n ] ++ Map.get(config, :req_options, []))\n |> Keyword.put(:retry, false)\n\n case Req.request(req_opts) do\n {:ok, %{status: 200, body: resp_body}} ->\n parse_response(resp_body)\n\n {:ok, %{status: status, body: resp_body}} ->\n {:error, parse_error(status, resp_body)}\n\n {:error, reason} ->\n {:error, \"HTTP request failed: #{inspect(reason)}\"}\n end\n end\n\n @impl true\n def stream(messages, tool_defs, config, on_chunk) when is_function(on_chunk, 1) do\n body = build_request_body(messages, tool_defs, config)\n url = \"#{config.api_url}#{Map.get(config, :chat_path, @default_chat_path)}\"\n\n OpenAIStream.stream(\n url,\n build_headers(config),\n body,\n on_chunk,\n Map.get(config, :req_options, [])\n )\n end\n\n defp build_headers(config) do\n base = [{\"content-type\", \"application/json\"}]\n\n base =\n case Map.get(config, :api_key) do\n nil -> base\n key -> [{\"authorization\", \"Bearer #{key}\"} | base]\n end\n\n base ++ Map.get(config, :extra_headers, [])\n end\n\n # --- Request Building ---\n\n defp build_request_body(messages, tool_defs, config) do\n openai_messages = build_messages(messages, config)\n\n body = %{\n \"model\" => config.model,\n \"max_tokens\" => Map.get(config, :max_tokens, @default_max_tokens),\n \"messages\" => openai_messages\n }\n\n case tool_defs do\n [] -> body\n defs -> Map.put(body, \"tools\", Enum.map(defs, &format_tool_def/1))\n end\n end\n\n defp build_messages(messages, config) do\n system_msgs =\n case Map.get(config, :system_prompt) do\n nil -> []\n prompt -> [%{\"role\" => \"system\", \"content\" => prompt}]\n end\n\n conv_msgs = Enum.flat_map(messages, &format_message/1)\n system_msgs ++ conv_msgs\n end\n\n defp format_message(%Message{role: :user, content: content}) when is_binary(content) do\n [%{\"role\" => \"user\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: content}) when is_binary(content) do\n [%{\"role\" => \"assistant\", \"content\" => content}]\n end\n\n defp format_message(%Message{role: :assistant, content: blocks}) when is_list(blocks) do\n tool_calls =\n blocks\n |> Enum.filter(&(&1[:type] == \"tool_use\"))\n |> Enum.map(fn call ->\n %{\n \"id\" => call.id,\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => call.name,\n \"arguments\" => Jason.encode!(call.input)\n }\n }\n end)\n\n text_parts =\n blocks\n |> Enum.filter(&(&1[:type] == \"text\"))\n |> Enum.map_join(\"\\n\", & &1.text)\n\n msg = %{\"role\" => \"assistant\"}\n\n msg =\n if(text_parts == \"\",\n do: Map.put(msg, \"content\", nil),\n else: Map.put(msg, \"content\", text_parts)\n )\n\n msg = if(tool_calls == [], do: msg, else: Map.put(msg, \"tool_calls\", tool_calls))\n [msg]\n end\n\n defp format_message(%Message{role: :user, content: blocks}) when is_list(blocks) do\n if Enum.any?(blocks, &(&1[:type] == \"tool_result\")) do\n blocks\n |> Enum.flat_map(fn\n %{type: \"tool_result\", tool_use_id: id, content: content} ->\n [%{\"role\" => \"tool\", \"tool_call_id\" => id, \"content\" => content}]\n\n _ ->\n []\n end)\n else\n parts = blocks |> Enum.map(&format_user_content_block/1) |> Enum.reject(&is_nil/1)\n [%{\"role\" => \"user\", \"content\" => parts}]\n end\n end\n\n defp format_user_content_block(%{type: \"text\", text: text}),\n do: %{\"type\" => \"text\", \"text\" => text}\n\n defp format_user_content_block(%{type: \"image\", mime_type: mime, data: data}),\n do: %{\"type\" => \"image_url\", \"image_url\" => %{\"url\" => \"data:#{mime};base64,#{data}\"}}\n\n defp format_user_content_block(_), do: nil\n\n defp format_tool_def(%{name: name, description: desc, input_schema: schema}) do\n %{\n \"type\" => \"function\",\n \"function\" => %{\n \"name\" => name,\n \"description\" => desc,\n \"parameters\" => Alloy.Provider.stringify_keys(schema)\n }\n }\n end\n\n # --- Response Parsing ---\n\n defp parse_response(body) when is_binary(body) do\n case Alloy.Provider.decode_body(body) do\n {:ok, decoded} -> parse_response(decoded)\n {:error, _} = err -> err\n end\n end\n\n defp parse_response(%{\"choices\" => [choice | _]} = resp) do\n message = choice[\"message\"]\n finish_reason = choice[\"finish_reason\"]\n usage = resp[\"usage\"] || %{}\n\n case parse_message_to_blocks(message) do\n {:ok, content_blocks} ->\n stop_reason = parse_finish_reason(finish_reason)\n\n {:ok,\n %{\n stop_reason: stop_reason,\n messages: [%Message{role: :assistant, content: content_blocks}],\n usage: %{\n input_tokens: Map.get(usage, \"prompt_tokens\", 0),\n output_tokens: Map.get(usage, \"completion_tokens\", 0)\n }\n }}\n\n {:error, _} = err ->\n err\n end\n end\n\n defp parse_response(%{\"error\" => error}) do\n {:error, \"#{error[\"type\"]}: #{error[\"message\"]}\"}\n end\n\n defp parse_message_to_blocks(message) do\n text_blocks =\n case message[\"content\"] do\n nil -> []\n \"\" -> []\n text -> [%{type: \"text\", text: text}]\n end\n\n tool_blocks =\n (message[\"tool_calls\"] || [])\n |> Enum.reduce_while([], fn tc, acc ->\n case Jason.decode(tc[\"function\"][\"arguments\"]) do\n {:ok, input} ->\n {:cont,\n acc ++\n [%{type: \"tool_use\", id: tc[\"id\"], name: tc[\"function\"][\"name\"], input: input}]}\n\n {:error, _} ->\n {:halt, {:error, \"Invalid JSON in tool call arguments for #{tc[\"function\"][\"name\"]}\"}}\n end\n end)\n\n case tool_blocks do\n {:error, _} = err -> err\n blocks -> {:ok, text_blocks ++ blocks}\n end\n end\n\n defp parse_finish_reason(\"stop\"), do: :end_turn\n defp parse_finish_reason(\"tool_calls\"), do: :tool_use\n defp parse_finish_reason(_), do: :end_turn\n\n defp parse_error(status, body) when is_binary(body) do\n case Jason.decode(body) do\n {:ok, %{\"error\" => error}} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{body}\"\n end\n end\n\n defp parse_error(status, body) when is_map(body) do\n case body do\n %{\"error\" => error} -> \"#{error[\"type\"]}: #{error[\"message\"]}\"\n _ -> \"HTTP #{status}: #{inspect(body)}\"\n end\n end\nend\n"}}}}