lynkr 9.14.2 → 9.14.7
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/tools/web-search-exec.js +182 -0
|
@@ -19,7 +19,7 @@ const { detectBypass, buildBypassResponse } = require("./bypass");
|
|
|
19
19
|
const crypto = require("crypto");
|
|
20
20
|
const { getSemanticCache } = require("../cache/semantic");
|
|
21
21
|
const { areSimilarToolCalls } = require("../clients/gpt-utils");
|
|
22
|
-
const {
|
|
22
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
23
23
|
const sessionAffinity = require("../routing/session-affinity");
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -59,6 +59,8 @@ function getDestinationUrl(providerType) {
|
|
|
59
59
|
return config.moonshot?.endpoint ?? 'unknown';
|
|
60
60
|
case 'baidu':
|
|
61
61
|
return config.baidu?.endpoint ?? 'unknown';
|
|
62
|
+
case 'fireworks':
|
|
63
|
+
return config.fireworks?.endpoint ?? 'unknown';
|
|
62
64
|
case 'codex':
|
|
63
65
|
return 'codex://app-server (local process)';
|
|
64
66
|
default:
|
|
@@ -365,66 +367,135 @@ function normaliseToolIdentifier(name = "") {
|
|
|
365
367
|
}
|
|
366
368
|
|
|
367
369
|
|
|
370
|
+
// (countToolCallsInHistory was removed along with the dedup force-terminate
|
|
371
|
+
// path — the dedup counter is observe-only and no longer needs to slice the
|
|
372
|
+
// current turn's tool results to synthesize a forced response.)
|
|
373
|
+
|
|
374
|
+
// === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
|
|
375
|
+
// These helpers track tool call signatures across multiple HTTP requests within
|
|
376
|
+
// the same session (client/passthrough mode). The inner-loop detection in
|
|
377
|
+
// runAgentLoop() only sees one request at a time, so repeated calls across
|
|
378
|
+
// requests escape it.
|
|
379
|
+
|
|
380
|
+
const DEDUP_MAX_SIGNATURES = 50;
|
|
381
|
+
// Observe-only threshold: how many similar calls before the counter emits
|
|
382
|
+
// its once-per-question log line + metrics signal. This is diagnostics, not
|
|
383
|
+
// enforcement — no message is ever injected and no turn is ever terminated
|
|
384
|
+
// on this signal (see the observe-only note at the consumer). Read at call
|
|
385
|
+
// time (not module load) so it's env-tunable per deployment and testable.
|
|
386
|
+
const DEDUP_OBSERVE_THRESHOLD_DEFAULT = 8;
|
|
387
|
+
|
|
388
|
+
function dedupWarnThreshold() {
|
|
389
|
+
const n = Number.parseInt(process.env.LYNKR_DEDUP_WARN_THRESHOLD, 10);
|
|
390
|
+
return Number.isNaN(n) || n < 2 ? DEDUP_OBSERVE_THRESHOLD_DEFAULT : n;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Cached minimum context window across the configured TIER_* models — the
|
|
394
|
+
// conservative floor for requests whose actual model can't be resolved
|
|
395
|
+
// (virtual names like "lynkr-auto" before a session has a pin). 60s TTL so
|
|
396
|
+
// tier-config hot-reloads are picked up.
|
|
397
|
+
let _minTierWindowCache = { at: 0, value: null };
|
|
398
|
+
const MIN_TIER_WINDOW_TTL_MS = 60_000;
|
|
399
|
+
|
|
400
|
+
function minConfiguredTierWindow() {
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
if (now - _minTierWindowCache.at < MIN_TIER_WINDOW_TTL_MS) return _minTierWindowCache.value;
|
|
403
|
+
let min = null;
|
|
404
|
+
try {
|
|
405
|
+
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
406
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
407
|
+
for (const m of getModelTierSelector().getAllConfiguredModels()) {
|
|
408
|
+
const w = contextWindowFor(m.model);
|
|
409
|
+
if (w && (!min || w < min)) min = w;
|
|
410
|
+
}
|
|
411
|
+
} catch { /* tier config unavailable — fall through to the default */ }
|
|
412
|
+
_minTierWindowCache = { at: now, value: min };
|
|
413
|
+
return min;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Absolute fallback when nothing at all resolves (no registry hit, no pin,
|
|
417
|
+
// no priced tier models). 200k-class minus headroom — the historical default.
|
|
418
|
+
const TOKEN_BUDGET_FALLBACK = 180000;
|
|
419
|
+
|
|
368
420
|
/**
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
421
|
+
* Model-aware token budget: the served model's real context window
|
|
422
|
+
* (registry-sourced) shrunk to a safe working budget. Single source of truth
|
|
423
|
+
* for both the token-budget enforcement pass AND the history compressor —
|
|
424
|
+
* the compressor must target the same budget the enforcer checks, or it
|
|
425
|
+
* over-compresses (live incident: fixed 10-message window kept ~56k tokens
|
|
426
|
+
* while 108.8k were available).
|
|
427
|
+
*
|
|
428
|
+
* Resolution order — most specific knowledge of the ACTUAL model wins:
|
|
429
|
+
* 1. the session pin's model — ground truth for what is really serving.
|
|
430
|
+
* Requested names lie in both virtual cases: "lynkr-auto" resolves in
|
|
431
|
+
* the registry (a literal 128k entry — the incident's mystery number),
|
|
432
|
+
* and tier-slot names (Claude Desktop's "claude-sonnet-5" = COMPLEX
|
|
433
|
+
* tier) resolve to the SLOT model's window, not the tier's configured
|
|
434
|
+
* model actually serving. The pin knows the truth; it wins.
|
|
435
|
+
* 2. requested model known to the registry (clients sending real names;
|
|
436
|
+
* "lynkr-*" virtual names are excluded — they must never resolve)
|
|
437
|
+
* 3. minimum window across configured TIER_* models (conservative floor
|
|
438
|
+
* for unpinned virtual-name requests: safe for whatever routing picks)
|
|
439
|
+
* 4. TOKEN_BUDGET_FALLBACK
|
|
440
|
+
*
|
|
441
|
+
* TOKEN_BUDGET_MAX is an OPT-IN cost/latency ceiling, applied only when the
|
|
442
|
+
* env var is explicitly set. It is deliberately no longer a default clamp:
|
|
443
|
+
* overflow safety is fully handled by the per-model ×0.85 math, and cost
|
|
444
|
+
* control belongs to the honest protocol layers (budgets, TPM) — not to a
|
|
445
|
+
* silent global squeeze that turns a 1M-context model into a 180k one.
|
|
446
|
+
* Read at call time so it's testable and live-tunable.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} requestedModel
|
|
449
|
+
* @param {string|null} [sessionId] - for pin-aware resolution (step 2)
|
|
450
|
+
* @returns {{ modelContextWindow: number, effectiveMax: number, effectiveWarning: number, source: string }}
|
|
372
451
|
*/
|
|
373
|
-
function
|
|
374
|
-
|
|
452
|
+
function computeModelTokenBudget(requestedModel, sessionId = null) {
|
|
453
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
375
454
|
|
|
376
|
-
|
|
377
|
-
let
|
|
378
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
379
|
-
const msg = messages[i];
|
|
380
|
-
if (msg?.role !== 'user') continue;
|
|
455
|
+
let modelContextWindow = null;
|
|
456
|
+
let source = null;
|
|
381
457
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
(block?.type === 'text' && block?.text?.trim?.().length > 0) ||
|
|
390
|
-
(block?.type === 'input_text' && block?.input_text?.trim?.().length > 0)
|
|
391
|
-
);
|
|
392
|
-
if (hasText) {
|
|
393
|
-
lastUserTextIndex = i;
|
|
394
|
-
break;
|
|
458
|
+
// 1. Session pin — the actually-serving model, when known.
|
|
459
|
+
if (sessionId) {
|
|
460
|
+
try {
|
|
461
|
+
const pin = sessionAffinity.getPin(sessionId);
|
|
462
|
+
if (pin?.model) {
|
|
463
|
+
modelContextWindow = contextWindowFor(pin.model);
|
|
464
|
+
if (modelContextWindow) source = 'session-pin';
|
|
395
465
|
}
|
|
396
|
-
}
|
|
466
|
+
} catch { /* pin store unavailable — fall through */ }
|
|
397
467
|
}
|
|
398
468
|
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
469
|
+
// 2. Requested model — only for real names. Lynkr's own virtual names
|
|
470
|
+
// must never resolve here (a "lynkr-auto" registry entry exists and
|
|
471
|
+
// longest-prefix matching extends it to any lynkr-* string).
|
|
472
|
+
const isVirtualName = /^lynkr[-_]/i.test(requestedModel || '');
|
|
473
|
+
if (!modelContextWindow && !isVirtualName) {
|
|
474
|
+
modelContextWindow = contextWindowFor(requestedModel);
|
|
475
|
+
if (modelContextWindow) source = 'requested-model';
|
|
476
|
+
}
|
|
404
477
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (
|
|
478
|
+
if (!modelContextWindow) {
|
|
479
|
+
modelContextWindow = minConfiguredTierWindow();
|
|
480
|
+
if (modelContextWindow) source = 'min-tier';
|
|
481
|
+
}
|
|
408
482
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
483
|
+
if (!modelContextWindow) {
|
|
484
|
+
modelContextWindow = TOKEN_BUDGET_FALLBACK;
|
|
485
|
+
source = 'default';
|
|
413
486
|
}
|
|
414
487
|
|
|
415
|
-
|
|
488
|
+
const modelMax = Math.floor(modelContextWindow * 0.85);
|
|
489
|
+
const explicitCap = process.env.TOKEN_BUDGET_MAX
|
|
490
|
+
? Number.parseInt(process.env.TOKEN_BUDGET_MAX, 10)
|
|
491
|
+
: null;
|
|
492
|
+
const effectiveMax = explicitCap && explicitCap > 0
|
|
493
|
+
? Math.min(modelMax, explicitCap)
|
|
494
|
+
: modelMax;
|
|
495
|
+
const effectiveWarning = Math.floor(effectiveMax * 0.65);
|
|
496
|
+
return { modelContextWindow, effectiveMax, effectiveWarning, source };
|
|
416
497
|
}
|
|
417
498
|
|
|
418
|
-
// === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
|
|
419
|
-
// These helpers track tool call signatures across multiple HTTP requests within
|
|
420
|
-
// the same session (client/passthrough mode). The inner-loop detection in
|
|
421
|
-
// runAgentLoop() only sees one request at a time, so repeated calls across
|
|
422
|
-
// requests escape it.
|
|
423
|
-
|
|
424
|
-
const DEDUP_MAX_SIGNATURES = 50;
|
|
425
|
-
const DEDUP_WARN_THRESHOLD = 5;
|
|
426
|
-
const DEDUP_TERMINATE_THRESHOLD = 8;
|
|
427
|
-
|
|
428
499
|
/**
|
|
429
500
|
* State-management tools are called repeatedly with similar args BY DESIGN —
|
|
430
501
|
* that's their job, not a loop. Opencode calls `todowrite` after every step;
|
|
@@ -1079,6 +1150,14 @@ function sanitizePayload(payload) {
|
|
|
1079
1150
|
} else {
|
|
1080
1151
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1081
1152
|
}
|
|
1153
|
+
} else if (providerType === "fireworks") {
|
|
1154
|
+
// Fireworks supports OpenAI-style tools - keep them in Anthropic format
|
|
1155
|
+
// They will be converted to OpenAI format in invokeFireworks
|
|
1156
|
+
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1157
|
+
delete clean.tools;
|
|
1158
|
+
} else {
|
|
1159
|
+
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1160
|
+
}
|
|
1082
1161
|
} else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") {
|
|
1083
1162
|
// Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the
|
|
1084
1163
|
// client converts to Chat Completions / Responses format. Without this
|
|
@@ -1434,7 +1513,11 @@ async function runAgentLoop({
|
|
|
1434
1513
|
cleanPayload.messages = historyCompression.compressHistory(originalMessages, {
|
|
1435
1514
|
keepRecentTurns: config.historyCompression?.keepRecentTurns ?? 10,
|
|
1436
1515
|
summarizeOlder: config.historyCompression?.summarizeOlder ?? true,
|
|
1437
|
-
enabled: true
|
|
1516
|
+
enabled: true,
|
|
1517
|
+
// Budget-driven recent window: keep as many verbatim recent
|
|
1518
|
+
// messages as the model's context budget allows, instead of a
|
|
1519
|
+
// fixed 10 (see compressHistory for the incident this fixes).
|
|
1520
|
+
budgetTokens: computeModelTokenBudget(requestedModel, session?.id ?? null).effectiveMax,
|
|
1438
1521
|
});
|
|
1439
1522
|
|
|
1440
1523
|
if (cleanPayload.messages !== originalMessages) {
|
|
@@ -1443,6 +1526,24 @@ async function runAgentLoop({
|
|
|
1443
1526
|
sessionId: session?.id ?? null,
|
|
1444
1527
|
...stats
|
|
1445
1528
|
}, 'History compression applied');
|
|
1529
|
+
|
|
1530
|
+
// Degradation visibility: severe compression means the model is
|
|
1531
|
+
// seeing a small fraction of the session. Count it, and warn
|
|
1532
|
+
// (once per session) past 50% — operators should not need log
|
|
1533
|
+
// forensics to learn their agent has amnesia.
|
|
1534
|
+
const pct = Number.parseFloat(stats.percentage) || 0;
|
|
1535
|
+
try { getMetricsCollector().recordHistoryCompression(pct); } catch { /* best-effort */ }
|
|
1536
|
+
if (pct > 50 && session?.metadata && !session.metadata._compressionWarned) {
|
|
1537
|
+
session.metadata._compressionWarned = true;
|
|
1538
|
+
logger.warn({
|
|
1539
|
+
sessionId: session?.id ?? null,
|
|
1540
|
+
percentage: stats.percentage,
|
|
1541
|
+
originalMessages: stats.originalMessages,
|
|
1542
|
+
compressedMessages: stats.compressedMessages,
|
|
1543
|
+
tokensOriginal: stats.tokensOriginal,
|
|
1544
|
+
tokensCompressed: stats.tokensCompressed,
|
|
1545
|
+
}, '[HistoryCompression] Severe compression — the model sees a small fraction of this session. Consider client-side compaction or a larger-context model.');
|
|
1546
|
+
}
|
|
1446
1547
|
}
|
|
1447
1548
|
}
|
|
1448
1549
|
} catch (err) {
|
|
@@ -1598,13 +1699,11 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1598
1699
|
cleanPayload.system = (cleanPayload.system || '') + '\n\nCRITICAL: You have NO tools available. Do NOT generate tool_use, function_call, or code_execution blocks. Output ONLY text content directly.';
|
|
1599
1700
|
}
|
|
1600
1701
|
|
|
1601
|
-
// Compute model-aware token budget thresholds
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
const modelContextWindow
|
|
1605
|
-
|
|
1606
|
-
const effectiveMax = Math.min(modelMax, config.tokenBudget?.max || 180000);
|
|
1607
|
-
const effectiveWarning = Math.floor(effectiveMax * 0.65);
|
|
1702
|
+
// Compute model-aware token budget thresholds (shared helper — also
|
|
1703
|
+
// consumed by the history-compression block above, which must target the
|
|
1704
|
+
// same budget it enforces).
|
|
1705
|
+
const { modelContextWindow, effectiveMax, effectiveWarning, source: budgetSource } =
|
|
1706
|
+
computeModelTokenBudget(requestedModel, session?.id ?? null);
|
|
1608
1707
|
|
|
1609
1708
|
logger.debug({
|
|
1610
1709
|
sessionId: session?.id ?? null,
|
|
@@ -1612,7 +1711,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1612
1711
|
modelContextWindow,
|
|
1613
1712
|
effectiveWarning,
|
|
1614
1713
|
effectiveMax,
|
|
1615
|
-
source:
|
|
1714
|
+
source: budgetSource,
|
|
1616
1715
|
}, 'Model-aware token budget computed');
|
|
1617
1716
|
|
|
1618
1717
|
if (steps === 1 && config.tokenBudget?.enforcement !== false) {
|
|
@@ -2231,6 +2330,33 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2231
2330
|
}
|
|
2232
2331
|
|
|
2233
2332
|
if (toolCalls.length > 0) {
|
|
2333
|
+
// Auto-resolve web_search/web_fetch server-side, but ONLY for clients
|
|
2334
|
+
// src/routing/client-profiles.js's detectClient() didn't recognize —
|
|
2335
|
+
// i.e. we have no signal the caller can fulfill these itself. Known
|
|
2336
|
+
// harnesses (Claude Code AND Claude Desktop — both present as
|
|
2337
|
+
// claude-cli/... since Desktop's gateway mode runs the same agent-sdk;
|
|
2338
|
+
// also Cursor, goose, Codex) already execute these client-side and
|
|
2339
|
+
// must keep doing so unchanged — this branch never fires for them.
|
|
2340
|
+
// Does NOT reintroduce general server-mode tool execution (removed
|
|
2341
|
+
// 2026-07-22, commit b32e988): only these two tool names, only for
|
|
2342
|
+
// unrecognized clients, and only when EVERY call in this batch is one
|
|
2343
|
+
// we can resolve (a mixed batch falls through to the normal
|
|
2344
|
+
// forward-to-client path below, untouched).
|
|
2345
|
+
const clientProfile = cleanPayload._clientProfile || null;
|
|
2346
|
+
if (!clientProfile) {
|
|
2347
|
+
const webSearchExec = require("../tools/web-search-exec");
|
|
2348
|
+
if (webSearchExec.canAutoResolveAll(toolCalls)) {
|
|
2349
|
+
logger.info({
|
|
2350
|
+
sessionId: session?.id ?? null,
|
|
2351
|
+
step: steps,
|
|
2352
|
+
tools: toolCalls.map((tc) => tc.function?.name ?? tc.name),
|
|
2353
|
+
}, "[web-search-exec] Auto-resolving web_search/web_fetch for unrecognized client");
|
|
2354
|
+
await webSearchExec.autoResolve(toolCalls, cleanPayload.messages);
|
|
2355
|
+
steps++;
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2234
2360
|
// Convert OpenAI/OpenRouter format to Anthropic format for session storage
|
|
2235
2361
|
let sessionContent;
|
|
2236
2362
|
if (providerType === "azure-anthropic") {
|
|
@@ -2589,6 +2715,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2589
2715
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
2590
2716
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
2591
2717
|
}
|
|
2718
|
+
} else if (actualProvider === "fireworks") {
|
|
2719
|
+
// Fireworks responses are already converted to Anthropic format in invokeFireworks
|
|
2720
|
+
anthropicPayload = databricksResponse.json;
|
|
2721
|
+
if (Array.isArray(anthropicPayload?.content)) {
|
|
2722
|
+
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
2723
|
+
}
|
|
2592
2724
|
} else if (actualProvider === "codex") {
|
|
2593
2725
|
// Codex responses are already in Anthropic format from invokeCodex
|
|
2594
2726
|
anthropicPayload = databricksResponse.json;
|
|
@@ -2866,88 +2998,36 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
2866
2998
|
|
|
2867
2999
|
const { maxCount, toolName: dedupToolName, signature: dedupSig } = getMaxDedupCount(session);
|
|
2868
3000
|
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
if (block?.type === 'tool_result' && block?.content) {
|
|
2889
|
-
const content = typeof block.content === 'string'
|
|
2890
|
-
? block.content
|
|
2891
|
-
: JSON.stringify(block.content);
|
|
2892
|
-
if (content && !content.includes('Found 0')) {
|
|
2893
|
-
toolResultsSummary += content + "\n";
|
|
2894
|
-
}
|
|
2895
|
-
}
|
|
2896
|
-
}
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
let responseText = `Based on the tool results, here's what I found:\n\n`;
|
|
2900
|
-
if (toolResultsSummary.trim()) {
|
|
2901
|
-
responseText += toolResultsSummary.trim();
|
|
2902
|
-
} else {
|
|
2903
|
-
responseText += `The tools executed but didn't return clear results. Please check the tool output above or try a different command.`;
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
const forcedResponse = {
|
|
2907
|
-
id: `msg_forced_${Date.now()}`,
|
|
2908
|
-
type: "message",
|
|
2909
|
-
role: "assistant",
|
|
2910
|
-
content: [{ type: "text", text: responseText }],
|
|
2911
|
-
model: requestedModel || "unknown",
|
|
2912
|
-
stop_reason: "end_turn",
|
|
2913
|
-
stop_sequence: null,
|
|
2914
|
-
usage: { input_tokens: 0, output_tokens: 100 },
|
|
2915
|
-
};
|
|
2916
|
-
|
|
2917
|
-
// Reset dedup after termination so next question starts fresh
|
|
2918
|
-
resetDedupTracking(session);
|
|
2919
|
-
// Persist to DB (non-ephemeral sessions only)
|
|
2920
|
-
if (session.id && !session._ephemeral) {
|
|
2921
|
-
try { upsertSession(session.id, { metadata: session.metadata }); } catch (e) {
|
|
2922
|
-
logger.debug({ err: e.message }, "Failed to persist dedup reset");
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
return {
|
|
2927
|
-
status: 200,
|
|
2928
|
-
body: forcedResponse,
|
|
2929
|
-
terminationReason: "tool_loop_guard",
|
|
2930
|
-
};
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
if (maxCount >= DEDUP_WARN_THRESHOLD && !dedup.warningInjected) {
|
|
3001
|
+
// OBSERVE-ONLY (deliberate design decision, 2026-09): the proxy meters
|
|
3002
|
+
// and reports — it never judges behavior into the conversation. The
|
|
3003
|
+
// previous enforcement here (an injected "loop guard" message at the
|
|
3004
|
+
// warn threshold, a forced synthetic response at the terminate
|
|
3005
|
+
// threshold) was removed after a live incident where it repeatedly
|
|
3006
|
+
// killed legitimate work: the proxy has the weakest evidence of any
|
|
3007
|
+
// layer (arg previews + similarity heuristics), so it carries an
|
|
3008
|
+
// irreducible false-positive floor, and a false positive here
|
|
3009
|
+
// sabotages the user's task invisibly. Loop *breaking* belongs to the
|
|
3010
|
+
// harness and the model; Lynkr's protection against runaway spend is
|
|
3011
|
+
// the honest, protocol-level layer: TPM limits (LYNKR_TPM_LIMIT),
|
|
3012
|
+
// budgets, and the loop-guard middleware's turn caps
|
|
3013
|
+
// (LYNKR_MAX_SESSION_TURNS / LYNKR_MAX_TOOL_TURNS) — all visible
|
|
3014
|
+
// 429/402 refusals, never ghostwritten messages.
|
|
3015
|
+
//
|
|
3016
|
+
// The counter itself stays: it costs nothing, mutates nothing, and is
|
|
3017
|
+
// exactly the signal an operator needs to diagnose loop-shaped waste
|
|
3018
|
+
// (log line + metrics; dashboards/statusline read the metrics).
|
|
3019
|
+
if (maxCount >= dedupWarnThreshold() && !dedup.warningInjected) {
|
|
2934
3020
|
logger.warn({
|
|
2935
3021
|
toolName: dedupToolName,
|
|
2936
3022
|
count: maxCount,
|
|
2937
|
-
threshold:
|
|
3023
|
+
threshold: dedupWarnThreshold(),
|
|
2938
3024
|
signature: dedupSig,
|
|
2939
3025
|
sessionId: session?.id ?? null,
|
|
2940
|
-
}, "[CrossRequestDedup]
|
|
2941
|
-
|
|
3026
|
+
}, "[CrossRequestDedup] Repeated similar tool calls observed (observe-only — no intervention)");
|
|
3027
|
+
try { getMetricsCollector().recordLoopGuard('observed'); } catch { /* metrics best-effort */ }
|
|
3028
|
+
// Field name kept for persisted-session compatibility; semantics are
|
|
3029
|
+
// now "signal emitted once per question", not "warning injected".
|
|
2942
3030
|
dedup.warningInjected = true;
|
|
2943
|
-
|
|
2944
|
-
// Inject a strict warning into the payload so the model sees it
|
|
2945
|
-
if (Array.isArray(payload?.messages)) {
|
|
2946
|
-
payload.messages.push({
|
|
2947
|
-
role: "user",
|
|
2948
|
-
content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${dedupToolName}" tool ${maxCount} times with identical or similar parameters across multiple requests. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Your response must contain your actual findings from the tool results gathered so far — NOT an acknowledgment or restatement of this warning. Do not mention this warning in your response.`,
|
|
2949
|
-
});
|
|
2950
|
-
}
|
|
2951
3031
|
}
|
|
2952
3032
|
|
|
2953
3033
|
// Persist dedup state (non-ephemeral sessions only)
|
|
@@ -2958,9 +3038,10 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
2958
3038
|
}
|
|
2959
3039
|
}
|
|
2960
3040
|
|
|
2961
|
-
// No count-based tool_loop_guard
|
|
2962
|
-
//
|
|
2963
|
-
//
|
|
3041
|
+
// No count-based tool_loop_guard and no dedup-based enforcement (see
|
|
3042
|
+
// observe-only note above). Hard limits live at the protocol layer:
|
|
3043
|
+
// maxSteps, maxDurationMs, provider token/rate limits, TPM, budgets,
|
|
3044
|
+
// and the loop-guard middleware's turn caps.
|
|
2964
3045
|
}
|
|
2965
3046
|
|
|
2966
3047
|
const { createTimer } = require("../utils/perf-timer");
|
|
@@ -3153,4 +3234,6 @@ module.exports = {
|
|
|
3153
3234
|
trimLoopMessages,
|
|
3154
3235
|
// Exported for unit testing of the live-stream routing badge.
|
|
3155
3236
|
buildRoutingBadge,
|
|
3237
|
+
// Exported for unit testing of pin-aware model budget resolution.
|
|
3238
|
+
computeModelTokenBudget,
|
|
3156
3239
|
};
|
|
@@ -261,7 +261,13 @@ async function handleNativeStream(req, res, opts = {}) {
|
|
|
261
261
|
});
|
|
262
262
|
res.set("X-Lynkr-Provider", tier.provider || "azure-anthropic");
|
|
263
263
|
if (tier.tier) res.set("X-Lynkr-Tier", tier.tier);
|
|
264
|
-
if (tier.model || req.body?.model)
|
|
264
|
+
if (tier.model || req.body?.model) {
|
|
265
|
+
const servedModel = tier.model || req.body.model;
|
|
266
|
+
res.set("X-Lynkr-Model", servedModel);
|
|
267
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
268
|
+
const contextWindow = contextWindowFor(servedModel);
|
|
269
|
+
if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
|
|
270
|
+
}
|
|
265
271
|
res.set("X-Lynkr-Routing-Method", "native-passthrough-stream");
|
|
266
272
|
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
267
273
|
|
|
@@ -28,6 +28,14 @@ const logger = require("../logger");
|
|
|
28
28
|
// stream:false predated this transformer). Caveat: reasoning_content deltas
|
|
29
29
|
// (kimi thinking) are not reshaped — thinking text is dropped from streamed
|
|
30
30
|
// responses; the buffered path still lifts it into thinking blocks.
|
|
31
|
+
// baidu (Qianfan's /v2/chat/completions) already returns the raw stream the
|
|
32
|
+
// same way moonshot does (invokeBaidu's `if (response?.stream) return
|
|
33
|
+
// response;`) and its endpoint is documented as OpenAI-compatible — but
|
|
34
|
+
// unlike moonshot this hasn't been E2E-verified against a live key as of
|
|
35
|
+
// this addition. If Qianfan's SSE deltas turn out not to match
|
|
36
|
+
// choices[0].delta exactly, remove it from this list (or set
|
|
37
|
+
// LYNKR_STREAM_TRANSFORM_PROVIDERS to exclude it) rather than patching the
|
|
38
|
+
// shared transformer for one provider's quirk.
|
|
31
39
|
const DEFAULT_OPENAI_SSE_PROVIDERS = [
|
|
32
40
|
"openai",
|
|
33
41
|
"atlas",
|
|
@@ -37,12 +45,36 @@ const DEFAULT_OPENAI_SSE_PROVIDERS = [
|
|
|
37
45
|
"lmstudio",
|
|
38
46
|
"llamacpp",
|
|
39
47
|
"moonshot",
|
|
48
|
+
"baidu",
|
|
49
|
+
// fireworks (api.fireworks.ai/inference/v1/chat/completions) is documented
|
|
50
|
+
// as OpenAI-compatible SSE — same baidu caveat applies: E2E-unverified
|
|
51
|
+
// against a live key as of this addition. If its deltas turn out not to
|
|
52
|
+
// match choices[0].delta exactly, remove it from this list rather than
|
|
53
|
+
// patching the shared transformer for one provider's quirk.
|
|
54
|
+
"fireworks",
|
|
40
55
|
];
|
|
41
56
|
|
|
57
|
+
// llama.cpp specific: reasoning-capable local builds (live-confirmed on this
|
|
58
|
+
// deployment's GPT-OSS model) emit `delta.reasoning_content` before any
|
|
59
|
+
// `delta.content`. This transformer only reads delta.content (see the
|
|
60
|
+
// per-chunk loop below) — reasoning deltas are silently dropped, so a client
|
|
61
|
+
// streaming through this path sees nothing at all during the thinking phase
|
|
62
|
+
// and, if reasoning consumes the whole max_tokens budget, nothing ever.
|
|
63
|
+
// Default OFF (buffered instead) for exactly the same reason Ollama already
|
|
64
|
+
// defaults to buffering (LYNKR_OLLAMA_BUFFER_RESPONSES) — the buffered path's
|
|
65
|
+
// convertOpenRouterResponseToAnthropic already lifts reasoning_content into a
|
|
66
|
+
// proper `thinking` block, so no data is lost there. Opt into live streaming
|
|
67
|
+
// (and accept dropped thinking text) via LYNKR_LLAMACPP_BUFFER_RESPONSES=false.
|
|
42
68
|
function _transformProviders() {
|
|
43
69
|
const env = process.env.LYNKR_STREAM_TRANSFORM_PROVIDERS;
|
|
70
|
+
if (env) {
|
|
71
|
+
return new Set(env.split(",").map((s) => s.trim()).filter(Boolean));
|
|
72
|
+
}
|
|
73
|
+
const bufferLlamacpp = process.env.LYNKR_LLAMACPP_BUFFER_RESPONSES !== "false";
|
|
44
74
|
return new Set(
|
|
45
|
-
|
|
75
|
+
bufferLlamacpp
|
|
76
|
+
? DEFAULT_OPENAI_SSE_PROVIDERS.filter((p) => p !== "llamacpp")
|
|
77
|
+
: DEFAULT_OPENAI_SSE_PROVIDERS,
|
|
46
78
|
);
|
|
47
79
|
}
|
|
48
80
|
|
|
@@ -260,6 +292,8 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
|
260
292
|
if (chunk.usage) {
|
|
261
293
|
if (chunk.usage.prompt_tokens != null) stats.usage.input_tokens = chunk.usage.prompt_tokens;
|
|
262
294
|
if (chunk.usage.completion_tokens != null) stats.usage.output_tokens = chunk.usage.completion_tokens;
|
|
295
|
+
const cached = chunk.usage.prompt_tokens_details?.cached_tokens;
|
|
296
|
+
if (cached != null) stats.usage.cache_read_input_tokens = cached;
|
|
263
297
|
}
|
|
264
298
|
|
|
265
299
|
const choice = chunk.choices?.[0];
|
|
@@ -334,7 +368,11 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
|
334
368
|
// argument JSON may be truncated mid-string.
|
|
335
369
|
if (!finishReason) {
|
|
336
370
|
logger.warn(
|
|
337
|
-
{
|
|
371
|
+
{
|
|
372
|
+
droppedToolFragments: toolAcc.size,
|
|
373
|
+
inputTokens: stats.usage.input_tokens,
|
|
374
|
+
outputTokens: stats.usage.output_tokens,
|
|
375
|
+
},
|
|
338
376
|
"[SSETransform] Upstream ended without finish_reason — signaling stream error"
|
|
339
377
|
);
|
|
340
378
|
yield _sse("error", {
|
|
@@ -352,10 +390,26 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
|
352
390
|
const stopReason = _mapFinishReason(finishReason);
|
|
353
391
|
stats.stopReason = stopReason;
|
|
354
392
|
stats.finishReason = finishReason;
|
|
393
|
+
// Usage ordering problem, solved here: the Anthropic protocol reports
|
|
394
|
+
// input_tokens in message_start, but OpenAI-shaped upstreams (Azure
|
|
395
|
+
// Responses API especially) only deliver usage at END of stream — so our
|
|
396
|
+
// message_start necessarily went out with input_tokens: 0. Emit the real
|
|
397
|
+
// cumulative usage in the final message_delta instead: Anthropic's own
|
|
398
|
+
// newer streams carry input_tokens/cache fields here, and SDK clients
|
|
399
|
+
// (incl. opencode's @ai-sdk/anthropic) merge message_delta usage over
|
|
400
|
+
// message_start. Without this, clients see only output tokens (~tens) and
|
|
401
|
+
// render nonsense like "Context: 64 tokens, 0% used" for a 60k-token
|
|
402
|
+
// prompt — and misprice every turn.
|
|
355
403
|
yield _sse("message_delta", {
|
|
356
404
|
type: "message_delta",
|
|
357
405
|
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
358
|
-
usage: {
|
|
406
|
+
usage: {
|
|
407
|
+
output_tokens: stats.usage.output_tokens ?? 0,
|
|
408
|
+
...(stats.usage.input_tokens != null ? { input_tokens: stats.usage.input_tokens } : {}),
|
|
409
|
+
...(stats.usage.cache_read_input_tokens != null
|
|
410
|
+
? { cache_read_input_tokens: stats.usage.cache_read_input_tokens }
|
|
411
|
+
: {}),
|
|
412
|
+
},
|
|
359
413
|
});
|
|
360
414
|
yield _sse("message_stop", { type: "message_stop" });
|
|
361
415
|
|
|
@@ -497,4 +551,9 @@ module.exports = {
|
|
|
497
551
|
// Exported for unit tests.
|
|
498
552
|
_openaiToAnthropicEvents,
|
|
499
553
|
_anthropicToOpenaiEvents,
|
|
554
|
+
// Exported for reuse by other SSE adapters (e.g. azure-responses-sse.js) —
|
|
555
|
+
// generic, provider-agnostic SSE byte-stream parsing with no OpenAI-specific
|
|
556
|
+
// behavior, worth sharing rather than reimplementing per adapter.
|
|
557
|
+
_iterateStream,
|
|
558
|
+
_sseDataLines,
|
|
500
559
|
};
|
package/src/routing/bandit.js
CHANGED
|
@@ -187,6 +187,45 @@ class LinUCBBandit {
|
|
|
187
187
|
return best;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Mean reward estimate for one arm in one context — θ·x with NO
|
|
192
|
+
* uncertainty bonus. This is the regression model r̂(x, a) the
|
|
193
|
+
* doubly-robust off-policy estimator consumes (routing/ope.js): the same
|
|
194
|
+
* ridge-regression state the live policy learns from, reused as the
|
|
195
|
+
* DR baseline instead of training a second model.
|
|
196
|
+
*
|
|
197
|
+
* Returns null when the arm has never been observed (identity-prior-only
|
|
198
|
+
* arms predict 0 by construction, which would bias DR toward "never
|
|
199
|
+
* tried" — better to let the estimator fall back to the IPS-only term).
|
|
200
|
+
*
|
|
201
|
+
* @param {string} tier
|
|
202
|
+
* @param {string} provider
|
|
203
|
+
* @param {string} model
|
|
204
|
+
* @param {number[]} context
|
|
205
|
+
* @returns {number|null} estimated reward in [0, 1], or null if unknown arm
|
|
206
|
+
*/
|
|
207
|
+
estimateReward(tier, provider, model, context) {
|
|
208
|
+
const key = this._armKey(tier, provider, model);
|
|
209
|
+
const arm = this.arms.get(key);
|
|
210
|
+
if (!arm || arm.count === 0) return null;
|
|
211
|
+
let ctx = context;
|
|
212
|
+
if (ctx.length !== this.dim) {
|
|
213
|
+
ctx = ctx.slice(0, this.dim);
|
|
214
|
+
while (ctx.length < this.dim) ctx.push(0);
|
|
215
|
+
}
|
|
216
|
+
let Ainv;
|
|
217
|
+
try {
|
|
218
|
+
Ainv = _inv(arm.A);
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const theta = _matVec(Ainv, arm.b);
|
|
223
|
+
const mean = _dot(theta, ctx);
|
|
224
|
+
// Rewards are trained in [0, 1]; clamp the linear extrapolation so a
|
|
225
|
+
// wild θ·x can't dominate the DR correction term.
|
|
226
|
+
return Math.max(0, Math.min(1, mean));
|
|
227
|
+
}
|
|
228
|
+
|
|
190
229
|
/**
|
|
191
230
|
* Update the chosen arm with the observed reward.
|
|
192
231
|
* @param {string} tier
|