@womp/spark-agent 5.0.2 → 5.2.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/README.md +363 -360
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +50 -5
- package/dist/capabilities/geometry.js +14 -14
- package/dist/capabilities/openscad-instructions.js +77 -77
- package/dist/capabilities/operations.js +28 -28
- package/dist/capabilities/scene.js +69 -69
- package/dist/context-budget.d.ts +54 -0
- package/dist/context-budget.js +308 -0
- package/dist/error-diagnostics.js +47 -5
- package/dist/geometry/schemas/backend-schema.json +3393 -3393
- package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
- package/dist/geometry.js +6 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/prompt.js +41 -41
- package/dist/server/fixtures/materials/matte-blue.json +14 -14
- package/dist/server/fixtures/materials/polished-copper.json +14 -14
- package/dist/server/sqlite-store.js +37 -37
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ModelMessage } from "ai";
|
|
2
|
+
/** Under prose's ~3.5-4, so the estimate errs high. */
|
|
3
|
+
export declare const CHARS_PER_TOKEN = 3;
|
|
4
|
+
/** About what one image at the providers' 1568 px ceiling costs. */
|
|
5
|
+
export declare const IMAGE_TOKENS = 1600;
|
|
6
|
+
export declare const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
7
|
+
/** Per tool: its name, description and input schema around the counted text. */
|
|
8
|
+
export declare const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
|
|
9
|
+
/** Start trimming once the estimate passes this share of the budget. */
|
|
10
|
+
export declare const TRIM_TRIGGER_RATIO = 0.9;
|
|
11
|
+
/** Then trim down to this share, so the next steps do not trim again. */
|
|
12
|
+
export declare const TRIM_TARGET_RATIO = 0.75;
|
|
13
|
+
/** A tool result at or below this is left whole: not worth the detail it loses. */
|
|
14
|
+
export declare const SMALL_TOOL_OUTPUT_TOKENS = 500;
|
|
15
|
+
export declare const OMITTED_HISTORY_TEXT = "[Earlier messages in this conversation were left out of this request to fit the model's context window.]";
|
|
16
|
+
export declare const OMITTED_HISTORY_IMAGE_TEXT = "[An earlier image was left out of this request to fit the model's context window.]";
|
|
17
|
+
export declare const OMITTED_TOOL_OUTPUT_TEXT = "[This earlier tool result was left out of this request to fit the model's context window.]";
|
|
18
|
+
/**
|
|
19
|
+
* The model's context window, or undefined when it is not known.
|
|
20
|
+
*
|
|
21
|
+
* Listed models first, then model families whose window is certain. Anything
|
|
22
|
+
* else -- a third-party model routed through OpenRouter -- is undefined, and the
|
|
23
|
+
* conversation is then left exactly as it was: overestimating a window can only
|
|
24
|
+
* repeat today's failure, but underestimating one would silently hide history
|
|
25
|
+
* from a model that could read it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function contextWindowFor(modelId: string): number | undefined;
|
|
28
|
+
export type ContextBudgetOptions = {
|
|
29
|
+
/** Undefined: the conversation is returned untouched, with no report. */
|
|
30
|
+
contextWindow: number | undefined;
|
|
31
|
+
/** Reserved for the response. */
|
|
32
|
+
maxOutputTokens?: number;
|
|
33
|
+
system?: string;
|
|
34
|
+
/** The request's tool set, by name. */
|
|
35
|
+
tools?: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
export type ContextBudgetReport = {
|
|
38
|
+
/** What the messages may use: the window less output, system and tools. */
|
|
39
|
+
budgetTokens: number;
|
|
40
|
+
originalTokens: number;
|
|
41
|
+
estimatedTokens: number;
|
|
42
|
+
omittedImages: number;
|
|
43
|
+
omittedToolOutputs: number;
|
|
44
|
+
omittedMessages: number;
|
|
45
|
+
/** False when even the current turn alone is over the budget. */
|
|
46
|
+
fits: boolean;
|
|
47
|
+
};
|
|
48
|
+
export type ContextBudgetResult = {
|
|
49
|
+
messages: ModelMessage[];
|
|
50
|
+
report?: ContextBudgetReport;
|
|
51
|
+
};
|
|
52
|
+
export declare function fitMessagesToContext(messages: ModelMessage[], options: ContextBudgetOptions): ContextBudgetResult;
|
|
53
|
+
/** The estimate for a list of messages, as `fitMessagesToContext` counts them. */
|
|
54
|
+
export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Keep one model request inside the model's context window.
|
|
2
|
+
//
|
|
3
|
+
// A Spark turn sends the whole conversation on every step: for a thread that
|
|
4
|
+
// Swan and Crow both write to, that is every earlier turn, every tool result
|
|
5
|
+
// (each up to MODEL_RESULT_BYTE_LIMIT, ./bounded-output.ts) and every image. Nothing
|
|
6
|
+
// bounded the total, so a long enough thread passed the provider's limit and every
|
|
7
|
+
// later turn failed on its first request ("prompt is too long: 1006655 tokens >
|
|
8
|
+
// 1000000 maximum") -- permanently, since each failed attempt only adds rows.
|
|
9
|
+
//
|
|
10
|
+
// This runs on the messages of every step, after the host's own preparation and
|
|
11
|
+
// before the request is built, in both executors: the shared loop (./agent.ts,
|
|
12
|
+
// which Crow runs) and the browser's own loop in Swan.
|
|
13
|
+
//
|
|
14
|
+
// WHAT, in this order, until the estimate is back under the target:
|
|
15
|
+
//
|
|
16
|
+
// 1. Images before the current turn, oldest first -- the most tokens for the
|
|
17
|
+
// least text, and the ones a later step least needs to see again.
|
|
18
|
+
// 2. Large tool results before the current turn, oldest first. The call and a
|
|
19
|
+
// result part stay, with a note in place of the output, so no provider is
|
|
20
|
+
// handed a call without its result.
|
|
21
|
+
// 3. Whole turns, oldest first, cut at user messages so every remaining call is
|
|
22
|
+
// still paired with its result. A note in their place tells the model that
|
|
23
|
+
// earlier messages were left out.
|
|
24
|
+
//
|
|
25
|
+
// The current turn -- the latest user message and everything after it -- is never
|
|
26
|
+
// changed. When it alone is over the window the request goes out as it is and the
|
|
27
|
+
// provider's own error says so; nothing here can make that turn smaller.
|
|
28
|
+
//
|
|
29
|
+
// ESTIMATES, NOT COUNTS. Counting needs the provider's tokenizer or a request.
|
|
30
|
+
// The estimate is deliberately high (CHARS_PER_TOKEN is under the ~3.5-4 of prose)
|
|
31
|
+
// and trimming starts at TRIM_TRIGGER_RATIO of the budget, so dense JSON that
|
|
32
|
+
// tokenizes worse than prose still lands under the real limit. Images count by
|
|
33
|
+
// number, never by their bytes.
|
|
34
|
+
//
|
|
35
|
+
// STABLE ACROSS STEPS. Once over, the conversation is trimmed to
|
|
36
|
+
// TRIM_TARGET_RATIO rather than to the limit, and always oldest first, so the next
|
|
37
|
+
// steps of the run trim the same prefix again instead of a little more each time --
|
|
38
|
+
// which keeps a provider's prompt cache warm.
|
|
39
|
+
import { SPARK_MODELS } from "./models.js";
|
|
40
|
+
/** Under prose's ~3.5-4, so the estimate errs high. */
|
|
41
|
+
export const CHARS_PER_TOKEN = 3;
|
|
42
|
+
/** About what one image at the providers' 1568 px ceiling costs. */
|
|
43
|
+
export const IMAGE_TOKENS = 1_600;
|
|
44
|
+
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
45
|
+
/** Per tool: its name, description and input schema around the counted text. */
|
|
46
|
+
export const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
|
|
47
|
+
/** Start trimming once the estimate passes this share of the budget. */
|
|
48
|
+
export const TRIM_TRIGGER_RATIO = 0.9;
|
|
49
|
+
/** Then trim down to this share, so the next steps do not trim again. */
|
|
50
|
+
export const TRIM_TARGET_RATIO = 0.75;
|
|
51
|
+
/** A tool result at or below this is left whole: not worth the detail it loses. */
|
|
52
|
+
export const SMALL_TOOL_OUTPUT_TOKENS = 500;
|
|
53
|
+
export const OMITTED_HISTORY_TEXT = "[Earlier messages in this conversation were left out of this request to fit the model's context window.]";
|
|
54
|
+
export const OMITTED_HISTORY_IMAGE_TEXT = "[An earlier image was left out of this request to fit the model's context window.]";
|
|
55
|
+
export const OMITTED_TOOL_OUTPUT_TEXT = "[This earlier tool result was left out of this request to fit the model's context window.]";
|
|
56
|
+
/**
|
|
57
|
+
* The model's context window, or undefined when it is not known.
|
|
58
|
+
*
|
|
59
|
+
* Listed models first, then model families whose window is certain. Anything
|
|
60
|
+
* else -- a third-party model routed through OpenRouter -- is undefined, and the
|
|
61
|
+
* conversation is then left exactly as it was: overestimating a window can only
|
|
62
|
+
* repeat today's failure, but underestimating one would silently hide history
|
|
63
|
+
* from a model that could read it.
|
|
64
|
+
*/
|
|
65
|
+
export function contextWindowFor(modelId) {
|
|
66
|
+
const listed = SPARK_MODELS.find(({ id }) => id === modelId)?.contextWindow;
|
|
67
|
+
if (listed !== undefined)
|
|
68
|
+
return listed;
|
|
69
|
+
if (/^claude-haiku-/.test(modelId))
|
|
70
|
+
return 200_000;
|
|
71
|
+
if (/^claude-/.test(modelId))
|
|
72
|
+
return 1_000_000;
|
|
73
|
+
if (/^gpt-6-/.test(modelId))
|
|
74
|
+
return 1_050_000;
|
|
75
|
+
if (/^gemini-/.test(modelId))
|
|
76
|
+
return 1_048_576;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
export function fitMessagesToContext(messages, options) {
|
|
80
|
+
if (options.contextWindow === undefined)
|
|
81
|
+
return { messages };
|
|
82
|
+
const budgetTokens = Math.max(0, options.contextWindow -
|
|
83
|
+
(options.maxOutputTokens ?? 0) -
|
|
84
|
+
textTokens(options.system ?? "") -
|
|
85
|
+
toolsTokens(options.tools));
|
|
86
|
+
const sizes = messages.map(messageTokens);
|
|
87
|
+
const originalTokens = sum(sizes);
|
|
88
|
+
const report = {
|
|
89
|
+
budgetTokens,
|
|
90
|
+
originalTokens,
|
|
91
|
+
estimatedTokens: originalTokens,
|
|
92
|
+
omittedImages: 0,
|
|
93
|
+
omittedToolOutputs: 0,
|
|
94
|
+
omittedMessages: 0,
|
|
95
|
+
fits: originalTokens <= budgetTokens,
|
|
96
|
+
};
|
|
97
|
+
if (originalTokens <= budgetTokens * TRIM_TRIGGER_RATIO)
|
|
98
|
+
return { messages, report };
|
|
99
|
+
// The current turn starts at the latest user message and is never changed.
|
|
100
|
+
const current = lastIndex(messages, ({ role }) => role === "user");
|
|
101
|
+
if (current <= 0)
|
|
102
|
+
return { messages, report };
|
|
103
|
+
const target = budgetTokens * TRIM_TARGET_RATIO;
|
|
104
|
+
const out = messages.slice();
|
|
105
|
+
let total = originalTokens;
|
|
106
|
+
/**
|
|
107
|
+
* Rewrites the parts of each earlier message, oldest first, until the estimate
|
|
108
|
+
* is under the target. `rewrite` answers the same part to keep it, or its
|
|
109
|
+
* replacement and roughly what that saves -- used only to stop partway through a
|
|
110
|
+
* message, which is then measured again.
|
|
111
|
+
*/
|
|
112
|
+
const rewriteEarlier = (rewrite) => {
|
|
113
|
+
for (let i = 0; i < current && total > target; i += 1) {
|
|
114
|
+
const message = out[i];
|
|
115
|
+
if (message === undefined || !Array.isArray(message.content))
|
|
116
|
+
continue;
|
|
117
|
+
let projected = total;
|
|
118
|
+
let changed = false;
|
|
119
|
+
const content = message.content.map((part) => {
|
|
120
|
+
if (projected <= target)
|
|
121
|
+
return part;
|
|
122
|
+
const result = rewrite(part, projected);
|
|
123
|
+
if (result.part === part)
|
|
124
|
+
return part;
|
|
125
|
+
changed = true;
|
|
126
|
+
projected -= result.saved;
|
|
127
|
+
return result.part;
|
|
128
|
+
});
|
|
129
|
+
if (!changed)
|
|
130
|
+
continue;
|
|
131
|
+
const next = { ...message, content };
|
|
132
|
+
const size = messageTokens(next);
|
|
133
|
+
total += size - (sizes[i] ?? 0);
|
|
134
|
+
sizes[i] = size;
|
|
135
|
+
out[i] = next;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
// 1. Earlier images.
|
|
139
|
+
rewriteEarlier((part, projected) => {
|
|
140
|
+
if (isImage(part)) {
|
|
141
|
+
report.omittedImages += 1;
|
|
142
|
+
return { part: omittedImage(), saved: IMAGE_TOKENS };
|
|
143
|
+
}
|
|
144
|
+
if (!isToolResult(part) || part.output.type !== "content" || !Array.isArray(part.output.value)) {
|
|
145
|
+
return { part, saved: 0 };
|
|
146
|
+
}
|
|
147
|
+
let saved = 0;
|
|
148
|
+
const value = part.output.value.map((item) => {
|
|
149
|
+
if (projected - saved <= target || !isImage(item))
|
|
150
|
+
return item;
|
|
151
|
+
report.omittedImages += 1;
|
|
152
|
+
saved += IMAGE_TOKENS;
|
|
153
|
+
return omittedImage();
|
|
154
|
+
});
|
|
155
|
+
return saved === 0 ? { part, saved } : { part: { ...part, output: { ...part.output, value } }, saved };
|
|
156
|
+
});
|
|
157
|
+
// 2. Earlier large tool results.
|
|
158
|
+
rewriteEarlier((part) => {
|
|
159
|
+
if (!isToolResult(part))
|
|
160
|
+
return { part, saved: 0 };
|
|
161
|
+
const saved = outputTokens(part.output);
|
|
162
|
+
if (saved <= SMALL_TOOL_OUTPUT_TOKENS)
|
|
163
|
+
return { part, saved: 0 };
|
|
164
|
+
report.omittedToolOutputs += 1;
|
|
165
|
+
return { part: { ...part, output: { type: "text", value: OMITTED_TOOL_OUTPUT_TEXT } }, saved };
|
|
166
|
+
});
|
|
167
|
+
// 3. Whole earlier turns, cut at user messages.
|
|
168
|
+
if (total > target) {
|
|
169
|
+
const note = { role: "user", content: [{ type: "text", text: OMITTED_HISTORY_TEXT }] };
|
|
170
|
+
const noteTokens = messageTokens(note);
|
|
171
|
+
const ends = [];
|
|
172
|
+
out.forEach(({ role }, i) => {
|
|
173
|
+
if (i > 0 && i < current && role === "user")
|
|
174
|
+
ends.push(i);
|
|
175
|
+
});
|
|
176
|
+
ends.push(current);
|
|
177
|
+
let cut = 0;
|
|
178
|
+
for (const end of ends) {
|
|
179
|
+
if (total + noteTokens <= target)
|
|
180
|
+
break;
|
|
181
|
+
for (let i = cut; i < end; i += 1) {
|
|
182
|
+
const message = out[i];
|
|
183
|
+
if (message === undefined || message.role === "system")
|
|
184
|
+
continue;
|
|
185
|
+
total -= sizes[i] ?? 0;
|
|
186
|
+
report.omittedMessages += 1;
|
|
187
|
+
}
|
|
188
|
+
cut = end;
|
|
189
|
+
}
|
|
190
|
+
if (cut > 0) {
|
|
191
|
+
const kept = out.slice(0, cut).filter(({ role }) => role === "system");
|
|
192
|
+
out.splice(0, cut, ...kept, note);
|
|
193
|
+
total += noteTokens;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
report.estimatedTokens = total;
|
|
197
|
+
report.fits = total <= budgetTokens;
|
|
198
|
+
return { messages: out, report };
|
|
199
|
+
}
|
|
200
|
+
/** The estimate for a list of messages, as `fitMessagesToContext` counts them. */
|
|
201
|
+
export function estimateMessagesTokens(messages) {
|
|
202
|
+
return sum(messages.map(messageTokens));
|
|
203
|
+
}
|
|
204
|
+
// ── estimation ──────────────────────────────────────────────────────────────
|
|
205
|
+
const estimates = new WeakMap();
|
|
206
|
+
function messageTokens(message) {
|
|
207
|
+
const known = estimates.get(message);
|
|
208
|
+
if (known !== undefined)
|
|
209
|
+
return known;
|
|
210
|
+
const content = message.content;
|
|
211
|
+
const size = MESSAGE_OVERHEAD_TOKENS +
|
|
212
|
+
(typeof content === "string"
|
|
213
|
+
? textTokens(content)
|
|
214
|
+
: Array.isArray(content)
|
|
215
|
+
? sum(content.map(partTokens))
|
|
216
|
+
: 0);
|
|
217
|
+
estimates.set(message, size);
|
|
218
|
+
return size;
|
|
219
|
+
}
|
|
220
|
+
function partTokens(part) {
|
|
221
|
+
if (isImage(part))
|
|
222
|
+
return IMAGE_TOKENS;
|
|
223
|
+
switch (part.type) {
|
|
224
|
+
case "text":
|
|
225
|
+
case "reasoning":
|
|
226
|
+
return textTokens(String(part.text ?? ""));
|
|
227
|
+
case "file":
|
|
228
|
+
return dataTokens(part.data);
|
|
229
|
+
case "tool-call":
|
|
230
|
+
return textTokens(String(part.toolName ?? "")) + textTokens(json(part.input));
|
|
231
|
+
case "tool-result":
|
|
232
|
+
return isToolResult(part) ? outputTokens(part.output) : 0;
|
|
233
|
+
default:
|
|
234
|
+
return textTokens(json(part));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function outputTokens(output) {
|
|
238
|
+
switch (output.type) {
|
|
239
|
+
case "text":
|
|
240
|
+
case "error-text":
|
|
241
|
+
return textTokens(String(output.value ?? ""));
|
|
242
|
+
case "content":
|
|
243
|
+
return Array.isArray(output.value)
|
|
244
|
+
? sum(output.value.map((item) => isImage(item) ? IMAGE_TOKENS : item.type === "text" ? textTokens(String(item.text ?? "")) : dataTokens(item.data)))
|
|
245
|
+
: 0;
|
|
246
|
+
default:
|
|
247
|
+
return textTokens(json(output.value));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function textTokens(text) {
|
|
251
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
252
|
+
}
|
|
253
|
+
function dataTokens(data) {
|
|
254
|
+
if (typeof data === "string")
|
|
255
|
+
return textTokens(data);
|
|
256
|
+
if (data instanceof Uint8Array || data instanceof ArrayBuffer)
|
|
257
|
+
return Math.ceil(data.byteLength / CHARS_PER_TOKEN);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
function toolsTokens(tools) {
|
|
261
|
+
if (!tools)
|
|
262
|
+
return 0;
|
|
263
|
+
return sum(Object.entries(tools).map(([name, tool]) => {
|
|
264
|
+
const description = tool?.description;
|
|
265
|
+
return (TOOL_DEFINITION_OVERHEAD_TOKENS + textTokens(name) + textTokens(typeof description === "string" ? description : ""));
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
/** JSON text, with binary left out: its size is not text the model reads. */
|
|
269
|
+
function json(value) {
|
|
270
|
+
try {
|
|
271
|
+
return (JSON.stringify(value, (_key, v) => (v instanceof Uint8Array || v instanceof ArrayBuffer ? "" : v)) ?? "");
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// ── shapes ──────────────────────────────────────────────────────────────────
|
|
278
|
+
function isImage(part) {
|
|
279
|
+
if (part.type === "image" || part.type === "image-data" || part.type === "image-url")
|
|
280
|
+
return true;
|
|
281
|
+
const mediaType = part.mediaType;
|
|
282
|
+
return ((part.type === "file" || part.type === "media" || part.type === "file-data" || part.type === "file-url") &&
|
|
283
|
+
typeof mediaType === "string" &&
|
|
284
|
+
mediaType.startsWith("image/"));
|
|
285
|
+
}
|
|
286
|
+
function isToolResult(part) {
|
|
287
|
+
return (part.type === "tool-result" &&
|
|
288
|
+
typeof part.output === "object" &&
|
|
289
|
+
part.output !== null &&
|
|
290
|
+
typeof part.output.type === "string");
|
|
291
|
+
}
|
|
292
|
+
function sum(values) {
|
|
293
|
+
let total = 0;
|
|
294
|
+
for (const value of values)
|
|
295
|
+
total += value;
|
|
296
|
+
return total;
|
|
297
|
+
}
|
|
298
|
+
function lastIndex(items, test) {
|
|
299
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
300
|
+
const item = items[i];
|
|
301
|
+
if (item !== undefined && test(item))
|
|
302
|
+
return i;
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
}
|
|
306
|
+
function omittedImage() {
|
|
307
|
+
return { type: "text", text: OMITTED_HISTORY_IMAGE_TEXT };
|
|
308
|
+
}
|
|
@@ -30,17 +30,48 @@ function text(value, limit = 1024) {
|
|
|
30
30
|
// Bound work as well as storage. Embedded JSON/request dumps are not diagnostics.
|
|
31
31
|
return String(value)
|
|
32
32
|
.slice(0, 8192)
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// An object, or an array of strings, objects or several numbers. An index
|
|
34
|
+
// such as `messages[2].content[0]` is not an array. `[[` is left to the
|
|
35
|
+
// inner bracket, so a placeholder after a `[` never matches on a rerun.
|
|
36
|
+
.replace(/\{[\s\S]*|\[(?=\s*(?:["'{]|-?\d[\d.eE+-]*\s*,))[\s\S]*/u, "[payload omitted]")
|
|
37
|
+
// A prompt, body or headers after its label, whatever follows.
|
|
38
|
+
.replace(/\b(?:request\s*body|response\s*body|headers|prompt)\s*[:=][\s\S]*/giu, "[payload omitted]")
|
|
39
|
+
// A request, payload or image only when a quoted value follows its label:
|
|
40
|
+
// "400 Bad Request: model not found" and "Invalid image: the image exceeds
|
|
41
|
+
// 5 MB" are the error, not a copy of the input. Objects and arrays after
|
|
42
|
+
// a label are already gone above.
|
|
43
|
+
.replace(/\b(?:request|payload|image)\s*[:=]\s*["'`\u201c\u201d\u2018\u2019][\s\S]*/giu, "[payload omitted]")
|
|
35
44
|
.replace(/data:[^\s]+/giu, "[data omitted]")
|
|
36
45
|
.replace(/https?:\/\/[^\s]+/giu, "[url omitted]")
|
|
37
|
-
|
|
46
|
+
// Userinfo on any other scheme (postgres, redis, mongodb+srv, ssh...);
|
|
47
|
+
// the scheme, host and path stay for staff. The last `@` before the host
|
|
48
|
+
// ends it, so a password containing `@` goes with it.
|
|
49
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/giu, "$1[credential omitted]@")
|
|
50
|
+
// A `user:password` whose password contains `/`, `?` or `#` (AWS-generated
|
|
51
|
+
// passwords can). The colon is required, so `s3://bucket/key@v2` stays, and
|
|
52
|
+
// up to five digits then `/` after it is a port and path, so
|
|
53
|
+
// `docker://registry:5000/repo@sha256:...` stays. A password of up to five
|
|
54
|
+
// digits then a `/` therefore survives.
|
|
55
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#:@]+:(?!\d{1,5}\/)[^\s@]*@/giu, "$1[credential omitted]@")
|
|
56
|
+
.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|(?:aws[_ -]?)?session[_ -]?token|x-amz-security-token|token|password|(?:aws[_ -]?)?secret[_ -]?access[_ -]?key|(?:client[_ -]?)?secret)\b\s*[:=]\s*[^\r\n,;]+/giu, "[credential omitted]")
|
|
38
57
|
.replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/giu, "[credential omitted]")
|
|
39
58
|
.replace(/\b(?:sk-|sk_|AIza)[A-Za-z0-9_-]+/gu, "[credential omitted]")
|
|
59
|
+
// GitHub, Slack, Google OAuth access tokens and AWS access key ids.
|
|
60
|
+
.replace(/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]+/gu, "[credential omitted]")
|
|
61
|
+
.replace(/\bxox[abprs]-[A-Za-z0-9-]+/gu, "[credential omitted]")
|
|
62
|
+
.replace(/\bya29\.[A-Za-z0-9_-]+/gu, "[credential omitted]")
|
|
63
|
+
// No trailing `\b`: the length limit below can cut a longer run to exactly
|
|
64
|
+
// 16 characters, which a rerun would then match.
|
|
65
|
+
.replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}/gu, "[credential omitted]")
|
|
40
66
|
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/gu, "[credential omitted]")
|
|
41
67
|
.replace(/[A-Za-z0-9+/=_-]{200,}/gu, "[data omitted]")
|
|
42
68
|
.slice(0, limit);
|
|
43
69
|
}
|
|
70
|
+
const PLACEHOLDER = /\[(?:payload|data|url|credential) omitted\]/gu;
|
|
71
|
+
/** Whether redacted text still says anything once its placeholders are removed. */
|
|
72
|
+
function informative(value) {
|
|
73
|
+
return value !== undefined && /[\p{L}\p{N}]/u.test(value.replace(PLACEHOLDER, ""));
|
|
74
|
+
}
|
|
44
75
|
export function sparkErrorDiagnostics(error) {
|
|
45
76
|
const seen = new Set();
|
|
46
77
|
const visit = (value, depth) => {
|
|
@@ -48,14 +79,25 @@ export function sparkErrorDiagnostics(error) {
|
|
|
48
79
|
const source = record(value);
|
|
49
80
|
const data = record(source.data);
|
|
50
81
|
const body = responseData(source.responseBody);
|
|
82
|
+
const nested = record(data.error ?? source.error ?? body.error);
|
|
51
83
|
const provider = record(data.error ?? source.error ?? body.error ?? body);
|
|
52
84
|
const headers = record(source.responseHeaders);
|
|
53
85
|
const status = source.statusCode ?? source.status ?? provider.status;
|
|
86
|
+
// A message redacted down to placeholders (a provider error whose message
|
|
87
|
+
// is its JSON body, say) gives way to the provider's own message, which is
|
|
88
|
+
// redacted the same way.
|
|
89
|
+
const own = text(source.message);
|
|
90
|
+
const fallback = text(provider.message ?? (source.message === undefined ? value : undefined));
|
|
54
91
|
const result = {
|
|
55
|
-
message:
|
|
92
|
+
message: (informative(own) ? own : informative(fallback) ? fallback : (own ?? fallback)) ||
|
|
93
|
+
"An error occurred.",
|
|
56
94
|
};
|
|
95
|
+
// The provider's error type (Anthropic's `overloaded_error`, OpenAI's
|
|
96
|
+
// `invalid_request_error`) stands in for a missing code. The AI SDK puts
|
|
97
|
+
// a mid-stream provider error's type on the error itself.
|
|
98
|
+
const type = nested.type ?? (source.name === "AI_StreamProviderError" ? source.type : undefined);
|
|
57
99
|
const name = text(source.name, 128);
|
|
58
|
-
const code = text(source.code ?? provider.code ?? data.code, 128);
|
|
100
|
+
const code = text(source.code ?? provider.code ?? data.code ?? (typeof type === "string" ? type : undefined), 128);
|
|
59
101
|
const requestId = text(source.requestId ??
|
|
60
102
|
source.request_id ??
|
|
61
103
|
provider.request_id ??
|