@matterailab/orbcode 0.1.10 → 0.1.14
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/README.md +141 -3
- package/dist/api/aiSdkClient.js +260 -0
- package/dist/api/client.js +2 -1
- package/dist/api/llmClient.js +19 -0
- package/dist/api/models.js +106 -0
- package/dist/api/provider.js +16 -0
- package/dist/config/settings.js +101 -1
- package/dist/core/agent.js +235 -10
- package/dist/core/hooks.js +358 -0
- package/dist/core/sessions.js +5 -1
- package/dist/headless.js +31 -3
- package/dist/prompts/system.js +4 -0
- package/dist/ui/App.js +85 -9
- package/dist/ui/components/HookTrustPrompt.js +21 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ activity rows, edit/command approvals, and todo tracking.
|
|
|
24
24
|
- [Approvals & safety](#approvals--safety)
|
|
25
25
|
- [Headless mode](#headless-mode)
|
|
26
26
|
- [Configuration](#configuration)
|
|
27
|
+
- [Hooks](#hooks)
|
|
27
28
|
- [Architecture](#architecture)
|
|
28
29
|
- [Tools](#tools)
|
|
29
30
|
- [Agent loop](#agent-loop)
|
|
@@ -156,6 +157,51 @@ The two Axon models are built in; `/model` opens a scroll-and-select picker
|
|
|
156
157
|
image input. Cost comes from the API's usage chunks (`is_byok`-aware) and is
|
|
157
158
|
shown in the status bar.
|
|
158
159
|
|
|
160
|
+
### Other providers (Anthropic, OpenAI-compatible)
|
|
161
|
+
|
|
162
|
+
The Axon models go through the MatterAI gateway as before. A `customModels`
|
|
163
|
+
entry that sets a `provider` is instead served through the
|
|
164
|
+
[Vercel AI SDK](https://sdk.vercel.ai), reusing the same agent loop, tools, and
|
|
165
|
+
approvals — auth is the provider's own key (env var or `apiKey`), not the
|
|
166
|
+
MatterAI login.
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"model": "claude-opus-4-8",
|
|
171
|
+
"customModels": [
|
|
172
|
+
{
|
|
173
|
+
"id": "claude-opus-4-8",
|
|
174
|
+
"name": "Claude Opus 4.8",
|
|
175
|
+
"provider": "anthropic",
|
|
176
|
+
"contextWindow": 1000000,
|
|
177
|
+
"maxOutputTokens": 64000,
|
|
178
|
+
"inputPrice": 0.000005,
|
|
179
|
+
"outputPrice": 0.000025,
|
|
180
|
+
"effort": "high"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"id": "some-model",
|
|
184
|
+
"provider": "openai-compatible",
|
|
185
|
+
"baseUrl": "https://api.other-host.com/v1"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- `provider: "anthropic"` → native `/v1/messages` (`@ai-sdk/anthropic`). Key from
|
|
192
|
+
`ANTHROPIC_API_KEY` (or `apiKey` on the entry). Adaptive thinking + reasoning
|
|
193
|
+
streaming are on by default; `effort` (`low`…`max`) tunes depth; prompt
|
|
194
|
+
caching breakpoints are set on the system prompt and conversation prefix
|
|
195
|
+
automatically. Thinking blocks are preserved across turns (stored with the
|
|
196
|
+
session and replayed with their signatures), so interleaved thinking with
|
|
197
|
+
tool use round-trips correctly. Set `"reasoning": false` to disable thinking
|
|
198
|
+
(e.g. for models that don't support `effort`).
|
|
199
|
+
- `provider: "openai-compatible"` → any OpenAI-compatible endpoint; requires
|
|
200
|
+
`baseUrl`. Key from `apiKey` on the entry.
|
|
201
|
+
|
|
202
|
+
Anything without a `provider` (or `provider: "matterai"`) keeps using the
|
|
203
|
+
MatterAI gateway untouched.
|
|
204
|
+
|
|
159
205
|
## The TUI
|
|
160
206
|
|
|
161
207
|
```
|
|
@@ -291,9 +337,9 @@ Two kinds of files under `~/.orbcode/`:
|
|
|
291
337
|
|
|
292
338
|
All keys are optional. `customModels` entries appear in the `/model` picker
|
|
293
339
|
alongside the built-in Axon models; `baseUrl` points the chat client at any
|
|
294
|
-
OpenAI-compatible gateway; `env` is applied to the process at startup
|
|
295
|
-
Precedence: env vars > project
|
|
296
|
-
config.json.
|
|
340
|
+
OpenAI-compatible gateway; `env` is applied to the process at startup; `hooks`
|
|
341
|
+
configures lifecycle hooks (see [Hooks](#hooks)). Precedence: env vars > project
|
|
342
|
+
settings.json > user settings.json > config.json.
|
|
297
343
|
|
|
298
344
|
Sessions are stored in `~/.orbcode/sessions/<id>.json` and power `/resume`
|
|
299
345
|
and `--resume <id>`.
|
|
@@ -312,6 +358,97 @@ and `--resume <id>`.
|
|
|
312
358
|
approval prompts (dangerous commands still always prompt); shift+tab cycles
|
|
313
359
|
them at runtime.
|
|
314
360
|
|
|
361
|
+
## Hooks
|
|
362
|
+
|
|
363
|
+
Hooks are shell commands OrbCode runs at fixed points in the agent loop — use
|
|
364
|
+
them to **block** dangerous actions, **auto-approve** trusted ones, **rewrite**
|
|
365
|
+
tool inputs, **inject context** into the model, **format code** after edits,
|
|
366
|
+
**notify** you, or **keep the agent working** until a condition is met. They use
|
|
367
|
+
the **same contract as Claude Code's hooks**, so scripts written for it work
|
|
368
|
+
here (just use `$ORBCODE_PROJECT_DIR` and OrbCode's tool names).
|
|
369
|
+
|
|
370
|
+
> 📖 **This is the overview. The complete, example-driven reference —
|
|
371
|
+
> per-event input/output, a copy-paste cookbook, debugging, and security — is in
|
|
372
|
+
> [docs/HOOKS.md](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md).**
|
|
373
|
+
|
|
374
|
+
### Two-minute example
|
|
375
|
+
|
|
376
|
+
Make OrbCode block `rm -rf` and append the git branch to every prompt.
|
|
377
|
+
|
|
378
|
+
**1.** Drop a guard script at `~/.orbcode/hooks/guard.sh` (and `chmod +x` it):
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
#!/usr/bin/env bash
|
|
382
|
+
input=$(cat) # OrbCode sends JSON on stdin
|
|
383
|
+
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
|
|
384
|
+
if printf '%s' "$cmd" | grep -Eq 'rm -rf (/|~|\*)'; then
|
|
385
|
+
echo "Refusing destructive command: $cmd" >&2 # stderr = the reason
|
|
386
|
+
exit 2 # exit 2 = block the tool
|
|
387
|
+
fi
|
|
388
|
+
exit 0
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**2.** Register it in `~/.orbcode/settings.json` (user-level) or a project's
|
|
392
|
+
`.orbcode/settings.json` — both are **merged**, so projects can add hooks
|
|
393
|
+
without clobbering your global ones. (User hooks always run; **project hooks are
|
|
394
|
+
disabled until you approve them** in a one-time trust prompt, since they run
|
|
395
|
+
shell commands from a repo — see [Security](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md#security).)
|
|
396
|
+
|
|
397
|
+
```json
|
|
398
|
+
{
|
|
399
|
+
"hooks": {
|
|
400
|
+
"PreToolUse": [
|
|
401
|
+
{
|
|
402
|
+
"matcher": "execute_command",
|
|
403
|
+
"hooks": [{ "type": "command", "command": "~/.orbcode/hooks/guard.sh", "timeout": 30 }]
|
|
404
|
+
}
|
|
405
|
+
],
|
|
406
|
+
"UserPromptSubmit": [
|
|
407
|
+
{ "hooks": [{ "type": "command", "command": "echo \"Git branch: $(git branch --show-current 2>/dev/null)\"" }] }
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Start OrbCode normally — that's all. Each event maps to a list of matchers; a
|
|
414
|
+
matcher has an optional `matcher` regex (omit, or use `"*"`, to match
|
|
415
|
+
everything; the regex is auto-anchored so `"execute_command"` matches exactly
|
|
416
|
+
that tool name) and a list of `command` hooks (`timeout` is per-command
|
|
417
|
+
seconds, default 10).
|
|
418
|
+
|
|
419
|
+
### Events at a glance
|
|
420
|
+
|
|
421
|
+
| event | when it fires | matcher tests |
|
|
422
|
+
| ------------------ | ---------------------------------------------- | ------------- |
|
|
423
|
+
| `SessionStart` | first turn of a session (or after `--resume`) | `source` |
|
|
424
|
+
| `UserPromptSubmit` | before each prompt is sent to the model | — |
|
|
425
|
+
| `PreToolUse` | before a tool runs (and before its approval) | tool name |
|
|
426
|
+
| `PostToolUse` | after a tool returns | tool name |
|
|
427
|
+
| `Notification` | when OrbCode needs permission or a follow-up | — |
|
|
428
|
+
| `Stop` | when the model is about to finish the turn | — |
|
|
429
|
+
| `PreCompact` | before `/compact` summarizes the conversation | `trigger` |
|
|
430
|
+
| `SessionEnd` | on quit, `/logout`, or end of a `-p` run | `reason` |
|
|
431
|
+
| `SubagentStop` | reserved; OrbCode has no subagents yet | — |
|
|
432
|
+
|
|
433
|
+
### How a hook talks back
|
|
434
|
+
|
|
435
|
+
A hook receives a JSON payload on **stdin** (`session_id`, `transcript_path`,
|
|
436
|
+
`cwd`, `hook_event_name`, plus event fields like `tool_name`/`tool_input`,
|
|
437
|
+
`prompt`, …) and influences OrbCode via its **exit code** — `0` success
|
|
438
|
+
(stdout becomes context for `UserPromptSubmit`/`SessionStart`), `2` block
|
|
439
|
+
(stderr is the reason), other = non-blocking warning — and/or a **JSON object
|
|
440
|
+
on stdout** for fine control (`decision`, `continue`, `systemMessage`, and a
|
|
441
|
+
`hookSpecificOutput` with `permissionDecision` allow/deny/ask, `updatedInput`,
|
|
442
|
+
`additionalContext`). When several hooks match, they run in parallel, the most
|
|
443
|
+
restrictive permission wins (`deny` > `ask` > `allow`), and a failing/slow hook
|
|
444
|
+
is timed out and never crashes the agent. Hooks run with a **redacted
|
|
445
|
+
environment** (your API token and other credential-like vars are stripped) and
|
|
446
|
+
injected context is wrapped in `<hook_context>` tags the model treats as
|
|
447
|
+
untrusted.
|
|
448
|
+
|
|
449
|
+
**→ Full reference with worked recipes for every event:
|
|
450
|
+
[docs/HOOKS.md](https://github.com/MatterAIOrg/OrbCode/blob/main/docs/HOOKS.md).**
|
|
451
|
+
|
|
315
452
|
## Architecture
|
|
316
453
|
|
|
317
454
|
```
|
|
@@ -336,6 +473,7 @@ src/
|
|
|
336
473
|
core/
|
|
337
474
|
agent.ts the agent loop (see below)
|
|
338
475
|
events.ts AgentEvent model consumed by the UI
|
|
476
|
+
hooks.ts lifecycle hooks engine, Claude-Code compatible (see Hooks)
|
|
339
477
|
ui/
|
|
340
478
|
App.tsx main Ink app: static finalized rows + dynamic streaming area
|
|
341
479
|
LoginView.tsx device-flow login screen with paste fallback
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
3
|
+
import { jsonSchema, streamText, tool, } from "ai";
|
|
4
|
+
import { REASONING_DETAILS_FIELD } from "./llmClient.js";
|
|
5
|
+
/**
|
|
6
|
+
* LLM transport backed by the Vercel AI SDK. Serves any non-MatterAI provider
|
|
7
|
+
* (Anthropic native `/v1/messages`, or any OpenAI-compatible endpoint) while
|
|
8
|
+
* speaking the same OpenAI-shaped message/tool contract the agent loop uses —
|
|
9
|
+
* translation happens entirely at this boundary. Auth is the provider's own
|
|
10
|
+
* key (env var or settings), never the MatterAI token.
|
|
11
|
+
*/
|
|
12
|
+
export class AiSdkClient {
|
|
13
|
+
options;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
async *createMessage(systemPrompt, messages, tools, abortSignal) {
|
|
18
|
+
const model = this.options.model;
|
|
19
|
+
const isAnthropic = model.provider === "anthropic";
|
|
20
|
+
// Replay stored thinking blocks only on Anthropic, where they round-trip
|
|
21
|
+
// with their signatures; other providers ignore the side-channel.
|
|
22
|
+
const aiMessages = toModelMessages(messages, isAnthropic);
|
|
23
|
+
if (isAnthropic)
|
|
24
|
+
applyCacheBreakpoint(aiMessages);
|
|
25
|
+
const aiTools = toAiTools(tools);
|
|
26
|
+
const hasTools = Object.keys(aiTools).length > 0;
|
|
27
|
+
const result = streamText({
|
|
28
|
+
model: this.resolveModel(),
|
|
29
|
+
system: this.buildSystem(systemPrompt, isAnthropic),
|
|
30
|
+
messages: aiMessages,
|
|
31
|
+
...(hasTools ? { tools: aiTools, toolChoice: "auto" } : {}),
|
|
32
|
+
maxOutputTokens: model.maxOutputTokens,
|
|
33
|
+
// No `temperature`: the current Claude models reject sampling params.
|
|
34
|
+
abortSignal,
|
|
35
|
+
providerOptions: this.providerOptions(isAnthropic),
|
|
36
|
+
});
|
|
37
|
+
for await (const part of result.fullStream) {
|
|
38
|
+
switch (part.type) {
|
|
39
|
+
case "text-delta":
|
|
40
|
+
if (part.text)
|
|
41
|
+
yield { type: "text", text: part.text };
|
|
42
|
+
break;
|
|
43
|
+
case "reasoning-delta":
|
|
44
|
+
if (part.text)
|
|
45
|
+
yield { type: "reasoning", text: part.text };
|
|
46
|
+
break;
|
|
47
|
+
case "tool-call":
|
|
48
|
+
yield {
|
|
49
|
+
type: "native_tool_calls",
|
|
50
|
+
toolCalls: [
|
|
51
|
+
{
|
|
52
|
+
id: part.toolCallId,
|
|
53
|
+
type: "function",
|
|
54
|
+
function: {
|
|
55
|
+
name: part.toolName,
|
|
56
|
+
arguments: JSON.stringify(part.input ?? {}),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
break;
|
|
62
|
+
case "finish":
|
|
63
|
+
yield this.usageChunk(part.totalUsage);
|
|
64
|
+
break;
|
|
65
|
+
case "error":
|
|
66
|
+
throw asError(part.error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Capture this turn's thinking blocks (with their provider signatures) so
|
|
70
|
+
// the agent can stash them on the assistant message and replay them next
|
|
71
|
+
// turn — required for interleaved thinking + tool use on the same model.
|
|
72
|
+
const reasoning = collectReasoningParts((await result.response).messages);
|
|
73
|
+
if (reasoning.length > 0) {
|
|
74
|
+
yield { type: "reasoning_details", details: reasoning };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Build the provider model handle for this request. */
|
|
78
|
+
resolveModel() {
|
|
79
|
+
const { provider, id, baseUrl, apiKey } = this.options.model;
|
|
80
|
+
switch (provider) {
|
|
81
|
+
case "anthropic":
|
|
82
|
+
// createAnthropic reads ANTHROPIC_API_KEY from the env when apiKey is absent.
|
|
83
|
+
return createAnthropic(apiKey ? { apiKey } : {})(id);
|
|
84
|
+
default: {
|
|
85
|
+
// Any OpenAI-compatible endpoint (OpenRouter, Together, Groq, a local
|
|
86
|
+
// server, even api.openai.com/v1). Adding a native provider package
|
|
87
|
+
// (e.g. @ai-sdk/google) is a new `case` here.
|
|
88
|
+
if (!baseUrl) {
|
|
89
|
+
throw new Error(`Model "${id}" uses provider "${provider}" but no baseUrl is set. Add a "baseUrl" to its customModels entry in settings.json.`);
|
|
90
|
+
}
|
|
91
|
+
return createOpenAICompatible({ name: provider ?? "openai-compatible", baseURL: baseUrl, apiKey })(id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Cache the system prompt (and, on Anthropic, the tool list with it). */
|
|
96
|
+
buildSystem(systemPrompt, isAnthropic) {
|
|
97
|
+
if (!isAnthropic)
|
|
98
|
+
return systemPrompt;
|
|
99
|
+
return {
|
|
100
|
+
role: "system",
|
|
101
|
+
content: systemPrompt,
|
|
102
|
+
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Adaptive thinking + effort + reasoning replay for Anthropic; nothing otherwise. */
|
|
106
|
+
providerOptions(isAnthropic) {
|
|
107
|
+
if (!isAnthropic)
|
|
108
|
+
return undefined;
|
|
109
|
+
const model = this.options.model;
|
|
110
|
+
if (model.reasoning === false)
|
|
111
|
+
return undefined;
|
|
112
|
+
return {
|
|
113
|
+
anthropic: {
|
|
114
|
+
thinking: { type: "adaptive", display: "summarized" },
|
|
115
|
+
effort: model.effort ?? "high",
|
|
116
|
+
sendReasoning: true,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
usageChunk(usage) {
|
|
121
|
+
const model = this.options.model;
|
|
122
|
+
const inputTokens = usage.inputTokens ?? 0;
|
|
123
|
+
const outputTokens = usage.outputTokens ?? 0;
|
|
124
|
+
return {
|
|
125
|
+
type: "usage",
|
|
126
|
+
inputTokens,
|
|
127
|
+
outputTokens,
|
|
128
|
+
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
|
|
129
|
+
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
|
130
|
+
totalCost: model.free ? 0 : inputTokens * model.inputPrice + outputTokens * model.outputPrice,
|
|
131
|
+
inferenceProvider: model.provider,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Translate OpenAI-shaped function tools into the AI SDK's client-side tool set
|
|
136
|
+
* (no `execute` — the agent loop runs the tool and feeds the result back). */
|
|
137
|
+
function toAiTools(tools) {
|
|
138
|
+
const set = {};
|
|
139
|
+
for (const t of tools) {
|
|
140
|
+
if (t.type !== "function")
|
|
141
|
+
continue;
|
|
142
|
+
set[t.function.name] = tool({
|
|
143
|
+
description: t.function.description,
|
|
144
|
+
inputSchema: jsonSchema(t.function.parameters ?? { type: "object", properties: {} }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return set;
|
|
148
|
+
}
|
|
149
|
+
/** Translate the OpenAI conversation history into AI SDK `ModelMessage`s.
|
|
150
|
+
* When `includeReasoning`, stored thinking blocks are prepended to each
|
|
151
|
+
* assistant turn (first, ahead of text/tool-call parts) for same-model replay. */
|
|
152
|
+
function toModelMessages(messages, includeReasoning) {
|
|
153
|
+
// tool_result parts need the tool name, which OpenAI only carries on the
|
|
154
|
+
// originating assistant tool_call — index it first.
|
|
155
|
+
const toolNameById = new Map();
|
|
156
|
+
for (const message of messages) {
|
|
157
|
+
if (message.role === "assistant" && message.tool_calls) {
|
|
158
|
+
for (const call of message.tool_calls) {
|
|
159
|
+
if (call.type === "function")
|
|
160
|
+
toolNameById.set(call.id, call.function.name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const message of messages) {
|
|
166
|
+
switch (message.role) {
|
|
167
|
+
case "user":
|
|
168
|
+
out.push({ role: "user", content: contentToText(message.content) });
|
|
169
|
+
break;
|
|
170
|
+
case "assistant": {
|
|
171
|
+
const parts = [];
|
|
172
|
+
if (includeReasoning)
|
|
173
|
+
parts.push(...storedReasoningParts(message));
|
|
174
|
+
const text = contentToText(message.content);
|
|
175
|
+
if (text)
|
|
176
|
+
parts.push({ type: "text", text });
|
|
177
|
+
for (const call of message.tool_calls ?? []) {
|
|
178
|
+
if (call.type !== "function")
|
|
179
|
+
continue;
|
|
180
|
+
parts.push({
|
|
181
|
+
type: "tool-call",
|
|
182
|
+
toolCallId: call.id,
|
|
183
|
+
toolName: call.function.name,
|
|
184
|
+
input: safeParseJson(call.function.arguments),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
out.push({ role: "assistant", content: parts.length > 0 ? parts : "" });
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "tool": {
|
|
191
|
+
const result = {
|
|
192
|
+
type: "tool-result",
|
|
193
|
+
toolCallId: message.tool_call_id,
|
|
194
|
+
toolName: toolNameById.get(message.tool_call_id) ?? "tool",
|
|
195
|
+
output: { type: "text", value: contentToText(message.content) },
|
|
196
|
+
};
|
|
197
|
+
out.push({ role: "tool", content: [result] });
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
// "system"/"developer"/"function" roles don't appear in OrbCode history
|
|
201
|
+
// (the system prompt is passed separately) — ignore defensively.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
/** Pull the reasoning parts out of the assistant message(s) the SDK assembled
|
|
207
|
+
* for this turn — these carry the provider signature needed to replay them. */
|
|
208
|
+
function collectReasoningParts(messages) {
|
|
209
|
+
const parts = [];
|
|
210
|
+
for (const message of messages) {
|
|
211
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
212
|
+
continue;
|
|
213
|
+
for (const part of message.content) {
|
|
214
|
+
if (part.type === "reasoning")
|
|
215
|
+
parts.push(part);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return parts;
|
|
219
|
+
}
|
|
220
|
+
/** Read back the reasoning parts the agent stashed on a prior assistant turn. */
|
|
221
|
+
function storedReasoningParts(message) {
|
|
222
|
+
const details = message[REASONING_DETAILS_FIELD];
|
|
223
|
+
return Array.isArray(details) ? details : [];
|
|
224
|
+
}
|
|
225
|
+
/** Tag the last message so Anthropic caches the conversation prefix up to here. */
|
|
226
|
+
function applyCacheBreakpoint(messages) {
|
|
227
|
+
const last = messages[messages.length - 1];
|
|
228
|
+
if (!last)
|
|
229
|
+
return;
|
|
230
|
+
last.providerOptions = {
|
|
231
|
+
...last.providerOptions,
|
|
232
|
+
anthropic: { ...last.providerOptions?.anthropic, cacheControl: { type: "ephemeral" } },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Flatten OpenAI string-or-parts content down to plain text. */
|
|
236
|
+
function contentToText(content) {
|
|
237
|
+
if (typeof content === "string")
|
|
238
|
+
return content;
|
|
239
|
+
if (Array.isArray(content)) {
|
|
240
|
+
return content
|
|
241
|
+
.map((part) => part && typeof part === "object" && "text" in part ? String(part.text ?? "") : "")
|
|
242
|
+
.join("");
|
|
243
|
+
}
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
function safeParseJson(raw) {
|
|
247
|
+
if (!raw)
|
|
248
|
+
return {};
|
|
249
|
+
try {
|
|
250
|
+
return JSON.parse(raw);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function asError(error) {
|
|
257
|
+
if (error instanceof Error)
|
|
258
|
+
return error;
|
|
259
|
+
return new Error(typeof error === "string" ? error : JSON.stringify(error));
|
|
260
|
+
}
|
package/dist/api/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import { API_GATEWAY_PATH, getUrlFromToken } from "../auth/auth.js";
|
|
3
3
|
import { DEFAULT_HEADERS, X_AXONCODE_TASKID, X_AXON_REPO, X_ORGANIZATIONID } from "./headers.js";
|
|
4
|
+
import { stripReasoningDetails } from "./llmClient.js";
|
|
4
5
|
import { getModel } from "./models.js";
|
|
5
6
|
export class AxonClient {
|
|
6
7
|
client;
|
|
@@ -26,7 +27,7 @@ export class AxonClient {
|
|
|
26
27
|
const requestOptions = {
|
|
27
28
|
model: model.id,
|
|
28
29
|
temperature: 0.2,
|
|
29
|
-
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
30
|
+
messages: [{ role: "system", content: systemPrompt }, ...stripReasoningDetails(messages)],
|
|
30
31
|
stream: true,
|
|
31
32
|
stream_options: { include_usage: true },
|
|
32
33
|
max_tokens: model.maxOutputTokens,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-standard field stashed on a persisted assistant message holding opaque
|
|
3
|
+
* reasoning blocks (with provider signatures) for same-model replay. Only the
|
|
4
|
+
* AI SDK client reads/writes it; it is stripped before any OpenAI request.
|
|
5
|
+
*/
|
|
6
|
+
export const REASONING_DETAILS_FIELD = "_reasoningDetails";
|
|
7
|
+
/** Drop the reasoning side-channel from assistant messages bound for an OpenAI
|
|
8
|
+
* `/chat/completions` request (it isn't a valid input field there). */
|
|
9
|
+
export function stripReasoningDetails(messages) {
|
|
10
|
+
return messages.map((message) => {
|
|
11
|
+
if (message.role !== "assistant")
|
|
12
|
+
return message;
|
|
13
|
+
const record = message;
|
|
14
|
+
if (!(REASONING_DETAILS_FIELD in record))
|
|
15
|
+
return message;
|
|
16
|
+
const { [REASONING_DETAILS_FIELD]: _omit, ...rest } = record;
|
|
17
|
+
return rest;
|
|
18
|
+
});
|
|
19
|
+
}
|
package/dist/api/models.js
CHANGED
|
@@ -1,4 +1,94 @@
|
|
|
1
|
+
/** Providers served by the built-in MatterAI gateway rather than the AI SDK. */
|
|
2
|
+
const MATTERAI_PROVIDERS = new Set(["matterai", "axon"]);
|
|
3
|
+
/** True when the model should be served via the Vercel AI SDK transport. */
|
|
4
|
+
export function usesAiSdk(model) {
|
|
5
|
+
return Boolean(model.provider) && !MATTERAI_PROVIDERS.has(model.provider);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Well-known Claude models, served natively via the Anthropic provider (AI SDK,
|
|
9
|
+
* `/v1/messages`). Built in so `--model claude-…` works without a settings.json
|
|
10
|
+
* entry; auth is `ANTHROPIC_API_KEY` (or a per-model `apiKey`), never MatterAI.
|
|
11
|
+
* Adaptive thinking + effort are on by default (see AiSdkClient); Haiku 4.5 sets
|
|
12
|
+
* `reasoning: false` because it rejects the `effort` parameter.
|
|
13
|
+
*/
|
|
14
|
+
export const ANTHROPIC_MODELS = {
|
|
15
|
+
"claude-opus-4-8": {
|
|
16
|
+
id: "claude-opus-4-8",
|
|
17
|
+
name: "Claude Opus 4.8",
|
|
18
|
+
description: "Anthropic's most capable Opus model — long-horizon agentic work, knowledge work, and coding.",
|
|
19
|
+
contextWindow: 1_000_000,
|
|
20
|
+
maxOutputTokens: 64000,
|
|
21
|
+
supportsImages: true,
|
|
22
|
+
inputPrice: 0.000005,
|
|
23
|
+
outputPrice: 0.000025,
|
|
24
|
+
free: false,
|
|
25
|
+
provider: "anthropic",
|
|
26
|
+
},
|
|
27
|
+
"claude-opus-4-7": {
|
|
28
|
+
id: "claude-opus-4-7",
|
|
29
|
+
name: "Claude Opus 4.7",
|
|
30
|
+
description: "Previous-generation Opus — highly autonomous, strong on agentic, vision, and memory tasks.",
|
|
31
|
+
contextWindow: 1_000_000,
|
|
32
|
+
maxOutputTokens: 64000,
|
|
33
|
+
supportsImages: true,
|
|
34
|
+
inputPrice: 0.000005,
|
|
35
|
+
outputPrice: 0.000025,
|
|
36
|
+
free: false,
|
|
37
|
+
provider: "anthropic",
|
|
38
|
+
},
|
|
39
|
+
"claude-opus-4-6": {
|
|
40
|
+
id: "claude-opus-4-6",
|
|
41
|
+
name: "Claude Opus 4.6",
|
|
42
|
+
description: "Older Opus with adaptive thinking; 1M context.",
|
|
43
|
+
contextWindow: 1_000_000,
|
|
44
|
+
maxOutputTokens: 64000,
|
|
45
|
+
supportsImages: true,
|
|
46
|
+
inputPrice: 0.000005,
|
|
47
|
+
outputPrice: 0.000025,
|
|
48
|
+
free: false,
|
|
49
|
+
provider: "anthropic",
|
|
50
|
+
},
|
|
51
|
+
"claude-sonnet-4-6": {
|
|
52
|
+
id: "claude-sonnet-4-6",
|
|
53
|
+
name: "Claude Sonnet 4.6",
|
|
54
|
+
description: "Anthropic's best balance of speed and intelligence; adaptive thinking, 1M context.",
|
|
55
|
+
contextWindow: 1_000_000,
|
|
56
|
+
maxOutputTokens: 64000,
|
|
57
|
+
supportsImages: true,
|
|
58
|
+
inputPrice: 0.000003,
|
|
59
|
+
outputPrice: 0.000015,
|
|
60
|
+
free: false,
|
|
61
|
+
provider: "anthropic",
|
|
62
|
+
},
|
|
63
|
+
"claude-haiku-4-5": {
|
|
64
|
+
id: "claude-haiku-4-5",
|
|
65
|
+
name: "Claude Haiku 4.5",
|
|
66
|
+
description: "Fastest, most cost-effective Claude model for simple, latency-sensitive tasks.",
|
|
67
|
+
contextWindow: 200_000,
|
|
68
|
+
maxOutputTokens: 64000,
|
|
69
|
+
supportsImages: true,
|
|
70
|
+
inputPrice: 0.000001,
|
|
71
|
+
outputPrice: 0.000005,
|
|
72
|
+
free: false,
|
|
73
|
+
provider: "anthropic",
|
|
74
|
+
// Haiku 4.5 rejects the `effort` parameter, so don't send thinking/effort.
|
|
75
|
+
reasoning: false,
|
|
76
|
+
},
|
|
77
|
+
"claude-fable-5": {
|
|
78
|
+
id: "claude-fable-5",
|
|
79
|
+
name: "Claude Fable 5",
|
|
80
|
+
description: "Anthropic's most capable widely released model — most demanding reasoning and long-horizon work.",
|
|
81
|
+
contextWindow: 1_000_000,
|
|
82
|
+
maxOutputTokens: 64000,
|
|
83
|
+
supportsImages: true,
|
|
84
|
+
inputPrice: 0.00001,
|
|
85
|
+
outputPrice: 0.00005,
|
|
86
|
+
free: false,
|
|
87
|
+
provider: "anthropic",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
1
90
|
export const AXON_MODELS = {
|
|
91
|
+
...ANTHROPIC_MODELS,
|
|
2
92
|
"axon-code-2-5-mini": {
|
|
3
93
|
id: "axon-code-2-5-mini",
|
|
4
94
|
name: "Axon Code 2.5 Mini (free)",
|
|
@@ -32,6 +122,17 @@ export const AXON_MODELS = {
|
|
|
32
122
|
outputPrice: 0.000009,
|
|
33
123
|
free: false,
|
|
34
124
|
},
|
|
125
|
+
"axon-eido-3-code-mini": {
|
|
126
|
+
id: "axon-eido-3-code-mini",
|
|
127
|
+
name: "Axon Eido 3 Mini",
|
|
128
|
+
description: "Axon Eido 3 Mini is a general purpose super intelligent LLM coding model for low-effort day-to-day tasks",
|
|
129
|
+
contextWindow: 400000,
|
|
130
|
+
maxOutputTokens: 64000,
|
|
131
|
+
supportsImages: true,
|
|
132
|
+
inputPrice: 0.000001,
|
|
133
|
+
outputPrice: 0.000003,
|
|
134
|
+
free: false,
|
|
135
|
+
},
|
|
35
136
|
};
|
|
36
137
|
export const DEFAULT_MODEL_ID = "axon-code-2-5-pro";
|
|
37
138
|
/** Add user-defined models (from settings.json) to the registry. */
|
|
@@ -49,6 +150,11 @@ export function registerCustomModels(models) {
|
|
|
49
150
|
inputPrice: model.inputPrice ?? 0,
|
|
50
151
|
outputPrice: model.outputPrice ?? 0,
|
|
51
152
|
free: (model.inputPrice ?? 0) === 0 && (model.outputPrice ?? 0) === 0,
|
|
153
|
+
provider: model.provider,
|
|
154
|
+
baseUrl: model.baseUrl,
|
|
155
|
+
apiKey: model.apiKey,
|
|
156
|
+
effort: model.effort,
|
|
157
|
+
reasoning: model.reasoning,
|
|
52
158
|
};
|
|
53
159
|
}
|
|
54
160
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AiSdkClient } from "./aiSdkClient.js";
|
|
2
|
+
import { AxonClient } from "./client.js";
|
|
3
|
+
import { getModel, usesAiSdk } from "./models.js";
|
|
4
|
+
/**
|
|
5
|
+
* Pick the transport for a model. MatterAI/Axon models keep using `AxonClient`
|
|
6
|
+
* (OpenAI `/chat/completions` against the gateway, with all its auth/headers/
|
|
7
|
+
* cost handling); anything declaring another `provider` goes through the Vercel
|
|
8
|
+
* AI SDK. Both implement `LLMClient`, so the agent loop is unaffected.
|
|
9
|
+
*/
|
|
10
|
+
export function createLLMClient(options) {
|
|
11
|
+
const model = getModel(options.modelId);
|
|
12
|
+
if (usesAiSdk(model)) {
|
|
13
|
+
return new AiSdkClient({ model });
|
|
14
|
+
}
|
|
15
|
+
return new AxonClient(options);
|
|
16
|
+
}
|