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
@@ -28,6 +28,32 @@ const clientProfiles = require("../routing/client-profiles");
28
28
 
29
29
  const router = express.Router();
30
30
 
31
+ /**
32
+ * Served-model decision headers for the OpenAI-compat ingress.
33
+ *
34
+ * The Anthropic ingress has carried X-Lynkr-* decision headers for a long
35
+ * time; this path never did — the same single-ingress gap as issue #100.
36
+ * X-Lynkr-Context-Window is the served model's REAL context window (the
37
+ * workweave pattern): behind a virtual model name, tier routing serves
38
+ * models with different windows per request, and this header is the
39
+ * client's authoritative per-turn compaction budget. Omitted (never
40
+ * guessed) for models the registry doesn't know.
41
+ *
42
+ * Safe on streaming paths: no-op once headers have flushed.
43
+ *
44
+ * @param {object} res - Express response
45
+ * @param {string|null} servedModel - model from the orchestrator result body
46
+ */
47
+ function setServedModelHeaders(res, servedModel) {
48
+ if (!servedModel || res.headersSent) return;
49
+ try {
50
+ res.set("X-Lynkr-Model", servedModel);
51
+ const { contextWindowFor } = require("../routing/model-registry");
52
+ const contextWindow = contextWindowFor(servedModel);
53
+ if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
54
+ } catch { /* headers are best-effort — never fail a response over them */ }
55
+ }
56
+
31
57
  /**
32
58
  * Resolve the model name for OpenAI responses.
33
59
  * In OpenClaw mode, returns the actual provider/model from routing metadata.
@@ -568,6 +594,35 @@ router.post("/chat/completions", async (req, res) => {
568
594
  logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
569
595
  }
570
596
 
597
+ // Explicit model/effort pin: Codex's model picker (or any OpenAI-format
598
+ // caller) can name a real OpenAI model + reasoning.effort combo that
599
+ // resolveTierForOpenAIModel recognizes. Mirrors router.js's model-id-pin
600
+ // block for the Anthropic /v1/messages route — this one is needed
601
+ // separately because these OpenAI-shaped handlers call
602
+ // orchestrator.processMessage() directly and never go through router.js's
603
+ // pin/classifier code at all. See ../routing/openai-model-slots.js.
604
+ try {
605
+ const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots");
606
+ const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null;
607
+ const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort);
608
+ if (_pinTier) {
609
+ const { getModelTierSelector } = require("../routing");
610
+ const _sel = getModelTierSelector().selectModel(_pinTier, null);
611
+ anthropicRequest._forceProvider = _sel.provider;
612
+ if (_sel.model) anthropicRequest._tierModel = _sel.model;
613
+ anthropicRequest._tierName = _pinTier;
614
+ anthropicRequest._forcedMethod = "openai_model_effort_pin";
615
+ logger.debug({
616
+ model: req.body?.model,
617
+ effort: _effort,
618
+ tier: _pinTier,
619
+ provider: _sel.provider,
620
+ }, "[OpenAI Router] Explicit model/effort pin — scoring bypassed");
621
+ }
622
+ } catch (err) {
623
+ logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed");
624
+ }
625
+
571
626
  const session = getSession(sessionId);
572
627
 
573
628
  if (req.body.stream) {
@@ -591,9 +646,11 @@ router.post("/chat/completions", async (req, res) => {
591
646
  session: session,
592
647
  options: {
593
648
  maxSteps: req.body?.max_steps,
594
- clientWantsStream: true
649
+ clientWantsStream: true,
650
+ tenantPolicy: res.locals?.tenantPolicy || null
595
651
  }
596
652
  });
653
+ setServedModelHeaders(res, result?.body?.model);
597
654
 
598
655
  // Phase 2b live stream: reshape Anthropic SSE events into OpenAI
599
656
  // chat.completion.chunk events in flight — first token reaches the
@@ -768,6 +825,25 @@ router.post("/chat/completions", async (req, res) => {
768
825
 
769
826
  logger.debug({ chunk: "finish", finishReason: openaiResponse.choices[0].finish_reason }, "Sending finish chunk");
770
827
  res.write(`data: ${JSON.stringify(finishChunk)}\n\n`);
828
+
829
+ // Trailing usage chunk (choices: [], usage populated) — same shape
830
+ // as the Phase 2b live-stream path above. Previously this buffered
831
+ // synthesis only ever logged openaiResponse.usage (see below) and
832
+ // never wrote it to the client, so streaming clients on this path
833
+ // saw zero token/context usage too.
834
+ if (openaiResponse.usage) {
835
+ const usageChunk = {
836
+ id: openaiResponse.id,
837
+ object: "chat.completion.chunk",
838
+ created: openaiResponse.created,
839
+ model: streamModel,
840
+ system_fingerprint: "fp_lynkr",
841
+ choices: [],
842
+ usage: openaiResponse.usage,
843
+ };
844
+ res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
845
+ }
846
+
771
847
  res.write("data: [DONE]\n\n");
772
848
 
773
849
  logger.debug({ contentLength: content.length, contentPreview: content.substring(0, 50) }, "=== SSE STREAM COMPLETE ===");
@@ -811,9 +887,11 @@ router.post("/chat/completions", async (req, res) => {
811
887
  headers: req.headers,
812
888
  session: session,
813
889
  options: {
814
- maxSteps: req.body?.max_steps
890
+ maxSteps: req.body?.max_steps,
891
+ tenantPolicy: res.locals?.tenantPolicy || null
815
892
  }
816
893
  });
894
+ setServedModelHeaders(res, result?.body?.model);
817
895
 
818
896
  logger.debug({
819
897
  resultKeys: Object.keys(result || {}),
@@ -1065,6 +1143,19 @@ function getConfiguredProviders() {
1065
1143
  });
1066
1144
  }
1067
1145
 
1146
+ if (config.fireworks?.apiKey) {
1147
+ providers.push({
1148
+ name: "fireworks",
1149
+ type: "fireworks-ai",
1150
+ models: [
1151
+ config.fireworks.model || "accounts/fireworks/models/kimi-k2-instruct-0905",
1152
+ "accounts/fireworks/models/kimi-k2-instruct-0905",
1153
+ "accounts/fireworks/models/glm-5p2",
1154
+ "accounts/fireworks/models/deepseek-v3p1"
1155
+ ]
1156
+ });
1157
+ }
1158
+
1068
1159
  if (config.vertex?.projectId) {
1069
1160
  providers.push({
1070
1161
  name: "vertex",
@@ -1831,6 +1922,32 @@ router.post("/responses", async (req, res) => {
1831
1922
  logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
1832
1923
  }
1833
1924
 
1925
+ // Explicit model/effort pin — see the identical block in /chat/completions
1926
+ // above for the full rationale. This is the route Codex Desktop actually
1927
+ // uses (wire_api="responses"), so this is the one that matters for its
1928
+ // model picker specifically.
1929
+ try {
1930
+ const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots");
1931
+ const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null;
1932
+ const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort);
1933
+ if (_pinTier) {
1934
+ const { getModelTierSelector } = require("../routing");
1935
+ const _sel = getModelTierSelector().selectModel(_pinTier, null);
1936
+ anthropicRequest._forceProvider = _sel.provider;
1937
+ if (_sel.model) anthropicRequest._tierModel = _sel.model;
1938
+ anthropicRequest._tierName = _pinTier;
1939
+ anthropicRequest._forcedMethod = "openai_model_effort_pin";
1940
+ logger.debug({
1941
+ model: req.body?.model,
1942
+ effort: _effort,
1943
+ tier: _pinTier,
1944
+ provider: _sel.provider,
1945
+ }, "[OpenAI Router] Explicit model/effort pin (responses) — scoring bypassed");
1946
+ }
1947
+ } catch (err) {
1948
+ logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed (responses)");
1949
+ }
1950
+
1834
1951
  const session = getSession(sessionId);
1835
1952
 
1836
1953
  if (req.body.stream) {
@@ -1855,9 +1972,11 @@ router.post("/responses", async (req, res) => {
1855
1972
  headers: req.headers,
1856
1973
  session: session,
1857
1974
  options: {
1858
- maxSteps: req.body?.max_steps
1975
+ maxSteps: req.body?.max_steps,
1976
+ tenantPolicy: res.locals?.tenantPolicy || null
1859
1977
  }
1860
1978
  });
1979
+ setServedModelHeaders(res, result?.body?.model);
1861
1980
  } finally {
1862
1981
  clearInterval(keepalive);
1863
1982
  }
@@ -2192,9 +2311,11 @@ router.post("/responses", async (req, res) => {
2192
2311
  headers: req.headers,
2193
2312
  session: session,
2194
2313
  options: {
2195
- maxSteps: req.body?.max_steps
2314
+ maxSteps: req.body?.max_steps,
2315
+ tenantPolicy: res.locals?.tenantPolicy || null
2196
2316
  }
2197
2317
  });
2318
+ setServedModelHeaders(res, result?.body?.model);
2198
2319
 
2199
2320
  const chatResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
2200
2321
  const responsesResponse = convertChatToResponses(chatResponse);
@@ -237,6 +237,22 @@ function getConfiguredProviders() {
237
237
  });
238
238
  }
239
239
 
240
+ // Check Fireworks AI (serverless open models)
241
+ if (config.fireworks?.apiKey) {
242
+ providers.push({
243
+ name: "fireworks",
244
+ type: "fireworks-ai",
245
+ baseUrl: config.fireworks.endpoint || "https://api.fireworks.ai/inference/v1",
246
+ enabled: true,
247
+ models: [
248
+ { id: config.fireworks.model || "accounts/fireworks/models/kimi-k2-instruct-0905", name: "Configured Model" },
249
+ { id: "accounts/fireworks/models/kimi-k2-instruct-0905", name: "Kimi K2 Instruct" },
250
+ { id: "accounts/fireworks/models/glm-5p2", name: "GLM 5.2" },
251
+ { id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" },
252
+ ]
253
+ });
254
+ }
255
+
240
256
  // Check Vertex AI (Google Cloud)
241
257
  if (config.vertex?.projectId) {
242
258
  const region = config.vertex.region || "us-east5";
package/src/api/router.js CHANGED
@@ -7,7 +7,9 @@ const logger = require("../logger");
7
7
  const { createRateLimiter } = require("./middleware/rate-limiter");
8
8
  const openaiRouter = require("./openai-router");
9
9
  const providersRouter = require("./providers-handler");
10
+ const claudeDesktopGatewayRouter = require("./claude-desktop-gateway");
10
11
  const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelector, analyzeRisk, checkSessionPin, writeSessionPin, checkPinScoreDrift } = require("../routing");
12
+ const { resolveTierForModelId } = require("../routing/model-slots");
11
13
 
12
14
  // Upstream streams can die without a clean end (reader.read() never
13
15
  // resolves on a dropped socket), hanging the client forever. Every
@@ -36,7 +38,7 @@ const { detectClient } = require("../routing/client-profiles");
36
38
  const { buildInteractionBlock } = require("../routing/interaction");
37
39
  const { validateCwd } = require("../workspace");
38
40
  const { renderText } = require("../utils/markdown-ansi");
39
- const { classifyAuthMode } = require("../auth-mode");
41
+ const { classifyAuthMode, isFirstPartyAnthropicClient } = require("../auth-mode");
40
42
 
41
43
  const router = express.Router();
42
44
 
@@ -306,21 +308,16 @@ async function handleOauthPassthrough(req, res, opts = {}) {
306
308
  const upstream = process.env.LYNKR_OAUTH_PASSTHROUGH_URL
307
309
  || "https://api.anthropic.com/v1/messages";
308
310
 
309
- // === Optional: memory injection at last-user-message tail ===
310
- // Headroom's P0-1 pattern: append memory context to the latest user
311
- // message's first text block. NEVER touches system prompt or frozen-prefix
312
- // messages, so the cache-hot zone Anthropic fingerprints stays intact.
313
- // Opt-in via LYNKR_OAUTH_MEMORY_INJECTION=true since any body mutation on
314
- // a subscription request has nonzero anti-abuse risk.
311
+ // NO body mutation on the passthrough — deliberate policy (2026-09). The
312
+ // memory-injection option that lived here (LYNKR_OAUTH_MEMORY_INJECTION)
313
+ // was removed: this function's whole contract is byte-for-byte fidelity
314
+ // to what the first-party client sent, and any mutation of subscription
315
+ // traffic both breaks that claim and carries anti-abuse risk. Memory
316
+ // injection remains available on the orchestrator paths, where Lynkr is
317
+ // openly transforming requests. The only change made below is stripping
318
+ // Lynkr-internal underscore-prefixed fields, which Anthropic would reject
319
+ // as unknown keys — protocol necessity, not content mutation.
315
320
  let bodyToSend = req.body;
316
- if (process.env.LYNKR_OAUTH_MEMORY_INJECTION === 'true' && config.memory?.enabled !== false) {
317
- try {
318
- bodyToSend = maybeInjectMemoryIntoUserTail(req.body);
319
- } catch (err) {
320
- logger.debug({ err: err.message }, "Memory injection skipped (non-fatal)");
321
- bodyToSend = req.body;
322
- }
323
- }
324
321
 
325
322
  // === Observability: start ===
326
323
  const startedAt = Date.now();
@@ -390,8 +387,13 @@ async function handleOauthPassthrough(req, res, opts = {}) {
390
387
  // Lynkr's own decision headers so callers can see which model answered.
391
388
  res.set("X-Lynkr-Provider", "azure-anthropic-passthrough");
392
389
  if (opts.tier?.tier) res.set("X-Lynkr-Tier", opts.tier.tier);
393
- if (req.body?.model) res.set("X-Lynkr-Model", req.body.model);
394
- res.set("X-Lynkr-Routing-Method", "oauth-subscription-stealth");
390
+ if (req.body?.model) {
391
+ res.set("X-Lynkr-Model", req.body.model);
392
+ const { contextWindowFor } = require("../routing/model-registry");
393
+ const contextWindow = contextWindowFor(req.body.model);
394
+ if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
395
+ }
396
+ res.set("X-Lynkr-Routing-Method", "oauth-subscription-passthrough");
395
397
 
396
398
  // Capture the response (buffered or streamed) so we can do observability hooks
397
399
  // on the way back without changing what the client sees.
@@ -730,86 +732,8 @@ function extractAnthropicMessageFromSSE(sseText) {
730
732
  return result;
731
733
  }
732
734
 
733
- /**
734
- * Append relevant memories to the FIRST TEXT BLOCK of the LATEST USER MESSAGE.
735
- *
736
- * Headroom's P0-1 pattern (`_append_context_to_latest_non_frozen_user_turn`).
737
- * The cache hot zone (system + frozen prefix) is NEVER touched. Mutating only
738
- * the latest user message — which is the request's "live zone" — keeps the
739
- * prompt-cache identity stable and avoids Anthropic anti-abuse fingerprint
740
- * divergence for subscription tokens.
741
- *
742
- * Returns the body unchanged if:
743
- * - Memory is disabled
744
- * - No memories retrieved
745
- * - Latest message is not a user turn (could be tool_result, assistant)
746
- *
747
- * Returns a new body with appended context otherwise. Original body never
748
- * mutated (returns a shallow-cloned messages array).
749
- */
750
- function maybeInjectMemoryIntoUserTail(body) {
751
- if (!body || !Array.isArray(body.messages) || body.messages.length === 0) return body;
752
-
753
- const lastIdx = body.messages.length - 1;
754
- const lastMsg = body.messages[lastIdx];
755
- if (!lastMsg || lastMsg.role !== "user") return body;
756
-
757
- const { retrieveRelevantMemories, formatMemoriesForContext, extractQueryFromMessage } =
758
- require("../memory/retriever");
759
- const query = extractQueryFromMessage(lastMsg);
760
- if (!query || query.length < 10) return body; // too short to be a useful query
761
-
762
- const memories = retrieveRelevantMemories(query, {
763
- limit: Math.min(parseInt(process.env.MEMORY_RETRIEVAL_LIMIT, 10) || 5, 10),
764
- sessionId: body._sessionId || null,
765
- includeGlobal: process.env.MEMORY_INCLUDE_GLOBAL !== "false",
766
- });
767
- if (!memories || memories.length === 0) return body;
768
-
769
- const formatted = formatMemoriesForContext(memories);
770
- if (!formatted) return body;
771
-
772
- const contextText = `\n\n## Relevant context from earlier sessions:\n${formatted}`;
773
-
774
- // Bound the injection size (Headroom uses a MemoryInjectionBudget; we use
775
- // a simpler char cap — ~1024 tokens * 4 chars/token = 4096 chars).
776
- const MAX_INJECTION_CHARS = 4096;
777
- const boundedContext = contextText.length > MAX_INJECTION_CHARS
778
- ? contextText.slice(0, MAX_INJECTION_CHARS) + "\n…"
779
- : contextText;
780
-
781
- // Clone messages array (shallow) so we don't mutate the caller's body.
782
- const newMessages = body.messages.slice();
783
-
784
- if (typeof lastMsg.content === "string") {
785
- newMessages[lastIdx] = { ...lastMsg, content: lastMsg.content + boundedContext };
786
- } else if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) {
787
- // Append to the FIRST text block, preserving every other block (images,
788
- // tool_use, etc.) untouched.
789
- const newContent = [];
790
- let appended = false;
791
- for (const block of lastMsg.content) {
792
- if (!appended && block && typeof block === "object" && block.type === "text") {
793
- newContent.push({ ...block, text: (block.text || "") + boundedContext });
794
- appended = true;
795
- } else {
796
- newContent.push(block);
797
- }
798
- }
799
- if (!appended) return body; // no text block to append to
800
- newMessages[lastIdx] = { ...lastMsg, content: newContent };
801
- } else {
802
- return body;
803
- }
804
-
805
- logger.debug({
806
- memoryCount: memories.length,
807
- appendedChars: boundedContext.length,
808
- }, "Memory injected into last-user-message tail");
809
-
810
- return { ...body, messages: newMessages };
811
- }
812
-
735
+ // (maybeInjectMemoryIntoUserTail was removed with the passthrough
736
+ // memory-injection option — the passthrough is byte-for-byte by contract.)
813
737
  /**
814
738
  * Estimate token count for messages.
815
739
  *
@@ -1092,7 +1016,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1092
1016
  //
1093
1017
  // All three paths now share window-scored intent tier picking
1094
1018
  // (`pickTierByIntent`). Subscription still has the additional
1095
- // azure-anthropic passthrough fork for anti-abuse stealth; everything
1019
+ // azure-anthropic passthrough fork (first-party clients only); everything
1096
1020
  // else just falls through to the orchestrator with the picked tier
1097
1021
  // pinned via _forceProvider/_tierModel. The reason all paths share the
1098
1022
  // scorer is that determineProviderSmart's full-body analysis inflates
@@ -1117,6 +1041,37 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1117
1041
  // in a session skip the whole window-scored intent pipeline.
1118
1042
  let tier;
1119
1043
  const pinCheck = checkSessionPin(req.body);
1044
+
1045
+ // Explicit model-id pin: Claude Desktop's model picker (or any Anthropic-
1046
+ // format caller) can name one of the fixed ids claude-desktop-gateway.js
1047
+ // advertises (see ../routing/model-slots.js). Unlike every signal below,
1048
+ // this one is EXPLICIT user intent, not an inference — it wins over
1049
+ // session pins, side-request detection, and content scoring alike.
1050
+ // "Lynkr Auto" (claude-fable-5) and any unrecognized id resolve to null
1051
+ // and fall through to the existing cascade unchanged.
1052
+ const modelPinTier = resolveTierForModelId(req.body?.model);
1053
+ if (modelPinTier) {
1054
+ const _sel = getModelTierSelector().selectModel(modelPinTier, null);
1055
+ tier = {
1056
+ tier: modelPinTier,
1057
+ provider: _sel.provider,
1058
+ model: _sel.model || null,
1059
+ score: null,
1060
+ method: 'model_id_pin',
1061
+ reason: 'client_selected_model',
1062
+ base_tier: null,
1063
+ escalation_source: null,
1064
+ pinned: false,
1065
+ switch_reason: null,
1066
+ propensity: 1.0,
1067
+ candidates: [{ provider: _sel.provider, model: _sel.model || null }],
1068
+ };
1069
+ logger.debug({
1070
+ model: req.body.model,
1071
+ tier: modelPinTier,
1072
+ provider: _sel.provider,
1073
+ }, "[Routing] Explicit model-id pin — scoring bypassed");
1074
+ } else {
1120
1075
  // Side-request detection. Claude Code fires internal background calls
1121
1076
  // (title generation, summarization, memory extraction, suggestion-mode
1122
1077
  // autocomplete) that REPLAY the conversation — so they share the
@@ -1343,11 +1298,13 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1343
1298
  }, "OAuth intent — side request (no tools), pin write skipped");
1344
1299
  }
1345
1300
  }
1301
+ } // end else — no explicit model-id pin, scored normally above
1346
1302
 
1347
- // Subscription-only fork: anti-abuse stealth passthrough when the picked
1348
- // tier resolves to azure-anthropic. Bypasses the orchestrator entirely
1349
- // so the inbound bytes hit api.anthropic.com unchanged (Anthropic
1350
- // fingerprints subscription clients; any mutation gets flagged).
1303
+ // Subscription-only fork: byte-for-byte passthrough to api.anthropic.com
1304
+ // when the picked tier resolves to azure-anthropic — FIRST-PARTY
1305
+ // Anthropic clients only (Claude Code / Claude Desktop wrapping their
1306
+ // own traffic; see auth-mode.js for the policy). Gated here at the
1307
+ // dispatch point, not just in classification, so the two can't drift.
1351
1308
  // Passthrough forwards the CLIENT's credentials; GUI harnesses that
1352
1309
  // spawn claude headless inject placeholder keys ("dummy") that Anthropic
1353
1310
  // 401s. Placeholder auth ⇒ serve REASONING via the provider's own
@@ -1357,13 +1314,14 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1357
1314
  const _placeholderAuth =
1358
1315
  !_clientAuthHdr &&
1359
1316
  (['dummy', 'test', 'placeholder', 'none', 'x', 'sk-dummy'].includes(_clientApiKey) || _clientApiKey.length < 8);
1317
+ const _firstParty = isFirstPartyAnthropicClient(req.headers);
1360
1318
  if (_placeholderAuth && authMode === 'subscription' && tier.provider === 'azure-anthropic') {
1361
1319
  logger.info({
1362
1320
  reqNumber: messagesRequestCount,
1363
1321
  xApiKeyShape: _clientApiKey.slice(0, 12),
1364
1322
  }, 'Placeholder client auth — skipping passthrough, serving REASONING via provider credentials');
1365
1323
  }
1366
- if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth) {
1324
+ if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth && _firstParty) {
1367
1325
  logger.debug({
1368
1326
  reqNumber: messagesRequestCount,
1369
1327
  authMode,
@@ -2090,6 +2048,12 @@ router.get("/api/tokens/stats", (req, res) => {
2090
2048
  }
2091
2049
  });
2092
2050
 
2051
+ // Claude Desktop third-party gateway: intercepts GET /v1/models for
2052
+ // Anthropic-API clients (anthropic-version header) and advertises Lynkr's
2053
+ // tiers as Claude model families. Mounted BEFORE openaiRouter so the
2054
+ // intercept wins; non-Anthropic callers fall through unchanged.
2055
+ router.use("/v1", claudeDesktopGatewayRouter);
2056
+
2093
2057
  // Mount OpenAI-compatible endpoints for Cursor IDE support
2094
2058
  router.use("/v1", openaiRouter);
2095
2059
 
package/src/auth-mode.js CHANGED
@@ -47,15 +47,34 @@
47
47
  * @module auth-mode
48
48
  */
49
49
 
50
+ // FIRST-PARTY Anthropic clients only (deliberate policy, 2026-09): the
51
+ // subscription classification — and with it the api.anthropic.com
52
+ // passthrough — is for Claude Code and Claude Desktop wrapping their OWN
53
+ // traffic. Third-party harness prefixes (codex-cli/, cursor/,
54
+ // github-copilot/, antigravity/) were removed: classifying them as
55
+ // 'subscription' handed the passthrough to exactly the harness category
56
+ // Anthropic's ToS enforcement targets. Those clients still work through
57
+ // Lynkr — they classify by token shape ('oauth'/'payg') and route through
58
+ // the orchestrator with the operator's configured providers.
59
+ //
60
+ // BREAKING CHANGE for anyone who had Cursor/Codex/Copilot riding the
61
+ // subscription path: that traffic now routes via configured tiers.
62
+ //
63
+ // If Claude Desktop's own requests carry a UA not listed here (capture one
64
+ // via the desktop gateway before assuming), add THAT — never a third-party
65
+ // harness. This is a UA check: it defines intended use, not cryptographic
66
+ // attestation.
50
67
  const SUBSCRIPTION_UA_PREFIXES = [
51
68
  'claude-cli/',
52
69
  'claude-code/',
53
- 'codex-cli/',
54
- 'cursor/',
55
70
  'claude-vscode/',
56
- 'github-copilot/',
57
71
  'anthropic-cli/',
58
- 'antigravity/',
72
+ // Claude Desktop (Electron app). Captured from a real gateway request:
73
+ // "Mozilla/5.0 (...) Claude/1.46388.1 Chrome/148... Electron/42..." —
74
+ // the app identifies as "Claude/<version>" inside a browser-style UA.
75
+ // Deliberately 'claude/' (with slash): no third-party harness UA carries
76
+ // that token, and the first-party ids above don't collide with it.
77
+ 'claude/',
59
78
  ];
60
79
 
61
80
  /**
@@ -110,7 +129,24 @@ function classifyAuthMode(headers) {
110
129
  return 'payg';
111
130
  }
112
131
 
132
+ /**
133
+ * Is this request from a first-party Anthropic client (Claude Code / Claude
134
+ * Desktop family)? Used as the explicit gate at the api.anthropic.com
135
+ * passthrough dispatch fork — the security decision lives at the point of
136
+ * dispatch, not only in classification, so the two can't silently drift
137
+ * apart. Same UA semantics as classifyAuthMode's subscription check.
138
+ *
139
+ * @param {object} headers
140
+ * @returns {boolean}
141
+ */
142
+ function isFirstPartyAnthropicClient(headers) {
143
+ const ua = getHeader(headers, 'user-agent').toLowerCase();
144
+ if (!ua) return false;
145
+ return SUBSCRIPTION_UA_PREFIXES.some((prefix) => ua.includes(prefix));
146
+ }
147
+
113
148
  module.exports = {
114
149
  classifyAuthMode,
150
+ isFirstPartyAnthropicClient,
115
151
  SUBSCRIPTION_UA_PREFIXES,
116
152
  };
@@ -75,6 +75,12 @@ class BudgetManager {
75
75
  minute_window_start INTEGER,
76
76
  hour_window_start INTEGER
77
77
  );
78
+
79
+ CREATE TABLE IF NOT EXISTS token_rate (
80
+ user_id TEXT PRIMARY KEY,
81
+ tokens_minute INTEGER NOT NULL DEFAULT 0,
82
+ minute_window_start INTEGER NOT NULL
83
+ );
78
84
  `);
79
85
 
80
86
  this.stmts = {
@@ -114,6 +120,86 @@ class BudgetManager {
114
120
  };
115
121
  }
116
122
 
123
+ /**
124
+ * Token-aware (TPM) rate limiting (ROUTING-NOTES §1 gap: request-count
125
+ * only). Off unless LYNKR_TPM_LIMIT is set — same "off unless configured"
126
+ * convention as the loop guard.
127
+ *
128
+ * Estimate → true-up pattern (the one every serious gateway converged on):
129
+ * the pre-flight check gates on the window's ACTUAL consumption plus the
130
+ * current request's cheap estimate; real usage lands in the window
131
+ * post-response via recordTokenUsage(). Soft admission control, not a hard
132
+ * ceiling — concurrent in-flight requests can overshoot by roughly one
133
+ * request's worth, by design.
134
+ *
135
+ * @param {string} userId
136
+ * @param {number} estimatedTokens — cheap pre-flight estimate for THIS request
137
+ * @returns {{allowed: boolean, reason?: string, limit?: number, current?: number, resetInMs?: number}}
138
+ */
139
+ checkTokenRate(userId, estimatedTokens = 0) {
140
+ if (!this.enabled) return { allowed: true };
141
+ const limit = Number.parseInt(process.env.LYNKR_TPM_LIMIT, 10);
142
+ if (!limit || limit <= 0 || Number.isNaN(limit)) return { allowed: true };
143
+
144
+ const now = Date.now();
145
+ const minuteWindow = 60 * 1000;
146
+ const row = this.db.prepare('SELECT * FROM token_rate WHERE user_id = ?').get(userId);
147
+ let tokensMinute = row?.tokens_minute ?? 0;
148
+ let windowStart = row?.minute_window_start ?? now;
149
+ if (now - windowStart >= minuteWindow) {
150
+ tokensMinute = 0;
151
+ windowStart = now;
152
+ this.db.prepare(`
153
+ INSERT INTO token_rate (user_id, tokens_minute, minute_window_start)
154
+ VALUES (?, 0, ?)
155
+ ON CONFLICT(user_id) DO UPDATE SET tokens_minute = 0, minute_window_start = excluded.minute_window_start
156
+ `).run(userId, windowStart);
157
+ }
158
+
159
+ if (tokensMinute + estimatedTokens > limit) {
160
+ return {
161
+ allowed: false,
162
+ reason: 'token_rate_limit_minute',
163
+ limit,
164
+ current: tokensMinute,
165
+ estimated: estimatedTokens,
166
+ resetInMs: minuteWindow - (now - windowStart),
167
+ };
168
+ }
169
+ return { allowed: true };
170
+ }
171
+
172
+ /**
173
+ * True-up: add ACTUAL token consumption to the user's TPM window after the
174
+ * response completes. No-op when TPM limiting is disabled.
175
+ * @param {string} userId
176
+ * @param {number} totalTokens — actual input+output tokens consumed
177
+ */
178
+ recordTokenUsage(userId, totalTokens) {
179
+ if (!this.enabled || !totalTokens || totalTokens <= 0) return;
180
+ const limit = Number.parseInt(process.env.LYNKR_TPM_LIMIT, 10);
181
+ if (!limit || limit <= 0 || Number.isNaN(limit)) return;
182
+ const now = Date.now();
183
+ const minuteWindow = 60 * 1000;
184
+ try {
185
+ const row = this.db.prepare('SELECT * FROM token_rate WHERE user_id = ?').get(userId);
186
+ const stale = !row || now - row.minute_window_start >= minuteWindow;
187
+ this.db.prepare(`
188
+ INSERT INTO token_rate (user_id, tokens_minute, minute_window_start)
189
+ VALUES (@user_id, @tokens, @window_start)
190
+ ON CONFLICT(user_id) DO UPDATE SET
191
+ tokens_minute = ${stale ? '@tokens' : 'tokens_minute + @tokens'},
192
+ minute_window_start = @window_start
193
+ `).run({
194
+ user_id: userId,
195
+ tokens: totalTokens,
196
+ window_start: stale ? now : row.minute_window_start,
197
+ });
198
+ } catch (err) {
199
+ logger.debug({ err: err.message }, '[Budget] token usage record failed');
200
+ }
201
+ }
202
+
117
203
  checkRateLimit(userId) {
118
204
  if (!this.enabled) return { allowed: true };
119
205