Skip to main content

lib/agent_sea/providers/anthropic.ex

defmodule AgentSea.Providers.Anthropic do
  @moduledoc """
  Anthropic Messages API provider. HTTP via `Req`; normalizes responses into
  `AgentSea.Response`.

  Options (passed through the agent's `provider: {__MODULE__, opts}`):

    * `:api_key`   — defaults to `ANTHROPIC_API_KEY`
    * `:base_url`  — defaults to `https://api.anthropic.com`
    * `:max_tokens`— defaults to 1024 (Anthropic requires it)
    * `:adapter`   — a `Req` adapter, used in tests to stub HTTP
  """

  @behaviour AgentSea.Provider

  alias AgentSea.{Response, ToolCall}

  @default_base "https://api.anthropic.com"
  @api_version "2023-06-01"
  @default_max_tokens 1024

  @impl AgentSea.Provider
  def complete(messages, opts) do
    body = build_body(messages, opts)

    case Req.post(req(opts), url: "/v1/messages", json: body) do
      {:ok, %Req.Response{status: 200, body: resp}} ->
        {:ok, normalize(resp)}

      {:ok, %Req.Response{status: status, body: resp}} ->
        {:error, {:http_error, status, resp}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl AgentSea.Provider
  def stream(messages, opts) do
    # Tests (and custom transports) can inject the raw byte stream via
    # `:body_stream`, bypassing Req entirely.
    chunks = opts[:body_stream] || request_stream(messages, opts)

    chunks
    |> AgentSea.Providers.SSE.events()
    |> Stream.flat_map(&to_stream_event/1)
  end

  @impl AgentSea.Provider
  def model_info("claude-opus-4-8") do
    %AgentSea.ModelInfo{
      context_window: 1_000_000,
      max_output_tokens: 64_000,
      tools: true,
      vision: true,
      thinking: true,
      effort: [:low, :medium, :high, :xhigh, :max]
    }
  end

  def model_info("claude-haiku-4-5") do
    %AgentSea.ModelInfo{
      context_window: 200_000,
      max_output_tokens: 32_000,
      tools: true,
      vision: true,
      thinking: false,
      effort: []
    }
  end

  def model_info(_other), do: nil

  # --- Request building ---

  defp req(opts) do
    api_key = opts[:api_key] || System.get_env("ANTHROPIC_API_KEY")

    base = [
      base_url: opts[:base_url] || @default_base,
      headers: [
        {"x-api-key", api_key || ""},
        {"anthropic-version", @api_version}
      ]
    ]

    # `:adapter` (and `:plug`) are only set when provided — used by tests to
    # stub HTTP without hitting the network.
    base
    |> maybe_kw(:adapter, opts[:adapter])
    |> maybe_kw(:plug, opts[:plug])
    |> Req.new()
  end

  defp maybe_kw(kw, _key, nil), do: kw
  defp maybe_kw(kw, key, value), do: Keyword.put(kw, key, value)

  # Open a streaming request and return Req's async body (an enumerable of raw
  # SSE byte chunks). Only used for real network calls; tests inject chunks.
  defp request_stream(messages, opts) do
    body = messages |> build_body(opts) |> Map.put(:stream, true)
    response = Req.post!(req(opts), url: "/v1/messages", json: body, into: :self)
    response.body
  end

  # Map an Anthropic SSE event to AgentSea's normalized stream events.
  defp to_stream_event(%{event: "content_block_delta", data: data}) do
    case Jason.decode(data) do
      {:ok, %{"delta" => %{"type" => "text_delta", "text" => text}}} ->
        [{:content, text}]

      {:ok, %{"delta" => %{"type" => "thinking_delta", "thinking" => text}}} ->
        [{:thinking, text}]

      _ ->
        []
    end
  end

  defp to_stream_event(%{event: "message_stop"}), do: [:done]
  defp to_stream_event(%{event: nil, data: "[DONE]"}), do: [:done]
  defp to_stream_event(_event), do: []

  defp build_body(messages, opts) do
    {system, turns} = split_system(messages)

    %{
      model: Keyword.fetch!(opts, :model),
      max_tokens: opts[:max_tokens] || @default_max_tokens,
      messages: Enum.map(turns, &to_anthropic_message/1)
    }
    |> maybe_put(:system, opts[:system_prompt] || system)
    |> maybe_put(:temperature, opts[:temperature])
    |> maybe_put(:tools, to_anthropic_tools(opts[:tools]))
  end

  defp split_system(messages) do
    {systems, rest} = Enum.split_with(messages, &(&1.role == :system))
    system = Enum.map_join(systems, "\n\n", & &1.content)
    {if(system == "", do: nil, else: system), rest}
  end

  # Tool result messages become a user turn carrying a tool_result block.
  defp to_anthropic_message(%{role: :tool} = msg) do
    %{
      "role" => "user",
      "content" => [
        %{
          "type" => "tool_result",
          "tool_use_id" => msg[:tool_call_id],
          "content" => msg.content
        }
      ]
    }
  end

  defp to_anthropic_message(%{role: role, content: content}) do
    %{"role" => to_string(role), "content" => content}
  end

  defp to_anthropic_tools(nil), do: nil

  defp to_anthropic_tools(tools) do
    Enum.map(tools, fn tool ->
      %{
        "name" => tool.name,
        "description" => tool.description,
        "input_schema" => json_schema(tool.schema)
      }
    end)
  end

  # Minimal NimbleOptions-keyword → JSON-schema object conversion.
  defp json_schema(schema) when is_list(schema) do
    properties =
      for {key, spec} <- schema, into: %{} do
        {to_string(key), %{"type" => json_type(spec[:type])}}
      end

    required =
      for {key, spec} <- schema, spec[:required], do: to_string(key)

    %{"type" => "object", "properties" => properties, "required" => required}
  end

  defp json_schema(_), do: %{"type" => "object", "properties" => %{}}

  defp json_type(:string), do: "string"
  defp json_type(:integer), do: "integer"
  defp json_type(:float), do: "number"
  defp json_type(:boolean), do: "boolean"
  defp json_type(_), do: "string"

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, _key, []), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  # --- Response normalization ---

  defp normalize(%{"content" => blocks} = resp) do
    text =
      blocks
      |> Enum.filter(&(&1["type"] == "text"))
      |> Enum.map_join("\n", & &1["text"])

    tool_calls =
      blocks
      |> Enum.filter(&(&1["type"] == "tool_use"))
      |> Enum.map(fn b ->
        %ToolCall{id: b["id"], name: b["name"], arguments: b["input"] || %{}}
      end)

    usage = resp["usage"] || %{}

    %Response{
      content: text,
      stop_reason: map_stop_reason(resp["stop_reason"]),
      tool_calls: tool_calls,
      usage: %{
        input_tokens: usage["input_tokens"] || 0,
        output_tokens: usage["output_tokens"] || 0
      },
      raw: resp
    }
  end

  defp map_stop_reason("end_turn"), do: :stop
  defp map_stop_reason("tool_use"), do: :tool_use
  defp map_stop_reason("max_tokens"), do: :length
  defp map_stop_reason(_), do: :stop
end