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.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- 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
|
+
}
|