@moikapy/lich 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ # Architecture Overview
2
+
3
+ Lich v0.2.0 is a small TypeScript AI agent harness: it drives a chat model in a
4
+ think-act-observe loop, lets the model call tools, compresses history when the
5
+ context budget demands it, and persists transcripts. It runs on Bun, is ESM
6
+ with NodeNext resolution, and its only runtime dependencies are `zod` (config
7
+ validation) and `ink` (the TUI). Everything else is Node/Bun built-ins.
8
+
9
+ This page is the map. The follow-up pages go deep on each area:
10
+ [agent loop](./agent-loop.md), [providers](./providers.md),
11
+ [tools](./tools.md), and [extending](./extending.md).
12
+
13
+ ## Layer diagram
14
+
15
+ ```mermaid
16
+ flowchart TB
17
+ subgraph entry["Entry surfaces"]
18
+ CLI["src/cli.ts<br/>one-shot and chat"]
19
+ TUI["src/tui/app.tsx<br/>ink TUI"]
20
+ GW["src/gateway/runner.ts<br/>webhook, telegram,<br/>discord, twitch"]
21
+ LIB["src/index.ts<br/>library exports"]
22
+ end
23
+ AGENT["Agent<br/>(src/agent/agent.ts)<br/>wiring, sessions, usage"]
24
+ LOOP["run_conversation<br/>(src/agent/loop.ts)"]
25
+ CHATFN["chat : ChatFn"]
26
+ TOOLFN["tools : ToolRunner"]
27
+ ROUTER["ProviderRouter + failover<br/>(src/providers/router.ts)"]
28
+ CLIENTS["openai_compat / anthropic / ollama<br/>HTTP clients"]
29
+ EXEC["ToolExecutor<br/>(src/tools/executor.ts)"]
30
+ REG["ToolRegistry<br/>(src/tools/registry.ts)"]
31
+ BUILTIN["12 builtin tools<br/>(src/tools/builtin/*)"]
32
+ COMP["ContextCompressor<br/>(src/context/compressor.ts)"]
33
+ SESSION["SessionStore<br/>(src/session/store.ts)"]
34
+
35
+ CLI --> AGENT
36
+ TUI --> AGENT
37
+ GW --> AGENT
38
+ LIB --> AGENT
39
+ AGENT --> LOOP
40
+ LOOP --> CHATFN
41
+ LOOP --> TOOLFN
42
+ CHATFN --> ROUTER
43
+ ROUTER --> CLIENTS
44
+ TOOLFN --> EXEC
45
+ EXEC --> REG
46
+ REG --> BUILTIN
47
+ LOOP -.-> COMP
48
+ AGENT -.-> SESSION
49
+ ```
50
+
51
+ Solid edges are direct calls; dashed edges are side services the loop and the
52
+ agent use between turns.
53
+
54
+ ## The dependency-inversion story
55
+
56
+ The loop is the heart of the system, and it deliberately imports **no**
57
+ concrete router and **no** concrete executor. It defines two narrow
58
+ structural interfaces ([`src/agent/loop.ts`](../../src/agent/loop.ts)):
59
+
60
+ - `ChatFn` - `(messages, tools, options?) => Promise<ChatResult>` (declared in
61
+ `src/context/compressor.ts`, since compression needs the same shape).
62
+ - `ToolRunner` - `{ execute(name, args) => Promise<ToolResult> }`.
63
+
64
+ `run_conversation` receives a `LoopDeps` object holding a `ChatFn`, a
65
+ `ToolRunner`, a `definitions()` callback for tool schemas, and an optional
66
+ emitter. The `Agent` class (`src/agent/agent.ts`) is the composition root: its
67
+ `loop_deps()` method wires the real implementations -
68
+
69
+ ```ts
70
+ chat: (messages, tools, chat_options) => this.router.chat_with_failover(messages, tools, chat_options),
71
+ tools: this.executor,
72
+ definitions: () => this.registry.definitions(),
73
+ emitter: this.events,
74
+ ```
75
+
76
+ (src/agent/agent.ts, `loop_deps()`)
77
+
78
+ Why this matters:
79
+
80
+ - **Testability.** The loop tests (`test/loop.test.ts`) run against a queued
81
+ fake `ChatFn` and a recording `ToolRunner`. No provider code, no HTTP, no
82
+ file system is touched, yet every event-ordering and stopping-condition
83
+ behavior is verified.
84
+ - **Swap-ability.** Compression reuses `ChatFn` to call the same model with a
85
+ summarization prompt, so compression automatically benefits from the same
86
+ failover chain as normal chat.
87
+ - **Small surface.** The loop cannot reach into provider configs, registries,
88
+ or session state even by accident; the compiler enforces the boundary.
89
+
90
+ ## Data flow of one user message
91
+
92
+ Walkthrough of a single `Agent.run({ input })` call
93
+ ([`src/agent/agent.ts`](../../src/agent/agent.ts)):
94
+
95
+ 1. **Usage collector attached.** `run()` subscribes a `collect_usage` handler
96
+ on `agent.events`; every `llm_end` event adds the call's token usage into a
97
+ per-run `Usage` total. The subscription is removed in a `finally` block.
98
+ 2. **Seed messages.** The caller's `history` (if any) is copied into a fresh
99
+ array and the new user message is appended. The caller's array is never
100
+ mutated.
101
+ 3. **Loop starts.** `run_conversation(deps, seed, params)` first applies the
102
+ system prompt via `seed_system_prompt` (prepend, or replace an existing
103
+ system message if its content differs) and then enters the turn loop
104
+ described in [agent loop](./agent-loop.md).
105
+ 4. **Each turn.** Abort check at the top of the turn, optional compression
106
+ check, then one LLM call through the router (`chat_with_failover`, which
107
+ walks providers with bounded in-place retries). The assistant message is
108
+ pushed onto the history.
109
+ 5. **Tools.** If the assistant message carries `tool_calls`, each call runs
110
+ through the `ToolExecutor` (30 s timeout, abort linking, output clamping)
111
+ and a `tool` message is appended per call. The loop then starts the next
112
+ turn. A turn with no tool calls is the final turn.
113
+ 6. **Outcome.** The loop returns a `LoopOutcome`: the full `messages` array,
114
+ the final assistant message (or the last one seen), the last `ChatResult`
115
+ on a real final, `turns_used`, and a `stopped_reason` of `final`, `budget`,
116
+ or `aborted`.
117
+ 7. **Session persist.** `persist_session()` appends one `meta` record
118
+ (`run_start`), then one `message` record per outcome message, then a
119
+ `budget_exhausted` meta record if the budget stopped the run, to a JSONL
120
+ file under `session_dir` (default `<work_dir>/.lich/sessions`). Persistence
121
+ is best-effort: failures are logged and the run still succeeds with
122
+ `session_path: undefined`.
123
+ 8. **Return.** `AgentRunResult` bundles the outcome, the full transcript
124
+ (prior history plus the new exchange), the collected `usage_total`, and the
125
+ session path.
126
+
127
+ ## Concurrency model
128
+
129
+ - **One `Agent` instance, one conversation at a time per call.** `Agent.run()`
130
+ is stateless across runs except for the shared, frozen `AgentConfig`: each
131
+ run builds its own history array and its own usage total.
132
+ - **Gateway serialization.** `GatewayBus` (`src/gateway/bus.ts`) keeps a
133
+ per-conversation promise chain (`chains` map keyed by `platform:chat_id`).
134
+ Concurrent messages for the same conversation are serialized; different
135
+ conversations run in parallel but share one `Agent`. Histories are capped
136
+ (40 messages, 200 conversations, oldest-first eviction).
137
+ - **No shared mutable state across conversations.** The bus never shares a
138
+ history array between keys; `Agent.run` copies what it is given. The TUI
139
+ serializes naturally: submissions are ignored while a run is in flight.
140
+ - **Aborts flow down.** Every layer accepts an `AbortSignal` (`AgentRunOptions`
141
+ -> `LoopParams` -> `ChatOptions` -> fetch signal; executor signal -> tool
142
+ signal). See [agent loop](./agent-loop.md#abort-semantics) and
143
+ [tools](./tools.md#executor-semantics).
144
+
145
+ ## Directory map
146
+
147
+ | Path | Responsibility |
148
+ | --- | --- |
149
+ | `src/index.ts` | Public library surface; pure re-exports plus `LICH_VERSION`. |
150
+ | `src/cli.ts` | Zero-dependency CLI: one-shot, `chat`, `tui`, `gateway`, `config`. |
151
+ | `src/cli_config.ts` | Config file discovery, loading, flag overrides, template. |
152
+ | `src/agent/agent.ts` | `Agent`: wires router, registry, executor; sessions; usage. |
153
+ | `src/agent/loop.ts` | `run_conversation`: the think-act-observe loop. |
154
+ | `src/agent/config.ts` | Zod config schema, defaults, derived `session_dir`, freeze. |
155
+ | `src/agent/events.ts` | `AgentEmitter` and the `AgentEvent` discriminated union. |
156
+ | `src/context/compressor.ts` | `compress_messages`, `should_compress`, `ChatFn`. |
157
+ | `src/context/tokens.ts` | chars/4 token estimator used for budget decisions. |
158
+ | `src/session/store.ts` | Append-only JSONL transcripts; `read_session_messages`. |
159
+ | `src/providers/types.ts` | `Message`, `LLMProvider`, `ProviderConfig`, `ProviderError`. |
160
+ | `src/providers/openai.ts` | OpenAI-compatible chat-completions client. |
161
+ | `src/providers/anthropic.ts` | Anthropic Messages API client. |
162
+ | `src/providers/ollama.ts` | Ollama `/api/chat` client. |
163
+ | `src/providers/router.ts` | Provider registry, lazy construction, failover walk. |
164
+ | `src/providers/failover.ts` | Retry classification, deterministic backoff. |
165
+ | `src/tools/types.ts` | `Tool`, `ToolResult`, `ToolContext`, `Toolset`. |
166
+ | `src/tools/guard.ts` | Path confinement, timeouts, clamping, arg coercion. |
167
+ | `src/tools/registry.ts` | Name-keyed tool registry; duplicate rejection. |
168
+ | `src/tools/executor.ts` | Never-throw execution with timeout and abort. |
169
+ | `src/tools/builtin/*` | The 12 builtin tools (see [tools](./tools.md)). |
170
+ | `src/gateway/bus.ts` | Conversation-keyed runner over one shared `Agent`. |
171
+ | `src/gateway/runner.ts` | Adapter construction, signal handling, process lifetime. |
172
+ | `src/gateway/{telegram,discord,twitch,webhook}.ts` | Platform adapters. |
173
+ | `src/tui/state.ts` | Pure TUI state machine (no ink imports). |
174
+ | `src/tui/app.tsx` | Ink components wiring events into the state machine. |
175
+ | `src/util/*` | `safe_json_parse`/`safe_stringify`/`truncate_text`, `sleep`, logger, JSON Schema types. |
176
+
177
+ ## Design trade-offs
178
+
179
+ - **No token streaming in the TUI.** Providers return complete messages; the
180
+ TUI shows phase (`thinking`/`tool`) rather than streaming text. This keeps
181
+ every provider client a simple request/response mapping.
182
+ - **chars/4 token estimate** (`src/context/tokens.ts`). Budget decisions do
183
+ not need exact token counts; a coarse estimator avoids per-provider
184
+ tokenizer dependencies at the cost of ~10-20% error.
185
+ - **Per-run history, capped in the gateway.** Long gateway conversations keep
186
+ the newest 40 messages; compression further protects the context budget.
187
+ - **One shared agent in the gateway.** Simpler than one agent per conversation;
188
+ isolation comes from per-conversation histories and the bus promise chains.
@@ -0,0 +1,91 @@
1
+ # Plugin architecture
2
+
3
+ How the plugin system loads user modules, merges tools into the registry, and intercepts tool calls with hooks. Source: [`src/plugins/`](../../src/plugins).
4
+
5
+ ## Loader flow
6
+
7
+ `create_agent_with_plugins` parses the config, loads every entry from `config.plugins`, then hands `LoadedPlugin[]` to the `Agent` constructor. Each entry is resolved against `work_dir`, dynamically imported, and validated by shape; failures accumulate as error strings instead of throwing.
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ A["config.plugins entries"] --> B{"entry empty?"}
12
+ B -- yes --> S["skip"]
13
+ B -- no --> C["path.resolve(work_dir, entry)"]
14
+ C --> D{"has module\nextension?"}
15
+ D -- no --> E["error: plugin_entry_not_a_module"]
16
+ D -- yes --> F["import(pathToFileURL(abs))"]
17
+ F --> G{"import ok?"}
18
+ G -- no --> H["error entry\n(import failure)"]
19
+ G -- yes --> I{"shape: default /\nplugin export /\nmodule itself"}
20
+ I -- none --> J["error: no plugin export"]
21
+ I -- ok --> K{"name unique?"}
22
+ K -- no --> L["error: duplicate_plugin_name"]
23
+ K -- yes --> M["LoadedPlugin collected"]
24
+ M --> N["Agent constructor"]
25
+ N --> O["registry merge:\nplugin tools appended\n(dup tool name → warn+skip)"]
26
+ N --> P["hook concat:\nPluginHooks[] in config order"]
27
+ P --> Q["HookedToolRunner wraps\nToolExecutor when hooks exist"]
28
+ E --> R["warn + continue"]
29
+ H --> R
30
+ J --> R
31
+ L --> R
32
+ ```
33
+
34
+ Three export shapes are accepted, checked in order: `default` export, a named `plugin` export, or the module object itself (top-level `name` plus `tools`/`hooks`). A bare object with just a `name` is rejected as a module-shape plugin (the `tools`/`hooks` surface must be present) to avoid treating arbitrary objects as plugins.
35
+
36
+ ## HookedToolRunner interception
37
+
38
+ When any plugin provides hooks, `Agent` wraps the `ToolExecutor` in a `HookedToolRunner` and the loop's `ToolRunner` dependency points at the wrapper instead. The loop is unchanged — it still calls `execute(name, args, context)`.
39
+
40
+ ```mermaid
41
+ sequenceDiagram
42
+ participant L as loop (run_tool_calls)
43
+ participant H as HookedToolRunner
44
+ participant B as before_tool_call hooks
45
+ participant E as ToolExecutor
46
+ participant A as after_tool_call hooks
47
+
48
+ L->>H: execute(name, args, context?)
49
+ H->>B: await hook(info, ctx) in order
50
+ B-->>H: {block: true, reason}?
51
+ alt first blocker wins
52
+ H-->>L: {ok: false, output: "", error: "blocked_by_plugin: reason"}
53
+ else no blocker
54
+ H->>E: execute(name, args, context?)
55
+ E-->>H: ToolResult
56
+ H->>A: await hook({...info, result_summary}, ctx)
57
+ note over A: summary = 300 chars of output/error
58
+ H-->>L: ToolResult unchanged
59
+ end
60
+ ```
61
+
62
+ Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({input_chars})` before `run_conversation` and `call_run_end({stopped_reason, turns_used})` after it (including the abort/throw path, via `finally`). Both are best-effort: hook throws are logged at `warn` and the run proceeds.
63
+
64
+ ## Design decisions
65
+
66
+ - **Explicit entries, no directory scan.** v1 loads only the files you list in `config.plugins`. Directory scanning would make runs depend on whatever happens to sit in a folder — non-reproducible, and a footgun for tools that write into `.lich/`. Explicit entries make the agent's tool surface a function of the config alone.
67
+ - **Observe-then-veto, not middleware.** `before_tool_call` may allow or veto but not rewrite args or results. Full middleware (argument rewriting, result transforms, ordering control) is a much larger design surface; v1 ships the 90% use case — guarding and auditing — with semantics simple enough to reason about: hooks run in config order and the first blocker wins.
68
+ - **Sync constructor + async factory.** `new Agent(config, plugins)` stays synchronous so existing callers and tests are untouched; the async work (dynamic imports) lives in `create_agent_with_plugins`. The trade-off: callers who construct `Agent` directly must load plugins themselves — documented in the library guide.
69
+ - **Errors as data.** The loader returns `{plugins, errors}` and `plugin_errors_summary` renders one warn line. Startup never crashes because one entry is broken, but misconfiguration is still visible in logs.
70
+
71
+ ## Extension surface
72
+
73
+ | Export | Kind | Purpose |
74
+ | --- | --- | --- |
75
+ | `Plugin` | type | `{name, version?, tools?, hooks?}` — what a plugin module exports. |
76
+ | `PluginHooks` | type | The four optional lifecycle hooks with their signatures. |
77
+ | `HookContext` | type | `{work_dir}` passed to every hook. |
78
+ | `LoadedPlugin` | type | `{plugin, entry}` — a loaded plugin and its source path. |
79
+ | `load_plugins` | function | `(entries, base_dir) => {plugins, errors}` — dynamic import + shape validation. |
80
+ | `plugin_errors_summary` | function | Joins error entries into one warn-able string. |
81
+ | `HookedToolRunner` | class | Wraps any `{execute}` runner with before/after interception. |
82
+ | `create_agent_with_plugins` | function | Parse config → load plugins → build `Agent` (warns on errors). |
83
+ | `config.plugins` | config | `string[]` of entry module specifiers relative to `work_dir`. |
84
+
85
+ ## Invariants
86
+
87
+ 1. A bad plugin never crashes startup: import failures, missing exports, and duplicate names all become error entries.
88
+ 2. Hooks and tools from later plugins cannot break earlier ones — every hook call is wrapped in try/catch with a `warn`.
89
+ 3. Plugin tools cannot shadow builtin or other plugin tools: duplicate registration is a warn + skip, so the visible tool set is deterministic given a config.
90
+ 4. The `ToolRunner` interface (`execute(name, args, context?)`) is structural — `HookedToolRunner` satisfies it without importing the loop.
91
+ 5. No new dependencies: loading uses `import()` + `node:url`, registration uses the existing `ToolRegistry`.
@@ -0,0 +1,273 @@
1
+ # Providers
2
+
3
+ Providers are the LLM adapters. Each one implements the same narrow interface
4
+ ([`src/providers/types.ts`](../../src/providers/types.ts)) and knows nothing
5
+ about the agent loop; the router and failover layer above them know nothing
6
+ about any individual provider's wire format.
7
+
8
+ ## The `LLMProvider` contract
9
+
10
+ ```ts
11
+ export interface LLMProvider {
12
+ readonly name: string;
13
+ readonly model: string;
14
+ chat(
15
+ messages: readonly Message[],
16
+ tools: readonly ToolDefinition[],
17
+ options?: ChatOptions,
18
+ ): Promise<ChatResult>;
19
+ }
20
+ ```
21
+
22
+ (src/providers/types.ts)
23
+
24
+ - **`Message`** is a union of four shapes: `system` and `user` (plain text
25
+ `content`), `assistant` (text plus optional `tool_calls`), and `tool` (a
26
+ result keyed by `tool_call_id` and `name`, with optional `is_error`).
27
+ - **`ToolDefinition`** is `{ name, description, parameters }` where
28
+ `parameters` is a JSON Schema object - passed through to providers verbatim.
29
+ - **`ChatResult`** carries the parsed `AssistantMessage`, token `Usage`, a
30
+ `finish_reason`, and the resolved `model` / `provider_name`.
31
+ - **`ChatOptions`** are `temperature`, `max_tokens`, `signal`, and one
32
+ ollama-only hint (`think`). Providers ignore what they do not support.
33
+
34
+ Every provider maps its HTTP/JSON wire dialect onto this contract and maps
35
+ every failure onto `ProviderError` with a `kind` from a fixed taxonomy. A
36
+ provider either resolves with a `ChatResult` or throws `ProviderError` (or a
37
+ raw `TypeError` from fetch, which the failover layer classifies as
38
+ `network`).
39
+
40
+ ### Finish-reason philosophy
41
+
42
+ `FinishReason` is `"stop" | "tool_calls" | "length" | "error" | "unknown"`.
43
+ The mapping philosophy: **`tool_calls` is derived from tool-call presence, not
44
+ from the provider's done/stop reason.** The reason string is informational;
45
+ the loop only branches on `message.tool_calls` (see
46
+ [agent loop](./agent-loop.md)).
47
+
48
+ This is load-bearing because of an ollama quirk: Ollama reports
49
+ `done_reason: "stop"` even when the response contains tool calls. The ollama
50
+ client documents this inline as "load-bearing":
51
+
52
+ ```ts
53
+ /**
54
+ * Load-bearing mapping: Ollama reports done_reason "stop" even when tool
55
+ * calls are present, so presence of tool_calls wins over done_reason.
56
+ */
57
+ function map_done_reason(done_reason: string | undefined, has_tool_calls: boolean): FinishReason {
58
+ if (has_tool_calls === true) {
59
+ return "tool_calls";
60
+ }
61
+ ...
62
+ }
63
+ ```
64
+
65
+ (src/providers/ollama.ts)
66
+
67
+ ## Client walkthroughs
68
+
69
+ All three clients share the same skeleton: resolve auth, build endpoint and
70
+ JSON body from typed DTOs, fetch (injectable `fetch_fn`), throw `ProviderError`
71
+ on non-OK or unparseable bodies, parse the success body into `ChatResult`.
72
+ They differ in wire mapping.
73
+
74
+ ### OpenAI-compatible (`src/providers/openai.ts`)
75
+
76
+ Endpoint: `POST {base_url}/chat/completions`, header `authorization: Bearer`.
77
+ Roles map 1:1; there is no message restructuring at all.
78
+
79
+ | Our `Message` | Wire format |
80
+ | --- | --- |
81
+ | `system` / `user` | `{ role, content }` verbatim |
82
+ | `assistant` | `{ role: "assistant", content, tool_calls?: [{ id, type: "function", function: { name, arguments } }] }` - **`arguments` is JSON-stringified** via `safe_stringify` |
83
+ | `tool` | `{ role: "tool", tool_call_id, content }` - 1:1, one wire message per tool message |
84
+ | `ToolDefinition` | `{ type: "function", function: { name, description, parameters } }` |
85
+
86
+ Response parsing: `choices[0].message`, string `arguments` parsed with
87
+ `safe_json_parse` (unparseable args become `{}` plus a
88
+ `[unparseable tool arguments]` note appended to the content), `finish_reason`
89
+ mapped `stop`/`tool_calls`/`length` verbatim, missing total tokens recomputed
90
+ as prompt + completion.
91
+
92
+ ### Anthropic (`src/providers/anthropic.ts`)
93
+
94
+ Endpoint: `POST {base_url}/v1/messages`, headers `x-api-key` and
95
+ `anthropic-version: 2023-06-01`. Two structural differences from our model:
96
+
97
+ | Our `Message` | Wire format |
98
+ | --- | --- |
99
+ | `system` (any position, any count) | Hoisted to top-level `system` string; all system contents joined with `\n`. Not part of `messages`. |
100
+ | `assistant` | `{ role: "assistant", content: blocks }` - a `text` block plus one `tool_use { id, name, input }` block per call; `input` is a **JSON object** |
101
+ | consecutive `tool` messages | **Merged into one `user` turn** whose content is the `tool_result` blocks (`tool_use_id`, text content, `is_error` passthrough) |
102
+ | `ToolDefinition` | `{ name, description, input_schema: parameters }` |
103
+
104
+ Why the merge: the Messages API requires strictly alternating user/assistant
105
+ roles, and tool results must be delivered as `tool_result` blocks inside a
106
+ `user` turn. The loop naturally produces several `tool` messages after one
107
+ assistant turn (one per call), so `to_anthropic_turns` buffers them
108
+ (`pending_tool_results`) and flushes them as a single user turn when the next
109
+ non-tool message arrives.
110
+
111
+ `max_tokens` is **required** by the API, so it defaults to 4096 when
112
+ `options.max_tokens` is absent. `stop_reason` maps: `end_turn` -> `stop`,
113
+ `tool_use` -> `tool_calls`, `max_tokens` -> `length`.
114
+
115
+ ### Ollama (`src/providers/ollama.ts`)
116
+
117
+ Endpoint: `POST {base_url}/api/chat`. Quirks, each deliberate:
118
+
119
+ | Aspect | Behavior |
120
+ | --- | --- |
121
+ | Streaming | `stream: false` is always sent; the client wants one complete JSON body. |
122
+ | Tool-call arguments | **JSON objects both ways**: requests send `function.arguments` as an object; responses accept an object, tolerate a JSON string (some proxies send one), and fall back to `{}` with a `[unparseable tool arguments]` note. |
123
+ | Tool results | Ollama has no tool-call ids: tool messages are sent 1:1 with `tool_name` (not `tool_call_id` / `name`). Correlation is positional. |
124
+ | Tool-call ids | Ollama returns none, so the client synthesizes `ollama_<base36 time>_<counter>` per call. |
125
+ | `done_reason` | Reports `"stop"` even with tool calls; tool-call presence wins (above). |
126
+ | 200 with error | Ollama can return HTTP 200 with `{ "error": "..." }`; the client checks `dto.error` first and throws `bad_request`. A 200 without `message` is also `bad_request`. |
127
+ | Generation caps | `max_tokens` maps to `options.num_predict`; `temperature` also lives under `options`. |
128
+ | Extras | Config `think: true` adds `think: true`; `keep_alive` (e.g. `"10m"`) is forwarded. |
129
+
130
+ Usage comes from `prompt_eval_count` / `eval_count`.
131
+
132
+ ## Error taxonomy
133
+
134
+ `ProviderErrorKind` and the mapping rules are identical across clients
135
+ (`status_to_error_kind` in each client), with one anthropic addition:
136
+
137
+ | Kind | Meaning | OpenAI mapping | Anthropic mapping | Ollama mapping |
138
+ | --- | --- | --- | --- | --- |
139
+ | `auth` | Missing/invalid credentials | 401, 403; missing key for api.openai.com (thrown before HTTP) | 401, 403; missing key (thrown before HTTP) | 401, 403 |
140
+ | `rate_limit` | Retryable throttling/instability | 429 and **any 5xx** | 429, **529 (overloaded)**, and any 5xx | 429 and any 5xx (529 falls under >= 500) |
141
+ | `overflow` | Context window exceeded | 400 whose body matches `/context\|token\|length/i` | 400 matching `/context\|token\|maximum/i` | 400 matching `/context\|token\|maximum\|too long/i` |
142
+ | `bad_request` | Everything else non-ok | 4xx not above; unparseable 2xx bodies | same | same, plus 200-with-error bodies |
143
+ | `network` | Fetch failed / aborted / timed out | fetch throws; body read failures; abort-like errors (`AbortError`, `TimeoutError`) | same | same |
144
+ | `unknown` | Anything else | non-`ProviderError` throwers get wrapped by the router with kind `unknown` | same | same |
145
+
146
+ Additional mapping details shared by all clients:
147
+
148
+ - `Retry-After` is parsed (seconds, floored at 0) into `retry_after_ms` on the
149
+ error; the retry layer honors it as a delay floor.
150
+ - Error bodies are truncated to 500 chars in the message.
151
+ - `classify_error` (`src/providers/failover.ts`) maps non-`ProviderError`
152
+ throwers: abort-like or `TypeError` (fetch's network failure signature)
153
+ become `network`; everything else `unknown`.
154
+
155
+ ## Router and failover
156
+
157
+ `ProviderRouter` (`src/providers/router.ts`) holds the config list and
158
+ constructs providers **lazily**: `get(name)` builds a client on first use and
159
+ caches it in a `Map`; `build_provider` is a small kind map
160
+ (`anthropic` -> `AnthropicProvider`, `ollama` -> `create_ollama_provider`,
161
+ everything else -> `OpenAICompatProvider`). Construction is cheap and side
162
+ effect free, so lazy vs eager is only an optimization - but it keeps
163
+ construction failures (there are none today; even missing keys throw at chat
164
+ time) out of config parsing.
165
+
166
+ `chat_with_failover` walks providers **in config order**:
167
+
168
+ ```mermaid
169
+ flowchart TD
170
+ START["chat_with_failover"] --> ABORT0{"caller signal<br/>already aborted?"}
171
+ ABORT0 -->|yes| THROWABORT["throw last error<br/>or router abort error"]
172
+ ABORT0 -->|no| P1["attempt provider n<br/>run_with_retries max 3"]
173
+ P1 -->|ok| DONE["return ChatResult"]
174
+ P1 -->|ProviderError| KIND{"error kind?"}
175
+ KIND -->|"rate_limit or network"| RETRIED["already retried in place<br/>with backoff (max 3 attempts)"]
176
+ KIND -->|"auth, overflow, bad_request"| NEXT["fail over immediately"]
177
+ RETRIED --> NEXT
178
+ NEXT --> MORE{"more providers?"}
179
+ MORE -->|yes| ABORT0
180
+ MORE -->|no| THROWLAST["throw last ProviderError"]
181
+ ```
182
+
183
+ `run_with_retries` (`src/providers/failover.ts`) retries only `rate_limit` and
184
+ `network` kinds, up to `max_attempts` (the router passes 3). Everything else
185
+ rethrows immediately, so auth/overflow/bad_request fail over to the next
186
+ provider on the first attempt.
187
+
188
+ **Backoff.** `compute_backoff_ms` is deterministic (no `Math.random`, per repo
189
+ convention):
190
+
191
+ ```text
192
+ exponential = floor(500 * 2**attempt) // attempt is 1-based
193
+ jitter = floor((500 * attempt) / 2)
194
+ delay = min(exponential + jitter, 8000)
195
+ ```
196
+
197
+ Attempt 1 waits 1250 ms, attempt 2 waits 2500 ms. The fixed jitter term
198
+ spreads simultaneous callers without nondeterminism (tests assert exact
199
+ values).
200
+
201
+ **`Retry-After` floor.** If the failed call produced `retry_after_ms` larger
202
+ than the computed backoff, the server value wins (`delay_for_error`).
203
+
204
+ **Abort semantics.** A caller abort is checked in three places: before each
205
+ provider in the router walk, after each failed attempt in the retry loop, and
206
+ during the backoff sleep itself (`sleep` rejects on abort, and the abort is
207
+ re-checked right after). Aborts rethrow immediately - they never trigger a
208
+ retry and never advance to the next provider. The router rethrows the last
209
+ provider error when the caller had already aborted mid-walk.
210
+
211
+ The retry loop is a `while` loop, never recursion (repo convention).
212
+
213
+ ## `ProviderConfig` reference
214
+
215
+ | Field | Applies to | Meaning |
216
+ | --- | --- | --- |
217
+ | `kind` | all | `"openai_compat" \| "anthropic" \| "ollama"` - selects the client. |
218
+ | `name` | all | Router key and `provider_name` on results/errors. |
219
+ | `model` | all | Model identifier sent to the API. |
220
+ | `base_url` | all | API root; per-kind default (`api.openai.com/v1`, `api.anthropic.com`, `localhost:11434`). |
221
+ | `api_key` | all (used by all three) | Direct key. Ollama treats it as optional proxy auth. |
222
+ | `api_key_env` | all | Env var to read the key from; per-kind default below. |
223
+ | `timeout_ms` | all | Per-call `AbortSignal.timeout`, merged with the caller signal. |
224
+ | `think` | ollama | Adds `think: true` to the request (thinking mode). |
225
+ | `keep_alive` | ollama | Model residency hint forwarded verbatim (e.g. `"10m"`). |
226
+ | `fetch_fn` | all | Injectable fetch for tests; defaults to global `fetch`. |
227
+
228
+ Env resolution rules per kind (who needs a key, and fallback order):
229
+
230
+ | Kind | Key required? | Resolution order |
231
+ | --- | --- | --- |
232
+ | `openai_compat` | Only for the well-known host `api.openai.com` | `api_key` -> `api_key_env` -> `OPENAI_API_KEY` (well-known host only) |
233
+ | `anthropic` | Always | `api_key` -> `api_key_env` -> `ANTHROPIC_API_KEY` |
234
+ | `ollama` | Never | `api_key` -> `api_key_env`; if one resolves, a `Bearer` header is sent for cloud proxies |
235
+
236
+ The openai rule is host-based on purpose: custom `base_url`s (LM Studio,
237
+ vLLM, OpenRouter-style gateways) may not want a key, so a missing key is only
238
+ fatal for `api.openai.com`, where it is guaranteed to fail. A missing
239
+ required key throws `auth` before any HTTP request is made.
240
+
241
+ ## Testing pattern
242
+
243
+ Provider tests inject a `fetch_fn` and assert on captured requests with
244
+ canned `Response` bodies - no network, no mocking library
245
+ (`test/providers.test.ts`):
246
+
247
+ ```ts
248
+ function mock_fetch(responder: (request: CapturedRequest) => MockReply): {
249
+ fetch_fn: typeof fetch;
250
+ requests: CapturedRequest[];
251
+ } {
252
+ const requests: CapturedRequest[] = [];
253
+ const fetch_fn: typeof fetch = (input, init) => {
254
+ const request: CapturedRequest = { url: String(input), init: init ?? {} };
255
+ requests.push(request);
256
+ const reply = responder(request);
257
+ const body_text = reply.text_body ?? JSON.stringify(reply.body ?? {});
258
+ return Promise.resolve(new Response(body_text, { status: reply.status, headers: reply.headers }));
259
+ };
260
+ return { fetch_fn, requests };
261
+ }
262
+ ```
263
+
264
+ (test/providers.test.ts)
265
+
266
+ Each test constructs the provider with `{ ...config, fetch_fn }`, calls
267
+ `chat`, then asserts on `requests[i].url`, parsed `init.body` (wire mapping)
268
+ and the returned `ChatResult` (response mapping). Error paths return canned
269
+ non-200 statuses or malformed bodies and assert the resulting
270
+ `ProviderError.kind`. The same pattern drives the ollama quirk tests
271
+ (`test/providers_ollama.test.ts`, including a 200-with-error body) and the
272
+ failover walk (`test/failover.test.ts`). See
273
+ [extending](./extending.md#add-a-provider) for the full recipe.