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