kritya 0.8.2-beta

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,460 @@
1
+ import OpenAI from "openai";
2
+ import { NVIDIA_BASE_URL } from "../config/config.js";
3
+ import { NOOP_TRACER } from "../telemetry/tracer.js";
4
+ import { recoverToolCalls } from "./textToolCalls.js";
5
+ /** Retry transient provider failures (429 / 5xx / network) with backoff. */
6
+ const MAX_ATTEMPTS = 4;
7
+ /**
8
+ * Thrown when a stream that opened successfully then goes quiet for longer
9
+ * than the idle timeout. This is its own error because it is invisible to
10
+ * every other guard: the request succeeded, nothing threw, and the socket is
11
+ * still open — the turn simply hangs forever with a spinner on it. Treated as
12
+ * retryable, since a provider that stalls mid-answer is the same class of
13
+ * transient failure as one that resets the connection.
14
+ */
15
+ export class StreamIdleError extends Error {
16
+ idleMs;
17
+ constructor(idleMs) {
18
+ super(`Provider stopped sending data for ${Math.round(idleMs / 1000)}s`);
19
+ this.name = "StreamIdleError";
20
+ this.idleMs = idleMs;
21
+ }
22
+ }
23
+ /**
24
+ * Transport-level error codes that mean "try again", beyond the four obvious
25
+ * ones. The UND_ERR_* family comes from undici (Node's fetch, which the OpenAI
26
+ * SDK uses) and shows up on exactly the flaky-network conditions retries exist
27
+ * for; ERR_STREAM_PREMATURE_CLOSE is what a cut response stream surfaces as.
28
+ */
29
+ const RETRYABLE_CODES = new Set([
30
+ "ECONNRESET",
31
+ "ECONNREFUSED",
32
+ "ETIMEDOUT",
33
+ "ENOTFOUND",
34
+ "EAI_AGAIN",
35
+ "EPIPE",
36
+ "EHOSTUNREACH",
37
+ "ENETUNREACH",
38
+ "ENETRESET",
39
+ "ERR_STREAM_PREMATURE_CLOSE",
40
+ "UND_ERR_SOCKET",
41
+ "UND_ERR_CONNECT_TIMEOUT",
42
+ "UND_ERR_HEADERS_TIMEOUT",
43
+ "UND_ERR_BODY_TIMEOUT",
44
+ ]);
45
+ /** OpenAI SDK error classes that always mean a transport failure, not a bad request. */
46
+ const RETRYABLE_ERROR_NAMES = new Set([
47
+ "APIConnectionError",
48
+ "APIConnectionTimeoutError",
49
+ "StreamIdleError",
50
+ ]);
51
+ /**
52
+ * A 404 whose response body was completely empty.
53
+ *
54
+ * NVIDIA's gateway returns these intermittently for a model that is working
55
+ * fine — the identical request succeeds on an immediate retry. A real "no
56
+ * such model" always carries a body (that gateway sends the text "404 page
57
+ * not found"; OpenAI-compatible providers send a JSON error), so an empty
58
+ * one is a transport-level blip rather than a verdict on the request.
59
+ *
60
+ * The SDK doesn't expose the raw body, so this keys off the message it
61
+ * synthesizes when there was nothing to parse (APIError.makeMessage).
62
+ * Rebuilding that string from the status rather than substring-matching
63
+ * keeps a genuine body that happens to contain the phrase from qualifying;
64
+ * client.test.ts pins the wording so an SDK change fails there instead of
65
+ * silently disabling this.
66
+ */
67
+ function isEmptyBodyNotFound(err) {
68
+ const status = err?.status;
69
+ if (status !== 404)
70
+ return false;
71
+ return err?.message === `${status} status code (no body)`;
72
+ }
73
+ /** Exported for tests; the retry loop below is the only real caller. */
74
+ export function isRetryable(err) {
75
+ if (err instanceof StreamIdleError)
76
+ return true;
77
+ const status = err?.status;
78
+ // 408 Request Timeout joins 429/5xx: the request never got a verdict, so
79
+ // re-sending it is safe and is usually the thing that works.
80
+ if (status === 429 || status === 408 || (typeof status === "number" && status >= 500))
81
+ return true;
82
+ // A 404 normally means "no such model" and must fail fast; an empty-bodied
83
+ // one is a gateway blip. See isEmptyBodyNotFound.
84
+ if (isEmptyBodyNotFound(err))
85
+ return true;
86
+ const name = err?.name;
87
+ if (name && RETRYABLE_ERROR_NAMES.has(name))
88
+ return true;
89
+ const code = err?.code;
90
+ if (code && RETRYABLE_CODES.has(code))
91
+ return true;
92
+ // undici nests the real cause one level down (fetch failed → cause).
93
+ const cause = err?.cause;
94
+ if (cause && cause !== err) {
95
+ const causeCode = cause?.code;
96
+ if (causeCode && RETRYABLE_CODES.has(causeCode))
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ /** Read one header case-insensitively from whatever shape the SDK attached. */
102
+ function headerValue(err, name) {
103
+ const headers = err?.headers;
104
+ if (!headers)
105
+ return undefined;
106
+ const get = headers.get;
107
+ if (typeof get === "function")
108
+ return get.call(headers, name) ?? undefined;
109
+ for (const [k, v] of Object.entries(headers)) {
110
+ if (k.toLowerCase() === name)
111
+ return typeof v === "string" ? v : String(v);
112
+ }
113
+ return undefined;
114
+ }
115
+ /** Longest Retry-After we'll honor. Beyond this, our own backoff and the
116
+ * user's patience are the better answer than sleeping for minutes. */
117
+ const MAX_RETRY_AFTER_MS = 60_000;
118
+ /**
119
+ * The provider's own `Retry-After`, in milliseconds, when it sent one. Backing
120
+ * off for less than a rate limiter asked for just burns another attempt
121
+ * against the same closed window — which is exactly how a four-attempt budget
122
+ * evaporates in two seconds on a free tier. Both header forms are accepted:
123
+ * delay-seconds and an HTTP-date. Exported for tests.
124
+ */
125
+ export function retryAfterMs(err) {
126
+ const raw = headerValue(err, "retry-after")?.trim();
127
+ if (!raw)
128
+ return undefined;
129
+ const seconds = Number(raw);
130
+ const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now();
131
+ if (!Number.isFinite(ms) || ms <= 0)
132
+ return undefined;
133
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
134
+ }
135
+ /** Message fragments providers use when the prompt exceeds the context window. */
136
+ const CONTEXT_OVERFLOW_RE = /context[ _-]?length|context[ _-]?window|maximum context|too many tokens|reduce the length|prompt is too long|input is too long/i;
137
+ /**
138
+ * Whether a failure means "this prompt does not fit", as opposed to any other
139
+ * bad request. It is worth separating because it is the one 400 with an
140
+ * automatic remedy — compact the history and the same turn can continue —
141
+ * whereas every other 400 is a genuine hard failure. Providers disagree on the
142
+ * wording and on the error `code`, so both are checked.
143
+ */
144
+ export function isContextOverflowError(err) {
145
+ const status = err?.status;
146
+ if (typeof status === "number" && status !== 400 && status !== 413)
147
+ return false;
148
+ const code = err?.code ?? "";
149
+ if (code === "context_length_exceeded" || code === "string_above_max_length")
150
+ return true;
151
+ const message = err instanceof Error ? err.message : String(err ?? "");
152
+ return CONTEXT_OVERFLOW_RE.test(message);
153
+ }
154
+ /**
155
+ * Thrown when a transient provider failure (429 / 5xx / network) survives
156
+ * every retry attempt. Distinguishing this from a hard failure (bad request,
157
+ * auth error, etc.) lets callers offer a targeted next step — e.g. "try
158
+ * another provider" — rather than a generic error message.
159
+ */
160
+ export class RetryExhaustedError extends Error {
161
+ status;
162
+ attempts;
163
+ cause;
164
+ constructor(cause, attempts) {
165
+ const status = cause?.status;
166
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
167
+ super(`Provider request failed after ${attempts} attempt(s)` +
168
+ (status ? ` (last status ${status})` : "") +
169
+ `: ${causeMsg}`);
170
+ this.name = "RetryExhaustedError";
171
+ this.status = status;
172
+ this.attempts = attempts;
173
+ this.cause = cause;
174
+ }
175
+ }
176
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
177
+ const t = setTimeout(resolve, ms);
178
+ signal?.addEventListener("abort", () => {
179
+ clearTimeout(t);
180
+ reject(new DOMException("Aborted", "AbortError"));
181
+ });
182
+ });
183
+ const DEFAULT_TEMPERATURE = 0.2;
184
+ const DEFAULT_TOP_P = 0.95;
185
+ const DEFAULT_MAX_TOKENS = 8192;
186
+ const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
187
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 60_000;
188
+ export class ProviderClient {
189
+ client;
190
+ temperature;
191
+ topP;
192
+ maxTokens;
193
+ streamIdleTimeoutMs;
194
+ constructor(apiKey, baseURL = NVIDIA_BASE_URL, sampling = {}, timeouts = {}) {
195
+ // We do our own streaming-aware retry loop, so disable the SDK's.
196
+ this.client = new OpenAI({
197
+ apiKey,
198
+ baseURL,
199
+ maxRetries: 0,
200
+ timeout: timeouts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
201
+ });
202
+ this.streamIdleTimeoutMs = timeouts.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
203
+ this.temperature =
204
+ sampling.temperature === null ? undefined : (sampling.temperature ?? DEFAULT_TEMPERATURE);
205
+ this.topP = sampling.topP === null ? undefined : (sampling.topP ?? DEFAULT_TOP_P);
206
+ this.maxTokens =
207
+ sampling.maxTokens === null ? undefined : (sampling.maxTokens ?? DEFAULT_MAX_TOKENS);
208
+ }
209
+ /**
210
+ * One `llm.chat` span covers the whole request *including* its retries and
211
+ * backoff, so the span's duration is the wall-clock cost the user actually
212
+ * paid. Each retry is recorded as an event on it, which is what turns "the
213
+ * turn felt slow" into "we were rate-limited three times".
214
+ */
215
+ async chat(model, messages, tools, callbacks, signal, trace) {
216
+ const span = (trace?.tracer ?? NOOP_TRACER).startSpan("llm.chat", {
217
+ parent: trace?.parent,
218
+ attributes: {
219
+ "kritya.model": model,
220
+ "kritya.message_count": messages.length,
221
+ "kritya.tool_count": tools.length,
222
+ },
223
+ });
224
+ let lastErr;
225
+ try {
226
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
227
+ try {
228
+ const result = await this.chatOnce(model, messages, tools, callbacks, signal);
229
+ span.setAttribute("kritya.attempts", attempt + 1);
230
+ span.setAttribute("kritya.tool_call_count", result.toolCalls.length);
231
+ if (result.usage) {
232
+ span.setAttribute("kritya.prompt_tokens", result.usage.promptTokens);
233
+ span.setAttribute("kritya.completion_tokens", result.usage.completionTokens);
234
+ span.setAttribute("kritya.cached_tokens", result.usage.cachedPromptTokens ?? 0);
235
+ }
236
+ else {
237
+ // The caller will fall back to estimating; say so on the span
238
+ // rather than leaving the token attributes silently absent.
239
+ span.setAttribute("kritya.usage_reported", false);
240
+ }
241
+ span.setStatus("OK");
242
+ return result;
243
+ }
244
+ catch (err) {
245
+ if (signal?.aborted || err?.name === "AbortError")
246
+ throw err;
247
+ lastErr = err;
248
+ if (!isRetryable(err))
249
+ throw err;
250
+ if (attempt === MAX_ATTEMPTS - 1)
251
+ throw new RetryExhaustedError(err, MAX_ATTEMPTS);
252
+ const status = err?.status;
253
+ // The provider's own Retry-After wins when it asks for longer than
254
+ // our schedule — under-waiting a rate limit just spends the next
255
+ // attempt on the same closed window.
256
+ const ownBackoffMs = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 250;
257
+ const serverBackoffMs = retryAfterMs(err);
258
+ const backoffMs = Math.max(ownBackoffMs, serverBackoffMs ?? 0);
259
+ span.addEvent("retry", {
260
+ "kritya.attempt": attempt + 1,
261
+ "kritya.backoff_ms": Math.round(backoffMs),
262
+ ...(serverBackoffMs !== undefined
263
+ ? { "kritya.retry_after_ms": Math.round(serverBackoffMs) }
264
+ : {}),
265
+ ...(status !== undefined ? { "kritya.status": status } : {}),
266
+ });
267
+ callbacks.onRetry?.(attempt + 1, status);
268
+ await sleep(backoffMs, signal);
269
+ }
270
+ }
271
+ throw lastErr;
272
+ }
273
+ catch (err) {
274
+ span.setStatus("ERROR", err instanceof Error ? err.message : String(err));
275
+ throw err;
276
+ }
277
+ finally {
278
+ span.end();
279
+ }
280
+ }
281
+ /**
282
+ * A single non-streaming completion, no tools, no retry loop — for
283
+ * server-initiated MCP sampling requests, which want one answer back, not
284
+ * a full agentic turn.
285
+ */
286
+ async complete(model, messages, maxTokens, signal) {
287
+ const response = await this.client.chat.completions.create({
288
+ model,
289
+ messages,
290
+ stream: false,
291
+ max_tokens: maxTokens ?? this.maxTokens,
292
+ temperature: this.temperature,
293
+ top_p: this.topP,
294
+ }, { signal });
295
+ const choice = response.choices[0];
296
+ if (!choice)
297
+ throw new Error("provider returned no completion");
298
+ return {
299
+ text: choice.message.content ?? "",
300
+ model: response.model,
301
+ stopReason: choice.finish_reason ?? "stop",
302
+ };
303
+ }
304
+ async chatOnce(model, messages, tools, callbacks, signal) {
305
+ const stream = await this.client.chat.completions.create({
306
+ model,
307
+ messages,
308
+ tools: tools.length
309
+ ? tools.map((t) => ({
310
+ type: "function",
311
+ function: {
312
+ name: t.name,
313
+ description: t.description,
314
+ parameters: t.parameters,
315
+ },
316
+ }))
317
+ : undefined,
318
+ ...(this.temperature !== undefined ? { temperature: this.temperature } : {}),
319
+ ...(this.topP !== undefined ? { top_p: this.topP } : {}),
320
+ ...(this.maxTokens !== undefined ? { max_tokens: this.maxTokens } : {}),
321
+ stream: true,
322
+ stream_options: { include_usage: true },
323
+ }, { signal });
324
+ let text = "";
325
+ const calls = new Map();
326
+ let usage;
327
+ let servedModel;
328
+ for await (const chunk of this.withIdleWatchdog(stream)) {
329
+ if (chunk.usage) {
330
+ usage = {
331
+ promptTokens: chunk.usage.prompt_tokens ?? 0,
332
+ completionTokens: chunk.usage.completion_tokens ?? 0,
333
+ cachedPromptTokens: chunk.usage.prompt_tokens_details?.cached_tokens ?? 0,
334
+ };
335
+ }
336
+ // Behind a router the chunk's own `model` is the real one that served
337
+ // it, not the route name that was requested — grab it once, from
338
+ // whichever chunk happens to carry it first.
339
+ if (!servedModel && chunk.model)
340
+ servedModel = chunk.model;
341
+ const delta = chunk.choices?.[0]?.delta;
342
+ if (!delta)
343
+ continue;
344
+ // Some NVIDIA-hosted models (DeepSeek R1, Nemotron reasoning modes) stream
345
+ // thinking on `reasoning_content`; OpenRouter and others use `reasoning`.
346
+ const reasoning = delta.reasoning_content ??
347
+ delta.reasoning;
348
+ if (reasoning)
349
+ callbacks.onReasoningDelta(reasoning);
350
+ if (delta.content) {
351
+ text += delta.content;
352
+ callbacks.onTextDelta(delta.content);
353
+ }
354
+ for (const tc of delta.tool_calls ?? []) {
355
+ const entry = calls.get(tc.index) ?? { id: "", name: "", argsJson: "" };
356
+ if (tc.id)
357
+ entry.id = tc.id;
358
+ if (tc.function?.name)
359
+ entry.name += tc.function.name;
360
+ if (tc.function?.arguments)
361
+ entry.argsJson += tc.function.arguments;
362
+ calls.set(tc.index, entry);
363
+ }
364
+ }
365
+ let toolCalls = [...calls.entries()]
366
+ .sort(([a], [b]) => a - b)
367
+ .map(([i, c]) => ({
368
+ id: c.id || `call_${i}`,
369
+ name: c.name,
370
+ argsJson: c.argsJson || "{}",
371
+ }));
372
+ // A model that writes its tool call into the text channel instead would
373
+ // otherwise end the turn here: no tool call, so the loop treats the JSON as
374
+ // the final answer, prints it, and the action never runs. Recovering it is
375
+ // narrow enough to be safe (see textToolCalls.ts) — and when it fires, the
376
+ // text *was* the call, so it must not also be shown as an answer.
377
+ let visibleText = text;
378
+ if (toolCalls.length === 0 && text.trim()) {
379
+ const recovered = recoverToolCalls(text, new Set(tools.map((t) => t.name)));
380
+ if (recovered.length) {
381
+ toolCalls = recovered;
382
+ visibleText = "";
383
+ }
384
+ }
385
+ const message = {
386
+ role: "assistant",
387
+ content: visibleText || null,
388
+ ...(toolCalls.length
389
+ ? {
390
+ tool_calls: toolCalls.map((c) => ({
391
+ id: c.id,
392
+ type: "function",
393
+ function: { name: c.name, arguments: c.argsJson },
394
+ })),
395
+ }
396
+ : {}),
397
+ };
398
+ return { message, text: visibleText, toolCalls, usage, model: servedModel };
399
+ }
400
+ /**
401
+ * Re-yield a stream's chunks, giving up if the gap between two of them
402
+ * exceeds the idle timeout.
403
+ *
404
+ * A `for await` over a stalled stream waits forever: nothing throws, the
405
+ * socket stays open, and neither the retry loop nor the user's Esc is
406
+ * reached, so the turn hangs with a live spinner on it until the process is
407
+ * killed. Racing each `next()` against a timer turns that into an ordinary
408
+ * retryable error. The underlying stream is aborted on the way out — without
409
+ * it the abandoned request keeps consuming a connection (and, on metered
410
+ * providers, keeps generating) after we've stopped reading.
411
+ */
412
+ async *withIdleWatchdog(stream) {
413
+ const idleMs = this.streamIdleTimeoutMs;
414
+ if (!Number.isFinite(idleMs) || idleMs <= 0) {
415
+ yield* stream;
416
+ return;
417
+ }
418
+ const iterator = stream[Symbol.asyncIterator]();
419
+ try {
420
+ for (;;) {
421
+ let timer;
422
+ const idle = new Promise((_, reject) => {
423
+ timer = setTimeout(() => reject(new StreamIdleError(idleMs)), idleMs);
424
+ });
425
+ const pending = iterator.next();
426
+ // If the timer wins the race, this promise still settles later with
427
+ // nobody awaiting it — pre-attach a handler so a late rejection can't
428
+ // surface as an unhandled rejection and take the process down.
429
+ pending.catch(() => { });
430
+ let next;
431
+ try {
432
+ next = await Promise.race([pending, idle]);
433
+ }
434
+ finally {
435
+ clearTimeout(timer);
436
+ }
437
+ if (next.done)
438
+ return;
439
+ yield next.value;
440
+ }
441
+ }
442
+ catch (err) {
443
+ if (err instanceof StreamIdleError) {
444
+ stream.controller?.abort();
445
+ }
446
+ throw err;
447
+ }
448
+ finally {
449
+ // Covers the caller breaking out early (an abort mid-turn) as well as
450
+ // the idle path above; returning a generator is a no-op if it's done.
451
+ //
452
+ // Deliberately not awaited. An async generator suspended at an `await`
453
+ // doesn't run its return until that await settles — so on the stalled
454
+ // stream this exists to escape, awaiting here would block for exactly as
455
+ // long as the hang we just refused to wait for, and the retry would
456
+ // never be reached.
457
+ void Promise.resolve(iterator.return?.()).catch(() => { });
458
+ }
459
+ }
460
+ }
@@ -0,0 +1,46 @@
1
+ import { NVIDIA_BASE_URL } from "../config/config.js";
2
+ import { ProviderClient, RetryExhaustedError, } from "./client.js";
3
+ import { SWITCHYARD_FALLBACK_MODELS, ensureSwitchyardSidecar } from "./switchyardSidecar.js";
4
+ /**
5
+ * Talks to the local switchyard-server sidecar for the smart weak/strong
6
+ * routing decision. Switchyard itself has no cross-model fallback (see
7
+ * docs/reference/toml_schema.md — `max_retries` only retries the same
8
+ * backend), so if the whole switchyard call exhausts its retries, this
9
+ * falls back to calling the remaining curated models directly against
10
+ * NVIDIA, in order, before giving up. The next turn always tries switchyard
11
+ * again first — a fallback here is per-turn, not a permanent downgrade.
12
+ */
13
+ export class SwitchyardProviderClient extends ProviderClient {
14
+ fallbacks;
15
+ constructor(switchyardBaseUrl, nvidiaApiKey, sampling = {}, timeouts = {}) {
16
+ super(nvidiaApiKey, switchyardBaseUrl, sampling, timeouts);
17
+ this.fallbacks = SWITCHYARD_FALLBACK_MODELS.map((model) => ({
18
+ model,
19
+ client: new ProviderClient(nvidiaApiKey, NVIDIA_BASE_URL, sampling, timeouts),
20
+ }));
21
+ }
22
+ async chat(model, messages, tools, callbacks, signal, trace) {
23
+ try {
24
+ return await super.chat(model, messages, tools, callbacks, signal, trace);
25
+ }
26
+ catch (err) {
27
+ if (!(err instanceof RetryExhaustedError) || signal?.aborted)
28
+ throw err;
29
+ let lastErr = err;
30
+ for (const fb of this.fallbacks) {
31
+ try {
32
+ return await fb.client.chat(fb.model, messages, tools, callbacks, signal, trace);
33
+ }
34
+ catch (fbErr) {
35
+ lastErr = fbErr;
36
+ }
37
+ }
38
+ throw lastErr;
39
+ }
40
+ }
41
+ }
42
+ /** Ensure the sidecar is up, then build a client pointed at it. */
43
+ export async function createSwitchyardClient(nvidiaApiKey, sampling = {}, timeouts = {}) {
44
+ const { baseUrl } = await ensureSwitchyardSidecar(nvidiaApiKey, NVIDIA_BASE_URL);
45
+ return new SwitchyardProviderClient(baseUrl, nvidiaApiKey, sampling, timeouts);
46
+ }