atom-agent 1.5.0 → 1.5.1
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 +30 -0
- package/README.md +1 -0
- package/dist/adapters.js +376 -8
- package/dist/agent/loop.js +39 -4
- package/dist/providers.js +11 -3
- package/dist/telemetry.js +53 -4
- package/dist/zen.js +417 -30
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.1 — 2026-09-12
|
|
4
|
+
|
|
5
|
+
- OpenCode Zen free tiers all usable (`src/adapters.ts`, `src/zen.ts`,
|
|
6
|
+
`src/providers.ts`): every request now carries the official-client
|
|
7
|
+
identity (`User-Agent: opencode/*` plus `x-opencode-session` /
|
|
8
|
+
`x-opencode-request`), clearing the upstream `429 FreeUsageLimitError`
|
|
9
|
+
and `400 MissingSessionID` gates for anonymous and keyed calls alike.
|
|
10
|
+
Suites in `tests/zen-headers.test.ts`
|
|
11
|
+
- New Responses-family transport (`src/adapters.ts`,
|
|
12
|
+
`src/zen.ts`): `muse-spark-1.2` / `muse-spark-1.3` (including the free
|
|
13
|
+
contributor tiers) ride Zen's `/responses` endpoint with full
|
|
14
|
+
retry/hook/compaction/media parity — tool calls, streaming tokens,
|
|
15
|
+
reasoning-effort mapping, and `incomplete` → `truncated` handling.
|
|
16
|
+
Routing is automatic by model family; every other provider is
|
|
17
|
+
byte-identical. Suites in `tests/zen-responses.test.ts`
|
|
18
|
+
- Picker lists all eight free Zen models (`FALLBACK_MODELS` in
|
|
19
|
+
`src/zen.ts`, `fallbackModels` in `src/providers.ts`): `big-pickle`,
|
|
20
|
+
`mimo-v2.5-free`, `ling-3.0-flash-fin-free`, `nemotron-3-ultra-free`,
|
|
21
|
+
`nemotron-3.5-lightning-free`, `deepseek-v4-flash-free`,
|
|
22
|
+
`muse-spark-1.3-contributor-free`, `muse-spark-1.2-contributor-free`
|
|
23
|
+
- Loop/telemetry phase timing (`src/agent/loop.ts`,
|
|
24
|
+
`src/agent/types.ts`, `src/telemetry.ts`): per-turn model vs tool
|
|
25
|
+
totals, slowest model call, and truncation notices surfaced through
|
|
26
|
+
`LoopStats`; telemetry schema v2 with v1 back-compat, failed-turn
|
|
27
|
+
partial replies preserved for post-mortem. Accuracy fixes: each failed
|
|
28
|
+
POST and each truncation counts exactly once; timeline events fire for
|
|
29
|
+
failed turns only
|
|
30
|
+
- Housekeeping: downloaded third-party skills (`.agents/skills/`,
|
|
31
|
+
`skills-lock.json`) are now gitignored
|
|
32
|
+
|
|
3
33
|
## 1.5.0 — 2026-09-12
|
|
4
34
|
|
|
5
35
|
- Local agentic Web UI (`src/web/server.ts`, `src/web/runtime.ts`,
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
> A fast, transparent AI coding agent for your terminal.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/atom-agent)
|
|
6
|
+
[](https://www.npmjs.com/package/atom-agent)
|
|
6
7
|
[](https://nodejs.org/)
|
|
7
8
|
[](LICENSE)
|
|
8
9
|
|
package/dist/adapters.js
CHANGED
|
@@ -10,6 +10,52 @@ import { chatToolDefinitions } from "./tools.js";
|
|
|
10
10
|
import { getProvider, modelsUrlForProvider, } from "./providers.js";
|
|
11
11
|
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
12
12
|
export const ANTHROPIC_MAX_TOKENS = 4096;
|
|
13
|
+
// ---- OpenCode Zen client identity (free `*-free` promo models) ----
|
|
14
|
+
//
|
|
15
|
+
// Zen's free pool is gated on official-client identity, verified live
|
|
16
|
+
// 2026-09-12 against https://opencode.ai/zen/v1/chat/completions:
|
|
17
|
+
// - `User-Agent: opencode/*` alone → 429 FreeUsageLimitError.
|
|
18
|
+
// - UA alone (no session) → 400 MissingSessionID
|
|
19
|
+
// ("OpenCode's free tier can only be used in OpenCode").
|
|
20
|
+
// - UA + `x-opencode-session: ses_…` → 200 (any value passes; the check
|
|
21
|
+
// is presence-only, the id needs no server-side registration).
|
|
22
|
+
// - `x-opencode-client` / `x-opencode-project` are NOT required (probed).
|
|
23
|
+
// Paid models are not gated.
|
|
24
|
+
// Lives here (not zen.ts) so both zen.ts and the key-validation path below
|
|
25
|
+
// share one constant without a runtime import cycle.
|
|
26
|
+
export const ZEN_CLIENT_UA = "opencode/1.18.16";
|
|
27
|
+
const ZEN_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
28
|
+
function zenRandomSuffix(length = 24) {
|
|
29
|
+
let out = "";
|
|
30
|
+
for (let i = 0; i < length; i++) {
|
|
31
|
+
out += ZEN_ID_ALPHABET[Math.floor(Math.random() * ZEN_ID_ALPHABET.length)];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
// One stable session per process (mirrors one client conversation; avoids
|
|
36
|
+
// minting a new server-side session per keystroke). Reset only on restart.
|
|
37
|
+
let cachedZenSessionId = null;
|
|
38
|
+
/** Stable `ses_…` id for this process (generated once, lazily). */
|
|
39
|
+
export function zenSessionId() {
|
|
40
|
+
if (!cachedZenSessionId)
|
|
41
|
+
cachedZenSessionId = `ses_${zenRandomSuffix()}`;
|
|
42
|
+
return cachedZenSessionId;
|
|
43
|
+
}
|
|
44
|
+
/** Fresh `msg_…` id per POST (mirrors one id per client message). */
|
|
45
|
+
export function zenRequestId() {
|
|
46
|
+
return `msg_${zenRandomSuffix()}`;
|
|
47
|
+
}
|
|
48
|
+
// Base headers for any Zen HTTP call (chat POST, models GET, key check).
|
|
49
|
+
// Anonymous-capable: omits Authorization when no key (never `Bearer `).
|
|
50
|
+
export function zenHeaders(apiKey, opts) {
|
|
51
|
+
return {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
54
|
+
"User-Agent": ZEN_CLIENT_UA,
|
|
55
|
+
"x-opencode-session": opts?.sessionId ?? zenSessionId(),
|
|
56
|
+
"x-opencode-request": opts?.requestId ?? zenRequestId(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
13
59
|
// ---- Reasoning-effort mappings (one /effort knob, three wire shapes) ----
|
|
14
60
|
//
|
|
15
61
|
// OpenAI-chat kind sends `reasoning_effort` verbatim (no mapping needed).
|
|
@@ -84,7 +130,7 @@ function parseArgsObject(raw) {
|
|
|
84
130
|
}
|
|
85
131
|
}
|
|
86
132
|
import { ephemeralBreakpoint, assemblePrefix } from "./prompt-cache.js";
|
|
87
|
-
import { hasMediaRefs, resolveMediaRefs, stripMedia, } from "./media.js";
|
|
133
|
+
import { hasMediaRefs, lowerOpenAIContent, resolveMediaRefs, stripMedia, } from "./media.js";
|
|
88
134
|
export function buildAnthropicBody(history, model, opts) {
|
|
89
135
|
const systems = [];
|
|
90
136
|
const messages = [];
|
|
@@ -443,10 +489,15 @@ export function buildGeminiBody(history, _model, opts) {
|
|
|
443
489
|
//
|
|
444
490
|
// Budget: env ATOM_STALL_TIMEOUT_MS when a finite value > 0 (max-clamped to
|
|
445
491
|
// 5min; an explicitly tiny value is the operator's choice, and lets tests
|
|
446
|
-
// use millisecond budgets), else the 60s default.
|
|
492
|
+
// use millisecond budgets), else the 60s default. Header budget (time to
|
|
493
|
+
// first `data:` line) defaults to 300s like opencode's headerTimeout —
|
|
494
|
+
// queued free-tier requests sit headerless for minutes while chunk stalls
|
|
495
|
+
// (mid-generation silence) trip much sooner. The hung read is left to
|
|
447
496
|
// settle — callers cancel/release the reader on the way out as before.
|
|
448
497
|
export const DEFAULT_SSE_STALL_TIMEOUT_MS = 60_000;
|
|
449
498
|
export const MAX_SSE_STALL_TIMEOUT_MS = 300_000;
|
|
499
|
+
export const DEFAULT_SSE_HEADER_TIMEOUT_MS = 300_000;
|
|
500
|
+
export const MAX_SSE_HEADER_TIMEOUT_MS = 300_000;
|
|
450
501
|
export function sseStallTimeoutMs() {
|
|
451
502
|
const raw = process.env.ATOM_STALL_TIMEOUT_MS;
|
|
452
503
|
if (raw !== undefined) {
|
|
@@ -456,6 +507,15 @@ export function sseStallTimeoutMs() {
|
|
|
456
507
|
}
|
|
457
508
|
return DEFAULT_SSE_STALL_TIMEOUT_MS;
|
|
458
509
|
}
|
|
510
|
+
export function sseHeaderTimeoutMs() {
|
|
511
|
+
const raw = process.env.ATOM_HEADER_TIMEOUT_MS;
|
|
512
|
+
if (raw !== undefined) {
|
|
513
|
+
const n = Number(raw.trim());
|
|
514
|
+
if (Number.isFinite(n) && n > 0)
|
|
515
|
+
return Math.min(Math.floor(n), MAX_SSE_HEADER_TIMEOUT_MS);
|
|
516
|
+
}
|
|
517
|
+
return DEFAULT_SSE_HEADER_TIMEOUT_MS;
|
|
518
|
+
}
|
|
459
519
|
export function isStallError(e) {
|
|
460
520
|
return e instanceof Error && e.message.startsWith("Truncated stream from model (stall:");
|
|
461
521
|
}
|
|
@@ -500,7 +560,9 @@ async function collectSSEText(res) {
|
|
|
500
560
|
tail = joined.slice(-8);
|
|
501
561
|
};
|
|
502
562
|
const throwIfDataStalled = () => {
|
|
503
|
-
|
|
563
|
+
// Header phase (no data yet) gets the generous header budget; once real
|
|
564
|
+
// SSE traffic exists the tighter chunk budget applies.
|
|
565
|
+
const budget = rawText.length === 0 ? sseHeaderTimeoutMs() : sseStallTimeoutMs();
|
|
504
566
|
if (Date.now() - lastDataAt > budget) {
|
|
505
567
|
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
506
568
|
}
|
|
@@ -519,7 +581,9 @@ async function collectSSEText(res) {
|
|
|
519
581
|
catch (e) {
|
|
520
582
|
if (isStallError(e)) {
|
|
521
583
|
// Free the dead socket on the way out, then surface the stall
|
|
522
|
-
// unchanged
|
|
584
|
+
// unchanged. Stalls are retryable upstream (one retry when data
|
|
585
|
+
// was already seen, full backoff for header stalls) — never
|
|
586
|
+
// swallowed here.
|
|
523
587
|
try {
|
|
524
588
|
await reader.cancel?.();
|
|
525
589
|
}
|
|
@@ -1145,6 +1209,307 @@ export function parseGeminiJson(data) {
|
|
|
1145
1209
|
}
|
|
1146
1210
|
return result;
|
|
1147
1211
|
}
|
|
1212
|
+
// Extract plain text from a history content value (string, or an OpenAI
|
|
1213
|
+
// parts array — text parts concatenate, anything else is skipped).
|
|
1214
|
+
function responsesTextOf(content) {
|
|
1215
|
+
if (typeof content === "string")
|
|
1216
|
+
return content;
|
|
1217
|
+
if (!Array.isArray(content))
|
|
1218
|
+
return "";
|
|
1219
|
+
let out = "";
|
|
1220
|
+
for (const p of content) {
|
|
1221
|
+
if (typeof p !== "object" || p === null)
|
|
1222
|
+
continue;
|
|
1223
|
+
if (typeof p["text"] === "string")
|
|
1224
|
+
out += p["text"];
|
|
1225
|
+
}
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
// Convert one user content value to Responses input parts: plain strings
|
|
1229
|
+
// ride as string content; descriptor-bearing strings lower through
|
|
1230
|
+
// lowerOpenAIContent into input_text/input_image parts (strip mode: prose
|
|
1231
|
+
// markers as a single input_text part).
|
|
1232
|
+
function responsesUserContent(content, strip) {
|
|
1233
|
+
const lowered = lowerOpenAIContent("user", content, strip ? "strip" : "send");
|
|
1234
|
+
if (typeof lowered === "string")
|
|
1235
|
+
return lowered;
|
|
1236
|
+
const parts = [];
|
|
1237
|
+
for (const p of lowered) {
|
|
1238
|
+
if (p["type"] === "text" && typeof p["text"] === "string") {
|
|
1239
|
+
parts.push({ type: "input_text", text: p["text"] });
|
|
1240
|
+
}
|
|
1241
|
+
else if (p["type"] === "image_url" &&
|
|
1242
|
+
typeof p["image_url"] === "object" &&
|
|
1243
|
+
p["image_url"] !== null &&
|
|
1244
|
+
typeof p["image_url"]["url"] === "string") {
|
|
1245
|
+
parts.push({
|
|
1246
|
+
type: "input_image",
|
|
1247
|
+
image_url: p["image_url"]["url"],
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return parts.length > 0 ? parts : "";
|
|
1252
|
+
}
|
|
1253
|
+
export function buildResponsesBody(history, model, opts) {
|
|
1254
|
+
const strip = opts?.stripMedia === true;
|
|
1255
|
+
const systems = [];
|
|
1256
|
+
const input = [];
|
|
1257
|
+
for (const m of history) {
|
|
1258
|
+
if (m.role === "system") {
|
|
1259
|
+
const lowered = lowerOpenAIContent("system", m.content, strip ? "strip" : "send");
|
|
1260
|
+
const text = responsesTextOf(lowered);
|
|
1261
|
+
if (text)
|
|
1262
|
+
systems.push(text);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (m.role === "user") {
|
|
1266
|
+
input.push({ role: "user", content: responsesUserContent(m.content, strip) });
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (m.role === "tool") {
|
|
1270
|
+
input.push({
|
|
1271
|
+
type: "function_call_output",
|
|
1272
|
+
call_id: m.tool_call_id,
|
|
1273
|
+
output: strip ? stripMedia(m.content) : m.content,
|
|
1274
|
+
});
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
// assistant: text rides as an assistant message, tool_calls as
|
|
1278
|
+
// function_call items (arguments stay a JSON string, as the API sends).
|
|
1279
|
+
const am = m;
|
|
1280
|
+
if (typeof am.content === "string" && am.content.length > 0) {
|
|
1281
|
+
input.push({ role: "assistant", content: am.content });
|
|
1282
|
+
}
|
|
1283
|
+
for (const tc of am.tool_calls ?? []) {
|
|
1284
|
+
input.push({
|
|
1285
|
+
type: "function_call",
|
|
1286
|
+
call_id: tc.id,
|
|
1287
|
+
name: tc.function.name,
|
|
1288
|
+
arguments: tc.function.arguments || "{}",
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
const body = { model, input };
|
|
1293
|
+
if (systems.length > 0)
|
|
1294
|
+
body.instructions = systems.join("\n\n");
|
|
1295
|
+
// Compaction path (includeTools:false) omits `tools` entirely — same
|
|
1296
|
+
// contract as every other kind ("no `tools` key", asserted in tests).
|
|
1297
|
+
if (opts?.includeTools !== false) {
|
|
1298
|
+
body.tools = toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
|
|
1299
|
+
type: "function",
|
|
1300
|
+
name: t.function.name,
|
|
1301
|
+
description: t.function.description,
|
|
1302
|
+
parameters: t.function.parameters,
|
|
1303
|
+
}));
|
|
1304
|
+
}
|
|
1305
|
+
return body;
|
|
1306
|
+
}
|
|
1307
|
+
// Server-authoritative effort rejection for the Responses `reasoning`
|
|
1308
|
+
// knob: a 400 naming it means this model/deployment has no such control.
|
|
1309
|
+
// Narrow (reasoning only) so unrelated 400s keep failing loudly.
|
|
1310
|
+
export function isResponsesEffortRejection(errorText) {
|
|
1311
|
+
return /reasoning/i.test(errorText);
|
|
1312
|
+
}
|
|
1313
|
+
// Build a ChatResult from one Responses `response` object (shared by the
|
|
1314
|
+
// streaming terminal event and the non-streaming JSON body):
|
|
1315
|
+
// output[] message items contribute output_text (refusals surface as text
|
|
1316
|
+
// so the turn never goes empty silently); function_call items become tool
|
|
1317
|
+
// calls (call_id first, id fallback — verified live shape carries both).
|
|
1318
|
+
// status "incomplete" (e.g. max_output_tokens cut the response) sets
|
|
1319
|
+
// `truncated` instead of throwing: the loop fails carried calls inline and
|
|
1320
|
+
// continues, same contract as chat finish_reason "length".
|
|
1321
|
+
export function parseResponsesObject(data) {
|
|
1322
|
+
const o = (typeof data === "object" && data !== null ? data : {});
|
|
1323
|
+
const output = o["output"];
|
|
1324
|
+
let text = "";
|
|
1325
|
+
const calls = [];
|
|
1326
|
+
if (Array.isArray(output)) {
|
|
1327
|
+
for (const item of output) {
|
|
1328
|
+
if (item["type"] === "message") {
|
|
1329
|
+
const content = item["content"];
|
|
1330
|
+
if (Array.isArray(content)) {
|
|
1331
|
+
for (const part of content) {
|
|
1332
|
+
if (part["type"] === "output_text" && typeof part["text"] === "string") {
|
|
1333
|
+
text += part["text"];
|
|
1334
|
+
}
|
|
1335
|
+
else if (part["type"] === "refusal" && typeof part["refusal"] === "string") {
|
|
1336
|
+
text += part["refusal"];
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
else if (item["type"] === "function_call") {
|
|
1342
|
+
const name = typeof item["name"] === "string" ? item["name"] : "";
|
|
1343
|
+
if (!name)
|
|
1344
|
+
continue; // nameless-drop parity with every other kind
|
|
1345
|
+
const callId = typeof item["call_id"] === "string" && item["call_id"]
|
|
1346
|
+
? item["call_id"]
|
|
1347
|
+
: typeof item["id"] === "string" && item["id"]
|
|
1348
|
+
? item["id"]
|
|
1349
|
+
: `responses-${calls.length}`;
|
|
1350
|
+
let args = "{}";
|
|
1351
|
+
const raw = item["arguments"];
|
|
1352
|
+
if (typeof raw === "string")
|
|
1353
|
+
args = raw;
|
|
1354
|
+
else if (typeof raw === "object" && raw !== null) {
|
|
1355
|
+
try {
|
|
1356
|
+
args = JSON.stringify(raw);
|
|
1357
|
+
}
|
|
1358
|
+
catch {
|
|
1359
|
+
args = "{}";
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
calls.push({ id: callId, type: "function", function: { name, arguments: args } });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (calls.length === 0 && text.trim() === "") {
|
|
1367
|
+
throw new Error("Empty reply from model (unexpected payload).");
|
|
1368
|
+
}
|
|
1369
|
+
const result = {
|
|
1370
|
+
content: text.length > 0 ? text : null,
|
|
1371
|
+
tool_calls: calls.length > 0 ? calls : undefined,
|
|
1372
|
+
};
|
|
1373
|
+
if (o["status"] === "incomplete")
|
|
1374
|
+
result.truncated = true;
|
|
1375
|
+
const usage = o["usage"];
|
|
1376
|
+
if (usage && typeof usage === "object") {
|
|
1377
|
+
const details = usage["input_tokens_details"];
|
|
1378
|
+
const hit = openAIUsage(usage["input_tokens"], usage["output_tokens"], {
|
|
1379
|
+
read: details && typeof details === "object"
|
|
1380
|
+
? details["cached_tokens"]
|
|
1381
|
+
: undefined,
|
|
1382
|
+
});
|
|
1383
|
+
if (hit)
|
|
1384
|
+
result.usage = hit;
|
|
1385
|
+
}
|
|
1386
|
+
const effort = o["reasoning"]?.["effort"];
|
|
1387
|
+
if (typeof effort === "string" && effort.trim().length > 0) {
|
|
1388
|
+
const label = effort.trim();
|
|
1389
|
+
result.reasoning = label.length > 24 ? `${label.slice(0, 24)}…` : label;
|
|
1390
|
+
}
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
export async function readResponsesSSEMessage(res, opts) {
|
|
1394
|
+
const { rawText, events } = await collectSSEText(res);
|
|
1395
|
+
let fullText = "";
|
|
1396
|
+
let sawData = false;
|
|
1397
|
+
let streamingAnnounced = false;
|
|
1398
|
+
function announce(kind, name) {
|
|
1399
|
+
if (kind === "streaming" && !streamingAnnounced) {
|
|
1400
|
+
streamingAnnounced = true;
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
opts?.onPhase?.(kind, name ?? "");
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
// ignore observer errors
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
const slots = new Map();
|
|
1410
|
+
function slotAt(index) {
|
|
1411
|
+
let slot = slots.get(index);
|
|
1412
|
+
if (!slot) {
|
|
1413
|
+
slot = { callId: "", name: "", args: "" };
|
|
1414
|
+
slots.set(index, slot);
|
|
1415
|
+
}
|
|
1416
|
+
return slot;
|
|
1417
|
+
}
|
|
1418
|
+
for (const { event, data } of events) {
|
|
1419
|
+
if (!data || data === "[DONE]")
|
|
1420
|
+
continue;
|
|
1421
|
+
let evt;
|
|
1422
|
+
try {
|
|
1423
|
+
evt = JSON.parse(data);
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
continue; // malformed JSON data line: skip, never crash
|
|
1427
|
+
}
|
|
1428
|
+
sawData = true;
|
|
1429
|
+
const o = evt;
|
|
1430
|
+
const type = typeof o["type"] === "string" ? o["type"] : "";
|
|
1431
|
+
if (type === "error") {
|
|
1432
|
+
const msg = typeof o["error"]?.["message"] === "string"
|
|
1433
|
+
? o["error"]["message"]
|
|
1434
|
+
: typeof o["message"] === "string"
|
|
1435
|
+
? o["message"]
|
|
1436
|
+
: "unknown streaming error";
|
|
1437
|
+
throw new Error(`Model error: ${msg}`.slice(0, 300));
|
|
1438
|
+
}
|
|
1439
|
+
if (type === "response.output_text.delta" && typeof o["delta"] === "string") {
|
|
1440
|
+
const frag = o["delta"];
|
|
1441
|
+
if (frag.length > 0) {
|
|
1442
|
+
fullText += frag;
|
|
1443
|
+
announce("streaming");
|
|
1444
|
+
try {
|
|
1445
|
+
opts?.onToken?.(fullText);
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
// ignore observer errors
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
if (type === "response.output_item.added") {
|
|
1454
|
+
const item = o["item"];
|
|
1455
|
+
if (item && item["type"] === "function_call") {
|
|
1456
|
+
const index = typeof o["output_index"] === "number" ? o["output_index"] : 0;
|
|
1457
|
+
const slot = slotAt(index);
|
|
1458
|
+
if (typeof item["call_id"] === "string")
|
|
1459
|
+
slot.callId = item["call_id"];
|
|
1460
|
+
if (typeof item["name"] === "string" && item["name"]) {
|
|
1461
|
+
slot.name = item["name"];
|
|
1462
|
+
try {
|
|
1463
|
+
opts?.onToolDelta?.(slot.name, index);
|
|
1464
|
+
}
|
|
1465
|
+
catch {
|
|
1466
|
+
// ignore
|
|
1467
|
+
}
|
|
1468
|
+
announce("tool", slot.name);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (type === "response.function_call_arguments.delta" && typeof o["delta"] === "string") {
|
|
1474
|
+
const index = typeof o["output_index"] === "number" ? o["output_index"] : 0;
|
|
1475
|
+
slotAt(index).args += o["delta"];
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
// Terminal states carry the authoritative response object: the final
|
|
1479
|
+
// result is built from it (never from accumulated deltas), so delta
|
|
1480
|
+
// drift cannot corrupt tool arguments. `failed` throws; `completed`
|
|
1481
|
+
// and `incomplete` return (incomplete flags truncated downstream).
|
|
1482
|
+
if (type === "response.completed" || type === "response.incomplete") {
|
|
1483
|
+
return parseResponsesObject(o["response"]);
|
|
1484
|
+
}
|
|
1485
|
+
if (type === "response.failed") {
|
|
1486
|
+
const resp = o["response"];
|
|
1487
|
+
const err = resp?.["error"];
|
|
1488
|
+
const msg = (typeof err?.["message"] === "string" ? err["message"] : null) ??
|
|
1489
|
+
"response failed";
|
|
1490
|
+
throw new Error(`Model error: ${msg}`.slice(0, 300));
|
|
1491
|
+
}
|
|
1492
|
+
// created / in_progress / content_part.* / output_item.done / ping:
|
|
1493
|
+
// no model output — ignored (pings must not extend stall budgets, and
|
|
1494
|
+
// collectSSEText already only counts `data:` lines for data-silence).
|
|
1495
|
+
}
|
|
1496
|
+
// Tolerance: a body with no SSE data lines is really single-shot JSON
|
|
1497
|
+
// (the non-streaming response object).
|
|
1498
|
+
if (!sawData) {
|
|
1499
|
+
const candidate = rawText.trim();
|
|
1500
|
+
if (candidate.length > 0) {
|
|
1501
|
+
try {
|
|
1502
|
+
return parseResponsesObject(JSON.parse(candidate));
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
if (e instanceof Error && e.message.startsWith("Empty reply"))
|
|
1506
|
+
throw e;
|
|
1507
|
+
// not a response object either -> truncation error below
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
throw new Error("Truncated stream from model (connection aborted before response.completed).");
|
|
1512
|
+
}
|
|
1148
1513
|
// ---- Models-list parsing per kind (pure; ANY failure -> fallback) ----
|
|
1149
1514
|
function entryId(entry) {
|
|
1150
1515
|
if (typeof entry === "string")
|
|
@@ -1239,11 +1604,14 @@ export async function validateProviderKey(id, apiKey, storedBaseURL) {
|
|
|
1239
1604
|
return { ok: true };
|
|
1240
1605
|
return { ok: false, error: `Gemini HTTP ${res.status}` };
|
|
1241
1606
|
}
|
|
1242
|
-
// OpenAI-kind: GET {base}/models with Bearer.
|
|
1607
|
+
// OpenAI-kind: GET {base}/models with Bearer. Zen also sends the
|
|
1608
|
+
// official-client identity (same gate family as the free-pool UA check).
|
|
1243
1609
|
const url = modelsUrlForProvider(id, storedBaseURL);
|
|
1244
|
-
const headers =
|
|
1245
|
-
|
|
1246
|
-
|
|
1610
|
+
const headers = id === "opencode-zen"
|
|
1611
|
+
? zenHeaders(apiKey)
|
|
1612
|
+
: {
|
|
1613
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1614
|
+
};
|
|
1247
1615
|
const res = await fetch(url, { headers });
|
|
1248
1616
|
if (res.ok)
|
|
1249
1617
|
return { ok: true };
|
package/dist/agent/loop.js
CHANGED
|
@@ -311,6 +311,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
311
311
|
bottleneck = { name, durationMs: Math.floor(durationMs) };
|
|
312
312
|
}
|
|
313
313
|
};
|
|
314
|
+
// v2 phase tracking: model time is measured alongside tool time so a 289s
|
|
315
|
+
// streaming stall is never hidden behind a 94ms glob again.
|
|
316
|
+
let slowestModel = null;
|
|
317
|
+
let modelTotalMs = 0;
|
|
318
|
+
let toolTotalMs = 0;
|
|
319
|
+
let truncationNotices = 0;
|
|
320
|
+
const noteModel = (id, durationMs) => {
|
|
321
|
+
if (!Number.isFinite(durationMs) || durationMs < 0)
|
|
322
|
+
return;
|
|
323
|
+
const floored = Math.floor(durationMs);
|
|
324
|
+
modelTotalMs += floored;
|
|
325
|
+
if (!slowestModel || floored > slowestModel.durationMs) {
|
|
326
|
+
slowestModel = { id, durationMs: floored };
|
|
327
|
+
}
|
|
328
|
+
};
|
|
314
329
|
const finishStats = () => {
|
|
315
330
|
try {
|
|
316
331
|
let endChars = startChars;
|
|
@@ -337,6 +352,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
337
352
|
durationMs: Math.max(0, Date.now() - turnStartMs),
|
|
338
353
|
bottleneck,
|
|
339
354
|
contextGrowthChars: endChars - startChars,
|
|
355
|
+
slowestModel,
|
|
356
|
+
modelTotalMs,
|
|
357
|
+
toolTotalMs,
|
|
358
|
+
dominantPhase: modelTotalMs >= toolTotalMs ? "model" : "tool",
|
|
359
|
+
truncationNotices,
|
|
340
360
|
};
|
|
341
361
|
opts?.onLoopStats?.(stats);
|
|
342
362
|
}
|
|
@@ -399,11 +419,17 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
399
419
|
// A failed POST still records its model call (with the error) so the
|
|
400
420
|
// trace shows what was attempted — the caller still rolls back.
|
|
401
421
|
const modelEnd = Date.now();
|
|
422
|
+
const failedMs = Math.max(0, modelEnd - modelStart);
|
|
423
|
+
noteModel(`model-step-${step}`, failedMs);
|
|
424
|
+
if (typeof e?.message === "string" && e.message.startsWith("Truncated stream")) {
|
|
425
|
+
truncationNotices += 1;
|
|
426
|
+
}
|
|
427
|
+
failures += 1;
|
|
402
428
|
reportModelCall({
|
|
403
429
|
step,
|
|
404
430
|
startedAt: telemetryIso(modelStart),
|
|
405
431
|
endedAt: telemetryIso(modelEnd),
|
|
406
|
-
durationMs:
|
|
432
|
+
durationMs: failedMs,
|
|
407
433
|
usageReported: false,
|
|
408
434
|
toolCallCount: 0,
|
|
409
435
|
finishReason: "error",
|
|
@@ -416,7 +442,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
416
442
|
// wins; every other failure throws exactly as before.
|
|
417
443
|
if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
|
|
418
444
|
emptyRounds += 1;
|
|
419
|
-
failures
|
|
445
|
+
// failures already counted once above for this failed POST.
|
|
420
446
|
history.push({ role: "assistant", content: "" });
|
|
421
447
|
history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
|
|
422
448
|
continue;
|
|
@@ -483,11 +509,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
483
509
|
// actually carried it (usageReported) — never synthesized here.
|
|
484
510
|
const modelEnd = Date.now();
|
|
485
511
|
const callsCount = (msg.tool_calls ?? []).length;
|
|
512
|
+
const okMs = Math.max(0, modelEnd - modelStart);
|
|
513
|
+
noteModel(`model-step-${step}`, okMs);
|
|
514
|
+
if (msg.truncated === true)
|
|
515
|
+
truncationNotices += 1;
|
|
486
516
|
reportModelCall({
|
|
487
517
|
step,
|
|
488
518
|
startedAt: telemetryIso(modelStart),
|
|
489
519
|
endedAt: telemetryIso(modelEnd),
|
|
490
|
-
durationMs:
|
|
520
|
+
durationMs: okMs,
|
|
491
521
|
usage: msg.usage,
|
|
492
522
|
usageReported: msg.usage !== undefined,
|
|
493
523
|
reasoningLabel: msg.reasoning,
|
|
@@ -851,8 +881,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
851
881
|
catch {
|
|
852
882
|
// observer errors never break the loop
|
|
853
883
|
}
|
|
854
|
-
if (typeof durationMs === "number")
|
|
884
|
+
if (typeof durationMs === "number") {
|
|
855
885
|
noteBottleneck(name, durationMs);
|
|
886
|
+
if (Number.isFinite(durationMs) && durationMs >= 0)
|
|
887
|
+
toolTotalMs += Math.floor(durationMs);
|
|
888
|
+
}
|
|
856
889
|
if (!isError && (name === "write" || name === "edit")) {
|
|
857
890
|
filesWritten = true;
|
|
858
891
|
verifiedAfterWrite = false;
|
|
@@ -904,6 +937,8 @@ export async function runLoopWithChat(chatFn, history, opts) {
|
|
|
904
937
|
// step/total-call budgets above keep bounding runaway retries.
|
|
905
938
|
// Truncated-without-calls never reaches here (handled by the turn-end
|
|
906
939
|
// gates above, exactly as before).
|
|
940
|
+
// NOTE: truncationNotices is counted at the POST (success) and stream-death
|
|
941
|
+
// sites above — not here — so each truncated response counts exactly once.
|
|
907
942
|
if (msg.truncated === true) {
|
|
908
943
|
for (let i = 0; i < calls.length; i++) {
|
|
909
944
|
const call = calls[i];
|
package/dist/providers.js
CHANGED
|
@@ -57,9 +57,10 @@ export const PROVIDERS = [
|
|
|
57
57
|
consoleURL: "https://opencode.ai/auth",
|
|
58
58
|
envVars: ["OPENCODE_ZEN_API_KEY"],
|
|
59
59
|
// Task 5: strong tool-reliable default (live-list + docs verified
|
|
60
|
-
// 2026-09-08, see DEFAULT_MODEL in src/zen.ts). Free
|
|
61
|
-
//
|
|
62
|
-
// replace kimi-k2.5 / minimax-m2.5
|
|
60
|
+
// 2026-09-08, see DEFAULT_MODEL in src/zen.ts). Free models ride along
|
|
61
|
+
// as fallbacks, selectable via /model (chat family + responses family;
|
|
62
|
+
// kimi-k2.6 / minimax-m2.7 replace kimi-k2.5 / minimax-m2.5, both
|
|
63
|
+
// deprecated upstream 2026-08-05).
|
|
63
64
|
defaultModel: "deepseek-v4-pro",
|
|
64
65
|
fallbackModels: [
|
|
65
66
|
"deepseek-v4-pro",
|
|
@@ -67,6 +68,13 @@ export const PROVIDERS = [
|
|
|
67
68
|
"glm-5.2",
|
|
68
69
|
"minimax-m2.7",
|
|
69
70
|
"big-pickle",
|
|
71
|
+
"mimo-v2.5-free",
|
|
72
|
+
"ling-3.0-flash-fin-free",
|
|
73
|
+
"nemotron-3-ultra-free",
|
|
74
|
+
"nemotron-3.5-lightning-free",
|
|
75
|
+
"deepseek-v4-flash-free",
|
|
76
|
+
"muse-spark-1.3-contributor-free",
|
|
77
|
+
"muse-spark-1.2-contributor-free",
|
|
70
78
|
],
|
|
71
79
|
notes: "OpenAI-compatible chat/completions. /effort sends reasoning_effort (Auto omits it).",
|
|
72
80
|
cache: {
|