atom-agent 1.4.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 +70 -0
- package/README.md +221 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +502 -21
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +250 -434
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/providers.js +11 -3
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +79 -5
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +1 -1
- package/dist/ui/diff-view.js +13 -5
- package/dist/ui/diff.js +67 -0
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +7 -5
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +81 -22
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +8 -5
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +532 -34
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/package.json +3 -2
package/dist/adapters.js
CHANGED
|
@@ -6,10 +6,56 @@
|
|
|
6
6
|
// Normalized output matches zen ChatResult:
|
|
7
7
|
// {content, tool_calls:[{id,function:{name,arguments}}], usage?}
|
|
8
8
|
// so runAgenticLoop/retry/rollback/status code is untouched.
|
|
9
|
-
import {
|
|
9
|
+
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).
|
|
@@ -64,10 +110,13 @@ export function geminiThinkingLevelFor(effort) {
|
|
|
64
110
|
export function isEffortRejection(errorText) {
|
|
65
111
|
return /reasoning_effort|reasoning effort|thinking_level|budget_tokens|\bthinking\b/i.test(errorText);
|
|
66
112
|
}
|
|
67
|
-
function toolDefs() {
|
|
113
|
+
function toolDefs(includeUpdateGoal = true) {
|
|
68
114
|
// Builtins plus extension-registered custom tools, so non-OpenAI kinds
|
|
69
115
|
// see the same model-visible surface as the OpenAI-chat path.
|
|
70
|
-
|
|
116
|
+
// includeUpdateGoal:false hides update_goal when the turn has no live
|
|
117
|
+
// goal (same contract as chatToolDefinitions — default keeps every
|
|
118
|
+
// existing caller byte-identical).
|
|
119
|
+
return chatToolDefinitions(includeUpdateGoal);
|
|
71
120
|
}
|
|
72
121
|
function parseArgsObject(raw) {
|
|
73
122
|
try {
|
|
@@ -81,11 +130,16 @@ function parseArgsObject(raw) {
|
|
|
81
130
|
}
|
|
82
131
|
}
|
|
83
132
|
import { ephemeralBreakpoint, assemblePrefix } from "./prompt-cache.js";
|
|
133
|
+
import { hasMediaRefs, lowerOpenAIContent, resolveMediaRefs, stripMedia, } from "./media.js";
|
|
84
134
|
export function buildAnthropicBody(history, model, opts) {
|
|
85
135
|
const systems = [];
|
|
86
136
|
const messages = [];
|
|
87
137
|
// Group consecutive tool messages into one user message with
|
|
88
138
|
// multiple tool_result blocks (Anthropic convention).
|
|
139
|
+
// Media lowering (see src/media.ts): histories without descriptors take
|
|
140
|
+
// the string path byte-identically; descriptor-bearing user/tool content
|
|
141
|
+
// expands to text + base64 image blocks (strip mode: prose markers).
|
|
142
|
+
const strip = opts?.stripMedia === true;
|
|
89
143
|
let pendingToolResults = [];
|
|
90
144
|
function flushTools() {
|
|
91
145
|
if (pendingToolResults.length === 0)
|
|
@@ -95,20 +149,64 @@ export function buildAnthropicBody(history, model, opts) {
|
|
|
95
149
|
}
|
|
96
150
|
for (const m of history) {
|
|
97
151
|
if (m.role === "system") {
|
|
98
|
-
systems.push(m.content);
|
|
152
|
+
systems.push(strip ? stripMedia(m.content) : m.content);
|
|
99
153
|
continue;
|
|
100
154
|
}
|
|
101
155
|
if (m.role === "tool") {
|
|
156
|
+
if (!hasMediaRefs(m.content)) {
|
|
157
|
+
pendingToolResults.push({
|
|
158
|
+
type: "tool_result",
|
|
159
|
+
tool_use_id: m.tool_call_id,
|
|
160
|
+
content: strip ? stripMedia(m.content) : m.content,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (strip) {
|
|
165
|
+
pendingToolResults.push({
|
|
166
|
+
type: "tool_result",
|
|
167
|
+
tool_use_id: m.tool_call_id,
|
|
168
|
+
content: stripMedia(m.content),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const { text, media } = resolveMediaRefs(m.content);
|
|
173
|
+
const blocks = [{ type: "text", text }];
|
|
174
|
+
for (const part of media) {
|
|
175
|
+
if (!part.ok)
|
|
176
|
+
continue; // placeholder already inline in text
|
|
177
|
+
blocks.push({
|
|
178
|
+
type: "image",
|
|
179
|
+
source: { type: "base64", media_type: part.mime, data: part.base64 },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
102
182
|
pendingToolResults.push({
|
|
103
183
|
type: "tool_result",
|
|
104
184
|
tool_use_id: m.tool_call_id,
|
|
105
|
-
content:
|
|
185
|
+
content: blocks,
|
|
106
186
|
});
|
|
107
187
|
continue;
|
|
108
188
|
}
|
|
109
189
|
flushTools();
|
|
110
190
|
if (m.role === "user") {
|
|
111
|
-
|
|
191
|
+
if (!hasMediaRefs(m.content)) {
|
|
192
|
+
messages.push({ role: "user", content: strip ? stripMedia(m.content) : m.content });
|
|
193
|
+
}
|
|
194
|
+
else if (strip) {
|
|
195
|
+
messages.push({ role: "user", content: stripMedia(m.content) });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const { text, media } = resolveMediaRefs(m.content);
|
|
199
|
+
const blocks = [{ type: "text", text }];
|
|
200
|
+
for (const part of media) {
|
|
201
|
+
if (!part.ok)
|
|
202
|
+
continue;
|
|
203
|
+
blocks.push({
|
|
204
|
+
type: "image",
|
|
205
|
+
source: { type: "base64", media_type: part.mime, data: part.base64 },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
messages.push({ role: "user", content: blocks });
|
|
209
|
+
}
|
|
112
210
|
}
|
|
113
211
|
else {
|
|
114
212
|
// assistant: text + tool_use blocks
|
|
@@ -140,7 +238,7 @@ export function buildAnthropicBody(history, model, opts) {
|
|
|
140
238
|
// Compaction path (includeTools:false) omits `tools` + `tool_choice`
|
|
141
239
|
// entirely — asserted in tests as "no `tools` key".
|
|
142
240
|
if (includeTools) {
|
|
143
|
-
const defs = toolDefs().map((t) => ({
|
|
241
|
+
const defs = toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
|
|
144
242
|
name: t.function.name,
|
|
145
243
|
description: t.function.description,
|
|
146
244
|
input_schema: t.function.parameters,
|
|
@@ -222,10 +320,15 @@ export function geminiHeaders(apiKey) {
|
|
|
222
320
|
};
|
|
223
321
|
}
|
|
224
322
|
export function buildGeminiBody(history, _model, opts) {
|
|
323
|
+
// Media lowering (see src/media.ts): histories without descriptors take
|
|
324
|
+
// the legacy path byte-identically. Tool-result images ride as inline_data
|
|
325
|
+
// user parts after their functionResponse (function responses stay text);
|
|
326
|
+
// user-message images ride inline. Strip mode: prose markers only.
|
|
327
|
+
const strip = opts?.stripMedia === true;
|
|
225
328
|
const systems = [];
|
|
226
329
|
for (const m of history) {
|
|
227
330
|
if (m.role === "system")
|
|
228
|
-
systems.push(m.content);
|
|
331
|
+
systems.push(strip ? stripMedia(m.content) : m.content);
|
|
229
332
|
}
|
|
230
333
|
// tool_call_id -> function name (tool messages carry only the id).
|
|
231
334
|
const nameById = new Map();
|
|
@@ -239,27 +342,83 @@ export function buildGeminiBody(history, _model, opts) {
|
|
|
239
342
|
}
|
|
240
343
|
const contents = [];
|
|
241
344
|
let pendingResponses = [];
|
|
345
|
+
// Images resolved out of tool results (flushed as user inline_data parts
|
|
346
|
+
// right after their functionResponse group).
|
|
347
|
+
let pendingToolImages = [];
|
|
242
348
|
function flushResponses() {
|
|
243
|
-
if (pendingResponses.length === 0)
|
|
349
|
+
if (pendingResponses.length === 0 && pendingToolImages.length === 0)
|
|
244
350
|
return;
|
|
245
|
-
|
|
246
|
-
|
|
351
|
+
if (pendingResponses.length > 0) {
|
|
352
|
+
contents.push({ role: "user", parts: pendingResponses });
|
|
353
|
+
pendingResponses = [];
|
|
354
|
+
}
|
|
355
|
+
if (pendingToolImages.length > 0) {
|
|
356
|
+
const parts = [
|
|
357
|
+
{
|
|
358
|
+
text: `Image(s) from tool result: ${pendingToolImages.map((i) => i.name).join(", ")}`,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
for (const img of pendingToolImages) {
|
|
362
|
+
parts.push({ inline_data: { mime_type: img.mime, data: img.data } });
|
|
363
|
+
}
|
|
364
|
+
pendingToolImages = [];
|
|
365
|
+
contents.push({ role: "user", parts });
|
|
366
|
+
}
|
|
247
367
|
}
|
|
248
368
|
for (const m of history) {
|
|
249
369
|
if (m.role === "system")
|
|
250
370
|
continue;
|
|
251
371
|
if (m.role === "tool") {
|
|
372
|
+
if (!hasMediaRefs(m.content)) {
|
|
373
|
+
pendingResponses.push({
|
|
374
|
+
functionResponse: {
|
|
375
|
+
name: nameById.get(m.tool_call_id) ?? "unknown",
|
|
376
|
+
response: { result: strip ? stripMedia(m.content) : m.content },
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (strip) {
|
|
382
|
+
pendingResponses.push({
|
|
383
|
+
functionResponse: {
|
|
384
|
+
name: nameById.get(m.tool_call_id) ?? "unknown",
|
|
385
|
+
response: { result: stripMedia(m.content) },
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const { text, media } = resolveMediaRefs(m.content);
|
|
252
391
|
pendingResponses.push({
|
|
253
392
|
functionResponse: {
|
|
254
393
|
name: nameById.get(m.tool_call_id) ?? "unknown",
|
|
255
|
-
response: { result:
|
|
394
|
+
response: { result: text },
|
|
256
395
|
},
|
|
257
396
|
});
|
|
397
|
+
for (const part of media) {
|
|
398
|
+
if (!part.ok)
|
|
399
|
+
continue;
|
|
400
|
+
pendingToolImages.push({ mime: part.mime, data: part.base64, name: part.name });
|
|
401
|
+
}
|
|
258
402
|
continue;
|
|
259
403
|
}
|
|
260
404
|
flushResponses();
|
|
261
405
|
if (m.role === "user") {
|
|
262
|
-
|
|
406
|
+
if (!hasMediaRefs(m.content)) {
|
|
407
|
+
contents.push({ role: "user", parts: [{ text: strip ? stripMedia(m.content) : m.content }] });
|
|
408
|
+
}
|
|
409
|
+
else if (strip) {
|
|
410
|
+
contents.push({ role: "user", parts: [{ text: stripMedia(m.content) }] });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
const { text, media } = resolveMediaRefs(m.content);
|
|
414
|
+
const parts = [{ text }];
|
|
415
|
+
for (const part of media) {
|
|
416
|
+
if (!part.ok)
|
|
417
|
+
continue;
|
|
418
|
+
parts.push({ inline_data: { mime_type: part.mime, data: part.base64 } });
|
|
419
|
+
}
|
|
420
|
+
contents.push({ role: "user", parts });
|
|
421
|
+
}
|
|
263
422
|
}
|
|
264
423
|
else {
|
|
265
424
|
const am = m;
|
|
@@ -290,7 +449,7 @@ export function buildGeminiBody(history, _model, opts) {
|
|
|
290
449
|
if (includeTools) {
|
|
291
450
|
body.tools = [
|
|
292
451
|
{
|
|
293
|
-
functionDeclarations: toolDefs().map((t) => ({
|
|
452
|
+
functionDeclarations: toolDefs(opts?.includeUpdateGoal !== false).map((t) => ({
|
|
294
453
|
name: t.function.name,
|
|
295
454
|
description: t.function.description,
|
|
296
455
|
parameters: stripGeminiSchemaKeys(t.function.parameters),
|
|
@@ -330,10 +489,15 @@ export function buildGeminiBody(history, _model, opts) {
|
|
|
330
489
|
//
|
|
331
490
|
// Budget: env ATOM_STALL_TIMEOUT_MS when a finite value > 0 (max-clamped to
|
|
332
491
|
// 5min; an explicitly tiny value is the operator's choice, and lets tests
|
|
333
|
-
// 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
|
|
334
496
|
// settle — callers cancel/release the reader on the way out as before.
|
|
335
497
|
export const DEFAULT_SSE_STALL_TIMEOUT_MS = 60_000;
|
|
336
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;
|
|
337
501
|
export function sseStallTimeoutMs() {
|
|
338
502
|
const raw = process.env.ATOM_STALL_TIMEOUT_MS;
|
|
339
503
|
if (raw !== undefined) {
|
|
@@ -343,6 +507,15 @@ export function sseStallTimeoutMs() {
|
|
|
343
507
|
}
|
|
344
508
|
return DEFAULT_SSE_STALL_TIMEOUT_MS;
|
|
345
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
|
+
}
|
|
346
519
|
export function isStallError(e) {
|
|
347
520
|
return e instanceof Error && e.message.startsWith("Truncated stream from model (stall:");
|
|
348
521
|
}
|
|
@@ -387,7 +560,9 @@ async function collectSSEText(res) {
|
|
|
387
560
|
tail = joined.slice(-8);
|
|
388
561
|
};
|
|
389
562
|
const throwIfDataStalled = () => {
|
|
390
|
-
|
|
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();
|
|
391
566
|
if (Date.now() - lastDataAt > budget) {
|
|
392
567
|
throw new Error(`Truncated stream from model (stall: no output for ${budget}ms — queued or stalled upstream; resend to retry).`);
|
|
393
568
|
}
|
|
@@ -406,7 +581,9 @@ async function collectSSEText(res) {
|
|
|
406
581
|
catch (e) {
|
|
407
582
|
if (isStallError(e)) {
|
|
408
583
|
// Free the dead socket on the way out, then surface the stall
|
|
409
|
-
// unchanged
|
|
584
|
+
// unchanged. Stalls are retryable upstream (one retry when data
|
|
585
|
+
// was already seen, full backoff for header stalls) — never
|
|
586
|
+
// swallowed here.
|
|
410
587
|
try {
|
|
411
588
|
await reader.cancel?.();
|
|
412
589
|
}
|
|
@@ -1032,6 +1209,307 @@ export function parseGeminiJson(data) {
|
|
|
1032
1209
|
}
|
|
1033
1210
|
return result;
|
|
1034
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
|
+
}
|
|
1035
1513
|
// ---- Models-list parsing per kind (pure; ANY failure -> fallback) ----
|
|
1036
1514
|
function entryId(entry) {
|
|
1037
1515
|
if (typeof entry === "string")
|
|
@@ -1126,11 +1604,14 @@ export async function validateProviderKey(id, apiKey, storedBaseURL) {
|
|
|
1126
1604
|
return { ok: true };
|
|
1127
1605
|
return { ok: false, error: `Gemini HTTP ${res.status}` };
|
|
1128
1606
|
}
|
|
1129
|
-
// 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).
|
|
1130
1609
|
const url = modelsUrlForProvider(id, storedBaseURL);
|
|
1131
|
-
const headers =
|
|
1132
|
-
|
|
1133
|
-
|
|
1610
|
+
const headers = id === "opencode-zen"
|
|
1611
|
+
? zenHeaders(apiKey)
|
|
1612
|
+
: {
|
|
1613
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1614
|
+
};
|
|
1134
1615
|
const res = await fetch(url, { headers });
|
|
1135
1616
|
if (res.ok)
|
|
1136
1617
|
return { ok: true };
|
|
@@ -51,6 +51,9 @@ export async function requestGoalVerdict(req) {
|
|
|
51
51
|
endpointOverride: req.endpointOverride,
|
|
52
52
|
disableTools: true,
|
|
53
53
|
maxOutputTokens: req.maxOutputTokens ?? GOAL_JUDGE_MAX_TOKENS,
|
|
54
|
+
// The judge needs prose, not pixels — same strip contract as the
|
|
55
|
+
// compaction summary POST.
|
|
56
|
+
stripMedia: true,
|
|
54
57
|
...(req.signal ? { signal: req.signal } : {}),
|
|
55
58
|
});
|
|
56
59
|
// Totals keep accumulating: forward real judge usage when present.
|