atom-agent 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 +27 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/App.js +2428 -0
- package/dist/adapters.js +926 -0
- package/dist/auth.js +122 -0
- package/dist/cli.js +28 -0
- package/dist/compact.js +277 -0
- package/dist/context-windows.js +112 -0
- package/dist/env-block.js +166 -0
- package/dist/permissions.js +129 -0
- package/dist/providers.js +224 -0
- package/dist/session.js +218 -0
- package/dist/skills.js +283 -0
- package/dist/snapshots.js +243 -0
- package/dist/system.js +22 -0
- package/dist/tools.js +1867 -0
- package/dist/zen.js +1862 -0
- package/package.json +54 -0
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
// Adapter boundary for non-OpenAI kinds (anthropic-messages,
|
|
2
|
+
// gemini-generate). OpenAI-chat kind reuses zen.ts verbatim.
|
|
3
|
+
// Pure translation + SSE parsing; no zen runtime imports (type-only)
|
|
4
|
+
// so zen.ts can import this module without a runtime cycle.
|
|
5
|
+
//
|
|
6
|
+
// Normalized output matches zen ChatResult:
|
|
7
|
+
// {content, tool_calls:[{id,function:{name,arguments}}], usage?}
|
|
8
|
+
// so runAgenticLoop/retry/rollback/status code is untouched.
|
|
9
|
+
import { TOOL_DEFINITIONS } from "./tools.js";
|
|
10
|
+
import { getProvider, modelsUrlForProvider, } from "./providers.js";
|
|
11
|
+
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
12
|
+
export const ANTHROPIC_MAX_TOKENS = 4096;
|
|
13
|
+
function toolDefs() {
|
|
14
|
+
return TOOL_DEFINITIONS;
|
|
15
|
+
}
|
|
16
|
+
function parseArgsObject(raw) {
|
|
17
|
+
try {
|
|
18
|
+
const v = JSON.parse(raw || "{}");
|
|
19
|
+
return typeof v === "object" && v !== null
|
|
20
|
+
? v
|
|
21
|
+
: {};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function buildAnthropicBody(history, model, opts) {
|
|
28
|
+
const systems = [];
|
|
29
|
+
const messages = [];
|
|
30
|
+
// Group consecutive tool messages into one user message with
|
|
31
|
+
// multiple tool_result blocks (Anthropic convention).
|
|
32
|
+
let pendingToolResults = [];
|
|
33
|
+
function flushTools() {
|
|
34
|
+
if (pendingToolResults.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
messages.push({ role: "user", content: [...pendingToolResults] });
|
|
37
|
+
pendingToolResults = [];
|
|
38
|
+
}
|
|
39
|
+
for (const m of history) {
|
|
40
|
+
if (m.role === "system") {
|
|
41
|
+
systems.push(m.content);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (m.role === "tool") {
|
|
45
|
+
pendingToolResults.push({
|
|
46
|
+
type: "tool_result",
|
|
47
|
+
tool_use_id: m.tool_call_id,
|
|
48
|
+
content: m.content,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
flushTools();
|
|
53
|
+
if (m.role === "user") {
|
|
54
|
+
messages.push({ role: "user", content: m.content });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// assistant: text + tool_use blocks
|
|
58
|
+
const am = m;
|
|
59
|
+
const blocks = [];
|
|
60
|
+
if (typeof am.content === "string" && am.content.length > 0) {
|
|
61
|
+
blocks.push({ type: "text", text: am.content });
|
|
62
|
+
}
|
|
63
|
+
for (const tc of am.tool_calls ?? []) {
|
|
64
|
+
blocks.push({
|
|
65
|
+
type: "tool_use",
|
|
66
|
+
id: tc.id,
|
|
67
|
+
name: tc.function.name,
|
|
68
|
+
input: parseArgsObject(tc.function.arguments),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (blocks.length === 0)
|
|
72
|
+
blocks.push({ type: "text", text: "" });
|
|
73
|
+
messages.push({ role: "assistant", content: blocks });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
flushTools();
|
|
77
|
+
const includeTools = opts?.includeTools !== false;
|
|
78
|
+
const body = {
|
|
79
|
+
model,
|
|
80
|
+
max_tokens: ANTHROPIC_MAX_TOKENS,
|
|
81
|
+
messages,
|
|
82
|
+
};
|
|
83
|
+
// Compaction path (includeTools:false) omits `tools` + `tool_choice`
|
|
84
|
+
// entirely — asserted in tests as "no `tools` key".
|
|
85
|
+
if (includeTools) {
|
|
86
|
+
body.tools = toolDefs().map((t) => ({
|
|
87
|
+
name: t.function.name,
|
|
88
|
+
description: t.function.description,
|
|
89
|
+
input_schema: t.function.parameters,
|
|
90
|
+
}));
|
|
91
|
+
body.tool_choice = { type: "auto" };
|
|
92
|
+
}
|
|
93
|
+
if (systems.length > 0)
|
|
94
|
+
body.system = systems.join("\n\n");
|
|
95
|
+
return body;
|
|
96
|
+
}
|
|
97
|
+
export function anthropicHeaders(apiKey) {
|
|
98
|
+
return {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"x-api-key": apiKey,
|
|
101
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// ---- Gemini request ----
|
|
105
|
+
// Gemini FunctionDeclaration parameters reject JSON-Schema keywords outside
|
|
106
|
+
// its subset — notably `additionalProperties` (HTTP 400: Unknown name
|
|
107
|
+
// "additionalProperties" at tools[...].function_declarations[...].parameters).
|
|
108
|
+
// TOOL_DEFINITIONS are OpenAI-canonical (every schema carries
|
|
109
|
+
// `additionalProperties: false`), so the GEMINI adapter strips the rejected
|
|
110
|
+
// keys here, recursively, at conversion time. Anthropic input_schema and
|
|
111
|
+
// OpenAI parameters accept them — those paths stay byte-identical, and the
|
|
112
|
+
// canonical schemas are never mutated.
|
|
113
|
+
const GEMINI_STRIPPED_SCHEMA_KEYS = new Set([
|
|
114
|
+
"additionalProperties",
|
|
115
|
+
"$schema",
|
|
116
|
+
"$id",
|
|
117
|
+
"$ref",
|
|
118
|
+
]);
|
|
119
|
+
function stripGeminiSchemaKeys(value) {
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return value.map(stripGeminiSchemaKeys);
|
|
122
|
+
if (typeof value === "object" && value !== null) {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const [k, v] of Object.entries(value)) {
|
|
125
|
+
if (GEMINI_STRIPPED_SCHEMA_KEYS.has(k))
|
|
126
|
+
continue;
|
|
127
|
+
out[k] = stripGeminiSchemaKeys(v);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
export function geminiChatUrl(model) {
|
|
134
|
+
return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
|
|
135
|
+
}
|
|
136
|
+
export function geminiGenerateUrl(model) {
|
|
137
|
+
return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
|
|
138
|
+
}
|
|
139
|
+
export function geminiHeaders(apiKey) {
|
|
140
|
+
return {
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
"x-goog-api-key": apiKey,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function buildGeminiBody(history, _model, opts) {
|
|
146
|
+
const systems = [];
|
|
147
|
+
for (const m of history) {
|
|
148
|
+
if (m.role === "system")
|
|
149
|
+
systems.push(m.content);
|
|
150
|
+
}
|
|
151
|
+
// tool_call_id -> function name (tool messages carry only the id).
|
|
152
|
+
const nameById = new Map();
|
|
153
|
+
for (const m of history) {
|
|
154
|
+
if (m.role === "assistant") {
|
|
155
|
+
for (const tc of m.tool_calls ?? []) {
|
|
156
|
+
if (tc.id)
|
|
157
|
+
nameById.set(tc.id, tc.function.name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const contents = [];
|
|
162
|
+
let pendingResponses = [];
|
|
163
|
+
function flushResponses() {
|
|
164
|
+
if (pendingResponses.length === 0)
|
|
165
|
+
return;
|
|
166
|
+
contents.push({ role: "user", parts: pendingResponses });
|
|
167
|
+
pendingResponses = [];
|
|
168
|
+
}
|
|
169
|
+
for (const m of history) {
|
|
170
|
+
if (m.role === "system")
|
|
171
|
+
continue;
|
|
172
|
+
if (m.role === "tool") {
|
|
173
|
+
pendingResponses.push({
|
|
174
|
+
functionResponse: {
|
|
175
|
+
name: nameById.get(m.tool_call_id) ?? "unknown",
|
|
176
|
+
response: { result: m.content },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
flushResponses();
|
|
182
|
+
if (m.role === "user") {
|
|
183
|
+
contents.push({ role: "user", parts: [{ text: m.content }] });
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const am = m;
|
|
187
|
+
const parts = [];
|
|
188
|
+
if (typeof am.content === "string" && am.content.length > 0) {
|
|
189
|
+
parts.push({ text: am.content });
|
|
190
|
+
}
|
|
191
|
+
for (const tc of am.tool_calls ?? []) {
|
|
192
|
+
parts.push({
|
|
193
|
+
functionCall: {
|
|
194
|
+
name: tc.function.name,
|
|
195
|
+
args: parseArgsObject(tc.function.arguments),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (parts.length === 0)
|
|
200
|
+
parts.push({ text: "" });
|
|
201
|
+
contents.push({ role: "model", parts });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
flushResponses();
|
|
205
|
+
const includeTools = opts?.includeTools !== false;
|
|
206
|
+
const body = {
|
|
207
|
+
contents,
|
|
208
|
+
};
|
|
209
|
+
// Compaction path (includeTools:false) omits `tools` entirely — asserted
|
|
210
|
+
// in tests as "no `tools` key".
|
|
211
|
+
if (includeTools) {
|
|
212
|
+
body.tools = [
|
|
213
|
+
{
|
|
214
|
+
functionDeclarations: toolDefs().map((t) => ({
|
|
215
|
+
name: t.function.name,
|
|
216
|
+
description: t.function.description,
|
|
217
|
+
parameters: stripGeminiSchemaKeys(t.function.parameters),
|
|
218
|
+
})),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
// Compaction cap (gemini kind uses generationConfig.maxOutputTokens).
|
|
223
|
+
if (typeof opts?.maxOutputTokens === "number" &&
|
|
224
|
+
Number.isFinite(opts.maxOutputTokens) &&
|
|
225
|
+
opts.maxOutputTokens > 0) {
|
|
226
|
+
body.generationConfig = { maxOutputTokens: Math.floor(opts.maxOutputTokens) };
|
|
227
|
+
}
|
|
228
|
+
if (systems.length > 0) {
|
|
229
|
+
body.system_instruction = { parts: [{ text: systems.join("\n\n") }] };
|
|
230
|
+
}
|
|
231
|
+
return body;
|
|
232
|
+
}
|
|
233
|
+
async function collectSSEText(res) {
|
|
234
|
+
const body = res.body;
|
|
235
|
+
const decoder = new TextDecoder();
|
|
236
|
+
let rawText = "";
|
|
237
|
+
if (body == null)
|
|
238
|
+
return { rawText, events: [] };
|
|
239
|
+
try {
|
|
240
|
+
if (typeof body.getReader === "function") {
|
|
241
|
+
const reader = body.getReader();
|
|
242
|
+
try {
|
|
243
|
+
for (;;) {
|
|
244
|
+
let chunk;
|
|
245
|
+
try {
|
|
246
|
+
chunk = await reader.read();
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
250
|
+
}
|
|
251
|
+
if (chunk.done)
|
|
252
|
+
break;
|
|
253
|
+
const v = chunk.value;
|
|
254
|
+
rawText +=
|
|
255
|
+
typeof v === "string"
|
|
256
|
+
? v
|
|
257
|
+
: decoder.decode(v, { stream: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
try {
|
|
262
|
+
reader.releaseLock?.();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// ignore
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
270
|
+
for await (const v of body) {
|
|
271
|
+
rawText +=
|
|
272
|
+
typeof v === "string"
|
|
273
|
+
? v
|
|
274
|
+
: decoder.decode(v, { stream: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const textFn = res.text;
|
|
279
|
+
if (typeof textFn === "function") {
|
|
280
|
+
rawText = String((await textFn.call(res)) ?? "");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
if (e instanceof Error && e.message.startsWith("Truncated stream"))
|
|
286
|
+
throw e;
|
|
287
|
+
throw new Error(`Truncated stream from model (connection aborted: ${e instanceof Error ? e.message : String(e)}).`);
|
|
288
|
+
}
|
|
289
|
+
// Split into SSE events: "event:" sets the type for following "data:".
|
|
290
|
+
const events = [];
|
|
291
|
+
let curEvent = "message";
|
|
292
|
+
for (const rawLine of rawText.split("\n")) {
|
|
293
|
+
let line = rawLine;
|
|
294
|
+
if (line.endsWith("\r"))
|
|
295
|
+
line = line.slice(0, -1);
|
|
296
|
+
if (line.length === 0) {
|
|
297
|
+
curEvent = "message";
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (line.startsWith(":"))
|
|
301
|
+
continue;
|
|
302
|
+
if (line.startsWith("event:")) {
|
|
303
|
+
curEvent = line.slice("event:".length).trim() || "message";
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!line.startsWith("data:"))
|
|
307
|
+
continue; // id:/retry: ignored
|
|
308
|
+
let payload = line.slice("data:".length);
|
|
309
|
+
if (payload.startsWith(" "))
|
|
310
|
+
payload = payload.slice(1);
|
|
311
|
+
events.push({ event: curEvent, data: payload });
|
|
312
|
+
// blank line resets below; consecutive data lines keep last event
|
|
313
|
+
}
|
|
314
|
+
return { rawText, events };
|
|
315
|
+
}
|
|
316
|
+
function finiteCount(value) {
|
|
317
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
318
|
+
? Math.floor(value)
|
|
319
|
+
: undefined;
|
|
320
|
+
}
|
|
321
|
+
function openAIUsage(prompt, completion) {
|
|
322
|
+
const out = {};
|
|
323
|
+
const p = finiteCount(prompt);
|
|
324
|
+
if (p !== undefined)
|
|
325
|
+
out.prompt_tokens = p;
|
|
326
|
+
const c = finiteCount(completion);
|
|
327
|
+
if (c !== undefined)
|
|
328
|
+
out.completion_tokens = c;
|
|
329
|
+
if (p !== undefined && c !== undefined)
|
|
330
|
+
out.total_tokens = p + c;
|
|
331
|
+
return out.prompt_tokens !== undefined ||
|
|
332
|
+
out.completion_tokens !== undefined ||
|
|
333
|
+
out.total_tokens !== undefined
|
|
334
|
+
? out
|
|
335
|
+
: undefined;
|
|
336
|
+
}
|
|
337
|
+
// Merge one POST's incrementally-reported usage: last value seen per key wins
|
|
338
|
+
// (one stream carries one POST's usage). `total` is authoritative when the
|
|
339
|
+
// provider sends one (Gemini totalTokenCount — kept verbatim, never
|
|
340
|
+
// recomputed over). Otherwise, when `recomputeTotal` is set (Anthropic never
|
|
341
|
+
// sends a total), total_tokens is recomputed from prompt+completion whenever
|
|
342
|
+
// both are known, so a split report (message_start input + message_delta
|
|
343
|
+
// output) never leaves the first chunk's stale total behind. A merge that
|
|
344
|
+
// still carries no usable count returns undefined (never an empty object).
|
|
345
|
+
function mergeUsage(base, partial, opts) {
|
|
346
|
+
if (partial === undefined && opts?.total === undefined)
|
|
347
|
+
return base;
|
|
348
|
+
const merged = { ...base, ...partial };
|
|
349
|
+
if (opts?.total !== undefined) {
|
|
350
|
+
merged.total_tokens = opts.total;
|
|
351
|
+
}
|
|
352
|
+
else if ((merged.total_tokens === undefined || opts?.recomputeTotal === true) &&
|
|
353
|
+
merged.prompt_tokens !== undefined &&
|
|
354
|
+
merged.completion_tokens !== undefined) {
|
|
355
|
+
merged.total_tokens = merged.prompt_tokens + merged.completion_tokens;
|
|
356
|
+
}
|
|
357
|
+
return merged.prompt_tokens !== undefined ||
|
|
358
|
+
merged.completion_tokens !== undefined ||
|
|
359
|
+
merged.total_tokens !== undefined
|
|
360
|
+
? merged
|
|
361
|
+
: undefined;
|
|
362
|
+
}
|
|
363
|
+
// ---- Anthropic SSE + JSON ----
|
|
364
|
+
export async function readAnthropicSSEMessage(res, opts) {
|
|
365
|
+
const { rawText, events } = await collectSSEText(res);
|
|
366
|
+
const blocks = [];
|
|
367
|
+
let usage;
|
|
368
|
+
let stopReason;
|
|
369
|
+
let sawData = false;
|
|
370
|
+
let sawStop = false;
|
|
371
|
+
let streamingAnnounced = false;
|
|
372
|
+
function announce() {
|
|
373
|
+
if (!streamingAnnounced) {
|
|
374
|
+
streamingAnnounced = true;
|
|
375
|
+
try {
|
|
376
|
+
opts?.onPhase?.("streaming");
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// ignore
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function blockAt(index) {
|
|
384
|
+
while (blocks.length <= index)
|
|
385
|
+
blocks.push({ kind: "text", text: "" });
|
|
386
|
+
return blocks[index];
|
|
387
|
+
}
|
|
388
|
+
for (const { data } of events) {
|
|
389
|
+
if (data === "[DONE]") {
|
|
390
|
+
sawStop = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!data)
|
|
394
|
+
continue;
|
|
395
|
+
let evt;
|
|
396
|
+
try {
|
|
397
|
+
evt = JSON.parse(data);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
continue; // malformed JSON data line: skip, never crash
|
|
401
|
+
}
|
|
402
|
+
sawData = true;
|
|
403
|
+
const o = evt;
|
|
404
|
+
const type = o["type"];
|
|
405
|
+
if (type === "message_start") {
|
|
406
|
+
const msg = o["message"];
|
|
407
|
+
const u = msg?.["usage"];
|
|
408
|
+
if (u) {
|
|
409
|
+
const hit = openAIUsage(u["input_tokens"], u["output_tokens"]);
|
|
410
|
+
const merged = mergeUsage(usage, hit, { recomputeTotal: true });
|
|
411
|
+
if (merged !== undefined)
|
|
412
|
+
usage = merged;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (type === "content_block_start") {
|
|
417
|
+
const index = typeof o["index"] === "number" ? o["index"] : 0;
|
|
418
|
+
const cb = o["content_block"];
|
|
419
|
+
const btype = cb?.["type"];
|
|
420
|
+
if (btype === "tool_use") {
|
|
421
|
+
const name = typeof cb?.["name"] === "string" ? cb["name"] : "";
|
|
422
|
+
const id = typeof cb?.["id"] === "string" ? cb["id"] : "";
|
|
423
|
+
blocks[index] = { kind: "tool", id, name, json: "" };
|
|
424
|
+
announce();
|
|
425
|
+
if (name) {
|
|
426
|
+
try {
|
|
427
|
+
opts?.onToolDelta?.(name, index);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// ignore
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
opts?.onPhase?.("tool", name);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// ignore
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
blocks[index] = { kind: "text", text: "" };
|
|
442
|
+
}
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (type === "content_block_delta") {
|
|
446
|
+
const index = typeof o["index"] === "number" ? o["index"] : 0;
|
|
447
|
+
const delta = o["delta"];
|
|
448
|
+
const dtype = delta?.["type"];
|
|
449
|
+
const slot = blockAt(index);
|
|
450
|
+
if (dtype === "text_delta" && typeof delta?.["text"] === "string") {
|
|
451
|
+
const frag = delta["text"];
|
|
452
|
+
announce();
|
|
453
|
+
if (slot.kind === "text")
|
|
454
|
+
slot.text += frag;
|
|
455
|
+
else
|
|
456
|
+
blocks[index] = { kind: "text", text: frag };
|
|
457
|
+
try {
|
|
458
|
+
opts?.onPhase?.("streaming");
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// ignore
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
opts?.onToken?.(fullText(blocks));
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// ignore
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (dtype === "input_json_delta" &&
|
|
471
|
+
typeof delta?.["partial_json"] === "string") {
|
|
472
|
+
const frag = delta["partial_json"];
|
|
473
|
+
announce();
|
|
474
|
+
if (slot.kind === "tool")
|
|
475
|
+
slot.json += frag;
|
|
476
|
+
// input_json fragments do not emit onToken (not user text)
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (type === "message_delta") {
|
|
481
|
+
const delta = o["delta"];
|
|
482
|
+
if (typeof delta?.["stop_reason"] === "string") {
|
|
483
|
+
stopReason = delta["stop_reason"];
|
|
484
|
+
}
|
|
485
|
+
const u = o["usage"];
|
|
486
|
+
// Tolerance: some streams put the counts at the top level of
|
|
487
|
+
// message_delta instead of under `usage` (nested `usage` wins).
|
|
488
|
+
const inputSrc = u?.["input_tokens"] !== undefined ? u["input_tokens"] : o["input_tokens"];
|
|
489
|
+
const outputSrc = u?.["output_tokens"] !== undefined ? u["output_tokens"] : o["output_tokens"];
|
|
490
|
+
if (u !== undefined || o["input_tokens"] !== undefined || o["output_tokens"] !== undefined) {
|
|
491
|
+
const hit = openAIUsage(inputSrc, outputSrc);
|
|
492
|
+
const merged = mergeUsage(usage, hit, { recomputeTotal: true });
|
|
493
|
+
if (merged !== undefined)
|
|
494
|
+
usage = merged;
|
|
495
|
+
}
|
|
496
|
+
// Some streams put output_tokens at top level of message_delta
|
|
497
|
+
if (stopReason !== undefined)
|
|
498
|
+
sawStop = true;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (type === "message_stop") {
|
|
502
|
+
sawStop = true;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
// Tolerance: non-streaming JSON body delivered as single SSE data line
|
|
506
|
+
// (e.g. {content:[...], stop_reason, usage}).
|
|
507
|
+
if (Array.isArray(o["content"]) && o["stop_reason"] !== undefined) {
|
|
508
|
+
return parseAnthropicJson(o);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Tolerance: body with no SSE data lines is really single-shot JSON.
|
|
512
|
+
if (!sawData) {
|
|
513
|
+
const candidate = rawText.trim();
|
|
514
|
+
if (candidate.length > 0) {
|
|
515
|
+
try {
|
|
516
|
+
const data = JSON.parse(candidate);
|
|
517
|
+
if (Array.isArray(data["content"]))
|
|
518
|
+
return parseAnthropicJson(data);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// fall through to truncation error
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
throw new Error("Truncated stream from model (connection aborted before [DONE]).");
|
|
525
|
+
}
|
|
526
|
+
if (!sawStop) {
|
|
527
|
+
throw new Error("Truncated stream from model (connection aborted before [DONE]).");
|
|
528
|
+
}
|
|
529
|
+
void stopReason;
|
|
530
|
+
return buildAnthropicResult(blocks, usage, opts);
|
|
531
|
+
}
|
|
532
|
+
function fullText(blocks) {
|
|
533
|
+
return blocks
|
|
534
|
+
.filter((b) => b.kind === "text")
|
|
535
|
+
.map((b) => b.text)
|
|
536
|
+
.join("");
|
|
537
|
+
}
|
|
538
|
+
function buildAnthropicResult(blocks, usage, opts) {
|
|
539
|
+
const text = fullText(blocks);
|
|
540
|
+
const calls = [];
|
|
541
|
+
blocks.forEach((b, i) => {
|
|
542
|
+
if (b.kind !== "tool")
|
|
543
|
+
return;
|
|
544
|
+
if (!b.name) {
|
|
545
|
+
if (b.id) {
|
|
546
|
+
try {
|
|
547
|
+
opts?.onWarning?.(`dropped tool call ${b.id} with no function name`);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// ignore
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
calls.push({
|
|
556
|
+
id: b.id || `anthropic-${i}`,
|
|
557
|
+
type: "function",
|
|
558
|
+
function: { name: b.name, arguments: b.json || "{}" },
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
if (calls.length === 0 && text.trim() === "") {
|
|
562
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
563
|
+
}
|
|
564
|
+
const result = {
|
|
565
|
+
content: text.length > 0 ? text : null,
|
|
566
|
+
tool_calls: calls.length > 0 ? calls : undefined,
|
|
567
|
+
};
|
|
568
|
+
if (usage !== undefined)
|
|
569
|
+
result.usage = usage;
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
// Non-streaming Anthropic JSON:
|
|
573
|
+
// {content:[{type:"text",text},{type:"tool_use",id,name,input}], usage:{input_tokens,output_tokens}}
|
|
574
|
+
export function parseAnthropicJson(data) {
|
|
575
|
+
const o = data;
|
|
576
|
+
const content = o["content"];
|
|
577
|
+
let text = "";
|
|
578
|
+
const calls = [];
|
|
579
|
+
if (Array.isArray(content)) {
|
|
580
|
+
for (let i = 0; i < content.length; i++) {
|
|
581
|
+
const b = content[i];
|
|
582
|
+
if (b["type"] === "text" && typeof b["text"] === "string") {
|
|
583
|
+
text += b["text"];
|
|
584
|
+
}
|
|
585
|
+
else if (b["type"] === "tool_use") {
|
|
586
|
+
const name = typeof b["name"] === "string" ? b["name"] : "";
|
|
587
|
+
if (!name)
|
|
588
|
+
continue; // nameless-drop parity
|
|
589
|
+
const id = typeof b["id"] === "string" ? b["id"] : `anthropic-${i}`;
|
|
590
|
+
let args = "{}";
|
|
591
|
+
try {
|
|
592
|
+
args = JSON.stringify(b["input"] ?? {});
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
args = "{}";
|
|
596
|
+
}
|
|
597
|
+
calls.push({ id, type: "function", function: { name, arguments: args } });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (calls.length === 0 && text.trim() === "") {
|
|
602
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
603
|
+
}
|
|
604
|
+
const result = {
|
|
605
|
+
content: text.length > 0 ? text : null,
|
|
606
|
+
tool_calls: calls.length > 0 ? calls : undefined,
|
|
607
|
+
};
|
|
608
|
+
const u = o["usage"];
|
|
609
|
+
if (u) {
|
|
610
|
+
const hit = openAIUsage(u["input_tokens"], u["output_tokens"]);
|
|
611
|
+
if (hit)
|
|
612
|
+
result.usage = hit;
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
// ---- Gemini SSE + JSON ----
|
|
617
|
+
export async function readGeminiSSEMessage(res, opts) {
|
|
618
|
+
const { rawText, events } = await collectSSEText(res);
|
|
619
|
+
let text = "";
|
|
620
|
+
const calls = [];
|
|
621
|
+
let usage;
|
|
622
|
+
let sawData = false;
|
|
623
|
+
let sawDone = false;
|
|
624
|
+
// functionCall accumulation: consecutive chunks for the same call merge
|
|
625
|
+
// their args objects; a new name starts a new slot.
|
|
626
|
+
function pushFunctionCall(name, args) {
|
|
627
|
+
let argsJson = "{}";
|
|
628
|
+
try {
|
|
629
|
+
argsJson = JSON.stringify(args ?? {});
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
argsJson = "{}";
|
|
633
|
+
}
|
|
634
|
+
const last = calls[calls.length - 1];
|
|
635
|
+
if (last && last.name === name && last.argsJson === "{}" && argsJson !== "{}") {
|
|
636
|
+
last.argsJson = argsJson;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Merge object fragments for a repeated name (tolerance for split args).
|
|
640
|
+
if (last && last.name === name) {
|
|
641
|
+
try {
|
|
642
|
+
const a = JSON.parse(last.argsJson);
|
|
643
|
+
const b = typeof args === "object" && args !== null
|
|
644
|
+
? args
|
|
645
|
+
: {};
|
|
646
|
+
last.argsJson = JSON.stringify({ ...a, ...b });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// fall through to new slot
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
calls.push({ name, argsJson, index: calls.length });
|
|
654
|
+
try {
|
|
655
|
+
opts?.onToolDelta?.(name, calls.length - 1);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// ignore
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
opts?.onPhase?.("tool", name);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// ignore
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
for (const { data } of events) {
|
|
668
|
+
if (data === "[DONE]") {
|
|
669
|
+
sawDone = true;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (!data)
|
|
673
|
+
continue;
|
|
674
|
+
let evt;
|
|
675
|
+
try {
|
|
676
|
+
evt = JSON.parse(data);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
sawData = true;
|
|
682
|
+
const o = evt;
|
|
683
|
+
const candidates = o["candidates"];
|
|
684
|
+
if (Array.isArray(candidates)) {
|
|
685
|
+
for (const c of candidates) {
|
|
686
|
+
const content = c["content"];
|
|
687
|
+
const parts = content?.["parts"];
|
|
688
|
+
if (!Array.isArray(parts))
|
|
689
|
+
continue;
|
|
690
|
+
for (const p of parts) {
|
|
691
|
+
if (typeof p["text"] === "string" && p["text"].length > 0) {
|
|
692
|
+
text += p["text"];
|
|
693
|
+
try {
|
|
694
|
+
opts?.onPhase?.("streaming");
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// ignore
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
opts?.onToken?.(text);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// ignore
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const fc = p["functionCall"];
|
|
707
|
+
if (fc && typeof fc["name"] === "string" && fc["name"]) {
|
|
708
|
+
pushFunctionCall(fc["name"], fc["args"]);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const um = o["usageMetadata"];
|
|
714
|
+
if (um) {
|
|
715
|
+
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"]);
|
|
716
|
+
const total = finiteCount(um["totalTokenCount"]);
|
|
717
|
+
const merged = mergeUsage(usage, hit, { total });
|
|
718
|
+
if (merged !== undefined)
|
|
719
|
+
usage = merged;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Tolerance: no SSE data lines -> single-shot :generateContent JSON.
|
|
723
|
+
if (!sawData) {
|
|
724
|
+
const candidate = rawText.trim();
|
|
725
|
+
if (candidate.length > 0) {
|
|
726
|
+
try {
|
|
727
|
+
const data = JSON.parse(candidate);
|
|
728
|
+
if (Array.isArray(data["candidates"]))
|
|
729
|
+
return parseGeminiJson(data);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// fall through
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
throw new Error("Truncated stream from model (connection aborted before [DONE]).");
|
|
736
|
+
}
|
|
737
|
+
// Gemini SSE streams do not always send [DONE]; a stream that produced
|
|
738
|
+
// data and ended cleanly is accepted. An empty stream is truncated.
|
|
739
|
+
void sawDone;
|
|
740
|
+
const tool_calls = [];
|
|
741
|
+
for (let i = 0; i < calls.length; i++) {
|
|
742
|
+
const c = calls[i];
|
|
743
|
+
if (!c.name)
|
|
744
|
+
continue; // nameless-drop parity
|
|
745
|
+
tool_calls.push({
|
|
746
|
+
id: `gemini-${i}`,
|
|
747
|
+
type: "function",
|
|
748
|
+
function: { name: c.name, arguments: c.argsJson },
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (tool_calls.length === 0 && text.trim() === "") {
|
|
752
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
753
|
+
}
|
|
754
|
+
const result = {
|
|
755
|
+
content: text.length > 0 ? text : null,
|
|
756
|
+
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
|
|
757
|
+
};
|
|
758
|
+
if (usage !== undefined)
|
|
759
|
+
result.usage = usage;
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
// Non-streaming :generateContent JSON:
|
|
763
|
+
// {candidates:[{content:{parts:[{text},{functionCall:{name,args}}]}}], usageMetadata}
|
|
764
|
+
export function parseGeminiJson(data) {
|
|
765
|
+
const o = data;
|
|
766
|
+
let text = "";
|
|
767
|
+
const calls = [];
|
|
768
|
+
const candidates = o["candidates"];
|
|
769
|
+
if (Array.isArray(candidates)) {
|
|
770
|
+
for (const c of candidates) {
|
|
771
|
+
const content = c["content"];
|
|
772
|
+
const parts = content?.["parts"];
|
|
773
|
+
if (!Array.isArray(parts))
|
|
774
|
+
continue;
|
|
775
|
+
for (const p of parts) {
|
|
776
|
+
if (typeof p["text"] === "string")
|
|
777
|
+
text += p["text"];
|
|
778
|
+
const fc = p["functionCall"];
|
|
779
|
+
if (fc && typeof fc["name"] === "string" && fc["name"]) {
|
|
780
|
+
let argsJson = "{}";
|
|
781
|
+
try {
|
|
782
|
+
argsJson = JSON.stringify(fc["args"] ?? {});
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
argsJson = "{}";
|
|
786
|
+
}
|
|
787
|
+
calls.push({
|
|
788
|
+
id: `gemini-${calls.length}`,
|
|
789
|
+
type: "function",
|
|
790
|
+
function: { name: fc["name"], arguments: argsJson },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (calls.length === 0 && text.trim() === "") {
|
|
797
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
798
|
+
}
|
|
799
|
+
const result = {
|
|
800
|
+
content: text.length > 0 ? text : null,
|
|
801
|
+
tool_calls: calls.length > 0 ? calls : undefined,
|
|
802
|
+
};
|
|
803
|
+
const um = o["usageMetadata"];
|
|
804
|
+
if (um) {
|
|
805
|
+
const hit = openAIUsage(um["promptTokenCount"], um["candidatesTokenCount"]);
|
|
806
|
+
const total = finiteCount(um["totalTokenCount"]);
|
|
807
|
+
const merged = mergeUsage(undefined, hit, { total });
|
|
808
|
+
if (merged !== undefined)
|
|
809
|
+
result.usage = merged;
|
|
810
|
+
}
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
// ---- Models-list parsing per kind (pure; ANY failure -> fallback) ----
|
|
814
|
+
function entryId(entry) {
|
|
815
|
+
if (typeof entry === "string")
|
|
816
|
+
return entry || null;
|
|
817
|
+
if (typeof entry !== "object" || entry === null)
|
|
818
|
+
return null;
|
|
819
|
+
const e = entry;
|
|
820
|
+
const id = e["id"] ?? e["name"];
|
|
821
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
822
|
+
}
|
|
823
|
+
// OpenAI-kind for NON-zen providers: accept every listed id.
|
|
824
|
+
export function parseOpenAIModelsList(data, fallback) {
|
|
825
|
+
try {
|
|
826
|
+
const entries = Array.isArray(data)
|
|
827
|
+
? data
|
|
828
|
+
: data?.data;
|
|
829
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
830
|
+
return [...fallback];
|
|
831
|
+
const picked = [];
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
const id = entryId(entry);
|
|
834
|
+
if (id)
|
|
835
|
+
picked.push(id);
|
|
836
|
+
}
|
|
837
|
+
return picked.length > 0 ? picked : [...fallback];
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return [...fallback];
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
export function parseAnthropicModelsList(data, fallback) {
|
|
844
|
+
try {
|
|
845
|
+
const entries = Array.isArray(data)
|
|
846
|
+
? data
|
|
847
|
+
: data?.data;
|
|
848
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
849
|
+
return [...fallback];
|
|
850
|
+
const picked = [];
|
|
851
|
+
for (const entry of entries) {
|
|
852
|
+
const id = entryId(entry);
|
|
853
|
+
if (id)
|
|
854
|
+
picked.push(id);
|
|
855
|
+
}
|
|
856
|
+
return picked.length > 0 ? picked : [...fallback];
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return [...fallback];
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
export function parseGeminiModelsList(data, fallback) {
|
|
863
|
+
try {
|
|
864
|
+
const o = data;
|
|
865
|
+
const entries = Array.isArray(o?.models) ? o.models : null;
|
|
866
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
867
|
+
return [...fallback];
|
|
868
|
+
const picked = [];
|
|
869
|
+
for (const entry of entries) {
|
|
870
|
+
let id = entryId(entry);
|
|
871
|
+
if (id && id.startsWith("models/"))
|
|
872
|
+
id = id.slice("models/".length);
|
|
873
|
+
if (id)
|
|
874
|
+
picked.push(id);
|
|
875
|
+
}
|
|
876
|
+
return picked.length > 0 ? picked : [...fallback];
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return [...fallback];
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// ---- Key validation (cheap GET per kind; mocked in tests, never live) ----
|
|
883
|
+
export async function validateProviderKey(id, apiKey, storedBaseURL) {
|
|
884
|
+
if (!apiKey)
|
|
885
|
+
return { ok: false, error: "missing API key" };
|
|
886
|
+
const def = getProvider(id);
|
|
887
|
+
if (!def)
|
|
888
|
+
return { ok: false, error: `unknown provider: ${id}` };
|
|
889
|
+
try {
|
|
890
|
+
if (id === "anthropic") {
|
|
891
|
+
const res = await fetch("https://api.anthropic.com/v1/models", {
|
|
892
|
+
headers: {
|
|
893
|
+
"x-api-key": apiKey,
|
|
894
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
if (res.ok)
|
|
898
|
+
return { ok: true };
|
|
899
|
+
return { ok: false, error: `Anthropic HTTP ${res.status}` };
|
|
900
|
+
}
|
|
901
|
+
if (id === "google-gemini") {
|
|
902
|
+
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models", { headers: { "x-goog-api-key": apiKey } });
|
|
903
|
+
if (res.ok)
|
|
904
|
+
return { ok: true };
|
|
905
|
+
return { ok: false, error: `Gemini HTTP ${res.status}` };
|
|
906
|
+
}
|
|
907
|
+
// OpenAI-kind: GET {base}/models with Bearer.
|
|
908
|
+
const url = modelsUrlForProvider(id, storedBaseURL);
|
|
909
|
+
const headers = {
|
|
910
|
+
Authorization: `Bearer ${apiKey}`,
|
|
911
|
+
};
|
|
912
|
+
const res = await fetch(url, { headers });
|
|
913
|
+
if (res.ok)
|
|
914
|
+
return { ok: true };
|
|
915
|
+
const label = id === "opencode-zen"
|
|
916
|
+
? "Zen"
|
|
917
|
+
: (def.name ?? String(id));
|
|
918
|
+
return { ok: false, error: `${label} HTTP ${res.status}` };
|
|
919
|
+
}
|
|
920
|
+
catch (e) {
|
|
921
|
+
return {
|
|
922
|
+
ok: false,
|
|
923
|
+
error: e instanceof Error ? e.message : String(e),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
}
|