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/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
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
399
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1010
|
-
|
|
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
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
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