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/dist/zen.js CHANGED
@@ -8,8 +8,8 @@ import * as path from "node:path";
8
8
  import { MAX_TOOL_STEPS, allToolDefinitions, chatToolDefinitions, getExtensionPromptHints, } from "./tools.js";
9
9
  import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
10
  import { discoverLocalProvider } from "./local-discovery.js";
11
- import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
12
- export { isStallError, readWithStall, sseStallTimeoutMs } from "./adapters.js";
11
+ import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, buildResponsesBody, isResponsesEffortRejection, isStallError, parseResponsesObject, readResponsesSSEMessage, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, zenHeaders, zenRequestId, } from "./adapters.js";
12
+ export { isStallError, readWithStall, sseHeaderTimeoutMs, sseStallTimeoutMs, ZEN_CLIENT_UA, zenHeaders, zenRequestId, zenSessionId } from "./adapters.js";
13
13
  import { KILO_FALLBACK_MODELS, fetchKiloModelsWithStatus, normalizeKiloChatError, } from "./kilo.js";
14
14
  import { splitSystemHead } from "./prompt-cache.js";
15
15
  import { SYSTEM_PROMPT } from "./system.js";
@@ -86,6 +86,18 @@ export function reasoningEffortParam(effort, _model) {
86
86
  return undefined;
87
87
  return normalized;
88
88
  }
89
+ // Wire value for the Responses `reasoning.effort` knob, or undefined when
90
+ // the param must be omitted (Auto, or an unknown effort string). Max maps
91
+ // to high — the deepest widely-supported level (same precedent as the
92
+ // Gemini thinkingLevel mapping in adapters.ts).
93
+ export function responsesEffortParam(effort) {
94
+ const normalized = normalizeEffort(effort);
95
+ if (normalized === "auto")
96
+ return undefined;
97
+ if (normalized === "max")
98
+ return "high";
99
+ return normalized;
100
+ }
89
101
  import { historyHasMedia, isImageRejection, lowerOpenAIContent, } from "./media.js";
90
102
  export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
91
103
  export { openTodoNeedles } from "./agent/gates.js";
@@ -171,9 +183,25 @@ export { isCancelError, LoopCancelledError } from "./agent/loop.js";
171
183
  // Retry-After) and weak-network throws both ride this policy. Cancellation
172
184
  // never retries. Delays grow exponentially under a 30s cap, so a fully dead
173
185
  // endpoint costs ~3.5min worst case before the turn fails loudly.
186
+ // 524/529 added for opencode parity (Cloudflare / overloaded gateways).
174
187
  export const MAX_RETRIES = 10;
175
- const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
188
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 524, 529]);
176
189
  const RETRY_AFTER_CAP_MS = 30_000;
190
+ // Per-model-call deadline (opencode options.timeout parity): bounds a single
191
+ // chat POST including streaming. Env ATOM_MODEL_TIMEOUT_MS, default 300s
192
+ // (matches opencode header/chunk defaults), max-clamped to 10min. Caller
193
+ // combines with the user-cancel signal per attempt via AbortSignal.any.
194
+ export const DEFAULT_MODEL_TIMEOUT_MS = 300_000;
195
+ export const MAX_MODEL_TIMEOUT_MS = 600_000;
196
+ export function modelTimeoutMs() {
197
+ const raw = process.env.ATOM_MODEL_TIMEOUT_MS;
198
+ if (raw !== undefined) {
199
+ const n = Number(raw.trim());
200
+ if (Number.isFinite(n) && n > 0)
201
+ return Math.min(Math.floor(n), MAX_MODEL_TIMEOUT_MS);
202
+ }
203
+ return DEFAULT_MODEL_TIMEOUT_MS;
204
+ }
177
205
  export function defaultSleep(ms) {
178
206
  return new Promise((resolve) => setTimeout(resolve, ms));
179
207
  }
@@ -221,15 +249,23 @@ function hasStreamBody(res) {
221
249
  return false;
222
250
  }
223
251
  }
224
- // Curated chat/completions-compatible models, verified from
225
- // https://opencode.ai/docs/zen. Used when the live model list cannot be
226
- // fetched or cannot confirm compatibility (network/auth/429/shape issues).
252
+ // Curated Zen models, verified from https://opencode.ai/docs/zen + the live
253
+ // /models list. Used when the live list cannot be fetched or cannot confirm
254
+ // compatibility (network/auth/429/shape issues). Covers both wire families:
255
+ // chat/completions (default transport) and responses-family (muse-spark-*,
256
+ // served from /responses — see isZenResponsesModel); the live filter below
257
+ // accepts ids from either set, the dispatcher routes by family.
227
258
  export const FALLBACK_MODELS = [
228
259
  "big-pickle",
229
260
  "mimo-v2.5-free",
230
261
  "ling-3.0-flash-fin-free",
231
262
  "nemotron-3-ultra-free",
232
263
  "nemotron-3.5-lightning-free",
264
+ "deepseek-v4-flash-free",
265
+ "muse-spark-1.3-contributor-free",
266
+ "muse-spark-1.2-contributor-free",
267
+ "muse-spark-1.3",
268
+ "muse-spark-1.2",
233
269
  "deepseek-v4-pro",
234
270
  "deepseek-v4-flash",
235
271
  "deepseek-v4-flash-vision-exp",
@@ -245,6 +281,26 @@ export const FALLBACK_MODELS = [
245
281
  "minimax-m2.7",
246
282
  "minimax-m3",
247
283
  ];
284
+ // Responses-family model prefixes: these ids are served from Zen's
285
+ // /responses endpoint (OpenAI Responses API shape), not /chat/completions.
286
+ // Verified from the endpoint column at https://opencode.ai/docs/zen.
287
+ const ZEN_RESPONSES_MODEL_PREFIXES = ["muse-spark-"];
288
+ /** True when a Zen model id must ride the Responses transport. */
289
+ export function isZenResponsesModel(model) {
290
+ return ZEN_RESPONSES_MODEL_PREFIXES.some((p) => model.startsWith(p));
291
+ }
292
+ export const RESPONSES_ENDPOINT_DEFAULT = "https://opencode.ai/zen/v1/responses";
293
+ // Derive the /responses endpoint from a chat/completions endpoint (mirrors
294
+ // modelsUrl above); falls back to the default when the shape is unknown.
295
+ export function responsesEndpointFor(chatEndpoint) {
296
+ const suffix = "/chat/completions";
297
+ if (chatEndpoint.endsWith(suffix)) {
298
+ return chatEndpoint.slice(0, -suffix.length) + "/responses";
299
+ }
300
+ if (chatEndpoint.endsWith("/responses"))
301
+ return chatEndpoint;
302
+ return RESPONSES_ENDPOINT_DEFAULT;
303
+ }
248
304
  export function endpointConfig() {
249
305
  return {
250
306
  endpoint: process.env.OPENCODE_ZEN_ENDPOINT ?? DEFAULT_ENDPOINT,
@@ -293,7 +349,11 @@ function entryId(entry) {
293
349
  export async function fetchModelsWithStatus(endpoint, apiKey) {
294
350
  try {
295
351
  const res = await fetch(modelsUrl(endpoint), {
296
- headers: { Authorization: `Bearer ${apiKey}` },
352
+ // Zen client identity: free `*-free` models are gated upstream on
353
+ // official-client headers (UA + `x-opencode-session`, else 429/400).
354
+ // Anonymous-safe: zenHeaders omits Authorization when no key instead
355
+ // of sending `Bearer `.
356
+ headers: zenHeaders(apiKey),
297
357
  });
298
358
  if (!res.ok)
299
359
  return { models: [...FALLBACK_MODELS], ok: false };
@@ -395,12 +455,14 @@ export async function readSSEMessage(res, opts) {
395
455
  // while a free-tier request sits queued).
396
456
  let lastDataAt = Date.now();
397
457
  // Fail fast when the stream flows (or idles) with no model output: same
398
- // permanent Truncated contract as a dead connection (passes through every
399
- // catch below untouched), same env knob as the per-read byte race. Checked
458
+ // Truncated contract as a dead connection (stalls are retryable upstream —
459
+ // one retry after data, backoff for header stalls), same env knobs as the
460
+ // per-read byte race. Header phase (no data yet) uses the generous header
461
+ // budget; established streams use the tighter chunk budget. Checked
400
462
  // after each drained chunk — legitimately slow generations keep emitting
401
463
  // `data:` lines, so only true silence trips it.
402
464
  function throwIfDataStalled() {
403
- const budget = sseStallTimeoutMs();
465
+ const budget = sawData ? sseStallTimeoutMs() : sseHeaderTimeoutMs();
404
466
  if (Date.now() - lastDataAt > budget) {
405
467
  throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
406
468
  }
@@ -787,6 +849,13 @@ opts, errorLabel = "Zen") {
787
849
  // input retries once with descriptors stripped to prose markers.
788
850
  let mediaStripped = false;
789
851
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
852
+ // Per-attempt model deadline: bounds the whole POST+stream (opencode
853
+ // options.timeout parity). User cancel still wins; deadline aborts map
854
+ // to retryable stall errors below, never to LoopCancelledError.
855
+ const deadlineMs = modelTimeoutMs();
856
+ let timedOut = false;
857
+ let timeoutId = null;
858
+ const attemptController = new AbortController();
790
859
  try {
791
860
  throwIfCancelled(signal);
792
861
  try {
@@ -846,6 +915,20 @@ opts, errorLabel = "Zen") {
846
915
  }
847
916
  if (effortParam !== undefined)
848
917
  payload["reasoning_effort"] = effortParam;
918
+ // Base headers per provider: Zen sends the official-client identity
919
+ // (`User-Agent: opencode/*` + `x-opencode-session`/`x-opencode-request`)
920
+ // so free `*-free` models get quota instead of 429 FreeUsageLimitError
921
+ // or 400 MissingSessionID. Session is stable per process, request is
922
+ // fresh per POST attempt. Other openai-chat providers keep the legacy
923
+ // shape byte-identical. Anonymous-safe everywhere: no key omits
924
+ // Authorization (never `Bearer `). Pre-request hooks below can still
925
+ // override/delete any key (string sets, null/undefined deletes).
926
+ const baseHeaders = hookProvider === "opencode-zen"
927
+ ? zenHeaders(apiKey, { requestId: zenRequestId() })
928
+ : {
929
+ "Content-Type": "application/json",
930
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
931
+ };
849
932
  // Pre-request hooks (ticket 08) run per POST attempt: payload
850
933
  // replacement must be a record (else ignored — downstream JSON/fetch
851
934
  // handling is never bypassed); header merge honors deletions.
@@ -857,18 +940,41 @@ opts, errorLabel = "Zen") {
857
940
  model,
858
941
  url: endpoint,
859
942
  payload,
860
- headers: {
861
- "Content-Type": "application/json",
862
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
863
- },
943
+ headers: { ...baseHeaders },
864
944
  })
865
945
  : {
866
946
  payload,
867
- headers: {
868
- "Content-Type": "application/json",
869
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
870
- },
947
+ headers: { ...baseHeaders },
871
948
  };
949
+ // Forward user cancel into the per-attempt controller, then arm the
950
+ // model deadline on the same controller.
951
+ if (signal) {
952
+ if (signal.aborted)
953
+ throw new LoopCancelledError();
954
+ signal.addEventListener("abort", () => {
955
+ try {
956
+ attemptController.abort();
957
+ }
958
+ catch {
959
+ // ignore
960
+ }
961
+ }, { once: true });
962
+ }
963
+ timeoutId = setTimeout(() => {
964
+ timedOut = true;
965
+ try {
966
+ attemptController.abort();
967
+ }
968
+ catch {
969
+ // ignore
970
+ }
971
+ }, deadlineMs);
972
+ try {
973
+ timeoutId.unref?.();
974
+ }
975
+ catch {
976
+ // ignore — environments without unref proceed regardless
977
+ }
872
978
  const res = await fetch(endpoint, {
873
979
  method: "POST",
874
980
  // Anonymous-capable providers (Kilo free models) omit Authorization
@@ -877,7 +983,7 @@ opts, errorLabel = "Zen") {
877
983
  // behavior is unchanged.
878
984
  headers: outgoing.headers,
879
985
  body: JSON.stringify(outgoing.payload),
880
- ...(signal ? { signal } : {}),
986
+ signal: attemptController.signal,
881
987
  });
882
988
  // Post-response observers (ticket 08): every resolved POST (ok and
883
989
  // HTTP-error alike), fail-open — never break the turn. Zero-cost when
@@ -912,6 +1018,8 @@ opts, errorLabel = "Zen") {
912
1018
  // ignore observer errors
913
1019
  }
914
1020
  throwIfCancelled(signal);
1021
+ if (timeoutId)
1022
+ clearTimeout(timeoutId);
915
1023
  continue;
916
1024
  }
917
1025
  // The server is the authority on effort support: a 400 naming the
@@ -930,11 +1038,16 @@ opts, errorLabel = "Zen") {
930
1038
  // ignore observer errors
931
1039
  }
932
1040
  throwIfCancelled(signal);
1041
+ if (timeoutId)
1042
+ clearTimeout(timeoutId);
933
1043
  continue;
934
1044
  }
935
1045
  const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
936
- if (!RETRYABLE_STATUS.has(res.status))
1046
+ if (!RETRYABLE_STATUS.has(res.status)) {
1047
+ if (timeoutId)
1048
+ clearTimeout(timeoutId);
937
1049
  throw err;
1050
+ }
938
1051
  if (attempt < MAX_RETRIES) {
939
1052
  throwIfCancelled(signal);
940
1053
  const delay = getRetryDelay(attempt, res);
@@ -944,11 +1057,15 @@ opts, errorLabel = "Zen") {
944
1057
  catch {
945
1058
  // ignore
946
1059
  }
1060
+ if (timeoutId)
1061
+ clearTimeout(timeoutId);
947
1062
  await sleep(delay);
948
1063
  throwIfCancelled(signal);
949
1064
  lastError = err;
950
1065
  continue;
951
1066
  }
1067
+ if (timeoutId)
1068
+ clearTimeout(timeoutId);
952
1069
  throw err;
953
1070
  }
954
1071
  if (!hasStreamBody(res)) {
@@ -993,26 +1110,287 @@ opts, errorLabel = "Zen") {
993
1110
  const reasoning = parseReasoningLabel(msg);
994
1111
  if (reasoning !== undefined)
995
1112
  result.reasoning = reasoning;
1113
+ if (timeoutId)
1114
+ clearTimeout(timeoutId);
996
1115
  return result;
997
1116
  }
998
- return await readSSEMessage(res, opts);
1117
+ try {
1118
+ const streamed = await readSSEMessage(res, { ...opts, signal: attemptController.signal });
1119
+ if (timeoutId)
1120
+ clearTimeout(timeoutId);
1121
+ return streamed;
1122
+ }
1123
+ catch (streamErr) {
1124
+ if (timeoutId)
1125
+ clearTimeout(timeoutId);
1126
+ throw streamErr;
1127
+ }
999
1128
  }
1000
1129
  catch (e) {
1130
+ if (timeoutId)
1131
+ clearTimeout(timeoutId);
1132
+ // User cancel wins over deadline: only the user's own signal maps to
1133
+ // LoopCancelledError. A deadline abort surfaces as a retryable stall.
1134
+ if (signal?.aborted)
1135
+ throw new LoopCancelledError();
1136
+ if (timedOut && !(e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))) {
1137
+ const deadlineErr = new Error(`Truncated stream from model (stall: no output for ${deadlineMs}ms — model deadline; resend to retry).`);
1138
+ if (attempt < MAX_RETRIES) {
1139
+ const delay = getRetryDelay(attempt, undefined);
1140
+ try {
1141
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (model deadline ${deadlineMs}ms)`);
1142
+ }
1143
+ catch {
1144
+ // ignore
1145
+ }
1146
+ try {
1147
+ await sleep(delay);
1148
+ }
1149
+ catch {
1150
+ // a failing sleep must not mask the original error
1151
+ }
1152
+ lastError = deadlineErr;
1153
+ throwIfCancelled(signal);
1154
+ continue;
1155
+ }
1156
+ throw deadlineErr;
1157
+ }
1001
1158
  // Cancellations (Ctrl+C / AbortSignal) are final: never retry, never
1002
1159
  // reframe — propagate so the caller can roll back + show (cancelled).
1003
- if (isCancelError(e) || signal?.aborted)
1160
+ if (isCancelError(e))
1004
1161
  throw new LoopCancelledError();
1005
1162
  // HTTP failures already handled above (retry or fail-fast): rethrow
1006
1163
  // without treating them as retryable network errors.
1007
1164
  if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
1008
1165
  throw e;
1009
- // Parsing/validation failures (empty reply, truncation) are permanent:
1010
- // never retry, surface immediately so the caller can roll back.
1166
+ // Empty replies are permanent: never retry, surface immediately.
1167
+ if (e instanceof Error && e.message.startsWith("Empty reply")) {
1168
+ throw e;
1169
+ }
1170
+ // Truncated streams: stall timeouts ride the normal network backoff
1171
+ // (opencode parity — SSE read timed out is retryable); an abort with
1172
+ // zero data gets exactly one immediate retry, then fails loudly.
1173
+ if (e instanceof Error && e.message.startsWith("Truncated stream")) {
1174
+ if (!isStallError(e) && attempt >= 1)
1175
+ throw e;
1176
+ if (attempt < MAX_RETRIES) {
1177
+ const delay = getRetryDelay(attempt, undefined);
1178
+ try {
1179
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e.message.slice(0, 120)})`);
1180
+ }
1181
+ catch {
1182
+ // ignore
1183
+ }
1184
+ try {
1185
+ await sleep(delay);
1186
+ }
1187
+ catch {
1188
+ // a failing sleep must not mask the original error
1189
+ }
1190
+ lastError = e;
1191
+ throwIfCancelled(signal);
1192
+ continue;
1193
+ }
1194
+ throw e;
1195
+ }
1196
+ // Anything else is a network-level throw: retry when attempts remain.
1197
+ if (attempt < MAX_RETRIES) {
1198
+ const delay = getRetryDelay(attempt, undefined);
1199
+ try {
1200
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (${e instanceof Error ? e.message : String(e)})`);
1201
+ }
1202
+ catch {
1203
+ // ignore
1204
+ }
1205
+ try {
1206
+ await sleep(delay);
1207
+ }
1208
+ catch {
1209
+ // a failing sleep must not mask the original error
1210
+ }
1211
+ lastError = e;
1212
+ continue;
1213
+ }
1214
+ throw e;
1215
+ }
1216
+ }
1217
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1218
+ }
1219
+ // Responses-family chat POST with tools attached (tool_choice omitted, so
1220
+ // the default auto applies). Sends {model, instructions?, input, stream:true}
1221
+ // plus `reasoning: {effort}` whenever opts.reasoningEffort is non-Auto (see
1222
+ // responsesEffortParam) — for every responses model on opencode-zen.
1223
+ // A 400 naming the knob means the model truly lacks it: warn once via
1224
+ // onWarning and retry without it. Parses the Responses SSE event stream
1225
+ // (see readResponsesSSEMessage in adapters.ts).
1226
+ // Retry/rollback/hook/error-label contract mirrors chatCompletion exactly:
1227
+ // network throws and HTTP 429/500/502/503/504 retry up to MAX_RETRIES with
1228
+ // the same backoff; other 4xx fail fast as `{errorLabel} HTTP {status}`;
1229
+ // empty replies and truncated streams (no response.completed) throw
1230
+ // permanently; status "incomplete" returns with `truncated: true`.
1231
+ // Callers must roll back the user turn on failure (see App submit).
1232
+ export async function chatCompletionResponses(endpoint, apiKey, model, history,
1233
+ // providerId (ticket 08): hook attribution for the responses transport —
1234
+ // the dispatcher passes its provider id, direct callers omit it and
1235
+ // default to "opencode-zen". Optional, wire-compatible.
1236
+ opts, errorLabel = "Zen") {
1237
+ const sleep = opts?.sleep ?? defaultSleep;
1238
+ const signal = opts?.signal ?? null;
1239
+ const hookProvider = opts?.providerId ?? "opencode-zen";
1240
+ const contextHooks = contextTransformers();
1241
+ const outgoingHistory = contextHooks.length > 0 ? await applyContextTransform(contextHooks, history) : history;
1242
+ let lastError = null;
1243
+ // Server-authoritative unsupported: when a 400 names the reasoning knob,
1244
+ // the flag below drops it and the loop retries without it (once per call).
1245
+ let effortDropped = false;
1246
+ // Same contract for vision input: a 400 naming image input retries once
1247
+ // with descriptors stripped to prose markers.
1248
+ let mediaStripped = false;
1249
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1250
+ try {
1251
+ throwIfCancelled(signal);
1252
+ try {
1253
+ opts?.onPhase?.("thinking");
1254
+ }
1255
+ catch {
1256
+ // ignore observer errors
1257
+ }
1258
+ const effortParam = effortDropped
1259
+ ? undefined
1260
+ : responsesEffortParam(opts?.reasoningEffort);
1261
+ const summaryOpts = opts;
1262
+ const mediaMode = opts?.stripMedia === true || mediaStripped
1263
+ ? "strip"
1264
+ : "send";
1265
+ const converted = buildResponsesBody(outgoingHistory, model, {
1266
+ // Compaction path only: tools disabled means NO `tools` key at all;
1267
+ // the normal loop always sends the schema so the model can discover
1268
+ // and call tools exactly like builtins. update_goal rides along only
1269
+ // for live goal turns (includeUpdateGoal); otherwise hidden.
1270
+ includeTools: summaryOpts?.disableTools === true ? false : undefined,
1271
+ includeUpdateGoal: opts?.includeUpdateGoal,
1272
+ stripMedia: mediaMode === "strip" ? true : undefined,
1273
+ });
1274
+ // mediaMode is recomputed per attempt, so the strip retry below
1275
+ // rebuilds the body in strip mode on its next pass through the loop.
1276
+ const payload = {
1277
+ model: converted.model,
1278
+ ...(converted.instructions !== undefined
1279
+ ? { instructions: converted.instructions }
1280
+ : {}),
1281
+ input: converted.input,
1282
+ stream: true,
1283
+ };
1284
+ if (converted.tools !== undefined)
1285
+ payload["tools"] = converted.tools;
1286
+ // Compaction path only: cap output (responses kind uses
1287
+ // max_output_tokens, same name as the cap option).
1288
+ if (typeof summaryOpts?.maxOutputTokens === "number" &&
1289
+ Number.isFinite(summaryOpts.maxOutputTokens) &&
1290
+ summaryOpts.maxOutputTokens > 0) {
1291
+ payload["max_output_tokens"] = Math.floor(summaryOpts.maxOutputTokens);
1292
+ }
1293
+ if (effortParam !== undefined)
1294
+ payload["reasoning"] = { effort: effortParam };
1295
+ const preHooks = beforeRequestInterceptors();
1296
+ const outgoing = preHooks.length > 0
1297
+ ? await applyBeforeRequest(preHooks, {
1298
+ provider: hookProvider,
1299
+ model,
1300
+ url: endpoint,
1301
+ payload,
1302
+ headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
1303
+ })
1304
+ : {
1305
+ payload,
1306
+ headers: { ...zenHeaders(apiKey, { requestId: zenRequestId() }) },
1307
+ };
1308
+ const res = await fetch(endpoint, {
1309
+ method: "POST",
1310
+ // Anonymous-capable: zenHeaders omits Authorization when no key —
1311
+ // never an empty `Bearer `.
1312
+ headers: outgoing.headers,
1313
+ body: JSON.stringify(outgoing.payload),
1314
+ ...(signal ? { signal } : {}),
1315
+ });
1316
+ const postHooks = afterResponseObservers();
1317
+ if (postHooks.length > 0) {
1318
+ await notifyAfterResponse(postHooks, {
1319
+ provider: hookProvider,
1320
+ model,
1321
+ url: endpoint,
1322
+ status: res.status,
1323
+ ok: res.ok,
1324
+ headers: snapshotResponseHeaders(res),
1325
+ });
1326
+ }
1327
+ if (!res.ok) {
1328
+ const errText = await safeErrorText(res);
1329
+ if (res.status === 400 &&
1330
+ !mediaStripped &&
1331
+ opts?.stripMedia !== true &&
1332
+ historyHasMedia(outgoingHistory) &&
1333
+ isImageRejection(errText)) {
1334
+ mediaStripped = true;
1335
+ try {
1336
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1337
+ }
1338
+ catch {
1339
+ // ignore observer errors
1340
+ }
1341
+ throwIfCancelled(signal);
1342
+ continue;
1343
+ }
1344
+ if (res.status === 400 &&
1345
+ effortParam !== undefined &&
1346
+ !effortDropped &&
1347
+ isResponsesEffortRejection(errText)) {
1348
+ effortDropped = true;
1349
+ try {
1350
+ opts?.onWarning?.(`reasoning effort "${effortParam}" is not supported by ${model} — continuing without it`);
1351
+ }
1352
+ catch {
1353
+ // ignore observer errors
1354
+ }
1355
+ throwIfCancelled(signal);
1356
+ continue;
1357
+ }
1358
+ const err = new Error(`${errorLabel} HTTP ${res.status}: ${errText.slice(0, 300)}`);
1359
+ if (!RETRYABLE_STATUS.has(res.status))
1360
+ throw err;
1361
+ if (attempt < MAX_RETRIES) {
1362
+ throwIfCancelled(signal);
1363
+ const delay = getRetryDelay(attempt, res);
1364
+ try {
1365
+ opts?.onPhase?.("retry", `attempt ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (HTTP ${res.status})`);
1366
+ }
1367
+ catch {
1368
+ // ignore
1369
+ }
1370
+ await sleep(delay);
1371
+ throwIfCancelled(signal);
1372
+ lastError = err;
1373
+ continue;
1374
+ }
1375
+ throw err;
1376
+ }
1377
+ if (!hasStreamBody(res)) {
1378
+ // Non-streaming responses object (no chat `choices` shape here —
1379
+ // parseResponsesObject reads the Responses `output` array).
1380
+ const data = await res.json();
1381
+ return parseResponsesObject(data);
1382
+ }
1383
+ return await readResponsesSSEMessage(res, opts);
1384
+ }
1385
+ catch (e) {
1386
+ if (isCancelError(e) || signal?.aborted)
1387
+ throw new LoopCancelledError();
1388
+ if (e instanceof Error && e.message.startsWith(`${errorLabel} HTTP`))
1389
+ throw e;
1011
1390
  if (e instanceof Error &&
1012
1391
  (e.message.startsWith("Empty reply") || e.message.startsWith("Truncated stream"))) {
1013
1392
  throw e;
1014
1393
  }
1015
- // Anything else is a network-level throw: retry when attempts remain.
1016
1394
  if (attempt < MAX_RETRIES) {
1017
1395
  const delay = getRetryDelay(attempt, undefined);
1018
1396
  try {
@@ -1489,11 +1867,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1489
1867
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1490
1868
  }
1491
1869
  // Provider dispatcher: openai-chat reuses chatCompletion with the provider's
1492
- // error label; anthropic/gemini go through their adapters. Effort rides
1493
- // every kind: reasoning_effort on openai-chat (all providers, all models),
1494
- // thinking budgets on anthropic-messages, thinkingLevel on gemini-generate.
1495
- // Auto omits the knob; a model that truly lacks it 400s and the transports
1496
- // above retry once without it.
1870
+ // error label; anthropic/gemini go through their adapters; Zen
1871
+ // responses-family models (muse-spark-*) ride chatCompletionResponses.
1872
+ // Effort rides every kind: reasoning_effort on openai-chat (all providers,
1873
+ // all models), reasoning.effort on responses, thinking budgets on
1874
+ // anthropic-messages, thinkingLevel on gemini-generate. Auto omits the knob
1875
+ // everywhere; a model that truly lacks it 400s and the transports above
1876
+ // retry once without it.
1497
1877
  export async function chatCompletionForProvider(provider, apiKey, model, history, opts) {
1498
1878
  const def = getProvider(provider);
1499
1879
  if (!def)
@@ -1556,6 +1936,13 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1556
1936
  throw normalizeKiloChatError(e, apiKey);
1557
1937
  }
1558
1938
  }
1939
+ // Zen responses-family (muse-spark-*, free contributor tiers included):
1940
+ // same chatOpts surface (effort/tools/compaction/media/hooks), Responses
1941
+ // wire shape + /responses endpoint. Only opencode-zen serves this family;
1942
+ // every other provider keeps the chat path below byte-identical.
1943
+ if (provider === "opencode-zen" && isZenResponsesModel(model)) {
1944
+ return chatCompletionResponses(responsesEndpointFor(endpoint), apiKey, model, history, chatOpts, providerLabel(provider));
1945
+ }
1559
1946
  return chatCompletion(endpoint, apiKey, model, history, chatOpts, providerLabel(provider));
1560
1947
  }
1561
1948
  export { evaluateTurnEnd, isCodePath, MAX_TODO_ROUNDS, MAX_VERIFY_ROUNDS, todoCompletionGate, TURN_END_GATES, verificationGate, } from "./agent/gates.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atom-agent",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Agentic terminal coding assistant: multi-provider LLM loop with local file/shell/web tools in an Ink (React) TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",