subrouter-cli 0.0.0-stage → 0.1.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/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live SSE verification for the subrouter gateway. The router's stream shapes are not
|
|
3
|
+
// documented, so run this before/while building the adapters, and record fixtures with
|
|
4
|
+
// --save for the offline test suite.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/sse-harness.ts --model deepseek/deepseek-v4-pro --dialect openai
|
|
7
|
+
// node scripts/sse-harness.ts --model anthropic/claude-sonnet-5 --dialect anthropic --tools --save test/fixtures/anthropic-tools.sse
|
|
8
|
+
//
|
|
9
|
+
// Run from the repo root. Needs SUBROUTER_API_KEY in the environment (or the config file).
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { RouterClient, RouterError, dialectFor, drainBody } from '../src/client.ts';
|
|
13
|
+
import { resolveConfig } from '../src/config.ts';
|
|
14
|
+
import type { Dialect } from '../src/types.ts';
|
|
15
|
+
|
|
16
|
+
const ECHO_TOOL_OPENAI = {
|
|
17
|
+
type: 'function',
|
|
18
|
+
function: {
|
|
19
|
+
name: 'echo',
|
|
20
|
+
description: 'Echo back the JSON object you are given.',
|
|
21
|
+
parameters: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: { value: { type: 'object', description: 'any JSON object to echo' } },
|
|
24
|
+
required: ['value'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ECHO_TOOL_ANTHROPIC = {
|
|
30
|
+
name: 'echo',
|
|
31
|
+
description: 'Echo back the JSON object you are given.',
|
|
32
|
+
input_schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: { value: { type: 'object', description: 'any JSON object to echo' } },
|
|
35
|
+
required: ['value'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface HarnessArgs {
|
|
40
|
+
model: string;
|
|
41
|
+
dialect: Dialect; // resolved: openai or anthropic
|
|
42
|
+
withTools: boolean;
|
|
43
|
+
savePath?: string;
|
|
44
|
+
prompt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv: string[]): HarnessArgs | null {
|
|
48
|
+
let model = '';
|
|
49
|
+
let dialectChoice: string | null = null;
|
|
50
|
+
let withTools = false;
|
|
51
|
+
let savePath: string | undefined;
|
|
52
|
+
let prompt: string | undefined;
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const arg = argv[i];
|
|
55
|
+
if (arg === '--model' && i + 1 < argv.length) model = argv[++i];
|
|
56
|
+
else if (arg === '--dialect' && i + 1 < argv.length) dialectChoice = argv[++i];
|
|
57
|
+
else if (arg === '--tools') withTools = true;
|
|
58
|
+
else if (arg === '--save' && i + 1 < argv.length) savePath = argv[++i];
|
|
59
|
+
else if (arg === '--prompt' && i + 1 < argv.length) prompt = argv[++i];
|
|
60
|
+
else if (arg === '--help' || arg === '-h') {
|
|
61
|
+
console.log(
|
|
62
|
+
'Usage: node scripts/sse-harness.ts --model <id> [--dialect openai|anthropic|auto] [--tools] [--save test/fixtures/name.sse] [--prompt "..."]',
|
|
63
|
+
);
|
|
64
|
+
return null;
|
|
65
|
+
} else {
|
|
66
|
+
console.error(`Unknown argument: ${arg}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!model) {
|
|
71
|
+
console.error('--model is required.');
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (dialectChoice !== null && dialectChoice !== 'openai' && dialectChoice !== 'anthropic' && dialectChoice !== 'auto') {
|
|
75
|
+
console.error('--dialect must be openai, anthropic, or auto.');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
let dialect: Dialect;
|
|
79
|
+
try {
|
|
80
|
+
dialect = dialectChoice === 'openai' ? 'openai' : dialectChoice === 'anthropic' ? 'anthropic' : dialectFor(model);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('× ' + (err instanceof Error ? err.message : String(err)));
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
model,
|
|
87
|
+
dialect,
|
|
88
|
+
withTools,
|
|
89
|
+
savePath,
|
|
90
|
+
prompt:
|
|
91
|
+
prompt ??
|
|
92
|
+
(withTools
|
|
93
|
+
? 'Call the echo tool with any JSON, then summarize what you did.'
|
|
94
|
+
: 'say hi'),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildBody(args: HarnessArgs): Record<string, unknown> {
|
|
99
|
+
const messages = [{ role: 'user', content: args.prompt }];
|
|
100
|
+
if (args.dialect === 'anthropic') {
|
|
101
|
+
return {
|
|
102
|
+
model: args.model,
|
|
103
|
+
max_tokens: 256,
|
|
104
|
+
messages,
|
|
105
|
+
stream: true,
|
|
106
|
+
...(args.withTools ? { tools: [ECHO_TOOL_ANTHROPIC] } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
model: args.model,
|
|
111
|
+
messages,
|
|
112
|
+
stream: true,
|
|
113
|
+
...(args.withTools ? { tools: [ECHO_TOOL_OPENAI] } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function run(): Promise<number> {
|
|
118
|
+
const args = parseArgs(process.argv.slice(2));
|
|
119
|
+
if (!args) return 2;
|
|
120
|
+
|
|
121
|
+
const { config } = resolveConfig();
|
|
122
|
+
if (!config.apiKey) {
|
|
123
|
+
console.error('No API key found — set SUBROUTER_API_KEY or create ~/.config/subrouter-cli/config.json.');
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const client = new RouterClient({ baseUrl: config.baseUrl, apiKey: config.apiKey });
|
|
127
|
+
const url = client.chatPath(args.dialect);
|
|
128
|
+
const body = buildBody(args);
|
|
129
|
+
|
|
130
|
+
console.log(`POST ${url}`);
|
|
131
|
+
console.log(`model=${args.model} dialect=${args.dialect} tools=${args.withTools ? 'yes' : 'no'}`);
|
|
132
|
+
console.log('');
|
|
133
|
+
|
|
134
|
+
const response = await fetch(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: client.headers(args.dialect),
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
signal: AbortSignal.timeout(300_000),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const text = await drainBody(response);
|
|
143
|
+
console.error(`HTTP ${response.status}:\n${text}`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Raw stream collection: text so far, frame buffer, summary fields.
|
|
148
|
+
const rawFrames: string[] = [];
|
|
149
|
+
let finishReason: string | undefined;
|
|
150
|
+
let stopReason: string | undefined;
|
|
151
|
+
let inputTokens: number | undefined;
|
|
152
|
+
let outputTokens: number | undefined;
|
|
153
|
+
let frameCount = 0;
|
|
154
|
+
|
|
155
|
+
const reader = response.body?.getReader();
|
|
156
|
+
if (!reader) {
|
|
157
|
+
console.error('No response body.');
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
const decoder = new TextDecoder('utf-8');
|
|
161
|
+
let pending = '';
|
|
162
|
+
let done = false;
|
|
163
|
+
|
|
164
|
+
while (!done) {
|
|
165
|
+
const { value, done: streamDone } = await reader.read();
|
|
166
|
+
if (streamDone) break;
|
|
167
|
+
pending += decoder.decode(value, { stream: true });
|
|
168
|
+
const lines = pending.split('\n');
|
|
169
|
+
pending = lines.pop() ?? '';
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (line.startsWith('event:')) {
|
|
172
|
+
console.log(`EVENT │ ${line.slice(6).trim()}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!line.startsWith('data:')) continue;
|
|
176
|
+
const payload = line.slice(5).trimStart();
|
|
177
|
+
if (!payload) continue; // heartbeat
|
|
178
|
+
frameCount++;
|
|
179
|
+
console.log(`RAW │ ${payload.length > 500 ? payload.slice(0, 500) + '…' : payload}`);
|
|
180
|
+
rawFrames.push(payload);
|
|
181
|
+
|
|
182
|
+
if (payload === '[DONE]') {
|
|
183
|
+
done = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
let event: Record<string, unknown>;
|
|
187
|
+
try {
|
|
188
|
+
event = JSON.parse(payload) as Record<string, unknown>;
|
|
189
|
+
} catch {
|
|
190
|
+
console.error('× unparseable frame — the stream is not JSON SSE?');
|
|
191
|
+
done = true;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (args.dialect === 'anthropic') {
|
|
195
|
+
const type = event.type;
|
|
196
|
+
if (type === 'message_start' && typeof event.message === 'object' && event.message) {
|
|
197
|
+
const usage = (event.message as Record<string, unknown>).usage as Record<string, unknown> | undefined;
|
|
198
|
+
inputTokens = usage?.input_tokens as number | undefined;
|
|
199
|
+
} else if (type === 'message_delta') {
|
|
200
|
+
const delta = event.delta as Record<string, unknown> | undefined;
|
|
201
|
+
stopReason = delta?.stop_reason as string | undefined;
|
|
202
|
+
const usage = event.usage as Record<string, unknown> | undefined;
|
|
203
|
+
if (typeof usage?.output_tokens === 'number') outputTokens = usage.output_tokens;
|
|
204
|
+
} else if (type === 'message_stop') {
|
|
205
|
+
done = true;
|
|
206
|
+
break;
|
|
207
|
+
} else if (type === 'error') {
|
|
208
|
+
done = true;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
const choice = (event.choices as Record<string, unknown>[] | undefined)?.[0];
|
|
213
|
+
if (choice) {
|
|
214
|
+
if (typeof choice.finish_reason === 'string') finishReason = choice.finish_reason;
|
|
215
|
+
}
|
|
216
|
+
const usage = event.usage as Record<string, unknown> | undefined;
|
|
217
|
+
if (usage) {
|
|
218
|
+
if (typeof usage.prompt_tokens === 'number') inputTokens = usage.prompt_tokens;
|
|
219
|
+
if (typeof usage.completion_tokens === 'number') outputTokens = usage.completion_tokens;
|
|
220
|
+
}
|
|
221
|
+
if (event.error) done = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log('');
|
|
227
|
+
console.log('SUMMARY');
|
|
228
|
+
console.log(`frames: ${frameCount}`);
|
|
229
|
+
console.log(
|
|
230
|
+
`finish: ${args.dialect === 'anthropic' ? `stop_reason=${stopReason ?? '?'}` : `finish_reason=${finishReason ?? '?'}`}`,
|
|
231
|
+
);
|
|
232
|
+
console.log(`usage: ${inputTokens ?? '?'} in / ${outputTokens ?? '?'} out`);
|
|
233
|
+
console.log(`ended: ${done ? 'clean (sentinel/stop seen)' : 'stream closed without sentinel'}`);
|
|
234
|
+
|
|
235
|
+
if (args.savePath) {
|
|
236
|
+
let content = rawFrames.map((f) => 'data: ' + f).join('\n\n') + '\n\n';
|
|
237
|
+
// Defensive scrub: never persist key material into fixtures.
|
|
238
|
+
content = content.split(config.apiKey).join('REDACTED');
|
|
239
|
+
content = content.replace(/sk-sub-[A-Za-z0-9_-]+/g, 'sk-sub-REDACTED');
|
|
240
|
+
fs.writeFileSync(args.savePath, content);
|
|
241
|
+
console.log(`saved: ${args.savePath} (${content.length} bytes)`);
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
process.exitCode = await run();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const e = err as RouterError;
|
|
250
|
+
console.error('× ' + e.message);
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Symlink this into your PATH (e.g. ln -s "$PWD/scripts/sub-wrapper.sh" ~/.local/bin/sub)
|
|
3
|
+
# or copy it there. Runs `sub` straight from source — Node >= 24 strips TypeScript natively,
|
|
4
|
+
# so there is no build step.
|
|
5
|
+
self=$0
|
|
6
|
+
while [ -L "$self" ]; do
|
|
7
|
+
dir=$(dirname "$self")
|
|
8
|
+
self=$(readlink "$self")
|
|
9
|
+
case "$self" in /*) ;; *) self=$dir/$self ;; esac
|
|
10
|
+
done
|
|
11
|
+
exec node "$(dirname "$self")/../src/index.ts" "$@"
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Anthropic Messages wire <-> normalized types (pinned 2023-06-01, like the Swift app).
|
|
2
|
+
// Tool results are batched into a single user turn (the flush pattern); signed thinking
|
|
3
|
+
// is retained for continuation but never displayed; refusal text is surfaced as content.
|
|
4
|
+
|
|
5
|
+
import { RouterError } from '../client.ts';
|
|
6
|
+
import { parseToolArgs } from './openai.ts';
|
|
7
|
+
import type {
|
|
8
|
+
AssistantTurn,
|
|
9
|
+
AnthropicThinkingBlock,
|
|
10
|
+
ChatMessage,
|
|
11
|
+
ChatRequest,
|
|
12
|
+
StopReason,
|
|
13
|
+
StreamEvent,
|
|
14
|
+
ToolCall,
|
|
15
|
+
TokenUsage,
|
|
16
|
+
} from '../types.ts';
|
|
17
|
+
|
|
18
|
+
type Wire = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
export const ANTHROPIC_DEFAULT_MAX_TOKENS = 8192;
|
|
21
|
+
const THINKING_BUDGETS = { low: 2048, medium: 4096, high: 8192, xhigh: 16384, max: 32000 };
|
|
22
|
+
|
|
23
|
+
function thinkingBlock(block: Wire): AnthropicThinkingBlock | undefined {
|
|
24
|
+
if (block.type === 'thinking') return {
|
|
25
|
+
type: 'thinking',
|
|
26
|
+
thinking: typeof block.thinking === 'string' ? block.thinking : '',
|
|
27
|
+
signature: typeof block.signature === 'string' ? block.signature : '',
|
|
28
|
+
};
|
|
29
|
+
if (block.type === 'redacted_thinking' && typeof block.data === 'string')
|
|
30
|
+
return { type: 'redacted_thinking', data: block.data };
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Normalized request -> Messages body. max_tokens is required on this dialect. */
|
|
35
|
+
export function toAnthropicBody(req: ChatRequest): Wire {
|
|
36
|
+
const messages: Wire[] = [];
|
|
37
|
+
let system = req.system;
|
|
38
|
+
let pendingToolResults: { id: string; content: string; isError: boolean }[] = [];
|
|
39
|
+
|
|
40
|
+
const flushToolResults = (): void => {
|
|
41
|
+
if (pendingToolResults.length === 0) return;
|
|
42
|
+
messages.push({
|
|
43
|
+
role: 'user',
|
|
44
|
+
content: pendingToolResults.map((r) => ({
|
|
45
|
+
type: 'tool_result',
|
|
46
|
+
tool_use_id: r.id,
|
|
47
|
+
content: r.content,
|
|
48
|
+
is_error: r.isError,
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
51
|
+
pendingToolResults = [];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (const m of req.messages) {
|
|
55
|
+
if (m.role === 'system') {
|
|
56
|
+
system = system ? `${system}\n${m.content}` : m.content; // stray system messages fold into the top-level prompt
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (m.role === 'assistant') {
|
|
60
|
+
flushToolResults();
|
|
61
|
+
const blocks: Wire[] = (m.thinkingBlocks ?? []).map((block) => ({ ...block }));
|
|
62
|
+
if (m.content) blocks.push({ type: 'text', text: m.content });
|
|
63
|
+
for (const c of m.toolCalls ?? []) {
|
|
64
|
+
blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.arguments });
|
|
65
|
+
}
|
|
66
|
+
if (blocks.length === 0) continue; // content-less turns are dropped (API rejects them)
|
|
67
|
+
messages.push({ role: 'assistant', content: blocks });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (m.role === 'tool') {
|
|
71
|
+
// Batch consecutive tool results into one user turn.
|
|
72
|
+
pendingToolResults.push({ id: m.toolCallId ?? '', content: m.content, isError: m.isError ?? false });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
flushToolResults(); // any non-tool message ends the batch (ordering matters)
|
|
76
|
+
if (m.images?.length) {
|
|
77
|
+
const content: Wire[] = m.content ? [{ type: 'text', text: m.content }] : [];
|
|
78
|
+
content.push(...m.images.map((image) => ({
|
|
79
|
+
type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data },
|
|
80
|
+
})));
|
|
81
|
+
messages.push({ role: 'user', content });
|
|
82
|
+
} else messages.push({ role: 'user', content: m.content });
|
|
83
|
+
}
|
|
84
|
+
flushToolResults();
|
|
85
|
+
|
|
86
|
+
const budget = req.effort && req.effort !== 'auto' ? THINKING_BUDGETS[req.effort] : undefined;
|
|
87
|
+
const maxTokens = req.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS;
|
|
88
|
+
const body: Wire = { model: req.model, max_tokens: budget ? Math.max(maxTokens, budget + 4096) : maxTokens, messages, stream: req.stream };
|
|
89
|
+
if (budget) body.thinking = { type: 'enabled', budget_tokens: budget };
|
|
90
|
+
if (system) body.system = system;
|
|
91
|
+
if (req.tools && req.tools.length > 0) {
|
|
92
|
+
body.tools = req.tools.map((t) => ({
|
|
93
|
+
name: t.name,
|
|
94
|
+
description: t.description,
|
|
95
|
+
input_schema: t.parameters,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** stop_reason mapping for the Messages API (Swift parity). */
|
|
102
|
+
export function mapAnthropicStopReason(reason: string): StopReason {
|
|
103
|
+
switch (reason) {
|
|
104
|
+
case 'end_turn':
|
|
105
|
+
case 'stop_sequence':
|
|
106
|
+
return 'stop';
|
|
107
|
+
case 'tool_use':
|
|
108
|
+
return 'tool_calls';
|
|
109
|
+
case 'max_tokens':
|
|
110
|
+
return 'length';
|
|
111
|
+
case 'refusal':
|
|
112
|
+
return 'refusal';
|
|
113
|
+
default:
|
|
114
|
+
return 'unknown';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface BlockState {
|
|
119
|
+
text: string;
|
|
120
|
+
thinking?: AnthropicThinkingBlock;
|
|
121
|
+
tool?: { id: string; name: string; partialJson: string };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Streaming assembly for the Messages API: message_start → input tokens;
|
|
126
|
+
* content_block_start(tool_use) → tool_call_start; text_delta → text;
|
|
127
|
+
* input_json_delta → tool_call_args (accumulated, parsed only at the end);
|
|
128
|
+
* thinking/signature deltas retained silently; message_delta → stop_reason/output tokens;
|
|
129
|
+
* message_stop → single stop event. Refusals surface as text.
|
|
130
|
+
*/
|
|
131
|
+
export class AnthropicStreamParser {
|
|
132
|
+
private textParts: string[] = [];
|
|
133
|
+
private blocks = new Map<number, BlockState>();
|
|
134
|
+
private toolSeq = 0; // tool_use blocks get sequential indexes, gaps or not
|
|
135
|
+
private inputTokens: number | undefined;
|
|
136
|
+
private outputTokens: number | undefined;
|
|
137
|
+
private stopReason: StopReason | undefined;
|
|
138
|
+
private done = false;
|
|
139
|
+
|
|
140
|
+
push(payload: string): StreamEvent[] {
|
|
141
|
+
if (this.done) return []; // message_stop is final — later frames are ignored
|
|
142
|
+
const data = JSON.parse(payload) as Wire;
|
|
143
|
+
const type = data.type;
|
|
144
|
+
const events: StreamEvent[] = [];
|
|
145
|
+
|
|
146
|
+
if (type === 'error') {
|
|
147
|
+
const err = data.error;
|
|
148
|
+
const errObj = typeof err === 'object' && err !== null ? (err as Wire) : undefined;
|
|
149
|
+
throw new RouterError(typeof errObj?.message === 'string' ? errObj.message : 'The router returned an error event.');
|
|
150
|
+
}
|
|
151
|
+
if (type === 'ping') return events;
|
|
152
|
+
|
|
153
|
+
if (type === 'message_start') {
|
|
154
|
+
const msg = data.message;
|
|
155
|
+
if (typeof msg === 'object' && msg !== null) {
|
|
156
|
+
const usage = (msg as Wire).usage;
|
|
157
|
+
if (typeof usage === 'object' && usage !== null) {
|
|
158
|
+
const u = usage as Wire;
|
|
159
|
+
this.inputTokens = typeof u.input_tokens === 'number' ? u.input_tokens : this.inputTokens;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return events;
|
|
163
|
+
}
|
|
164
|
+
if (type === 'content_block_start') {
|
|
165
|
+
const block = data.content_block;
|
|
166
|
+
const index = typeof data.index === 'number' ? data.index : this.blocks.size;
|
|
167
|
+
if (typeof block === 'object' && block !== null && (block as Wire).type === 'tool_use') {
|
|
168
|
+
const b = block as Wire;
|
|
169
|
+
const seq = this.toolSeq++;
|
|
170
|
+
this.blocks.set(index, {
|
|
171
|
+
text: '',
|
|
172
|
+
tool: { id: typeof b.id === 'string' ? b.id : '', name: typeof b.name === 'string' ? b.name : '', partialJson: '' },
|
|
173
|
+
});
|
|
174
|
+
events.push({ kind: 'tool_call_start', index: seq, id: this.blocks.get(index)!.tool!.id, name: this.blocks.get(index)!.tool!.name });
|
|
175
|
+
} else {
|
|
176
|
+
this.blocks.set(index, { text: '', thinking: typeof block === 'object' && block !== null ? thinkingBlock(block as Wire) : undefined });
|
|
177
|
+
}
|
|
178
|
+
return events;
|
|
179
|
+
}
|
|
180
|
+
if (type === 'content_block_delta') {
|
|
181
|
+
const index = typeof data.index === 'number' ? data.index : 0;
|
|
182
|
+
const delta = data.delta;
|
|
183
|
+
if (typeof delta !== 'object' || delta === null) return events;
|
|
184
|
+
const d = delta as Wire;
|
|
185
|
+
const state = this.blocks.get(index) ?? { text: '' };
|
|
186
|
+
this.blocks.set(index, state);
|
|
187
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') {
|
|
188
|
+
this.textParts.push(d.text);
|
|
189
|
+
events.push({ kind: 'text', text: d.text });
|
|
190
|
+
} else if (d.type === 'refusal_delta' && typeof d.refusal === 'string') {
|
|
191
|
+
this.textParts.push(d.refusal); // surfaced as content, not as a tool result
|
|
192
|
+
events.push({ kind: 'text', text: d.refusal });
|
|
193
|
+
} else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
|
|
194
|
+
if (!state.tool) state.tool = { id: '', name: '', partialJson: '' };
|
|
195
|
+
state.tool.partialJson += d.partial_json;
|
|
196
|
+
events.push({ kind: 'tool_call_args', index: this.seqFor(index), delta: d.partial_json });
|
|
197
|
+
} else if (state.thinking?.type === 'thinking') {
|
|
198
|
+
if (d.type === 'thinking_delta' && typeof d.thinking === 'string') state.thinking.thinking += d.thinking;
|
|
199
|
+
else if (d.type === 'signature_delta' && typeof d.signature === 'string') state.thinking.signature += d.signature;
|
|
200
|
+
}
|
|
201
|
+
// Signed thinking is retained for tool continuations, never emitted to the UI.
|
|
202
|
+
return events;
|
|
203
|
+
}
|
|
204
|
+
if (type === 'content_block_stop') {
|
|
205
|
+
return events;
|
|
206
|
+
}
|
|
207
|
+
if (type === 'message_delta') {
|
|
208
|
+
const delta = data.delta;
|
|
209
|
+
if (typeof delta === 'object' && delta !== null && typeof (delta as Wire).stop_reason === 'string') {
|
|
210
|
+
this.stopReason = mapAnthropicStopReason((delta as Wire).stop_reason as string);
|
|
211
|
+
}
|
|
212
|
+
const usage = data.usage;
|
|
213
|
+
if (typeof usage === 'object' && usage !== null && typeof (usage as Wire).output_tokens === 'number') {
|
|
214
|
+
this.outputTokens = (usage as Wire).output_tokens as number;
|
|
215
|
+
}
|
|
216
|
+
return events;
|
|
217
|
+
}
|
|
218
|
+
if (type === 'message_stop') {
|
|
219
|
+
this.done = true;
|
|
220
|
+
const usage: TokenUsage | undefined =
|
|
221
|
+
this.inputTokens !== undefined && this.outputTokens !== undefined
|
|
222
|
+
? { inputTokens: this.inputTokens, outputTokens: this.outputTokens }
|
|
223
|
+
: undefined;
|
|
224
|
+
events.push({ kind: 'stop', stopReason: this.stopReason ?? 'unknown', usage });
|
|
225
|
+
return events;
|
|
226
|
+
}
|
|
227
|
+
return events; // unknown event types are ignored, never fatal
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private seqFor(index: number): number {
|
|
231
|
+
// Map a content-block index back to the sequential tool index it was assigned.
|
|
232
|
+
const ordered = [...this.blocks.entries()];
|
|
233
|
+
let seq = 0;
|
|
234
|
+
for (const [i, s] of ordered) {
|
|
235
|
+
if (i === index) return seq;
|
|
236
|
+
if (s.tool) seq++;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
assistantMessage(): ChatMessage {
|
|
242
|
+
const toolCalls: ToolCall[] = [];
|
|
243
|
+
for (const s of this.blocks.values()) {
|
|
244
|
+
if (!s.tool) continue;
|
|
245
|
+
const { arguments: parsed, invalid } = parseToolArgs(s.tool.partialJson);
|
|
246
|
+
toolCalls.push({
|
|
247
|
+
id: s.tool.id,
|
|
248
|
+
name: s.tool.name,
|
|
249
|
+
arguments: parsed,
|
|
250
|
+
argumentsJson: s.tool.partialJson,
|
|
251
|
+
argumentsInvalid: invalid,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const message: ChatMessage = { role: 'assistant', content: this.textParts.join('') };
|
|
255
|
+
if (toolCalls.length > 0) message.toolCalls = toolCalls;
|
|
256
|
+
const thinkingBlocks = [...this.blocks.values()].flatMap((state) => state.thinking ? [{ ...state.thinking }] : []);
|
|
257
|
+
if (thinkingBlocks.length) message.thinkingBlocks = thinkingBlocks;
|
|
258
|
+
return message;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Non-streaming Messages response -> normalized turn. */
|
|
263
|
+
export function parseAnthropicCompleteBody(json: unknown): AssistantTurn {
|
|
264
|
+
if (typeof json !== 'object' || json === null) {
|
|
265
|
+
throw new RouterError('Unexpected response from the router (not an object).');
|
|
266
|
+
}
|
|
267
|
+
const body = json as Wire;
|
|
268
|
+
if (body.type === 'error') {
|
|
269
|
+
const err = body.error;
|
|
270
|
+
const errObj = typeof err === 'object' && err !== null ? (err as Wire) : undefined;
|
|
271
|
+
throw new RouterError(typeof errObj?.message === 'string' ? errObj.message : 'The router returned an error.');
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(body.content)) {
|
|
274
|
+
throw new RouterError('Unexpected response from the router (no content array).');
|
|
275
|
+
}
|
|
276
|
+
const textParts: string[] = [];
|
|
277
|
+
const toolCalls: ToolCall[] = [];
|
|
278
|
+
const thinkingBlocks: AnthropicThinkingBlock[] = [];
|
|
279
|
+
for (const block of body.content as Wire[]) {
|
|
280
|
+
if (typeof block !== 'object' || block === null) continue;
|
|
281
|
+
const thinking = thinkingBlock(block);
|
|
282
|
+
if (thinking) thinkingBlocks.push(thinking);
|
|
283
|
+
if (block.type === 'text' && typeof block.text === 'string') textParts.push(block.text);
|
|
284
|
+
if (block.type === 'refusal' && typeof block.refusal === 'string') textParts.push(block.refusal);
|
|
285
|
+
if (block.type === 'tool_use') {
|
|
286
|
+
const input = block.input;
|
|
287
|
+
const rawArgs = typeof input === 'object' && input !== null ? JSON.stringify(input) : '';
|
|
288
|
+
const { arguments: parsed, invalid } = parseToolArgs(rawArgs);
|
|
289
|
+
toolCalls.push({
|
|
290
|
+
id: typeof block.id === 'string' ? block.id : '',
|
|
291
|
+
name: typeof block.name === 'string' ? block.name : '',
|
|
292
|
+
arguments: parsed,
|
|
293
|
+
argumentsJson: rawArgs,
|
|
294
|
+
argumentsInvalid: invalid,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const message: ChatMessage = { role: 'assistant', content: textParts.join('') };
|
|
299
|
+
if (toolCalls.length > 0) message.toolCalls = toolCalls;
|
|
300
|
+
if (thinkingBlocks.length) message.thinkingBlocks = thinkingBlocks;
|
|
301
|
+
const stopReason = typeof body.stop_reason === 'string' ? mapAnthropicStopReason(body.stop_reason) : 'unknown';
|
|
302
|
+
const usageWire = body.usage;
|
|
303
|
+
let usage: TokenUsage | undefined;
|
|
304
|
+
if (typeof usageWire === 'object' && usageWire !== null) {
|
|
305
|
+
const u = usageWire as Wire;
|
|
306
|
+
if (typeof u.input_tokens === 'number' && typeof u.output_tokens === 'number') {
|
|
307
|
+
usage = { inputTokens: u.input_tokens, outputTokens: u.output_tokens };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { message, stopReason, usage };
|
|
311
|
+
}
|