@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +463 -0
  11. package/src/cli/commands.js +1150 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +132 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,645 @@
1
+ import OpenAI from 'openai';
2
+ import { CancelledError, ProviderError, isRecoverableError } from '../utils/errors.js';
3
+ import { redact } from '../utils/redaction.js';
4
+ import { toToolArgumentsString } from '../utils/validation.js';
5
+ import { MODEL_ID_PATTERN, MAX_MODEL_ID_LENGTH } from './catalog.js';
6
+
7
+ const RETRY_BASE_MS = 500;
8
+ const RETRY_MAX_MS = 8000;
9
+
10
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+
12
+ function abortableSleep(ms, signal) {
13
+ if (!signal) return sleep(ms);
14
+ return new Promise((resolve, reject) => {
15
+ const timer = setTimeout(() => {
16
+ signal.removeEventListener('abort', onAbort);
17
+ resolve();
18
+ }, ms);
19
+ const onAbort = () => {
20
+ clearTimeout(timer);
21
+ reject(new CancelledError());
22
+ };
23
+ signal.addEventListener('abort', onAbort, { once: true });
24
+ });
25
+ }
26
+
27
+ function statusOf(error) {
28
+ return error?.status ?? error?.response?.status ?? null;
29
+ }
30
+
31
+ const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT']);
32
+ const NETWORK_CODES = new Set([
33
+ 'ECONNREFUSED',
34
+ 'ENOTFOUND',
35
+ 'EAI_AGAIN',
36
+ 'ECONNRESET',
37
+ 'ECONNABORTED',
38
+ 'EPIPE',
39
+ 'EHOSTUNREACH',
40
+ 'ENETUNREACH',
41
+ 'UND_ERR_SOCKET',
42
+ ]);
43
+
44
+ /**
45
+ * True when a provider failure is worth retrying: the request may succeed on
46
+ * the next attempt (network blip, rate limit, server error) or the failure is
47
+ * known to be repairable locally (a poisoned tool-call payload).
48
+ */
49
+ export function isRecoverableProviderError(error) {
50
+ return isRecoverableError(error);
51
+ }
52
+
53
+ /**
54
+ * Walk error.cause (fetch/undici nesting) looking for a known code.
55
+ * The OpenAI SDK v7 wraps connection failures so the code can live two
56
+ * or three levels below the top-level error, with no top-level `code`.
57
+ */
58
+ function findCauseCode(error, codes, depth = 0) {
59
+ if (!error || depth > 6) return '';
60
+ const code = String(error.code || '');
61
+ if (codes.has(code)) return code;
62
+ return findCauseCode(error.cause, codes, depth + 1);
63
+ }
64
+
65
+ function causeDetail(error) {
66
+ const messages = [];
67
+ for (let current = error?.cause, depth = 0; current && depth < 4; current = current.cause, depth += 1) {
68
+ if (typeof current.message === 'string' && current.message) messages.push(current.message);
69
+ }
70
+ return messages.join(' / ').slice(0, 300);
71
+ }
72
+
73
+ function retryAfterMs(error) {
74
+ const headers = error?.headers ?? error?.response?.headers;
75
+ let value = null;
76
+ if (headers?.get) value = headers.get('retry-after');
77
+ else if (headers && typeof headers === 'object') value = headers['retry-after'];
78
+ if (!value) return null;
79
+ const seconds = Number(value);
80
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
81
+ const date = Date.parse(value);
82
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now());
83
+ return null;
84
+ }
85
+
86
+ /** Map raw SDK/network failures onto actionable ProviderError instances. */
87
+ export function mapProviderError(error, {
88
+ model,
89
+ providerName = 'NVIDIA',
90
+ apiKeyPath = 'providers.nvidia.apiKey',
91
+ docsUrl = null,
92
+ } = {}) {
93
+ if (error instanceof ProviderError) return error;
94
+ if (error instanceof CancelledError) return error;
95
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return new CancelledError('Request cancelled.');
96
+
97
+ const message = redact(String(error?.message || error || 'Unknown provider error')).slice(0, 800);
98
+ const status = statusOf(error);
99
+ const code = String(error?.code || '');
100
+ const errorName = String(error?.constructor?.name || error?.name || '');
101
+ const timeoutCode = findCauseCode(error, TIMEOUT_CODES);
102
+ const networkCode = findCauseCode(error, NETWORK_CODES);
103
+ const lower = message.toLowerCase();
104
+ const modelLabel = model ? `"${model}"` : 'the configured model';
105
+ const where = docsUrl ? ` Verify access on ${docsUrl}.` : '';
106
+
107
+ if (errorName === 'APIConnectionTimeoutError' || timeoutCode || /timed? ?out/.test(lower)) {
108
+ return new ProviderError(`The ${providerName} request timed out. ${message}`, {
109
+ code: 'PROVIDER_TIMEOUT',
110
+ retryable: true,
111
+ status,
112
+ cause: error,
113
+ });
114
+ }
115
+ if (errorName === 'APIConnectionError' || networkCode) {
116
+ const detail = causeDetail(error);
117
+ return new ProviderError(
118
+ `Cannot reach the ${providerName} API (${networkCode || 'connection error'}). ${message}${detail ? ` ${detail}` : ''}`,
119
+ { code: 'PROVIDER_NETWORK', retryable: true, status, cause: error },
120
+ );
121
+ }
122
+ if (status === 401) {
123
+ return new ProviderError(
124
+ `${providerName} API authentication failed (401). The key is invalid, revoked, or not loaded. Check ${apiKeyPath} in config.json and run /doctor.`,
125
+ { code: 'AUTH_INVALID', status },
126
+ );
127
+ }
128
+ if (status === 403) {
129
+ return new ProviderError(
130
+ `${providerName} API returned 403 Forbidden. Your key may not have access to ${modelLabel} or the endpoint.${where} ${message}`,
131
+ { code: 'AUTH_FORBIDDEN', status },
132
+ );
133
+ }
134
+ if (status === 404) {
135
+ return new ProviderError(
136
+ `${providerName} API returned 404. ${modelLabel} or the endpoint path may be wrong. Check the model id and base URL (expected .../v1). ${message}`,
137
+ { code: 'MODEL_NOT_FOUND', status },
138
+ );
139
+ }
140
+ if (status === 429) {
141
+ return new ProviderError(
142
+ `${providerName} API rate limit reached (429). Provider-side quotas may apply; wait and retry, or reduce request size. ${message}`,
143
+ { code: 'RATE_LIMIT', status, retryable: true },
144
+ );
145
+ }
146
+ if (status !== null && status >= 500) {
147
+ return new ProviderError(`${providerName} API server error (${status}). This is usually transient. ${message}`, {
148
+ code: 'SERVER_ERROR',
149
+ status,
150
+ retryable: true,
151
+ });
152
+ }
153
+ if (status === 400 || status === 422) {
154
+ if (/context|too many tokens|maximum.*token|token limit|input length/.test(lower)) {
155
+ return new ProviderError(
156
+ `The request exceeded the model context window. Start a new session or reduce context. ${message}`,
157
+ { code: 'CONTEXT_LENGTH', status },
158
+ );
159
+ }
160
+ // The provider validates every `function.arguments` string it receives.
161
+ // A single truncated tool call poisons the whole conversation, so this is
162
+ // recoverable: the history can be repaired and the request replayed.
163
+ if (/function\.arguments|must be a valid JSON object string|tool_calls\[/.test(lower)) {
164
+ return new ProviderError(
165
+ `The provider rejected a tool call in the conversation history because its arguments were not valid JSON. The history has been repaired; the request is being retried. ${message}`,
166
+ { code: 'INVALID_TOOL_ARGUMENTS', status, retryable: true },
167
+ );
168
+ }
169
+ if (/tool|function/.test(lower) && /not (support|available)|unsupported|invalid/.test(lower)) {
170
+ return new ProviderError(
171
+ `${modelLabel} may not support OpenAI-style tool calling on this endpoint. Try a model with tool-call support or use a smaller task. ${message}`,
172
+ { code: 'TOOLS_UNSUPPORTED', status },
173
+ );
174
+ }
175
+ return new ProviderError(`${providerName} API rejected the request (${status}). ${message}`, { code: 'BAD_REQUEST', status });
176
+ }
177
+ return new ProviderError(`Provider request failed: ${message}`, { code: 'PROVIDER_ERROR', status, retryable: false, cause: error });
178
+ }
179
+
180
+ /** Provider-exposed reasoning text (NVIDIA NIM uses `reasoning_content`). */
181
+ export function extractReasoning(message = {}) {
182
+ for (const key of ['reasoning_content', 'reasoning']) {
183
+ const value = message?.[key];
184
+ if (typeof value === 'string' && value) return value;
185
+ }
186
+ return '';
187
+ }
188
+
189
+ export function normalizeResponse(response, { durationMs = null } = {}) {
190
+ const choice = response?.choices?.[0];
191
+ if (!choice) {
192
+ throw new ProviderError('Provider returned no choices (empty response body).', {
193
+ code: 'EMPTY_RESPONSE',
194
+ retryable: true,
195
+ });
196
+ }
197
+ const message = choice.message || {};
198
+ const toolCalls = (message.tool_calls || []).map((call, index) => ({
199
+ id: call.id || `call_synthetic_${index}_${Date.now()}`,
200
+ name: call.function?.name || '',
201
+ argumentsText: typeof call.function?.arguments === 'string' ? call.function.arguments : JSON.stringify(call.function?.arguments ?? {}),
202
+ type: call.type || 'function',
203
+ })).filter((call) => call.name);
204
+
205
+ const usage = response.usage || {};
206
+ return {
207
+ id: response.id || null,
208
+ model: response.model || null,
209
+ content: typeof message.content === 'string' ? message.content : '',
210
+ reasoning: extractReasoning(message),
211
+ toolCalls,
212
+ finishReason: choice.finish_reason || null,
213
+ usage: {
214
+ prompt: usage.prompt_tokens ?? 0,
215
+ completion: usage.completion_tokens ?? 0,
216
+ total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
217
+ },
218
+ durationMs,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Accumulate OpenAI-compatible stream chunks. Tool-call names/arguments can be
224
+ * split across chunks and are keyed by their `index`.
225
+ */
226
+ export function createStreamAccumulator() {
227
+ return {
228
+ id: null,
229
+ model: null,
230
+ content: '',
231
+ reasoning: '',
232
+ finishReason: null,
233
+ usage: { prompt: 0, completion: 0, total: 0 },
234
+ toolCalls: new Map(),
235
+ choiceChunks: 0,
236
+ emitted: false,
237
+ };
238
+ }
239
+
240
+ export function absorbStreamChunk(acc, chunk, { onReasoning = null, onContent = null } = {}) {
241
+ if (chunk?.id) acc.id = chunk.id;
242
+ if (chunk?.model) acc.model = chunk.model;
243
+ if (chunk?.usage) {
244
+ acc.usage.prompt = chunk.usage.prompt_tokens ?? acc.usage.prompt;
245
+ acc.usage.completion = chunk.usage.completion_tokens ?? acc.usage.completion;
246
+ acc.usage.total = chunk.usage.total_tokens ?? acc.usage.total;
247
+ }
248
+ const choice = chunk?.choices?.[0];
249
+ if (!choice) return;
250
+ acc.choiceChunks += 1;
251
+ if (choice.finish_reason) acc.finishReason = choice.finish_reason;
252
+ const delta = choice.delta || {};
253
+ const reasoningDelta = typeof delta.reasoning_content === 'string'
254
+ ? delta.reasoning_content
255
+ : typeof delta.reasoning === 'string'
256
+ ? delta.reasoning
257
+ : '';
258
+ if (reasoningDelta) {
259
+ acc.reasoning += reasoningDelta;
260
+ acc.emitted = true;
261
+ onReasoning?.(reasoningDelta);
262
+ }
263
+ if (typeof delta.content === 'string' && delta.content) {
264
+ acc.content += delta.content;
265
+ acc.emitted = true;
266
+ onContent?.(delta.content);
267
+ }
268
+ for (const call of delta.tool_calls || []) {
269
+ const index = Number.isInteger(call.index) ? call.index : acc.toolCalls.size;
270
+ const entry = acc.toolCalls.get(index) || { id: null, name: '', argumentsText: '', type: 'function' };
271
+ if (call.id) entry.id = call.id;
272
+ if (call.type) entry.type = call.type;
273
+ if (call.function?.name) entry.name += call.function.name;
274
+ if (typeof call.function?.arguments === 'string') entry.argumentsText += call.function.arguments;
275
+ else if (call.function?.arguments) entry.argumentsText += JSON.stringify(call.function.arguments);
276
+ acc.toolCalls.set(index, entry);
277
+ }
278
+ }
279
+
280
+ export function finalizeStream(acc, { durationMs = null } = {}) {
281
+ if (acc.choiceChunks === 0) {
282
+ throw new ProviderError('Provider stream ended without any choices.', { code: 'EMPTY_RESPONSE', retryable: true });
283
+ }
284
+ const toolCalls = [...acc.toolCalls.entries()]
285
+ .sort(([a], [b]) => a - b)
286
+ .map(([index, call]) => ({
287
+ id: call.id || `call_synthetic_${index}_${Date.now()}`,
288
+ name: call.name,
289
+ argumentsText: call.argumentsText || '{}',
290
+ type: call.type || 'function',
291
+ }))
292
+ .filter((call) => call.name);
293
+ return {
294
+ id: acc.id,
295
+ model: acc.model,
296
+ content: acc.content,
297
+ reasoning: acc.reasoning,
298
+ toolCalls,
299
+ finishReason: acc.finishReason,
300
+ usage: acc.usage,
301
+ durationMs,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Client for any OpenAI-compatible Chat Completions endpoint.
307
+ *
308
+ * The class name is historical (NVIDIA was the first provider); every provider
309
+ * in the catalog uses it with its own descriptor. `providerName` and
310
+ * `apiKeyPath` only affect user-facing error text — credentials are never
311
+ * printed, and a provider is never called without its own key.
312
+ */
313
+ export class NvidiaProvider {
314
+ constructor(config, { client = null, logger = null, fetchImpl = null, sleepImpl = null } = {}) {
315
+ if (!config?.apiKey) {
316
+ const path = config?.apiKeyPath || `providers.${config?.providerId || 'nvidia'}.apiKey`;
317
+ throw new ProviderError(
318
+ `Provider ${config?.providerId || 'nvidia'} has no API key. Set ${path} in config.json (\`ryuu --settings\`) before using it.`,
319
+ { code: 'AUTH_MISSING' },
320
+ );
321
+ }
322
+ this.config = config;
323
+ this.logger = logger;
324
+ this.sleep = sleepImpl || abortableSleep;
325
+ this.fetchImpl = fetchImpl;
326
+ // An injected client is used verbatim (tests); otherwise the SDK client is
327
+ // rebuilt whenever the selection changes.
328
+ this.externalClient = Boolean(client);
329
+ this.client = client || this.#createClient();
330
+ }
331
+
332
+ #createClient() {
333
+ return new OpenAI({
334
+ apiKey: this.config.apiKey,
335
+ baseURL: this.config.baseURL,
336
+ timeout: this.config.requestTimeoutMs,
337
+ maxRetries: 0,
338
+ ...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
339
+ });
340
+ }
341
+
342
+ get model() {
343
+ return this.config.model;
344
+ }
345
+
346
+ /** Declared provider capabilities; unknown means "assume the usual". */
347
+ get capabilities() {
348
+ return this.config.capabilities || null;
349
+ }
350
+
351
+ get providerId() {
352
+ return this.config.providerId || null;
353
+ }
354
+
355
+ get providerName() {
356
+ return this.config.providerName || 'NVIDIA';
357
+ }
358
+
359
+ get apiKeyPath() {
360
+ return this.config.apiKeyPath || `providers.${this.providerId || 'nvidia'}.apiKey`;
361
+ }
362
+
363
+ /**
364
+ * Repoint this provider at another provider or model without rebuilding the
365
+ * agent (conversation memory and the worklog stay intact).
366
+ */
367
+ updateSelection({ providerId, providerName, apiKeyPath, docsUrl = null, baseURL, apiKey, model, capabilities = null } = {}) {
368
+ if (!apiKey) {
369
+ const path = apiKeyPath || `providers.${providerId || 'unknown'}.apiKey`;
370
+ throw new ProviderError(
371
+ `Provider ${providerId || 'unknown'} has no API key. Set ${path} in config.json before switching.`,
372
+ { code: 'AUTH_MISSING' },
373
+ );
374
+ }
375
+ if (model && (!MODEL_ID_PATTERN.test(model) || model.length > MAX_MODEL_ID_LENGTH)) {
376
+ throw new ProviderError(`Invalid model id: ${JSON.stringify(model)}`, { code: 'INVALID_MODEL' });
377
+ }
378
+ // Mutate in place: the app shares this object as its effective config.
379
+ Object.assign(this.config, {
380
+ providerId: providerId || this.config.providerId,
381
+ providerName: providerName || this.config.providerName,
382
+ apiKeyPath: apiKeyPath || this.config.apiKeyPath,
383
+ docsUrl,
384
+ baseURL: baseURL || this.config.baseURL,
385
+ apiKey,
386
+ model: model || this.config.model,
387
+ capabilities: capabilities || this.config.capabilities || null,
388
+ });
389
+ if (!this.externalClient) this.client = this.#createClient();
390
+ return this;
391
+ }
392
+
393
+ /**
394
+ * Send a chat completion request with bounded exponential backoff.
395
+ * Never logs the API key or request headers.
396
+ */
397
+ async chat({
398
+ messages,
399
+ tools = [],
400
+ signal = null,
401
+ maxTokens = null,
402
+ stream = false,
403
+ onReasoning = null,
404
+ onContent = null,
405
+ onFirstToken = null,
406
+ stallTimeoutMs = 0,
407
+ timeoutMs = null,
408
+ } = {}) {
409
+ if (!Array.isArray(messages) || messages.length === 0) {
410
+ throw new ProviderError('Cannot call the provider without messages.', { code: 'BAD_REQUEST' });
411
+ }
412
+ // Validate the selection before any bytes leave the machine.
413
+ const selectedModel = String(this.config.model || '');
414
+ if (!selectedModel || !MODEL_ID_PATTERN.test(selectedModel) || selectedModel.length > MAX_MODEL_ID_LENGTH) {
415
+ throw new ProviderError(
416
+ `Invalid model id for provider ${this.config.providerId || 'unknown'}: ${JSON.stringify(this.config.model)}. Pick one with /model (or set the provider's model variable).`,
417
+ { code: 'INVALID_MODEL' },
418
+ );
419
+ }
420
+ const capabilities = this.config.capabilities || null;
421
+ const supportsTools = capabilities?.tools !== false;
422
+ const streaming = Boolean(stream) && capabilities?.streaming !== false;
423
+ if (tools.length > 0 && !supportsTools) {
424
+ this.logger?.warn('provider does not support tool calling; tools are not sent', {
425
+ provider: this.config.providerId,
426
+ });
427
+ }
428
+ // Never let a broken tool-call payload reach the provider: one invalid
429
+ // `function.arguments` string fails the whole request with a 400.
430
+ const sanitized = sanitizeMessagesForRequest(messages);
431
+ if (sanitized.repaired > 0) {
432
+ this.logger?.warn('repaired invalid tool-call arguments before sending', { count: sanitized.repaired });
433
+ }
434
+ const body = {
435
+ model: selectedModel,
436
+ messages: sanitized.messages,
437
+ max_tokens: Math.max(64, Math.min(maxTokens ?? this.config.maxOutputTokens, 32768)),
438
+ stream: streaming,
439
+ };
440
+ if (tools.length > 0 && supportsTools) {
441
+ body.tools = tools;
442
+ body.tool_choice = 'auto';
443
+ }
444
+
445
+ const startedAt = Date.now();
446
+ const effectiveTimeout = Math.max(1000, Number(timeoutMs ?? this.config.requestTimeoutMs) || 1000);
447
+ let attempt = 0;
448
+ for (;;) {
449
+ if (signal?.aborted) throw new CancelledError();
450
+ let emitted = false;
451
+ let streamedReasoning = '';
452
+ // Per-attempt stream state. Declared outside the inner try so the catch
453
+ // can tell a provider stall apart from a cancellation or a real error.
454
+ let controller = null;
455
+ let stalled = false;
456
+ let stallTimer = null;
457
+ let detachAbort = () => {};
458
+ const clearStall = () => {
459
+ if (stallTimer) {
460
+ clearTimeout(stallTimer);
461
+ stallTimer = null;
462
+ }
463
+ };
464
+ try {
465
+ let normalized;
466
+ if (streaming) {
467
+ const request = {
468
+ ...body,
469
+ stream: true,
470
+ stream_options: { include_usage: true },
471
+ };
472
+ // A stream can stay open indefinitely: the request timeout only
473
+ // covers the request, not a server that dribbles bytes forever. This
474
+ // controller adds a per-chunk idle watchdog on top of the timeout.
475
+ controller = new AbortController();
476
+ if (signal) {
477
+ if (signal.aborted) controller.abort();
478
+ else {
479
+ const onAbort = () => controller.abort();
480
+ signal.addEventListener('abort', onAbort, { once: true });
481
+ detachAbort = () => signal.removeEventListener('abort', onAbort);
482
+ }
483
+ }
484
+ const idleMs = Math.max(0, Number(stallTimeoutMs) || 0);
485
+ const armStall = () => {
486
+ if (!idleMs) return;
487
+ clearStall();
488
+ stallTimer = setTimeout(() => {
489
+ stalled = true;
490
+ controller.abort();
491
+ }, idleMs);
492
+ stallTimer.unref?.();
493
+ };
494
+ armStall();
495
+ const iterator = await this.client.chat.completions.create(request, {
496
+ signal: controller.signal,
497
+ timeout: effectiveTimeout,
498
+ });
499
+ const acc = createStreamAccumulator();
500
+ let firstTokenSeen = false;
501
+ for await (const chunk of iterator) {
502
+ if (signal?.aborted) throw new CancelledError();
503
+ armStall();
504
+ absorbStreamChunk(acc, chunk, {
505
+ onReasoning: (text) => {
506
+ emitted = true;
507
+ streamedReasoning += text;
508
+ onReasoning?.(text);
509
+ },
510
+ onContent: (text) => {
511
+ emitted = true;
512
+ onContent?.(text);
513
+ },
514
+ });
515
+ if (!firstTokenSeen && (acc.emitted || acc.toolCalls.size > 0)) {
516
+ firstTokenSeen = true;
517
+ onFirstToken?.({ latencyMs: Date.now() - startedAt });
518
+ }
519
+ }
520
+ normalized = finalizeStream(acc, { durationMs: Date.now() - startedAt });
521
+ } else {
522
+ const response = await this.client.chat.completions.create(body, {
523
+ signal: signal || undefined,
524
+ timeout: effectiveTimeout,
525
+ });
526
+ normalized = normalizeResponse(response, { durationMs: Date.now() - startedAt });
527
+ }
528
+ this.logger?.debug('provider response', {
529
+ provider: this.config.providerId,
530
+ model: normalized.model,
531
+ stream: streaming,
532
+ toolCalls: normalized.toolCalls.length,
533
+ reasoningChars: (normalized.reasoning || '').length,
534
+ finishReason: normalized.finishReason,
535
+ usage: normalized.usage,
536
+ durationMs: normalized.durationMs,
537
+ attempt,
538
+ });
539
+ return normalized;
540
+ } catch (raw) {
541
+ clearStall();
542
+ detachAbort();
543
+ if (signal?.aborted) throw new CancelledError();
544
+ if (stalled) {
545
+ const seconds = Math.round((Number(stallTimeoutMs) || 0) / 1000);
546
+ this.logger?.warn('provider stream stalled', { attempt, idleSeconds: seconds, emitted });
547
+ throw new ProviderError(
548
+ `The response stream stopped sending data for ${seconds}s and was aborted. The model or endpoint is not responding; nothing was executed.`,
549
+ { code: 'STREAM_STALLED', retryable: !emitted },
550
+ );
551
+ }
552
+ const error = mapProviderError(raw, {
553
+ model: this.config.model,
554
+ providerName: this.providerName,
555
+ apiKeyPath: this.apiKeyPath,
556
+ docsUrl: this.config.docsUrl || null,
557
+ });
558
+ if (emitted) {
559
+ // Retrying after partial output would duplicate reasoning/content.
560
+ this.logger?.warn('provider stream interrupted', { code: error.code, attempt });
561
+ throw new ProviderError(
562
+ `The response stream was interrupted after partial output (${streamedReasoning.length} reasoning chars received); it was not retried to avoid duplicate text. ${error.message}`,
563
+ { code: 'STREAM_INTERRUPTED', retryable: false, status: error.status, cause: error },
564
+ );
565
+ }
566
+ const canRetry = error.retryable && attempt < this.config.maxRetries;
567
+ if (!canRetry) {
568
+ this.logger?.warn('provider request failed', { code: error.code, status: error.status, attempt });
569
+ throw error;
570
+ }
571
+ const backoff = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
572
+ const jittered = Math.round(backoff * (0.7 + Math.random() * 0.6));
573
+ const delay = Math.max(jittered, retryAfterMs(raw) ?? 0);
574
+ this.logger?.warn('provider request retry', {
575
+ code: error.code,
576
+ status: error.status,
577
+ attempt: attempt + 1,
578
+ maxRetries: this.config.maxRetries,
579
+ delayMs: delay,
580
+ });
581
+ await this.sleep(delay, signal);
582
+ attempt += 1;
583
+ }
584
+ }
585
+ }
586
+
587
+ /** Safe connectivity check. Returns structured status; never prints the key. */
588
+ async ping({ signal = null } = {}) {
589
+ const startedAt = Date.now();
590
+ try {
591
+ const response = await this.chat({
592
+ messages: [
593
+ { role: 'system', content: 'You are a connectivity probe. Reply with exactly: OK' },
594
+ { role: 'user', content: 'Reply with OK.' },
595
+ ],
596
+ maxTokens: 16,
597
+ signal,
598
+ });
599
+ return {
600
+ ok: true,
601
+ model: response.model || this.config.model,
602
+ latencyMs: Date.now() - startedAt,
603
+ sample: (response.content || '').trim().slice(0, 40),
604
+ usage: response.usage,
605
+ };
606
+ } catch (error) {
607
+ return {
608
+ ok: false,
609
+ model: this.config.model,
610
+ latencyMs: Date.now() - startedAt,
611
+ error: { code: error.code || 'PROVIDER_ERROR', message: error.message, status: error.status ?? null },
612
+ };
613
+ }
614
+ }
615
+ }
616
+
617
+ export function createProvider(config, deps = {}) {
618
+ return new NvidiaProvider(config, deps);
619
+ }
620
+
621
+ /**
622
+ * Guarantee that every tool call in the request carries a valid JSON object
623
+ * string.
624
+ *
625
+ * The provider validates the whole conversation on every request, so one
626
+ * truncated payload anywhere in the history makes the request fail with a 400
627
+ * and the task dies. Repairing the payload here is the last line of defence:
628
+ * the agent already sanitizes what it stores, but a session resumed from disk
629
+ * or a plugin-provided message could still carry a broken payload.
630
+ */
631
+ export function sanitizeMessagesForRequest(messages = []) {
632
+ let repaired = 0;
633
+ const sanitized = messages.map((message) => {
634
+ if (!message || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) return message;
635
+ const toolCalls = message.tool_calls.map((call) => {
636
+ const fn = call?.function || {};
637
+ const original = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {});
638
+ const fixed = toToolArgumentsString(original);
639
+ if (fixed !== original) repaired += 1;
640
+ return { ...call, function: { ...fn, arguments: fixed } };
641
+ });
642
+ return { ...message, tool_calls: toolCalls };
643
+ });
644
+ return { messages: sanitized, repaired };
645
+ }