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.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. 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
- throw new Error('gemini-cli model listing needs a credential: set GEMINI_API_KEY or GOOGLE_API_KEY (the `gemini` CLI login token cannot list models)');
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
- throw new Error('codex-cli model listing needs a credential: set OPENAI_API_KEY (a ChatGPT-plan `codex` login has no platform API key)');
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) {
@@ -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: opts.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
- for await (const chunk of iterateBody(res.body)) {
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
  },
@@ -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: opts.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
- for await (const chunk of iterateBody(res.body)) {
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
- // Drain any tool calls that haven't been flushed by finish_reason.
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: opts.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
- for await (const chunk of iterateBody(res.body)) {
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
- for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
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
  },