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.
Files changed (61) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +10 -4
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. 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 { getModelRegistrySync } = require("../routing/model-registry");
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
- * Count tool_use and tool_result blocks in message history.
370
- * Only counts tools from the CURRENT TURN (after the last user text message).
371
- * This prevents the guard from blocking new questions after a previous loop.
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 countToolCallsInHistory(messages) {
374
- if (!Array.isArray(messages)) return { toolUseCount: 0, toolResultCount: 0 };
452
+ function computeModelTokenBudget(requestedModel, sessionId = null) {
453
+ const { contextWindowFor } = require("../routing/model-registry");
375
454
 
376
- // Find the index of the last user message that contains actual text (not just tool_result)
377
- let lastUserTextIndex = -1;
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
- // Check if this user message has actual text content (not just tool_result)
383
- if (typeof msg.content === 'string' && msg.content.trim().length > 0) {
384
- lastUserTextIndex = i;
385
- break;
386
- }
387
- if (Array.isArray(msg.content)) {
388
- const hasText = msg.content.some(block =>
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
- // Count only tool_use/tool_result AFTER the last user text message
400
- let toolUseCount = 0;
401
- let toolResultCount = 0;
402
-
403
- const startIndex = lastUserTextIndex >= 0 ? lastUserTextIndex : 0;
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
- for (let i = startIndex; i < messages.length; i++) {
406
- const msg = messages[i];
407
- if (!msg || !Array.isArray(msg.content)) continue;
478
+ if (!modelContextWindow) {
479
+ modelContextWindow = minConfiguredTierWindow();
480
+ if (modelContextWindow) source = 'min-tier';
481
+ }
408
482
 
409
- for (const block of msg.content) {
410
- if (block?.type === 'tool_use') toolUseCount++;
411
- if (block?.type === 'tool_result') toolResultCount++;
412
- }
483
+ if (!modelContextWindow) {
484
+ modelContextWindow = TOKEN_BUDGET_FALLBACK;
485
+ source = 'default';
413
486
  }
414
487
 
415
- return { toolUseCount, toolResultCount, lastUserTextIndex };
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
- const registry = getModelRegistrySync();
1603
- const modelInfo = registry.getCost(requestedModel);
1604
- const modelContextWindow = modelInfo?.context || config.tokenBudget?.max || 180000;
1605
- const modelMax = Math.floor(modelContextWindow * 0.85);
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: modelInfo?.source || 'default',
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
- if (maxCount >= DEDUP_TERMINATE_THRESHOLD) {
2870
- // Force-terminate: same pattern as existing tool_loop_guard
2871
- logger.error({
2872
- toolName: dedupToolName,
2873
- count: maxCount,
2874
- threshold: DEDUP_TERMINATE_THRESHOLD,
2875
- signature: dedupSig,
2876
- sessionId: session?.id ?? null,
2877
- }, "[CrossRequestDedup] FORCE TERMINATING - repeated tool call across requests");
2878
-
2879
- // Extract tool results summary from current turn
2880
- let toolResultsSummary = "";
2881
- const messages = payload?.messages || [];
2882
- const { lastUserTextIndex: luIdx } = countToolCallsInHistory(messages);
2883
- const startIdx = luIdx >= 0 ? luIdx : 0;
2884
- for (let i = startIdx; i < messages.length; i++) {
2885
- const msg = messages[i];
2886
- if (!msg || !Array.isArray(msg.content)) continue;
2887
- for (const block of msg.content) {
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: DEDUP_WARN_THRESHOLD,
3023
+ threshold: dedupWarnThreshold(),
2938
3024
  signature: dedupSig,
2939
3025
  sessionId: session?.id ?? null,
2940
- }, "[CrossRequestDedup] Warning - repeated tool call detected across requests");
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. Natural limits (maxSteps, maxDurationMs,
2962
- // provider token/rate limits, client-side loop detection, and the
2963
- // cross-request dedup above) are sufficient protection.
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) res.set("X-Lynkr-Model", 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
- env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_OPENAI_SSE_PROVIDERS,
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
- { droppedToolFragments: toolAcc.size, outputTokens: stats.usage.output_tokens },
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: { output_tokens: stats.usage.output_tokens ?? 0 },
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
  };
@@ -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