lib/planck/ai.ex

defmodule Planck.AI do
  @moduledoc """
  Typed LLM provider abstraction built on top of `req_llm`.

  `Planck.AI` provides a provider-agnostic interface for streaming and completing
  LLM requests. It defines the canonical types (`Model`, `Message`, `Context`, `Tool`)
  and streaming event protocol (`Stream`) consumed by `Planck.Agent`.

  ## Streaming

      model = Planck.AI.Models.Anthropic.all() |> hd()
      context = %Planck.AI.Context{
        system: "You are a helpful assistant.",
        messages: [%Planck.AI.Message{role: :user, content: [{:text, "Hello!"}]}]
      }

      model
      |> Planck.AI.stream(context, temperature: 0.7)
      |> Enum.each(fn
        {:text_delta, text} -> IO.write(text)
        {:done, _} -> IO.puts("")
        _ -> :ok
      end)

  ## Completing

      {:ok, message} = Planck.AI.complete(model, context)

  ## Model catalog

      Planck.AI.list_providers()
      #=> [:anthropic, :openai, :ollama, :llama_cpp]

      Planck.AI.list_models(:anthropic)
      #=> [%Planck.AI.Model{id: "claude-opus-4-5", ...}, ...]

      {:ok, model} = Planck.AI.get_model(:anthropic, "claude-sonnet-4-6")

  """

  alias Planck.AI.{Adapter, Context, Message, Model, Stream}
  alias Planck.AI.Models.{Anthropic, Google, LlamaCpp, Ollama, OpenAI}

  @providers [:anthropic, :openai, :google, :ollama, :llama_cpp]

  @doc """
  Streams a request to the LLM, returning a lazy stream of `StreamEvent` tuples.

  Keyword opts (e.g. `temperature: 0.7`, `max_tokens: 2048`) are forwarded
  directly to `req_llm`, which handles per-provider parameter translation.

  ## Examples

      Planck.AI.stream(model, context, temperature: 1.0)
      |> Enum.each(fn
        {:text_delta, t} -> IO.write(t)
        _ -> :ok
      end)

  """
  @spec stream(Model.t(), Context.t()) :: Enumerable.t(Stream.t())
  @spec stream(Model.t(), Context.t(), keyword()) :: Enumerable.t(Stream.t())
  def stream(%Model{} = model, %Context{} = context, opts \\ []) do
    merged = Keyword.merge(model.default_opts, opts)
    {model_spec, messages, req_opts} = Adapter.to_req_llm(model, context, merged)

    case req_llm_client().stream_text(model_spec, messages, req_opts) do
      {:ok, response} ->
        response.stream |> Planck.AI.Stream.from_req_llm()

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

  @doc """
  Sends a request to the LLM and blocks until the full response is received.

  Internally consumes `stream/3` and assembles a `Message` from the events.
  Returns `{:ok, message}` on success or `{:error, reason}` on failure.

  ## Examples

      {:ok, %Planck.AI.Message{} = message} = Planck.AI.complete(model, context)

  """
  @spec complete(Model.t(), Context.t()) :: {:ok, Message.t()} | {:error, term()}
  @spec complete(Model.t(), Context.t(), keyword()) ::
          {:ok, Message.t()} | {:error, term()}
  def complete(%Model{} = model, %Context{} = context, opts \\ []) do
    model |> stream(context, opts) |> collect_stream()
  end

  @doc """
  Returns all supported provider atoms.

  ## Examples

      iex> Planck.AI.list_providers()
      [:anthropic, :openai, :google, :ollama, :llama_cpp]

  """
  @spec list_providers() :: [atom()]
  def list_providers, do: @providers

  @doc """
  Returns all known models for a given provider.

  Cloud providers (`:anthropic`, `:openai`, `:google`) source their catalog from
  LLMDB — a bundled snapshot loaded into `:persistent_term` on first call.

  Local providers (`:ollama`, `:llama_cpp`) query the running server at call time.
  Pass `base_url:` in `opts` to target a non-default server address.

  ## Examples

      iex> Planck.AI.list_models(:anthropic)
      [%Planck.AI.Model{provider: :anthropic, ...}, ...]

  """
  @spec list_models(atom()) :: [Model.t()]
  @spec list_models(atom(), keyword()) :: [Model.t()]
  def list_models(provider, opts \\ [])
  def list_models(:anthropic, opts), do: Anthropic.all(opts)
  def list_models(:openai, opts), do: OpenAI.all(opts)
  def list_models(:google, opts), do: Google.all(opts)
  def list_models(:ollama, opts), do: Ollama.all(opts)
  def list_models(:llama_cpp, opts), do: LlamaCpp.all(opts)
  def list_models(_, _), do: []

  @doc """
  Looks up a model by provider and id.

  Returns `{:ok, model}` if found, `{:error, :not_found}` otherwise.

  ## Examples

      iex> Planck.AI.get_model(:anthropic, "claude-sonnet-4-6")
      {:ok, %Planck.AI.Model{id: "claude-sonnet-4-6", ...}}

      iex> Planck.AI.get_model(:anthropic, "does-not-exist")
      {:error, :not_found}

      iex> Planck.AI.get_model(:llama_cpp, "mistral-7b", base_url: "http://10.0.0.5:8080")
      {:ok, %Planck.AI.Model{id: "mistral-7b", ...}}

  """
  @spec get_model(atom(), String.t()) :: {:ok, Model.t()} | {:error, :not_found}
  @spec get_model(atom(), String.t(), keyword()) :: {:ok, Model.t()} | {:error, :not_found}
  def get_model(provider, id, opts \\ []) do
    case Enum.find(list_models(provider, opts), &(&1.id == id)) do
      nil -> {:error, :not_found}
      model -> {:ok, model}
    end
  end

  # --- Private ---

  defp req_llm_client do
    Application.get_env(:planck_ai, :req_llm_client, Planck.AI.ReqLLM)
  end

  @spec collect_stream(Enumerable.t()) :: {:ok, Message.t()} | {:error, term()}
  defp collect_stream(stream) do
    result =
      Enum.reduce_while(stream, {:ok, []}, fn
        {:error, reason}, _ ->
          {:halt, {:error, reason}}

        {:done, _}, {:ok, parts} ->
          {:halt, {:ok, %Message{role: :assistant, content: Enum.reverse(parts)}}}

        {:text_delta, text}, {:ok, parts} ->
          {:cont, {:ok, [{:text, text} | parts]}}

        {:thinking_delta, text}, {:ok, parts} ->
          {:cont, {:ok, [{:thinking, text} | parts]}}

        {:tool_call_complete, %{id: id, name: name, args: args}}, {:ok, parts} ->
          {:cont, {:ok, [{:tool_call, id, name, args} | parts]}}

        _other, acc ->
          {:cont, acc}
      end)

    case result do
      {:ok, parts} when is_list(parts) ->
        {:ok, %Message{role: :assistant, content: Enum.reverse(parts)}}

      other ->
        other
    end
  end
end