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 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
  [![npm version](https://img.shields.io/npm/v/atom-agent.svg)](https://www.npmjs.com/package/atom-agent)
6
+ [![npm downloads](https://img.shields.io/npm/dm/atom-agent.svg)](https://www.npmjs.com/package/atom-agent)
6
7
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-blue.svg)](https://nodejs.org/)
7
8
  [![License](https://img.shields.io/npm/l/atom-agent.svg)](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. The hung read is left to
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
- const budget = sseStallTimeoutMs();
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 (permanent Truncated contract never retried).
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
- Authorization: `Bearer ${apiKey}`,
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 };
@@ -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: Math.max(0, modelEnd - modelStart),
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 += 1;
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: Math.max(0, modelEnd - modelStart),
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 big-pickle stays
61
- // listed as a fallback, selectable via /model. kimi-k2.6 / minimax-m2.7
62
- // replace kimi-k2.5 / minimax-m2.5 (both deprecated upstream 2026-08-05).
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: {