lynkr 8.0.0 → 9.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- package/.lynkr/telemetry.db-wal +0 -0
- package/README.md +196 -322
- package/lynkr-skill.tar.gz +0 -0
- package/package.json +4 -3
- package/src/api/openai-router.js +64 -13
- package/src/api/providers-handler.js +171 -3
- package/src/api/router.js +9 -2
- package/src/clients/circuit-breaker.js +10 -247
- package/src/clients/codex-process.js +342 -0
- package/src/clients/codex-utils.js +143 -0
- package/src/clients/databricks.js +210 -63
- package/src/clients/resilience.js +540 -0
- package/src/clients/retry.js +22 -167
- package/src/clients/standard-tools.js +23 -0
- package/src/config/index.js +77 -0
- package/src/context/compression.js +42 -9
- package/src/context/distill.js +492 -0
- package/src/orchestrator/index.js +48 -8
- package/src/routing/complexity-analyzer.js +258 -5
- package/src/routing/index.js +12 -2
- package/src/routing/latency-tracker.js +148 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/quality-scorer.js +113 -0
- package/src/routing/telemetry.js +464 -0
- package/src/server.js +13 -12
- package/src/tools/code-graph.js +538 -0
- package/src/tools/code-mode.js +304 -0
- package/src/tools/index.js +4 -0
- package/src/tools/lazy-loader.js +18 -0
- package/src/tools/mcp-remote.js +7 -0
- package/src/tools/smart-selection.js +11 -0
- package/src/tools/tinyfish.js +358 -0
- package/src/tools/truncate.js +1 -0
- package/src/utils/payload.js +206 -0
- package/src/utils/perf-timer.js +80 -0
- package/.github/FUNDING.yml +0 -15
- package/.github/workflows/README.md +0 -215
- package/.github/workflows/ci.yml +0 -69
- package/.github/workflows/index.yml +0 -62
- package/.github/workflows/web-tools-tests.yml +0 -56
- package/CITATIONS.bib +0 -6
- package/DEPLOYMENT.md +0 -1001
- package/LYNKR-TUI-PLAN.md +0 -984
- package/PERFORMANCE-REPORT.md +0 -866
- package/PLAN-per-client-model-routing.md +0 -252
- package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
- package/docs/BingSiteAuth.xml +0 -4
- package/docs/docs-style.css +0 -478
- package/docs/docs.html +0 -198
- package/docs/google5be250e608e6da39.html +0 -1
- package/docs/index.html +0 -577
- package/docs/index.md +0 -584
- package/docs/robots.txt +0 -4
- package/docs/sitemap.xml +0 -44
- package/docs/style.css +0 -1223
- package/docs/toon-integration-spec.md +0 -130
- package/documentation/README.md +0 -101
- package/documentation/api.md +0 -806
- package/documentation/claude-code-cli.md +0 -679
- package/documentation/codex-cli.md +0 -397
- package/documentation/contributing.md +0 -571
- package/documentation/cursor-integration.md +0 -734
- package/documentation/docker.md +0 -874
- package/documentation/embeddings.md +0 -762
- package/documentation/faq.md +0 -713
- package/documentation/features.md +0 -403
- package/documentation/headroom.md +0 -519
- package/documentation/installation.md +0 -758
- package/documentation/memory-system.md +0 -476
- package/documentation/production.md +0 -636
- package/documentation/providers.md +0 -1009
- package/documentation/routing.md +0 -476
- package/documentation/testing.md +0 -629
- package/documentation/token-optimization.md +0 -325
- package/documentation/tools.md +0 -697
- package/documentation/troubleshooting.md +0 -969
- package/final-test.js +0 -33
- package/headroom-sidecar/config.py +0 -93
- package/headroom-sidecar/requirements.txt +0 -14
- package/headroom-sidecar/server.py +0 -451
- package/monitor-agents.sh +0 -31
- package/scripts/audit-log-reader.js +0 -399
- package/scripts/compact-dictionary.js +0 -204
- package/scripts/test-deduplication.js +0 -448
- package/src/db/database.sqlite +0 -0
- package/te +0 -11622
- package/test/README.md +0 -212
- package/test/azure-openai-config.test.js +0 -213
- package/test/azure-openai-error-resilience.test.js +0 -238
- package/test/azure-openai-format-conversion.test.js +0 -354
- package/test/azure-openai-integration.test.js +0 -287
- package/test/azure-openai-routing.test.js +0 -175
- package/test/azure-openai-streaming.test.js +0 -171
- package/test/bedrock-integration.test.js +0 -457
- package/test/comprehensive-test-suite.js +0 -928
- package/test/config-validation.test.js +0 -207
- package/test/cursor-integration.test.js +0 -484
- package/test/format-conversion.test.js +0 -578
- package/test/hybrid-routing-integration.test.js +0 -269
- package/test/hybrid-routing-performance.test.js +0 -428
- package/test/llamacpp-integration.test.js +0 -882
- package/test/lmstudio-integration.test.js +0 -347
- package/test/memory/extractor.test.js +0 -398
- package/test/memory/retriever.test.js +0 -613
- package/test/memory/retriever.test.js.bak +0 -585
- package/test/memory/search.test.js +0 -537
- package/test/memory/search.test.js.bak +0 -389
- package/test/memory/store.test.js +0 -344
- package/test/memory/store.test.js.bak +0 -312
- package/test/memory/surprise.test.js +0 -300
- package/test/memory-performance.test.js +0 -472
- package/test/openai-integration.test.js +0 -683
- package/test/openrouter-error-resilience.test.js +0 -418
- package/test/passthrough-mode.test.js +0 -385
- package/test/performance-benchmark.js +0 -351
- package/test/performance-tests.js +0 -528
- package/test/routing.test.js +0 -225
- package/test/toon-compression.test.js +0 -131
- package/test/web-tools.test.js +0 -329
- package/test-agents-simple.js +0 -43
- package/test-cli-connection.sh +0 -33
- package/test-learning-unit.js +0 -126
- package/test-learning.js +0 -112
- package/test-parallel-agents.sh +0 -124
- package/test-parallel-direct.js +0 -155
- package/test-subagents.sh +0 -117
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
const { URL } = require("url");
|
|
2
|
+
const { Agent } = require("undici");
|
|
3
|
+
const config = require("../config");
|
|
4
|
+
const logger = require("../logger");
|
|
5
|
+
const { registerTool } = require(".");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Dedicated HTTP agent for TinyFish SSE streams.
|
|
9
|
+
* The default webAgent in web-client.js has a 30s bodyTimeout which is too
|
|
10
|
+
* short for browser-automation tasks that can take up to 120s.
|
|
11
|
+
*/
|
|
12
|
+
const sseAgent = new Agent({
|
|
13
|
+
connections: 10,
|
|
14
|
+
pipelining: 1,
|
|
15
|
+
keepAliveTimeout: 60000,
|
|
16
|
+
connectTimeout: 15000,
|
|
17
|
+
bodyTimeout: 0, // no body timeout — we manage timeout via AbortController
|
|
18
|
+
headersTimeout: 15000,
|
|
19
|
+
maxRedirections: 3,
|
|
20
|
+
strictContentLength: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Argument normalisers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function normalizeUrl(args) {
|
|
28
|
+
const raw = args.url ?? args.uri ?? args.href ?? args.target_url;
|
|
29
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
30
|
+
throw new Error("web_agent requires a non-empty url string.");
|
|
31
|
+
}
|
|
32
|
+
// Validate URL
|
|
33
|
+
try {
|
|
34
|
+
new URL(raw.trim());
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`web_agent received an invalid URL: ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
return raw.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeGoal(args) {
|
|
42
|
+
const goal = args.goal ?? args.task ?? args.prompt ?? args.instruction;
|
|
43
|
+
if (typeof goal !== "string" || goal.trim().length === 0) {
|
|
44
|
+
throw new Error("web_agent requires a non-empty goal string.");
|
|
45
|
+
}
|
|
46
|
+
return goal.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveBrowserProfile(args) {
|
|
50
|
+
const profile = args.browser_profile ?? args.browserProfile ?? config.tinyfish.browserProfile;
|
|
51
|
+
if (profile === "stealth") return "stealth";
|
|
52
|
+
return "lite";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// SSE stream consumer
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function consumeSSEStream(response, timeoutMs) {
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let buffer = "";
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
68
|
+
const err = new Error(`TinyFish SSE stream timed out after ${timeoutMs}ms`);
|
|
69
|
+
err.code = "ETIMEDOUT";
|
|
70
|
+
err.status = 504;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { done, value } = await reader.read();
|
|
75
|
+
if (done) break;
|
|
76
|
+
|
|
77
|
+
buffer += decoder.decode(value, { stream: true });
|
|
78
|
+
|
|
79
|
+
// SSE events are separated by double newlines
|
|
80
|
+
const parts = buffer.split("\n\n");
|
|
81
|
+
// Keep the last (possibly incomplete) chunk in the buffer
|
|
82
|
+
buffer = parts.pop() || "";
|
|
83
|
+
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
// Extract the data: line(s)
|
|
86
|
+
const lines = part.split("\n");
|
|
87
|
+
let dataStr = "";
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (line.startsWith("data: ")) {
|
|
90
|
+
dataStr += line.slice(6);
|
|
91
|
+
} else if (line.startsWith("data:")) {
|
|
92
|
+
dataStr += line.slice(5);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!dataStr) continue;
|
|
97
|
+
|
|
98
|
+
let event;
|
|
99
|
+
try {
|
|
100
|
+
event = JSON.parse(dataStr);
|
|
101
|
+
} catch {
|
|
102
|
+
// Not valid JSON — skip this SSE frame
|
|
103
|
+
logger.debug({ raw: dataStr.slice(0, 200) }, "TinyFish: non-JSON SSE frame, skipping");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.debug(
|
|
108
|
+
{ type: event.type, status: event.status },
|
|
109
|
+
"TinyFish SSE event"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (event.type === "COMPLETE" || event.type === "complete") {
|
|
113
|
+
const status = (event.status ?? "").toUpperCase();
|
|
114
|
+
if (status === "COMPLETED" || status === "SUCCESS") {
|
|
115
|
+
return event.resultJson ?? event.result ?? event.data ?? event;
|
|
116
|
+
}
|
|
117
|
+
// Task failed
|
|
118
|
+
const errMsg = event.error ?? event.message ?? "TinyFish task failed";
|
|
119
|
+
const err = new Error(typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
|
|
120
|
+
err.code = "TINYFISH_TASK_FAILED";
|
|
121
|
+
err.status = 502;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Stream ended without a COMPLETE event
|
|
128
|
+
const err = new Error("TinyFish SSE stream ended without a COMPLETE event");
|
|
129
|
+
err.code = "TINYFISH_INCOMPLETE";
|
|
130
|
+
err.status = 502;
|
|
131
|
+
throw err;
|
|
132
|
+
} finally {
|
|
133
|
+
reader.releaseLock();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Core API call
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async function callTinyFishAPI({ url, goal, browserProfile, proxyConfig, timeoutMs }) {
|
|
142
|
+
const endpoint = config.tinyfish.endpoint;
|
|
143
|
+
const apiKey = config.tinyfish.apiKey;
|
|
144
|
+
|
|
145
|
+
if (!apiKey) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
status: 503,
|
|
149
|
+
content: JSON.stringify({
|
|
150
|
+
error: "tinyfish_not_configured",
|
|
151
|
+
message:
|
|
152
|
+
"TinyFish API key is not configured. Set TINYFISH_API_KEY in your .env file. Get a key from https://tinyfish.ai",
|
|
153
|
+
}, null, 2),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const body = {
|
|
158
|
+
url,
|
|
159
|
+
goal,
|
|
160
|
+
browserProfile,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (proxyConfig) {
|
|
164
|
+
body.proxy = proxyConfig;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(endpoint, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
"X-API-Key": apiKey,
|
|
176
|
+
Accept: "text/event-stream",
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
dispatcher: sseAgent,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Handle non-2xx responses before attempting SSE parse
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const text = await response.text().catch(() => "");
|
|
186
|
+
const shouldRetry = response.status === 429 || response.status >= 500;
|
|
187
|
+
|
|
188
|
+
if (shouldRetry) {
|
|
189
|
+
// Retry once with 2s backoff
|
|
190
|
+
logger.warn(
|
|
191
|
+
{ status: response.status, body: text.slice(0, 200) },
|
|
192
|
+
"TinyFish API error, retrying once"
|
|
193
|
+
);
|
|
194
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
195
|
+
|
|
196
|
+
const retryController = new AbortController();
|
|
197
|
+
const retryTimeout = setTimeout(() => retryController.abort(), timeoutMs);
|
|
198
|
+
try {
|
|
199
|
+
const retryResponse = await fetch(endpoint, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"X-API-Key": apiKey,
|
|
204
|
+
Accept: "text/event-stream",
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify(body),
|
|
207
|
+
signal: retryController.signal,
|
|
208
|
+
dispatcher: sseAgent,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!retryResponse.ok) {
|
|
212
|
+
const retryText = await retryResponse.text().catch(() => "");
|
|
213
|
+
const err = new Error(
|
|
214
|
+
`TinyFish API error (${retryResponse.status}): ${retryResponse.statusText}`
|
|
215
|
+
);
|
|
216
|
+
err.status = retryResponse.status;
|
|
217
|
+
err.body = retryText;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const result = await consumeSSEStream(retryResponse, timeoutMs);
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
status: 200,
|
|
225
|
+
result,
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(retryTimeout);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const err = new Error(
|
|
233
|
+
`TinyFish API error (${response.status}): ${response.statusText}`
|
|
234
|
+
);
|
|
235
|
+
err.status = response.status;
|
|
236
|
+
err.body = text;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await consumeSSEStream(response, timeoutMs);
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
status: 200,
|
|
244
|
+
result,
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error.name === "AbortError") {
|
|
248
|
+
const err = new Error(`TinyFish request timed out after ${timeoutMs}ms`);
|
|
249
|
+
err.code = "ETIMEDOUT";
|
|
250
|
+
err.status = 504;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Tool registration
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function registerTinyFishTool() {
|
|
264
|
+
registerTool(
|
|
265
|
+
"web_agent",
|
|
266
|
+
async ({ args = {} }) => {
|
|
267
|
+
const url = normalizeUrl(args);
|
|
268
|
+
const goal = normalizeGoal(args);
|
|
269
|
+
const browserProfile = resolveBrowserProfile(args);
|
|
270
|
+
const timeoutMs = config.tinyfish.timeoutMs;
|
|
271
|
+
|
|
272
|
+
// Build proxy config if enabled
|
|
273
|
+
let proxyConfig = null;
|
|
274
|
+
if (config.tinyfish.proxyEnabled) {
|
|
275
|
+
proxyConfig = {
|
|
276
|
+
enabled: true,
|
|
277
|
+
country: config.tinyfish.proxyCountry,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const response = await callTinyFishAPI({
|
|
283
|
+
url,
|
|
284
|
+
goal,
|
|
285
|
+
browserProfile,
|
|
286
|
+
proxyConfig,
|
|
287
|
+
timeoutMs,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Guard clause: not configured
|
|
291
|
+
if (!response.ok && response.status === 503) {
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const resultStr =
|
|
296
|
+
typeof response.result === "string"
|
|
297
|
+
? response.result
|
|
298
|
+
: JSON.stringify(response.result, null, 2);
|
|
299
|
+
|
|
300
|
+
logger.debug(
|
|
301
|
+
{
|
|
302
|
+
url,
|
|
303
|
+
goal: goal.slice(0, 100),
|
|
304
|
+
browserProfile,
|
|
305
|
+
resultLength: resultStr.length,
|
|
306
|
+
},
|
|
307
|
+
"TinyFish web_agent completed"
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
status: 200,
|
|
313
|
+
content: resultStr,
|
|
314
|
+
metadata: {
|
|
315
|
+
url,
|
|
316
|
+
goal,
|
|
317
|
+
browserProfile,
|
|
318
|
+
resultLength: resultStr.length,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
} catch (err) {
|
|
322
|
+
logger.error(
|
|
323
|
+
{ err, url, goal: goal.slice(0, 100) },
|
|
324
|
+
"web_agent request failed"
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
status: err.status ?? 500,
|
|
329
|
+
content: JSON.stringify(
|
|
330
|
+
{
|
|
331
|
+
error: err.code ?? "web_agent_failed",
|
|
332
|
+
message: err.message,
|
|
333
|
+
url,
|
|
334
|
+
...(err.status ? { http_status: err.status } : {}),
|
|
335
|
+
},
|
|
336
|
+
null,
|
|
337
|
+
2
|
|
338
|
+
),
|
|
339
|
+
metadata: {
|
|
340
|
+
url,
|
|
341
|
+
goal,
|
|
342
|
+
error_code: err.code,
|
|
343
|
+
...(err.status ? { http_status: err.status } : {}),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{ category: "tinyfish" }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function registerTinyFishTools() {
|
|
353
|
+
registerTinyFishTool();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
registerTinyFishTools,
|
|
358
|
+
};
|
package/src/tools/truncate.js
CHANGED
|
@@ -8,6 +8,7 @@ const TRUNCATION_LIMITS = {
|
|
|
8
8
|
Glob: { maxChars: 8000, strategy: 'head' },
|
|
9
9
|
WebFetch: { maxChars: 16000, strategy: 'head' },
|
|
10
10
|
WebSearch: { maxChars: 12000, strategy: 'head' },
|
|
11
|
+
WebAgent: { maxChars: 16000, strategy: 'head' },
|
|
11
12
|
LSP: { maxChars: 8000, strategy: 'head' },
|
|
12
13
|
Edit: { maxChars: 8000, strategy: 'middle' },
|
|
13
14
|
Write: { maxChars: 8000, strategy: 'middle' },
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Payload Cloning & Size Estimation
|
|
3
|
+
*
|
|
4
|
+
* Optimizes deep-cloning of LLM request payloads to avoid
|
|
5
|
+
* wasting memory on large base64 media blocks that will be
|
|
6
|
+
* discarded by flattenBlocks() for most providers.
|
|
7
|
+
*
|
|
8
|
+
* @module utils/payload
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const logger = require('../logger');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Estimate the byte size of message content without full serialization.
|
|
15
|
+
* Scans for base64 image/audio data blocks and text blocks.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} payload - Request payload
|
|
18
|
+
* @returns {number} Estimated size in bytes
|
|
19
|
+
*/
|
|
20
|
+
function estimateContentSize(payload) {
|
|
21
|
+
if (!payload || !Array.isArray(payload.messages)) return 0;
|
|
22
|
+
|
|
23
|
+
let size = 0;
|
|
24
|
+
for (const msg of payload.messages) {
|
|
25
|
+
if (!msg) continue;
|
|
26
|
+
|
|
27
|
+
if (typeof msg.content === 'string') {
|
|
28
|
+
size += msg.content.length;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Array.isArray(msg.content)) continue;
|
|
33
|
+
|
|
34
|
+
for (const block of msg.content) {
|
|
35
|
+
if (!block || typeof block !== 'object') continue;
|
|
36
|
+
|
|
37
|
+
if (block.text) {
|
|
38
|
+
size += block.text.length;
|
|
39
|
+
}
|
|
40
|
+
// Anthropic image format
|
|
41
|
+
if (block.source?.data) {
|
|
42
|
+
size += block.source.data.length;
|
|
43
|
+
}
|
|
44
|
+
// OpenAI image_url format (inline base64)
|
|
45
|
+
if (block.image_url?.url && block.image_url.url.startsWith('data:')) {
|
|
46
|
+
size += block.image_url.url.length;
|
|
47
|
+
}
|
|
48
|
+
// tool_result content
|
|
49
|
+
if (block.type === 'tool_result' && typeof block.content === 'string') {
|
|
50
|
+
size += block.content.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return size;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if any message content block has base64 media data exceeding threshold.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} payload - Request payload
|
|
62
|
+
* @param {number} threshold - Size threshold in bytes (default 1MB)
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function hasLargeMedia(payload, threshold = 1_048_576) {
|
|
66
|
+
if (!payload || !Array.isArray(payload.messages)) return false;
|
|
67
|
+
|
|
68
|
+
for (const msg of payload.messages) {
|
|
69
|
+
if (!msg || !Array.isArray(msg.content)) continue;
|
|
70
|
+
|
|
71
|
+
for (const block of msg.content) {
|
|
72
|
+
if (!block || typeof block !== 'object') continue;
|
|
73
|
+
|
|
74
|
+
// Anthropic base64 image
|
|
75
|
+
if (block.source?.data && block.source.data.length > threshold) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
// OpenAI inline base64 image
|
|
79
|
+
if (block.image_url?.url && block.image_url.url.length > threshold) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Block types that flattenBlocks() discards (returns empty string)
|
|
89
|
+
const HEAVY_BLOCK_TYPES = new Set(['image', 'audio', 'image_url', 'video']);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a content block is a heavy media block that flattenBlocks() discards.
|
|
93
|
+
* @param {Object} block
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function isHeavyMediaBlock(block) {
|
|
97
|
+
if (!block || typeof block !== 'object') return false;
|
|
98
|
+
if (HEAVY_BLOCK_TYPES.has(block.type)) return true;
|
|
99
|
+
if (block.source?.type === 'base64') return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clone payload optimized for providers that will flatten content.
|
|
105
|
+
* Skips cloning heavy media blocks since flattenBlocks() discards them.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} payload
|
|
108
|
+
* @returns {Object} Cloned payload with media placeholders
|
|
109
|
+
*/
|
|
110
|
+
function cloneWithFlattenAwareness(payload) {
|
|
111
|
+
const clean = { ...payload };
|
|
112
|
+
|
|
113
|
+
// Deep-clone messages array but skip heavy media blocks
|
|
114
|
+
if (Array.isArray(payload.messages)) {
|
|
115
|
+
clean.messages = payload.messages.map(msg => {
|
|
116
|
+
if (!msg) return msg;
|
|
117
|
+
const cloned = { ...msg };
|
|
118
|
+
|
|
119
|
+
if (Array.isArray(msg.content)) {
|
|
120
|
+
cloned.content = msg.content.map(block => {
|
|
121
|
+
if (!block || typeof block !== 'object') return block;
|
|
122
|
+
|
|
123
|
+
// Skip heavy media blocks — flattenBlocks() produces "" for these
|
|
124
|
+
if (isHeavyMediaBlock(block)) {
|
|
125
|
+
return { type: block.type, _skipped: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Shallow clone small blocks (text, tool_result, tool_use)
|
|
129
|
+
if (block.type === 'tool_result' && typeof block.content === 'object') {
|
|
130
|
+
return { ...block, content: JSON.parse(JSON.stringify(block.content)) };
|
|
131
|
+
}
|
|
132
|
+
return { ...block };
|
|
133
|
+
});
|
|
134
|
+
} else if (typeof msg.content === 'object' && msg.content !== null) {
|
|
135
|
+
cloned.content = { ...msg.content };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clone tool_calls if present
|
|
139
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
140
|
+
cloned.tool_calls = JSON.parse(JSON.stringify(msg.tool_calls));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return cloned;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Deep-clone small arrays that get mutated
|
|
148
|
+
if (Array.isArray(payload.tools)) {
|
|
149
|
+
clean.tools = JSON.parse(JSON.stringify(payload.tools));
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(payload.system)) {
|
|
152
|
+
clean.system = JSON.parse(JSON.stringify(payload.system));
|
|
153
|
+
} else if (typeof payload.system === 'string') {
|
|
154
|
+
clean.system = payload.system;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return clean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Smart deep-clone a request payload.
|
|
162
|
+
*
|
|
163
|
+
* - If willFlatten is true: skips cloning heavy media blocks (they'll be discarded)
|
|
164
|
+
* - If willFlatten is false: uses structuredClone (faster than JSON round-trip)
|
|
165
|
+
* - Falls back to JSON.parse(JSON.stringify()) for compatibility
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} payload - Request payload to clone
|
|
168
|
+
* @param {Object} options
|
|
169
|
+
* @param {boolean} options.willFlatten - Whether flattenBlocks() will run (true for most providers)
|
|
170
|
+
* @returns {Object} Cloned payload
|
|
171
|
+
*/
|
|
172
|
+
function clonePayloadSmart(payload, options = {}) {
|
|
173
|
+
if (!payload) return {};
|
|
174
|
+
|
|
175
|
+
const { willFlatten = false } = options;
|
|
176
|
+
|
|
177
|
+
// Fast path: provider will flatten content — skip cloning media blocks
|
|
178
|
+
if (willFlatten && Array.isArray(payload.messages)) {
|
|
179
|
+
const hasMedia = payload.messages.some(msg =>
|
|
180
|
+
Array.isArray(msg?.content) && msg.content.some(isHeavyMediaBlock)
|
|
181
|
+
);
|
|
182
|
+
if (hasMedia) {
|
|
183
|
+
logger.debug('[payload] Using flatten-aware clone (skipping media blocks)');
|
|
184
|
+
return cloneWithFlattenAwareness(payload);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Medium path: structuredClone (faster, no string intermediate)
|
|
189
|
+
if (typeof structuredClone === 'function') {
|
|
190
|
+
try {
|
|
191
|
+
return structuredClone(payload);
|
|
192
|
+
} catch {
|
|
193
|
+
// structuredClone can fail on functions, symbols, etc.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Slow path: JSON round-trip (original behavior)
|
|
198
|
+
return JSON.parse(JSON.stringify(payload));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
estimateContentSize,
|
|
203
|
+
hasLargeMedia,
|
|
204
|
+
clonePayloadSmart,
|
|
205
|
+
isHeavyMediaBlock,
|
|
206
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Performance Timer
|
|
3
|
+
*
|
|
4
|
+
* Lightweight timing instrumentation for the request hot path.
|
|
5
|
+
* Enable with LOG_LEVEL=debug or PERF_TIMER=true to see per-request
|
|
6
|
+
* breakdown of where time is spent.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const timer = createTimer('processMessage');
|
|
10
|
+
* timer.mark('sanitizePayload');
|
|
11
|
+
* // ... do work ...
|
|
12
|
+
* timer.mark('cacheCheck');
|
|
13
|
+
* // ... do work ...
|
|
14
|
+
* timer.done(); // logs full breakdown
|
|
15
|
+
*
|
|
16
|
+
* @module utils/perf-timer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { performance } = require('perf_hooks');
|
|
20
|
+
const logger = require('../logger');
|
|
21
|
+
|
|
22
|
+
const ENABLED = process.env.PERF_TIMER === 'true';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a performance timer for a named operation.
|
|
26
|
+
* @param {string} name - Timer name (e.g., 'processMessage', 'invokeModel')
|
|
27
|
+
* @returns {{ mark: (label: string) => void, done: () => Object }}
|
|
28
|
+
*/
|
|
29
|
+
function createTimer(name) {
|
|
30
|
+
if (!ENABLED) {
|
|
31
|
+
// No-op when disabled — zero overhead
|
|
32
|
+
return {
|
|
33
|
+
mark() {},
|
|
34
|
+
done() { return null; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
const marks = [];
|
|
40
|
+
let lastMark = start;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/**
|
|
44
|
+
* Record a checkpoint.
|
|
45
|
+
* @param {string} label - What just completed
|
|
46
|
+
*/
|
|
47
|
+
mark(label) {
|
|
48
|
+
const now = performance.now();
|
|
49
|
+
marks.push({
|
|
50
|
+
label,
|
|
51
|
+
elapsed: now - lastMark,
|
|
52
|
+
cumulative: now - start,
|
|
53
|
+
});
|
|
54
|
+
lastMark = now;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Finish timing and log the breakdown.
|
|
59
|
+
* @returns {Object} Timing breakdown
|
|
60
|
+
*/
|
|
61
|
+
done() {
|
|
62
|
+
const total = performance.now() - start;
|
|
63
|
+
const breakdown = {};
|
|
64
|
+
|
|
65
|
+
for (const m of marks) {
|
|
66
|
+
breakdown[m.label] = `${m.elapsed.toFixed(2)}ms`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logger.info({
|
|
70
|
+
timer: name,
|
|
71
|
+
totalMs: total.toFixed(2),
|
|
72
|
+
breakdown,
|
|
73
|
+
}, `[perf] ${name}: ${total.toFixed(1)}ms`);
|
|
74
|
+
|
|
75
|
+
return { name, totalMs: total, marks, breakdown };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { createTimer };
|
package/.github/FUNDING.yml
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# These are supported funding model platforms
|
|
2
|
-
|
|
3
|
-
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
4
|
-
patreon: # Replace with a single Patreon username
|
|
5
|
-
open_collective: # Replace with a single Open Collective username
|
|
6
|
-
ko_fi: # Replace with a single Ko-fi username
|
|
7
|
-
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
-
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
-
liberapay: # Replace with a single Liberapay username
|
|
10
|
-
issuehunt: # Replace with a single IssueHunt username
|
|
11
|
-
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
12
|
-
polar: # Replace with a single Polar username
|
|
13
|
-
buy_me_a_coffee: srinivasveera
|
|
14
|
-
thanks_dev: # Replace with a single thanks.dev username
|
|
15
|
-
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|