# `PtcRunner.LLM`
[🔗](https://github.com/andreasronge/ptc_runner/blob/main/lib/ptc_runner/llm.ex#L1)

Behaviour and convenience API for LLM adapters.

Provides a standard interface for LLM providers, with auto-discovery of the
built-in `ReqLLMAdapter` when `req_llm` is available.

## Configuration

Set a custom adapter in config:

    config :ptc_runner, :llm_adapter, MyApp.LLMAdapter

Or the built-in adapter is used automatically when `{:req_llm, "~> 1.8"}` is
added to your dependencies.

## Usage

    # Pass model aliases directly to SubAgent (recommended)
    {:ok, step} = PtcRunner.SubAgent.run(agent, llm: "haiku")

    # Or create a callback with a full provider:model string
    llm = PtcRunner.LLM.callback("openrouter:anthropic/claude-haiku-4.5", cache: true)
    {:ok, step} = PtcRunner.SubAgent.run(agent, llm: llm)

    # Stream responses through SubAgent for real-time chat UX
    on_chunk = fn %{delta: text} -> send(self(), {:chunk, text}) end
    {:ok, step} = PtcRunner.SubAgent.run(agent, llm: llm, on_chunk: on_chunk)

    # Stream responses directly (without SubAgent)
    {:ok, stream} = PtcRunner.LLM.stream("openrouter:anthropic/claude-haiku-4.5", %{system: "...", messages: [...]})
    stream |> Stream.each(fn
      %{delta: text} -> send_chunk(text)
      %{done: true, tokens: t} -> track_usage(t)
    end) |> Stream.run()

## Testing

The `llm:` option on `SubAgent.run/2` accepts any 1-arity function. For tests,
pass an inline lambda instead of a real callback — there is no separate
`stub`/`mock`/`fake` helper:

    mock_llm = fn _request -> {:ok, ~S|(return {:result 42})|} end
    {:ok, step} = PtcRunner.SubAgent.run(agent, llm: mock_llm)

See [Testing SubAgents](guides/subagent-testing.md) for scripted callbacks,
error paths, and integration testing patterns.

## Custom Adapters

Implement the `PtcRunner.LLM` behaviour:

    defmodule MyApp.LLMAdapter do
      @behaviour PtcRunner.LLM

      @impl true
      def call(model, request) do
        # Your implementation
      end

      @impl true
      def stream(model, request) do
        # Optional streaming support
      end
    end

# `chunk`

```elixir
@type chunk() :: %{delta: String.t()} | %{done: true, tokens: tokens()}
```

# `message`

```elixir
@type message() :: %{role: :system | :user | :assistant | :tool, content: String.t()}
```

# `response`

```elixir
@type response() :: %{content: String.t(), tokens: tokens()}
```

# `tokens`

```elixir
@type tokens() :: %{
  optional(:input) =&gt; non_neg_integer(),
  optional(:output) =&gt; non_neg_integer(),
  optional(:cache_creation) =&gt; non_neg_integer(),
  optional(:cache_read) =&gt; non_neg_integer(),
  optional(:total_cost) =&gt; float()
}
```

# `tool_call_response`

```elixir
@type tool_call_response() :: %{
  tool_calls: [map()],
  content: String.t() | nil,
  tokens: tokens()
}
```

# `call`

```elixir
@callback call(model :: String.t(), request :: map()) :: {:ok, map()} | {:error, term()}
```

Make an LLM call.

The `request` map contains:
- `:system` - System prompt string
- `:messages` - List of message maps
- `:schema` - JSON Schema map (triggers structured output)
- `:tools` - Tool definitions (triggers tool calling)
- `:cache` - Boolean for prompt caching

Returns `{:ok, response}` or `{:error, reason}`.

# `stream`
*optional* 

```elixir
@callback stream(model :: String.t(), request :: map()) ::
  {:ok, Enumerable.t()} | {:error, term()}
```

Stream an LLM response.

Returns `{:ok, stream}` where stream is an `Enumerable` of chunk maps:
- `%{delta: "text"}` for content chunks
- `%{done: true, tokens: %{...}}` for the final chunk

# `adapter!`

```elixir
@spec adapter!() :: module()
```

Returns the configured LLM adapter module.

Resolution order:
1. `config :ptc_runner, :llm_adapter, MyAdapter`
2. `PtcRunner.LLM.ReqLLMAdapter` if `req_llm` is available
3. Raises if no adapter found

# `call`

```elixir
@spec call(String.t(), map()) :: {:ok, map()} | {:error, term()}
```

Make a direct LLM call using the configured adapter.

## Examples

    {:ok, response} = PtcRunner.LLM.call("amazon_bedrock:anthropic.claude-haiku-4-5-20251001-v1:0", %{
      system: "You are helpful.",
      messages: [%{role: :user, content: "Hello"}]
    })

# `callback`

```elixir
@spec callback(
  String.t(),
  keyword()
) :: (map() -&gt; {:ok, map()} | {:error, term()})
```

Create a SubAgent-compatible callback function for a model.

Resolves model aliases via the configured model registry before
creating the callback. Already-resolved `provider:model` strings pass
through unchanged.

When the request map contains a `:stream` key with a callback function,
the callback will use `adapter.stream/2` (if available) and pipe chunks
through the stream function. The return value remains `{:ok, %{content, tokens}}`
so downstream code is unaffected.

## Options

- `:cache` - Enable prompt caching (default: false)
- `:adapter` - Override the LLM adapter module for this callback only.
  When omitted, falls back to the globally configured adapter via
  `adapter!/0` (read at callback-construction time, not per-call). Tests
  SHOULD pass `:adapter` directly so they can run async without
  racing global `Application.put_env(:ptc_runner, :llm_adapter, …)`.

## Examples

    # Using aliases (resolved via Registry)
    llm = PtcRunner.LLM.callback("haiku")
    {:ok, step} = PtcRunner.SubAgent.run(agent, llm: llm)

    # Using provider:alias format
    llm = PtcRunner.LLM.callback("bedrock:haiku", cache: true)

    # Using full model ID (passes through)
    llm = PtcRunner.LLM.callback("openrouter:anthropic/claude-haiku-4.5")

    # Inject a specific adapter (e.g. in tests)
    llm = PtcRunner.LLM.callback("ollama:test-model", adapter: MyMockAdapter)

# `stream`

```elixir
@spec stream(String.t(), map()) :: {:ok, Enumerable.t()} | {:error, term()}
```

Stream an LLM response using the configured adapter.

Returns `{:ok, stream}` where stream emits `%{delta: text}` and `%{done: true, tokens: map()}`.

## Examples

    {:ok, stream} = PtcRunner.LLM.stream("openrouter:anthropic/claude-haiku-4.5", %{
      system: "You are helpful.",
      messages: [%{role: :user, content: "Tell me a story"}]
    })
    stream |> Stream.each(fn
      %{delta: text} -> IO.write(text)
      %{done: true} -> IO.puts("")
    end) |> Stream.run()

---

*Consult [api-reference.md](api-reference.md) for complete listing*
