@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.
- package/CHANGELOG.md +24 -0
- package/README.md +186 -0
- package/dist/chunk-P52U5M3L.js +3431 -0
- package/dist/chunk-P52U5M3L.js.map +1 -0
- package/dist/chunk-ZVK3MUPC.js +7 -0
- package/dist/chunk-ZVK3MUPC.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +409 -0
- package/dist/cli.js.map +1 -0
- package/dist/gateway-CWPVIU3W.js +752 -0
- package/dist/gateway-CWPVIU3W.js.map +1 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/tui-V7ATLIKW.js +430 -0
- package/dist/tui-V7ATLIKW.js.map +1 -0
- package/docs/.vitepress/config.mts +55 -0
- package/docs/architecture/agent-loop.md +234 -0
- package/docs/architecture/extending.md +284 -0
- package/docs/architecture/overview.md +188 -0
- package/docs/architecture/plugins.md +91 -0
- package/docs/architecture/providers.md +273 -0
- package/docs/architecture/tools.md +180 -0
- package/docs/design/council/architecture-review.md +47 -0
- package/docs/design/council/security-review.md +39 -0
- package/docs/design/council/simplicity-review.md +45 -0
- package/docs/design/self-improvement-loop.md +166 -0
- package/docs/getting-started.md +133 -0
- package/docs/index.md +68 -0
- package/docs/user-guide/cli.md +182 -0
- package/docs/user-guide/gateway.md +168 -0
- package/docs/user-guide/library.md +181 -0
- package/docs/user-guide/plugins.md +120 -0
- package/docs/user-guide/tui.md +76 -0
- package/package.json +54 -0
|
@@ -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.
|