lazyclaw 6.3.1 → 6.5.0
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/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
|
@@ -175,6 +175,33 @@ export function _codexStoredApiKey({ home, readFileSync } = {}) {
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// A ChatGPT-plan `codex` login has no platform API key, so /v1/models can't be
|
|
179
|
+
// listed. The one model such a login can actually use is the one configured in
|
|
180
|
+
// ~/.codex/config.toml (`model = "gpt-5.5"`). Return it so the picker shows the
|
|
181
|
+
// account's real model instead of a "fetch failed" error. Returns [] when the
|
|
182
|
+
// file is missing or has no model line.
|
|
183
|
+
export function _codexConfigModels({ home, readFileSync } = {}) {
|
|
184
|
+
const h = home || os.homedir();
|
|
185
|
+
const read = readFileSync || fs.readFileSync;
|
|
186
|
+
try {
|
|
187
|
+
const txt = read(path.join(h, '.codex/config.toml'), 'utf8');
|
|
188
|
+
const m = /^\s*model\s*=\s*"([^"]+)"/m.exec(String(txt));
|
|
189
|
+
return m && m[1] ? [m[1]] : [];
|
|
190
|
+
} catch { return []; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Same idea for a `gemini` Google-account login: no listable platform catalogue,
|
|
194
|
+
// but ~/.gemini/settings.json may pin a model. Returns [] when absent.
|
|
195
|
+
export function _geminiConfigModels({ home, readFileSync } = {}) {
|
|
196
|
+
const h = home || os.homedir();
|
|
197
|
+
const read = readFileSync || fs.readFileSync;
|
|
198
|
+
try {
|
|
199
|
+
const obj = JSON.parse(read(path.join(h, '.gemini/settings.json'), 'utf8'));
|
|
200
|
+
const m = obj && (obj.model || obj.defaultModel || (obj.model && obj.model.name));
|
|
201
|
+
return typeof m === 'string' && m ? [m] : [];
|
|
202
|
+
} catch { return []; }
|
|
203
|
+
}
|
|
204
|
+
|
|
178
205
|
export async function fetchModelsForProvider(deps) {
|
|
179
206
|
const providerId = deps && deps.providerId;
|
|
180
207
|
const key = (id) => (typeof deps?.resolveAuthKey === 'function' ? deps.resolveAuthKey(id) : '') || '';
|
|
@@ -199,7 +226,9 @@ export async function fetchModelsForProvider(deps) {
|
|
|
199
226
|
if (providerId === 'gemini-cli') {
|
|
200
227
|
const apiKey = key('gemini-cli') || key('gemini') || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
|
|
201
228
|
if (apiKey) return fetchGeminiModels({ apiKey, fetchImpl: deps?.fetchImpl });
|
|
202
|
-
|
|
229
|
+
// Google-account login can't list a platform catalogue — surface the model
|
|
230
|
+
// pinned in ~/.gemini/settings.json (if any) instead of throwing.
|
|
231
|
+
return deps?._geminiConfigModels ? deps._geminiConfigModels() : _geminiConfigModels();
|
|
203
232
|
}
|
|
204
233
|
if (providerId === 'codex-cli') {
|
|
205
234
|
const apiKey = key('codex-cli') || key('openai') || process.env.OPENAI_API_KEY
|
|
@@ -208,7 +237,9 @@ export async function fetchModelsForProvider(deps) {
|
|
|
208
237
|
const { fetchOpenAICompatModels } = await import('./openai_compat.mjs');
|
|
209
238
|
return fetchOpenAICompatModels({ baseUrl: 'https://api.openai.com/v1', apiKey, fetch: deps?.fetchImpl });
|
|
210
239
|
}
|
|
211
|
-
|
|
240
|
+
// ChatGPT-plan login has no platform API key — list the model the local
|
|
241
|
+
// codex config is set to use instead of erroring with "fetch failed".
|
|
242
|
+
return deps?._codexConfigModels ? deps._codexConfigModels() : _codexConfigModels();
|
|
212
243
|
}
|
|
213
244
|
const c = modelCatalogueFor(deps);
|
|
214
245
|
if (!c) {
|
package/providers/ollama.mjs
CHANGED
|
@@ -45,6 +45,61 @@ class ConnectionError extends Error {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
class TimeoutError extends Error {
|
|
49
|
+
constructor(idleMs) {
|
|
50
|
+
super(`ollama: idle timeout — no stream activity for ${idleMs}ms`);
|
|
51
|
+
this.name = 'TimeoutError';
|
|
52
|
+
this.code = 'TIMEOUT';
|
|
53
|
+
this.idleMs = idleMs;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
58
|
+
|
|
59
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
60
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
61
|
+
function resolveIdleTimeoutMs(opts) {
|
|
62
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
63
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
64
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
65
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
69
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
70
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
71
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
72
|
+
// `controller` is aborted on idle expiry so the real connection tears down.
|
|
73
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
74
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
75
|
+
try {
|
|
76
|
+
while (true) {
|
|
77
|
+
let timer;
|
|
78
|
+
const idle = new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
controller.abort();
|
|
81
|
+
reject(new TimeoutError(idleMs));
|
|
82
|
+
}, idleMs);
|
|
83
|
+
});
|
|
84
|
+
let step;
|
|
85
|
+
try {
|
|
86
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
if (step.done) return;
|
|
91
|
+
yield step.value;
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
95
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
96
|
+
// would re-hang us. The controller abort already tears down the socket.
|
|
97
|
+
if (typeof iterator.return === 'function') {
|
|
98
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
async function* iterateBody(body) {
|
|
49
104
|
if (body && typeof body.getReader === 'function') {
|
|
50
105
|
const reader = body.getReader();
|
|
@@ -64,6 +119,28 @@ async function* iterateBody(body) {
|
|
|
64
119
|
throw new Error('ollama: response body is not iterable');
|
|
65
120
|
}
|
|
66
121
|
|
|
122
|
+
// The final `done:true` frame carries prompt_eval_count (input) / eval_count
|
|
123
|
+
// (output) and a done_reason. Report tokens via onUsage (cost 0 for a local
|
|
124
|
+
// model) and signal a num_predict/context cut (done_reason 'length') so the
|
|
125
|
+
// caller can warn rather than treat a truncated answer as complete.
|
|
126
|
+
function _emitOllamaDone(obj, opts) {
|
|
127
|
+
const inT = obj.prompt_eval_count;
|
|
128
|
+
const outT = obj.eval_count;
|
|
129
|
+
if ((inT != null || outT != null) && typeof opts.onUsage === 'function') {
|
|
130
|
+
try {
|
|
131
|
+
opts.onUsage({
|
|
132
|
+
inputTokens: inT ?? null,
|
|
133
|
+
outputTokens: outT ?? null,
|
|
134
|
+
totalTokens: (inT ?? 0) + (outT ?? 0),
|
|
135
|
+
totalCostUsd: 0,
|
|
136
|
+
});
|
|
137
|
+
} catch { /* never fail the stream on a usage callback */ }
|
|
138
|
+
}
|
|
139
|
+
if (obj.done_reason === 'length' && typeof opts.onTruncated === 'function') {
|
|
140
|
+
try { opts.onTruncated('length'); } catch { /* never fail the stream on a callback */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
67
144
|
export const ollamaProvider = {
|
|
68
145
|
name: 'ollama',
|
|
69
146
|
/**
|
|
@@ -87,13 +164,28 @@ export const ollamaProvider = {
|
|
|
87
164
|
|
|
88
165
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
89
166
|
|
|
167
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
168
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
169
|
+
// connection (or a user cancel) tears down the real socket.
|
|
170
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
171
|
+
const idleController = new AbortController();
|
|
172
|
+
const fetchSignal = opts.signal
|
|
173
|
+
? (typeof AbortSignal.any === 'function'
|
|
174
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
175
|
+
: idleController.signal)
|
|
176
|
+
: idleController.signal;
|
|
177
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
178
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
179
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
90
182
|
let res;
|
|
91
183
|
try {
|
|
92
184
|
res = await fetchFn(`${baseUrl}/api/chat`, {
|
|
93
185
|
method: 'POST',
|
|
94
186
|
headers: { 'content-type': 'application/json' },
|
|
95
187
|
body: JSON.stringify({ model, messages: apiMessages, stream: true }),
|
|
96
|
-
signal:
|
|
188
|
+
signal: fetchSignal,
|
|
97
189
|
});
|
|
98
190
|
} catch (err) {
|
|
99
191
|
// Distinguish "ollama isn't running" from generic network errors so
|
|
@@ -112,7 +204,10 @@ export const ollamaProvider = {
|
|
|
112
204
|
|
|
113
205
|
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
114
206
|
let buffer = '';
|
|
115
|
-
|
|
207
|
+
try {
|
|
208
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
209
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
210
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
116
211
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
117
212
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
118
213
|
// Ollama is newline-delimited JSON, not SSE — split on '\n', parse
|
|
@@ -125,7 +220,7 @@ export const ollamaProvider = {
|
|
|
125
220
|
try {
|
|
126
221
|
const obj = JSON.parse(line);
|
|
127
222
|
if (obj?.message?.content) yield obj.message.content;
|
|
128
|
-
if (obj?.done) return;
|
|
223
|
+
if (obj?.done) { _emitOllamaDone(obj, opts); return; }
|
|
129
224
|
if (obj?.error) throw new ApiError(500, obj.error);
|
|
130
225
|
} catch (err) {
|
|
131
226
|
if (err instanceof ApiError) throw err;
|
|
@@ -133,6 +228,11 @@ export const ollamaProvider = {
|
|
|
133
228
|
}
|
|
134
229
|
}
|
|
135
230
|
}
|
|
231
|
+
} finally {
|
|
232
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
233
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
136
236
|
// Any trailing bytes the decoder still holds onto get flushed and
|
|
137
237
|
// parsed once. Empty after a clean stream.
|
|
138
238
|
const tail = decoder.decode();
|
|
@@ -140,6 +240,7 @@ export const ollamaProvider = {
|
|
|
140
240
|
try {
|
|
141
241
|
const obj = JSON.parse(tail);
|
|
142
242
|
if (obj?.message?.content) yield obj.message.content;
|
|
243
|
+
if (obj?.done) _emitOllamaDone(obj, opts);
|
|
143
244
|
} catch { /* malformed tail — drop */ }
|
|
144
245
|
}
|
|
145
246
|
},
|
package/providers/openai.mjs
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
16
16
|
|
|
17
|
+
// OpenAI o-series reasoning models (o1/o3/o4...) reject `max_tokens` with
|
|
18
|
+
// HTTP 400 'Unsupported parameter: max_tokens, use max_completion_tokens'.
|
|
19
|
+
// They are surfaced in this provider's own suggestedModels (registry.mjs).
|
|
20
|
+
function isReasoningModel(model) {
|
|
21
|
+
return /^o\d/i.test(String(model || ''));
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
class InvalidApiKeyError extends Error {
|
|
18
25
|
constructor(message = 'invalid OpenAI api key') {
|
|
19
26
|
super(message);
|
|
@@ -41,6 +48,61 @@ class RateLimitError extends Error {
|
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
class TimeoutError extends Error {
|
|
52
|
+
constructor(idleMs) {
|
|
53
|
+
super(`openai: idle timeout — no stream activity for ${idleMs}ms`);
|
|
54
|
+
this.name = 'TimeoutError';
|
|
55
|
+
this.code = 'TIMEOUT';
|
|
56
|
+
this.idleMs = idleMs;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
61
|
+
|
|
62
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
63
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
64
|
+
function resolveIdleTimeoutMs(opts) {
|
|
65
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
66
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
67
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
68
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
72
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
73
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
74
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
75
|
+
// `controller` is aborted on idle expiry so the real connection tears down.
|
|
76
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
77
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
let timer;
|
|
81
|
+
const idle = new Promise((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
controller.abort();
|
|
84
|
+
reject(new TimeoutError(idleMs));
|
|
85
|
+
}, idleMs);
|
|
86
|
+
});
|
|
87
|
+
let step;
|
|
88
|
+
try {
|
|
89
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
if (step.done) return;
|
|
94
|
+
yield step.value;
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
98
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
99
|
+
// would re-hang us. The controller abort already tears down the socket.
|
|
100
|
+
if (typeof iterator.return === 'function') {
|
|
101
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
44
106
|
class ApiError extends Error {
|
|
45
107
|
constructor(status, body) {
|
|
46
108
|
super(`openai api ${status}: ${String(body).slice(0, 200)}`);
|
|
@@ -120,10 +182,14 @@ export const openaiProvider = {
|
|
|
120
182
|
|
|
121
183
|
const body = {
|
|
122
184
|
model,
|
|
123
|
-
max_tokens: opts.maxTokens || DEFAULT_MAX_TOKENS,
|
|
124
185
|
stream: true,
|
|
125
186
|
messages: apiMessages,
|
|
126
187
|
};
|
|
188
|
+
// Reasoning models take max_completion_tokens; everything else takes
|
|
189
|
+
// max_tokens. Never emit both — the o-series rejects max_tokens.
|
|
190
|
+
const tokenCap = opts.maxTokens || DEFAULT_MAX_TOKENS;
|
|
191
|
+
if (isReasoningModel(model)) body.max_completion_tokens = tokenCap;
|
|
192
|
+
else body.max_tokens = tokenCap;
|
|
127
193
|
// Tool-use passthrough mirrors the anthropic provider: opts.tools is an
|
|
128
194
|
// array of OpenAI-shaped tools. opts.toolChoice maps to tool_choice.
|
|
129
195
|
if (Array.isArray(opts.tools) && opts.tools.length > 0) {
|
|
@@ -140,6 +206,20 @@ export const openaiProvider = {
|
|
|
140
206
|
}
|
|
141
207
|
|
|
142
208
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
209
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
210
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
211
|
+
// connection (or a user cancel) tears down the real socket.
|
|
212
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
213
|
+
const idleController = new AbortController();
|
|
214
|
+
const fetchSignal = opts.signal
|
|
215
|
+
? (typeof AbortSignal.any === 'function'
|
|
216
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
217
|
+
: idleController.signal)
|
|
218
|
+
: idleController.signal;
|
|
219
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
220
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
221
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
222
|
+
}
|
|
143
223
|
const res = await fetchFn('https://api.openai.com/v1/chat/completions', {
|
|
144
224
|
method: 'POST',
|
|
145
225
|
headers: {
|
|
@@ -147,7 +227,7 @@ export const openaiProvider = {
|
|
|
147
227
|
'authorization': `Bearer ${opts.apiKey}`,
|
|
148
228
|
},
|
|
149
229
|
body: JSON.stringify(body),
|
|
150
|
-
signal:
|
|
230
|
+
signal: fetchSignal,
|
|
151
231
|
});
|
|
152
232
|
|
|
153
233
|
if (!res.ok) {
|
|
@@ -165,6 +245,7 @@ export const openaiProvider = {
|
|
|
165
245
|
// shape that mirrors the anthropic provider's onUsage payload so
|
|
166
246
|
// callers don't have to special-case per provider.
|
|
167
247
|
let usage = null;
|
|
248
|
+
let lastFinish = null;
|
|
168
249
|
// OpenAI streams tool_calls as deltas with an `index` we use as the
|
|
169
250
|
// accumulation key. Each delta may carry a partial id, name, and/or
|
|
170
251
|
// arguments string. We assemble until the stream signals
|
|
@@ -187,7 +268,25 @@ export const openaiProvider = {
|
|
|
187
268
|
});
|
|
188
269
|
} catch { /* never let a callback abort the stream */ }
|
|
189
270
|
};
|
|
190
|
-
|
|
271
|
+
// Fire onUsage + (if truncated) onTruncated EXACTLY ONCE — from the [DONE]
|
|
272
|
+
// sentinel, or from the post-loop flush if the upstream closed after the
|
|
273
|
+
// usage frame without ever sending [DONE].
|
|
274
|
+
let finalized = false;
|
|
275
|
+
const finalize = () => {
|
|
276
|
+
if (finalized) return;
|
|
277
|
+
finalized = true;
|
|
278
|
+
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
279
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
280
|
+
try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
|
|
281
|
+
}
|
|
282
|
+
if (lastFinish === 'length' && typeof opts.onTruncated === 'function') {
|
|
283
|
+
try { opts.onTruncated('length'); } catch { /* ditto */ }
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
288
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
289
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
191
290
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
192
291
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
193
292
|
let consumed = 0;
|
|
@@ -195,11 +294,7 @@ export const openaiProvider = {
|
|
|
195
294
|
consumed = frame.nextCursor;
|
|
196
295
|
if (!frame.data) continue;
|
|
197
296
|
if (frame.data === '[DONE]') {
|
|
198
|
-
|
|
199
|
-
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
200
|
-
if (usage && typeof opts.onUsage === 'function') {
|
|
201
|
-
try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
|
|
202
|
-
}
|
|
297
|
+
finalize();
|
|
203
298
|
return;
|
|
204
299
|
}
|
|
205
300
|
try {
|
|
@@ -226,6 +321,7 @@ export const openaiProvider = {
|
|
|
226
321
|
toolCallsByIndex.set(idx, cur);
|
|
227
322
|
}
|
|
228
323
|
}
|
|
324
|
+
if (choice?.finish_reason) lastFinish = choice.finish_reason;
|
|
229
325
|
if (choice?.finish_reason === 'tool_calls') {
|
|
230
326
|
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
231
327
|
}
|
|
@@ -235,6 +331,12 @@ export const openaiProvider = {
|
|
|
235
331
|
}
|
|
236
332
|
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
237
333
|
}
|
|
334
|
+
} finally {
|
|
335
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
336
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
finalize(); // flush captured usage/truncation if the stream ended without [DONE]
|
|
238
340
|
const tail = decoder.decode();
|
|
239
341
|
if (tail) buffer += tail;
|
|
240
342
|
},
|
|
@@ -48,6 +48,61 @@ class RateLimitError extends Error {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
class TimeoutError extends Error {
|
|
52
|
+
constructor(idleMs) {
|
|
53
|
+
super(`openai-compat: idle timeout — no stream activity for ${idleMs}ms`);
|
|
54
|
+
this.name = 'TimeoutError';
|
|
55
|
+
this.code = 'TIMEOUT';
|
|
56
|
+
this.idleMs = idleMs;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
61
|
+
|
|
62
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
63
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
64
|
+
function resolveIdleTimeoutMs(opts) {
|
|
65
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
66
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
67
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
68
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
72
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
73
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
74
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
75
|
+
// `controller` is aborted on idle expiry so the real connection tears down.
|
|
76
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
77
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
let timer;
|
|
81
|
+
const idle = new Promise((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
controller.abort();
|
|
84
|
+
reject(new TimeoutError(idleMs));
|
|
85
|
+
}, idleMs);
|
|
86
|
+
});
|
|
87
|
+
let step;
|
|
88
|
+
try {
|
|
89
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
if (step.done) return;
|
|
94
|
+
yield step.value;
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
98
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
99
|
+
// would re-hang us. The controller abort already tears down the socket.
|
|
100
|
+
if (typeof iterator.return === 'function') {
|
|
101
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
51
106
|
class ApiError extends Error {
|
|
52
107
|
constructor(status, body) {
|
|
53
108
|
super(`openai-compat ${status}: ${String(body).slice(0, 200)}`);
|
|
@@ -175,12 +230,26 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
175
230
|
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
176
231
|
|
|
177
232
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
233
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
234
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
235
|
+
// connection (or a user cancel) tears down the real socket.
|
|
236
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
237
|
+
const idleController = new AbortController();
|
|
238
|
+
const fetchSignal = opts.signal
|
|
239
|
+
? (typeof AbortSignal.any === 'function'
|
|
240
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
241
|
+
: idleController.signal)
|
|
242
|
+
: idleController.signal;
|
|
243
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
244
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
245
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
246
|
+
}
|
|
178
247
|
const url = `${baseUrl}/chat/completions`;
|
|
179
248
|
const res = await fetchFn(url, {
|
|
180
249
|
method: 'POST',
|
|
181
250
|
headers,
|
|
182
251
|
body: JSON.stringify(body),
|
|
183
|
-
signal:
|
|
252
|
+
signal: fetchSignal,
|
|
184
253
|
});
|
|
185
254
|
|
|
186
255
|
if (!res.ok) {
|
|
@@ -193,6 +262,7 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
193
262
|
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
194
263
|
let buffer = '';
|
|
195
264
|
let usage = null;
|
|
265
|
+
let lastFinish = null;
|
|
196
266
|
const toolCallsByIndex = new Map();
|
|
197
267
|
const flushToolCall = (idx) => {
|
|
198
268
|
const tc = toolCallsByIndex.get(idx);
|
|
@@ -211,7 +281,24 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
211
281
|
});
|
|
212
282
|
} catch { /* never let a callback abort the stream */ }
|
|
213
283
|
};
|
|
214
|
-
|
|
284
|
+
// Fire onUsage + (if truncated) onTruncated EXACTLY ONCE — from [DONE] or
|
|
285
|
+
// the post-loop flush if the upstream closes without the sentinel.
|
|
286
|
+
let finalized = false;
|
|
287
|
+
const finalize = () => {
|
|
288
|
+
if (finalized) return;
|
|
289
|
+
finalized = true;
|
|
290
|
+
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
291
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
292
|
+
try { opts.onUsage(usage); } catch { /* swallow */ }
|
|
293
|
+
}
|
|
294
|
+
if (lastFinish === 'length' && typeof opts.onTruncated === 'function') {
|
|
295
|
+
try { opts.onTruncated('length'); } catch { /* swallow */ }
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
300
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
301
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
215
302
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
216
303
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
217
304
|
let consumed = 0;
|
|
@@ -219,10 +306,7 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
219
306
|
consumed = frame.nextCursor;
|
|
220
307
|
if (!frame.data) continue;
|
|
221
308
|
if (frame.data === '[DONE]') {
|
|
222
|
-
|
|
223
|
-
if (usage && typeof opts.onUsage === 'function') {
|
|
224
|
-
try { opts.onUsage(usage); } catch { /* swallow */ }
|
|
225
|
-
}
|
|
309
|
+
finalize();
|
|
226
310
|
return;
|
|
227
311
|
}
|
|
228
312
|
try {
|
|
@@ -247,6 +331,7 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
247
331
|
toolCallsByIndex.set(idx, cur);
|
|
248
332
|
}
|
|
249
333
|
}
|
|
334
|
+
if (choice?.finish_reason) lastFinish = choice.finish_reason;
|
|
250
335
|
if (choice?.finish_reason === 'tool_calls') {
|
|
251
336
|
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
252
337
|
}
|
|
@@ -256,6 +341,12 @@ export function makeOpenAICompatProvider(cfg) {
|
|
|
256
341
|
}
|
|
257
342
|
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
258
343
|
}
|
|
344
|
+
} finally {
|
|
345
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
346
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
finalize(); // flush captured usage/truncation if the stream ended without [DONE]
|
|
259
350
|
const tail = decoder.decode();
|
|
260
351
|
if (tail) buffer += tail;
|
|
261
352
|
},
|