lynkr 9.7.3 → 9.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +63 -25
  2. package/bin/cli.js +16 -1
  3. package/bin/lynkr-init.js +44 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +23 -2
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/build-eval-set.js +256 -0
  10. package/scripts/calibrate-thresholds.js +38 -157
  11. package/scripts/compact-dictionary.js +204 -0
  12. package/scripts/mine-difficulty-anchors.js +288 -0
  13. package/scripts/test-deduplication.js +448 -0
  14. package/scripts/validate-difficulty-classifier.js +123 -0
  15. package/scripts/validate-intent-anchors.js +186 -0
  16. package/scripts/ws7-anchor-replay.js +108 -0
  17. package/skills/lynkr/SKILL.md +195 -0
  18. package/src/api/middleware/loop-guard.js +87 -0
  19. package/src/api/middleware/request-logging.js +5 -64
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +120 -101
  22. package/src/api/providers-handler.js +27 -2
  23. package/src/api/router.js +467 -125
  24. package/src/budget/index.js +2 -19
  25. package/src/cache/semantic.js +9 -0
  26. package/src/clients/databricks.js +455 -142
  27. package/src/clients/gpt-utils.js +11 -105
  28. package/src/clients/openai-format.js +10 -3
  29. package/src/clients/openrouter-utils.js +49 -24
  30. package/src/clients/prompt-cache-injection.js +1 -0
  31. package/src/clients/provider-capabilities.js +1 -1
  32. package/src/clients/responses-format.js +34 -3
  33. package/src/clients/routing.js +15 -0
  34. package/src/config/index.js +36 -2
  35. package/src/context/gcf.js +275 -0
  36. package/src/context/tool-result-compressor.js +932 -47
  37. package/src/dashboard/api.js +1 -0
  38. package/src/logger/index.js +14 -1
  39. package/src/memory/search.js +12 -40
  40. package/src/memory/tools.js +3 -24
  41. package/src/orchestrator/bypass.js +4 -2
  42. package/src/orchestrator/index.js +120 -85
  43. package/src/routing/affinity-store.js +194 -0
  44. package/src/routing/agentic-detector.js +36 -6
  45. package/src/routing/bandit.js +25 -6
  46. package/src/routing/calibration.js +212 -0
  47. package/src/routing/classifier-setup.js +207 -0
  48. package/src/routing/client-profiles.js +292 -0
  49. package/src/routing/complexity-analyzer.js +88 -15
  50. package/src/routing/deescalator.js +148 -0
  51. package/src/routing/degradation.js +109 -0
  52. package/src/routing/difficulty-classifier.js +219 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +931 -90
  55. package/src/routing/intent-score.js +441 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +286 -13
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +86 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
package/README.md CHANGED
@@ -2,25 +2,27 @@
2
2
 
3
3
  ### An LLM Gateway which optimises your token usage.
4
4
 
5
- **87.6% fewer tokens on JSON tool results. 53% fewer tokens on tool-heavy requests. 171ms semantic cache hits. Zero code changes.**
5
+ **84% fewer tokens on JSON tool results. 53% fewer tokens on tool-heavy requests. Sub-300ms semantic cache hits. Zero code changes.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/lynkr.svg)](https://www.npmjs.com/package/lynkr)
8
- [![Tests](https://img.shields.io/badge/tests-699%20passing-brightgreen)](https://github.com/Fast-Editor/Lynkr)
8
+ [![Tests](https://img.shields.io/badge/tests-1041%20passing-brightgreen)](https://github.com/Fast-Editor/Lynkr)
9
9
  [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Node.js](https://img.shields.io/badge/node-20%2B-green)](https://nodejs.org)
11
11
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Fast-Editor/Lynkr)
12
12
 
13
13
  <table>
14
14
  <tr>
15
- <td align="center"><strong>87.6%</strong><br/>JSON Compression</td>
15
+ <td align="center"><strong>84%</strong><br/>JSON Compression</td>
16
16
  <td align="center"><strong>53%</strong><br/>Tool Token Reduction</td>
17
- <td align="center"><strong>171ms</strong><br/>Semantic Cache Hits</td>
17
+ <td align="center"><strong>&lt;300ms</strong><br/>Semantic Cache Hits</td>
18
18
  <td align="center"><strong>13+</strong><br/>LLM Providers</td>
19
19
  <td align="center"><strong>0</strong><br/>Code Changes Required</td>
20
20
  </tr>
21
21
  </table>
22
22
 
23
- > Numbers from a live benchmark against LiteLLM on identical workloads. [See full report →](BENCHMARK_REPORT.md)
23
+ > Numbers from the bundled benchmark against LiteLLM on identical free local backends — run it yourself: `node benchmark-tier-routing.js`. It doubles as a 19-scenario routing regression harness (currently 12/12 correctness checks), and `MODE=routing` runs a routing-only head-to-head that judges **both** proxies on the same acceptable-tier sets — including LiteLLM's Auto Router v2. [How it works →](docs/benchmarking.md)
24
+
25
+ > **Third-party benchmark:** on [RouterArena](https://github.com/RouteWorks/RouterArena) (ICLR 2026, 8,400 queries) Lynkr's routing scores **67.65 arena / 68.41% accuracy at $0.29 per 1K queries with 92.38 robustness** — above GPT-5's built-in router and NotDiamond at a fraction of their cost. [Methodology & caveats →](docs/routerarena-benchmark.md)
24
26
 
25
27
  ---
26
28
 
@@ -40,8 +42,9 @@ lynkr wrap claude
40
42
 
41
43
  **Wrapping gives you:**
42
44
  - ✅ Tier routing (send simple tasks to free Ollama, complex to your subscription/API)
43
- - ✅ TOON/RTK compression (87% token reduction on tool outputs)
44
- - ✅ Semantic caching (171ms cache hits)
45
+ - ✅ Sticky sessions: one routing decision per conversation via content fingerprinting, with automatic escalation when the task outgrows the model
46
+ - ✅ TOON/RTK compression (84% token reduction on large JSON tool outputs)
47
+ - ✅ Semantic caching (sub-300ms cache hits, 0 tokens billed)
45
48
  - ✅ **3-5x more usage from the same subscription limits**
46
49
  - ✅ Works with OAuth (Claude, Copilot, Cursor) or API keys (Aider, Codex)
47
50
 
@@ -180,9 +183,10 @@ Claude Code / Cursor / Codex / Cline / Continue
180
183
  Lynkr
181
184
  ┌─────────────────────┐
182
185
  │ Strip unused tools │ ← 53% fewer tokens on tool calls
183
- │ Compress JSON blobs │ ← 87.6% on large tool results
184
- │ Semantic cache │ ← 171ms hits, 0 tokens billed
186
+ │ Compress JSON blobs │ ← 84% on large tool results
187
+ │ Semantic cache │ ← <300ms hits, 0 tokens billed
185
188
  │ Route by complexity │ ← cheap model for simple, cloud for hard
189
+ │ Learn from outcomes │ ← kNN + bandit + auto-calibration
186
190
  └─────────────────────┘
187
191
 
188
192
  Ollama | Bedrock | Azure | Moonshot | OpenRouter | OpenAI
@@ -190,9 +194,10 @@ Claude Code / Cursor / Codex / Cline / Continue
190
194
 
191
195
  **What you get:**
192
196
  - ✅ **53% fewer tokens** on tool-heavy requests (Claude Code, Cursor sessions)
193
- - ✅ **87.6% compression** on large JSON tool results (grep, file reads, test output)
194
- - ✅ **Semantic cache** serves repeated queries in 171ms with 0 tokens billed
195
- - ✅ **Automatic tier routing** — simple questions go to cheap models, complex ones escalate
197
+ - ✅ **84% compression** on large JSON tool results (grep, file reads, test output)
198
+ - ✅ **Semantic cache** serves repeated queries in under 300ms with 0 tokens billed
199
+ - ✅ **Automatic tier routing** — simple questions go to cheap models, complex ones escalate; sessions stick to one model until the task genuinely outgrows it
200
+ - ✅ **A closed learning loop** — every outcome trains a kNN router and bandit, and tier thresholds re-calibrate nightly from your own traffic
196
201
  - ✅ Route through **your company's infrastructure** (Databricks, Azure, Bedrock)
197
202
  - ✅ **Zero code changes** — just change one environment variable
198
203
 
@@ -241,11 +246,11 @@ POLICY_MAX_STEPS=50
241
246
  POLICY_MAX_TOOL_CALLS=100
242
247
  ```
243
248
 
244
- Lynkr analyzes each request and routes it to the appropriate tier. Simple questions use fast models. Complex refactoring uses powerful models.
249
+ Lynkr analyzes each request and routes it to the appropriate tier. Simple questions use fast models. Complex refactoring uses powerful models. The scorer combines anchor-embedding classification (WS7, payload-invariant) with an LLM difficulty classifier (Phase 6, added 2026-07-19) that catches topic-vs-difficulty confounding — `list the exports from this file` correctly routes to MEDIUM instead of getting boosted to REASONING by "technical vocabulary" alone.
245
250
 
246
- **Result:** 70-90% of requests use cheaper/faster models. Only hard problems hit expensive models.
251
+ **Result:** 70-90% of requests use cheaper/faster models. Only hard problems hit expensive models. **15× reduction in expensive-tier over-routing** vs the anchor-only baseline on the 381-prompt eval set (0.6% vs ~15%).
247
252
 
248
- Tier configuration is strictly authoritative — bandit exploration is constrained to the models you've listed in `TIER_*`, and multi-turn conversations score with a recency-weighted sliding window so context isn't lost on short follow-ups. See [`docs/intent-window-routing.md`](docs/intent-window-routing.md).
253
+ Tier configuration is strictly authoritative — bandit exploration is constrained to the models you've listed in `TIER_*`, and multi-turn conversations score with a recency-weighted sliding window so context isn't lost on short follow-ups. Conversations get a content-fingerprint session id (clients like Claude Code send none), the decision pins for the session, and a guarded escape ladder (risk keywords, force phrases, score drift, context overflow) re-escalates the moment a task outgrows its model. Full pipeline: [`docs/routing-intelligence.md`](docs/routing-intelligence.md) · intent scorer: [`docs/intent-window-routing.md`](docs/intent-window-routing.md) · verify any change: [`docs/benchmarking.md`](docs/benchmarking.md).
249
254
 
250
255
  ---
251
256
 
@@ -408,7 +413,7 @@ npm start
408
413
 
409
414
  Head-to-head against **LiteLLM** on the **same backends** (Ollama `minimax-m2.5`, Moonshot, Azure OpenAI), 9 scenarios across 4 feature categories. Apples-to-apples comparison is Lynkr vs LiteLLM **billed tokens on the same scenario**. Run with `node benchmark-tier-routing.js`.
410
415
 
411
- > _Run: June 5, 2026 · Lynkr v9.3.2 · LiteLLM v1.87.1 · macOS, Apple Silicon._
416
+ > _Runs: token benchmarks — June 5, 2026 (Lynkr v9.3.2 · LiteLLM v1.87.1). Tier routing head-to-head — **re-run July 19, 2026** (Lynkr working tree w/ Phase-6 classifier + config B · LiteLLM v1.94.0.dev1 Auto Router v2). macOS, Apple Silicon._
412
417
 
413
418
  ### Token reduction (vs LiteLLM, same model & prompt)
414
419
 
@@ -428,23 +433,56 @@ Lynkr strips irrelevant tool schemas (smart tool selection) and binary-compresse
428
433
 
429
434
  Near-identical prompts return cached responses in 171ms. Zero model tokens billed on a cache hit.
430
435
 
431
- ### Tier routing
436
+ ### Tier routing — vs LiteLLM Auto Router v2 (re-run July 19, 2026)
437
+
438
+ LiteLLM v1.94 shipped a native complexity router (`auto_router/complexity_router`) with the same four tier names Lynkr uses. Head-to-head on the **same backends** with identical prompts, both proxies live, both judged against the same acceptable-tier sets (11 routing scenarios, `MODE=routing node benchmark-tier-routing.js`, LiteLLM config in `litellm-autorouter-v2.yaml`):
439
+
440
+ | Router | Routing-correct | Notes |
441
+ |---|---|---|
442
+ | **Lynkr (Phase-6 classifier + config B)** | **11/11 ✅** | anchor embedding + local LLM classifier reconcile + FORCE_REASONING patterns + risk-remap; ~500ms warm classifier call, cached |
443
+ | LiteLLM v2 — heuristic (default) | **4/11** | every miss *under-routed* — banking security analysis, whole-pipeline refactor, prod auth-file fix, autonomous agentic loop all sent to a 7B local model |
444
+ | LiteLLM v2 — LLM classifier | 6–8/11 (non-deterministic) | paid GPT-5.2 call + ~2–3s on **every** request; fails outright with local classifier models |
445
+
446
+ **The 7 requests LiteLLM v2 misroutes to a 7B ollama model (should be COMPLEX or REASONING):**
432
447
 
433
- | Request | Lynkr routes to | LiteLLM routes to |
448
+ | What you typed | Lynkr sends to | LiteLLM sends to |
434
449
  |---|---|---|
435
- | "What does git stash do?" | `minimax-m2.5` (local, free) | Ollama (local) |
436
- | JWT vs cookies security analysis | `moonshot` (cloud correct) | **Ollama (local wrong call)** |
450
+ | "Analyse security trade-offs of JWT vs httpOnly cookies for a banking app" | Claude Opus (top) | **ollama 7B** |
451
+ | "Refactor the entire ingestion pipeline and give me the plan" | z.ai GLM-5.2 (mid) | **ollama 7B** |
452
+ | "Fix the null-check bug in `src/auth/middleware.ts`" | Claude Opus (risk-remap) | **ollama 7B** ❌ |
453
+ | "Figure out why this test is flaky. You have full autonomy — iterate until 10 runs pass" | Claude Opus (autonomous) | **ollama 7B** ❌ |
454
+ | "please summarize the exports of this file quickly" | ollama minimax (correct — MEDIUM) | ollama minimax — but for the wrong reason (LiteLLM under-routed to SIMPLE) |
455
+
456
+ Lynkr layers the following, in decreasing priority, to catch every miss LiteLLM leaks:
437
457
 
438
- Lynkr scores each request on 15 dimensions (token count, code complexity, reasoning markers, risk signals, agentic patterns) and escalates automatically. LiteLLM's `cost-based-routing` sends everything to the cheapest model regardless of complexity.
458
+ - FORCE_REASONING regex (`ultrathink`, `prove`, `security audit`, `from first principles`) deterministic top-tier
459
+ - Risk classifier (auth/middleware/credentials paths) → REASONING under config B
460
+ - Agentic detector (`AUTONOMOUS` workflows) → REASONING minimum
461
+ - Anchor-embedding classifier over 4 difficulty classes + FRONTIER_MIN_SIM floor
462
+ - Local LLM classifier (qwen2.5:3b) reconciles borderline anchor calls
463
+ - Envelope invariance — scores cleaned user text only, ignoring tool schemas / history / system-reminders
464
+
465
+ LiteLLM's router reads the raw last message with no envelope-stripping, no verify-then-escalate cascade, no risk classifier, no agentic detection — its fallbacks trigger only on HTTP errors, never on a bad answer.
466
+
467
+ _Fairness notes: the 11 scenarios derive from Lynkr's own regression suite, so Lynkr has home-field advantage — the transferable finding is the **direction** of LiteLLM's failures (systematic under-routing to cheap on hard prompts), not the exact scores. Lynkr's top tiers used Claude Opus 4.8 / z.ai GLM-5.2; LiteLLM was given the identical tier targets._
439
468
 
440
469
  ### Cost projection (100,000 requests/month, same backend)
441
470
 
471
+ **Direct cost (raw meter):**
472
+
442
473
  | | Monthly cost | vs LiteLLM |
443
474
  |---|---|---|
444
- | LiteLLM | ~$818 | baseline |
445
- | **Lynkr** | **~$409** | **~50% cheaper** |
475
+ | LiteLLM (TOON tool-heavy scenario) | ~$818 | baseline |
476
+ | **Lynkr (TOON tool-heavy)** | **~$409** | **~50% cheaper** via token optimization |
477
+
478
+ **Effective cost per correct-tier answer** (July 19 head-to-head, all 11 routing scenarios):
479
+
480
+ | | Direct $/mo | Effective $/mo if you re-issue misroutes at correct tier |
481
+ |---|---|---|
482
+ | **Lynkr** | ~$5.50 (spends where it should, doesn't where it shouldn't) | ~$5.50 |
483
+ | LiteLLM v2 heuristic | $0 (under-routes everything to ollama) | ~$89 (7 misroutes need re-issue on Moonshot/COMPLEX) |
446
484
 
447
- _Based on a tool-heavy agentic session (TOON scenario). On equal footing same provider, same modelLynkr is cheaper due to token optimization._
485
+ The story isn't "Lynkr always spends less." It's **Lynkr spends where it should and doesn't where it shouldn't**. LiteLLM's zero-dollar cost on complex prompts is bought with wrong-tier answers to hard worka 7B model answering "prove this rate limiter is fair under concurrent refill" is silent quality collapse, not a savings.
448
486
 
449
487
  → [Full benchmark report with methodology](BENCHMARK_REPORT.md)
450
488
 
@@ -469,7 +507,7 @@ With tier routing + token optimization: **additional 50-87% savings** on cloud p
469
507
  | **Claude Code native** | ✅ Drop-in | ⚠️ Requires config | ❌ | ⚠️ Partial |
470
508
  | **Cursor native** | ✅ Drop-in | ⚠️ Partial | ❌ | ⚠️ Partial |
471
509
  | **Local models** | Ollama, llama.cpp, LM Studio | Ollama only | ❌ | ❌ |
472
- | **Automatic tier routing** | ✅ 15-dimension scorer | ⚠️ Cost-only | ❌ | ❌ Manual metadata |
510
+ | **Automatic tier routing** | ✅ embedding intent + 13-dimension scorer, verified cascade | ⚠️ Auto Router v2 (v1.94): heuristic under-routes, LLM classifier billed per request | ❌ | ❌ Manual metadata |
473
511
  | **TOON JSON compression** | ✅ up to 87.6% | ❌ | ❌ | ❌ |
474
512
  | **Smart tool selection** | ✅ up to 60% token reduction | ❌ | ❌ | ❌ |
475
513
  | **Semantic cache** | ✅ 171ms hits, 0 tokens | ❌ | ❌ | ✅ Prompt cache only |
package/bin/cli.js CHANGED
@@ -7,19 +7,31 @@ const pkg = require('../package.json');
7
7
  // don't start the proxy. Add new subcommands here, not in scattered binaries.
8
8
  const SUBCOMMANDS = {
9
9
  usage: path.join(__dirname, "lynkr-usage.js"),
10
+ stats: path.join(__dirname, "lynkr-usage.js"),
10
11
  trajectory: path.join(__dirname, "lynkr-trajectory.js"),
11
12
  wrap: path.join(__dirname, "wrap.js"),
12
13
  init: path.join(__dirname, "lynkr-init.js"),
13
14
  };
14
15
 
15
16
  const sub = process.argv[2];
16
- if (sub && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, sub)) {
17
+ // `lynkr start` is an alias for `lynkr` (start the proxy). Drop the token
18
+ // and fall through so users can use whichever spelling they prefer.
19
+ if (sub === 'start') {
20
+ process.argv.splice(2, 1);
21
+ } else if (sub && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, sub)) {
17
22
  process.argv.splice(2, 1); // drop the subcommand token so the script's own arg parser is happy
18
23
  // Subcommand scripts check this to decide whether to invoke their main()
19
24
  // when they're require()'d (vs being loaded by a test for unit-checking).
20
25
  process.env._LYNKR_SUBCMD = sub;
21
26
  require(SUBCOMMANDS[sub]);
22
27
  return;
28
+ } else if (sub && !sub.startsWith('-')) {
29
+ // Unknown positional commands must error loudly, not silently boot the
30
+ // server (`lynkr codex` used to start a bare gateway and never say why).
31
+ console.error(`Error: unknown command '${sub}'.`);
32
+ console.error(`Did you mean: lynkr wrap ${sub}`);
33
+ console.error(`Known commands: ${Object.keys(SUBCOMMANDS).join(', ')}, start (or no command to start the server)`);
34
+ process.exit(1);
23
35
  }
24
36
 
25
37
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
@@ -35,8 +47,11 @@ ${pkg.description}
35
47
 
36
48
  Usage:
37
49
  lynkr [options] Start the proxy server (default)
50
+ lynkr start [options] Alias for the above
51
+ lynkr init [options] Interactive setup wizard (writes .env, pulls classifier model)
38
52
  lynkr wrap <target> [options] Wrap CLI tools through Lynkr proxy
39
53
  lynkr usage [options] Show AI spend report and tier-routing savings
54
+ lynkr stats [options] Shareable savings-receipt card (also: lynkr usage --card)
40
55
  lynkr trajectory [options] Export agent trajectories as JSONL training data
41
56
 
42
57
  Options:
package/bin/lynkr-init.js CHANGED
@@ -98,6 +98,15 @@ const PROVIDERS = {
98
98
  extras: [],
99
99
  defaultModel: 'anthropic/claude-sonnet-4',
100
100
  },
101
+ edenai: {
102
+ label: 'Eden AI (600+ models, one key, EU/GDPR)',
103
+ local: false,
104
+ creds: [
105
+ { key: 'EDENAI_API_KEY', label: 'Eden AI API key', secret: true },
106
+ ],
107
+ extras: [],
108
+ defaultModel: 'anthropic/claude-sonnet-4-5',
109
+ },
101
110
  databricks: {
102
111
  label: 'Databricks Foundation Models',
103
112
  local: false,
@@ -154,7 +163,7 @@ const PROVIDERS = {
154
163
 
155
164
  const PROVIDER_ORDER = [
156
165
  'ollama', 'llamacpp', 'lmstudio',
157
- 'azure-anthropic', 'azure-openai', 'openai', 'openrouter',
166
+ 'azure-anthropic', 'azure-openai', 'openai', 'openrouter', 'edenai',
158
167
  'databricks', 'bedrock', 'vertex', 'zai', 'moonshot',
159
168
  ];
160
169
  const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
@@ -304,6 +313,10 @@ const BASELINE_ENV = {
304
313
  OPENROUTER_EMBEDDINGS_MODEL: 'openai/text-embedding-ada-002',
305
314
  OPENROUTER_ENDPOINT: 'https://openrouter.ai/api/v1/chat/completions',
306
315
  OPENROUTER_MAX_TOOLS_FOR_ROUTING: '15',
316
+ EDENAI_API_KEY: '',
317
+ EDENAI_MODEL: 'openai/gpt-4o-mini',
318
+ EDENAI_EMBEDDINGS_MODEL: 'openai/text-embedding-ada-002',
319
+ EDENAI_ENDPOINT: 'https://api.edenai.run/v3/chat/completions',
307
320
  MOONSHOT_API_KEY: '',
308
321
  MOONSHOT_ENDPOINT: 'https://api.moonshot.ai/v1/chat/completions',
309
322
  MOONSHOT_MODEL: 'kimi-k2.6',
@@ -537,6 +550,36 @@ async function runInteractive(opts) {
537
550
  close();
538
551
  console.log('');
539
552
  writeEnvFile(buildEnvContent(env, isWrap, tierConfig), opts);
553
+
554
+ // ── Classifier bootstrap: detect ollama, pull model, warm-up ──
555
+ // Runs AFTER .env is written so the classifier can read tier config
556
+ // (currently uses hardcoded qwen2.5:3b, but leaves room for the future
557
+ // fine-tuned model to plug in via SIMPLE tier or an env override).
558
+ if (!opts.dryRun) {
559
+ console.log('');
560
+ console.log('Setting up the difficulty classifier…');
561
+ try {
562
+ const { ensureClassifierReady } = require('../src/routing/classifier-setup');
563
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
564
+ const prompt = (q) => new Promise((res) => rl.question(q, (a) => res(a)));
565
+ const result = await ensureClassifierReady({
566
+ mode: 'interactive',
567
+ log: (m) => console.log(m),
568
+ warn: (m) => console.warn(m),
569
+ prompt,
570
+ });
571
+ rl.close();
572
+ if (result.ready) {
573
+ console.log('✓ Classifier ready.');
574
+ } else if (result.reason === 'ollama_missing') {
575
+ console.log('(Install Ollama and re-run `lynkr init` to enable the classifier — Lynkr will still start without it, using anchor-only scoring.)');
576
+ } else {
577
+ console.log(`(Classifier disabled: ${result.reason}. Lynkr will fall back to anchor-only scoring.)`);
578
+ }
579
+ } catch (err) {
580
+ console.warn(`⚠ Classifier setup skipped: ${err.message}`);
581
+ }
582
+ }
540
583
  } catch (err) {
541
584
  close();
542
585
  throw err;
@@ -18,6 +18,10 @@ const path = require("path");
18
18
  // Make sure config/logger pick up the workspace root
19
19
  process.env.WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || path.resolve(__dirname, "..");
20
20
 
21
+ // Reports and receipts go to stdout for humans (and screenshots) — telemetry
22
+ // init logs would pollute them. Respect an explicit LOG_LEVEL override.
23
+ if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = "silent";
24
+
21
25
  const aggregator = require("../src/usage/aggregator");
22
26
 
23
27
  function parseArgs(argv) {
@@ -26,6 +30,7 @@ function parseArgs(argv) {
26
30
  const a = argv[i];
27
31
  const next = argv[i + 1];
28
32
  if (a === "--json") opts.json = true;
33
+ else if (a === "--card") opts.card = true;
29
34
  else if (a === "--days" && next) {
30
35
  opts.window = `${parseInt(next, 10)}d`;
31
36
  i++;
@@ -204,6 +209,74 @@ function printReport(usage) {
204
209
  console.log("");
205
210
  }
206
211
 
212
+ // ---------------------------------------------------------------------------
213
+ // Shareable receipt card (`lynkr stats` / `lynkr usage --card`)
214
+ // ---------------------------------------------------------------------------
215
+
216
+ const LOCAL_PROVIDERS = new Set(["ollama", "llamacpp", "llama-cpp", "llama_cpp", "lmstudio", "lm-studio"]);
217
+
218
+ function printCard(usage, opts) {
219
+ const { totals, byProvider, flagship, window } = usage;
220
+
221
+ let localReqs = 0;
222
+ let cloudReqs = 0;
223
+ for (const [provider, bucket] of Object.entries(byProvider)) {
224
+ if (LOCAL_PROVIDERS.has(String(provider).toLowerCase())) localReqs += bucket.requests;
225
+ else cloudReqs += bucket.requests;
226
+ }
227
+ const totalReqs = localReqs + cloudReqs;
228
+ const pct = (n) => (totalReqs > 0 ? `${Math.round((n / totalReqs) * 100)}%` : "0%");
229
+
230
+ let savings = { total: 0, byCategory: {} };
231
+ try {
232
+ const telemetry = require("../src/routing/telemetry");
233
+ const since = aggregator.resolveSince(opts.window) ?? 0;
234
+ savings = telemetry.getSavingsSummary(since);
235
+ } catch { /* savings table optional */ }
236
+
237
+ const WIDTH = 46;
238
+ const line = (label, value) => {
239
+ const l = ` ${label}`;
240
+ const v = `${value} `;
241
+ const gap = Math.max(1, WIDTH - l.length - v.length);
242
+ return `│${l}${" ".repeat(gap)}${v}│`;
243
+ };
244
+ const blank = `│${" ".repeat(WIDTH)}│`;
245
+
246
+ const rows = [
247
+ `╭${"─".repeat(WIDTH)}╮`,
248
+ line("Lynkr — savings receipt", `last ${window}`),
249
+ blank,
250
+ line("Requests routed", fmtInt(totalReqs)),
251
+ line(" local (free)", `${fmtInt(localReqs)} · ${pct(localReqs)}`),
252
+ line(" cloud", `${fmtInt(cloudReqs)} · ${pct(cloudReqs)}`),
253
+ blank,
254
+ ];
255
+
256
+ if (savings.total > 0) {
257
+ rows.push(line("Tokens saved", fmtTokens(savings.total)));
258
+ const labels = {
259
+ tool_stripping: " tool-schema stripping",
260
+ compression: " JSON compression",
261
+ cache_hit: " semantic cache hits",
262
+ };
263
+ for (const [cat, label] of Object.entries(labels)) {
264
+ if (savings.byCategory[cat]) rows.push(line(label, fmtTokens(savings.byCategory[cat])));
265
+ }
266
+ rows.push(blank);
267
+ }
268
+
269
+ rows.push(line("Est. cost avoided*", fmtUSD(totals.saved)));
270
+ rows.push(blank);
271
+ rows.push(line("github.com/Fast-Editor/Lynkr", `v${require("../package.json").version}`));
272
+ rows.push(`╰${"─".repeat(WIDTH)}╯`);
273
+ rows.push(colour(` *estimate vs routing everything to ${flagship}`, C.dim));
274
+
275
+ console.log("");
276
+ for (const row of rows) console.log(row);
277
+ console.log("");
278
+ }
279
+
207
280
  function main() {
208
281
  const opts = parseArgs(process.argv);
209
282
  const usage = aggregator.getUsage(opts);
@@ -213,6 +286,11 @@ function main() {
213
286
  return;
214
287
  }
215
288
 
289
+ if (opts.card || process.env._LYNKR_SUBCMD === "stats") {
290
+ printCard(usage, opts);
291
+ return;
292
+ }
293
+
216
294
  printReport(usage);
217
295
  }
218
296
 
package/bin/wrap.js CHANGED
@@ -224,7 +224,7 @@ async function wrapClaude() {
224
224
  // Show stats if enabled and clean exit
225
225
  if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
226
226
  try {
227
- await showSessionStats();
227
+ await showSessionStats(port);
228
228
  } catch (err) {
229
229
  // Stats are nice-to-have, don't fail on error
230
230
  }
@@ -323,9 +323,21 @@ async function wrapCodex() {
323
323
  binaryName: 'codex',
324
324
  findBinary: findCodexBinary,
325
325
  envVar: 'OPENAI_API_BASE',
326
+ // Modern Codex (Rust CLI) ignores OPENAI_API_BASE — with ChatGPT-plan
327
+ // auth it talks straight to its own backend. A custom model_provider
328
+ // passed via -c overrides is the only supported redirect, and it beats
329
+ // every config file. env_key must name an env var that is set.
330
+ extraArgs: (port) => [
331
+ '-c', 'model_providers.lynkr.name="Lynkr"',
332
+ '-c', `model_providers.lynkr.base_url="http://localhost:${port}/v1"`,
333
+ '-c', 'model_providers.lynkr.wire_api="responses"',
334
+ '-c', 'model_providers.lynkr.env_key="LYNKR_API_KEY"',
335
+ '-c', 'model_provider="lynkr"',
336
+ ],
337
+ extraEnv: { LYNKR_API_KEY: 'lynkr-local' },
326
338
  installInstructions: [
327
- ' • Install OpenAI CLI: pip install openai',
328
- ' • Or: npm install -g openai',
339
+ ' • brew install --cask codex',
340
+ ' • Or: npm install -g @openai/codex',
329
341
  ],
330
342
  });
331
343
  }
@@ -409,10 +421,12 @@ async function wrapGeneric(opts) {
409
421
  console.log('');
410
422
 
411
423
  // 4. Launch binary with Lynkr as base URL
412
- const child = spawn(binaryPath, targetArgs, {
424
+ const extraArgs = typeof opts.extraArgs === 'function' ? opts.extraArgs(port) : [];
425
+ const child = spawn(binaryPath, [...extraArgs, ...targetArgs], {
413
426
  env: {
414
427
  ...process.env,
415
428
  [opts.envVar]: `http://localhost:${port}`,
429
+ ...(opts.extraEnv || {}),
416
430
  },
417
431
  stdio: 'inherit',
418
432
  });
@@ -451,7 +465,7 @@ async function wrapGeneric(opts) {
451
465
  // Show stats if enabled and clean exit
452
466
  if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
453
467
  try {
454
- await showSessionStats();
468
+ await showSessionStats(port);
455
469
  } catch (err) {
456
470
  // Stats are nice-to-have, don't fail on error
457
471
  }
@@ -628,19 +642,39 @@ function formatDuration(ms) {
628
642
  return `${seconds}s`;
629
643
  }
630
644
 
631
- async function showSessionStats() {
632
- try {
633
- const { getMetricsCollector } = require('../src/observability/metrics');
634
- const metricsCollector = getMetricsCollector();
635
- const metrics = metricsCollector.getMetrics();
636
-
637
- // Check if we have any data
638
- const hasRequests = metrics && (
639
- (typeof metrics.totalRequests === 'number' && metrics.totalRequests > 0) ||
640
- (typeof metrics.requestCount === 'number' && metrics.requestCount > 0)
645
+ async function showSessionStats(port) {
646
+ // Read metrics from the RUNNING gateway over HTTP first — correct under
647
+ // cluster mode (workers hold the counters, not this process) — and fall
648
+ // back to the in-process collector. Field names follow getMetrics()'s
649
+ // snake_case output (requests_total, tokens_total, cost_usd_total):
650
+ // this function historically read camelCase fields that never existed,
651
+ // so every session ended with "No requests tracked" regardless of
652
+ // traffic.
653
+ const fetchMetrics = () => new Promise((resolve) => {
654
+ if (!port) return resolve(null);
655
+ const req = require('http').get(
656
+ `http://localhost:${port}/metrics/observability`,
657
+ (res) => {
658
+ let buf = '';
659
+ res.on('data', (c) => { buf += c; });
660
+ res.on('end', () => {
661
+ try { resolve(JSON.parse(buf)); } catch { resolve(null); }
662
+ });
663
+ }
641
664
  );
665
+ req.on('error', () => resolve(null));
666
+ req.setTimeout(1500, () => { req.destroy(); resolve(null); });
667
+ });
642
668
 
643
- if (!hasRequests) {
669
+ try {
670
+ let metrics = await fetchMetrics();
671
+ if (!metrics) {
672
+ const { getMetricsCollector } = require('../src/observability/metrics');
673
+ metrics = getMetricsCollector().getMetrics();
674
+ }
675
+
676
+ const requestCount = Number(metrics?.requests_total) || 0;
677
+ if (!requestCount) {
644
678
  console.log('');
645
679
  console.log('╭─ Lynkr Session Stats ────────────────────────────────');
646
680
  console.log('│ No requests tracked (check dashboard for details)');
@@ -650,29 +684,20 @@ async function showSessionStats() {
650
684
 
651
685
  console.log('');
652
686
  console.log('╭─ Lynkr Session Stats ────────────────────────────────');
687
+ console.log(`│ Requests ${requestCount} (${Number(metrics.requests_errors_total) || 0} errors)`);
653
688
 
654
- const requestCount = metrics.totalRequests || metrics.requestCount || 0;
655
- console.log(`│ Requests ${requestCount}`);
656
-
657
- if (metrics.tokensUsed || metrics.tokensSaved) {
658
- const tokensUsed = metrics.tokensUsed || 0;
659
- const tokensSaved = metrics.tokensSaved || 0;
660
- const originalTokens = tokensUsed + tokensSaved;
661
- if (originalTokens > 0) {
662
- const savingsPercent = Math.round((tokensSaved / originalTokens) * 100);
663
- console.log(`│ Tokens Original: ${originalTokens.toLocaleString()} → Routed: ${tokensUsed.toLocaleString()} (${savingsPercent}% saved)`);
664
- }
689
+ const tokensIn = Number(metrics.tokens_input_total) || 0;
690
+ const tokensOut = Number(metrics.tokens_output_total) || 0;
691
+ if (tokensIn || tokensOut) {
692
+ console.log(`│ Tokens in ${tokensIn.toLocaleString()} · out ${tokensOut.toLocaleString()}`);
665
693
  }
666
694
 
667
- if (metrics.tierBreakdown && Object.keys(metrics.tierBreakdown).length > 0) {
668
- const tiers = Object.entries(metrics.tierBreakdown)
669
- .map(([tier, count]) => `${tier}: ${count}`)
670
- .join(' ');
671
- console.log(`│ Tier Mix ${tiers}`);
672
- }
695
+ const cost = Number(metrics.cost_usd_total) || 0;
696
+ console.log(`│ Est. cost $${cost.toFixed(4)}`);
673
697
 
674
- if (metrics.cacheHits && metrics.cacheHits > 0) {
675
- console.log(`│ Cache Hits ${metrics.cacheHits}`);
698
+ const p95 = metrics.latency_ms && Number(metrics.latency_ms.p95);
699
+ if (p95) {
700
+ console.log(`│ Latency p95 ${p95.toLocaleString()} ms`);
676
701
  }
677
702
 
678
703
  console.log('╰──────────────────────────────────────────────────────');
@@ -0,0 +1,22 @@
1
+ {
2
+ "_comment": "WS7.1b — difficulty anchor exemplars. All texts are REAL user asks from live telemetry / the engineering ledger (do not invent synthetic anchors; mine new ones with the §7 telemetry query). Embedded at boot via the kNN router's embed(); centroid vectors cached in difficulty-anchors.vectors.json (safe to delete — regenerates).",
3
+ "trivial": [
4
+ "hi",
5
+ "How are you",
6
+ "ok thanks",
7
+ "345+32",
8
+ "how do I install golang on mac?",
9
+ "what is meaning of purging?",
10
+ "Explain the difference between TCP and UDP in two sentences."
11
+ ],
12
+ "substantive": [
13
+ "Review this retry helper for bugs and race conditions",
14
+ "give me a plan to refactor this code",
15
+ "Read the current repo and explain it to me"
16
+ ],
17
+ "heavyweight": [
18
+ "Do an architecture review of the orchestrator",
19
+ "analyze every module in src/ for circular dependencies and give me a dependency-order refactor plan",
20
+ "design a horizontally scalable architecture for this service with failure-mode analysis"
21
+ ]
22
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.7.3",
3
+ "version": "9.9.1",
4
+ "files": [
5
+ "index.js",
6
+ "install.sh",
7
+ "bin",
8
+ "config",
9
+ "public",
10
+ "skills",
11
+ "lynkr-skill.tar.gz",
12
+ "src",
13
+ "!src/db/database.sqlite",
14
+ "!src/**/*.db",
15
+ "!src/**/*.backup",
16
+ "native/index.js",
17
+ "native/lynkr-native.node",
18
+ "native/src/lib.rs",
19
+ "native/Cargo.toml",
20
+ "models/router/README.md",
21
+ "scripts",
22
+ "!scripts/export-routellm-bert.py"
23
+ ],
4
24
  "description": "Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.",
5
25
  "main": "index.js",
6
26
  "bin": {
@@ -16,7 +36,7 @@
16
36
  "dev": "nodemon index.js",
17
37
  "lint": "eslint src index.js",
18
38
  "test": "npm run test:unit && npm run test:performance",
19
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/agent-learning.test.js test/tool-call-response-metadata.test.js",
39
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/agent-learning.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js",
20
40
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
21
41
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
22
42
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -71,6 +91,7 @@
71
91
  "@azure/openai": "^2.0.0",
72
92
  "@babel/parser": "^7.29.0",
73
93
  "@babel/traverse": "^7.29.0",
94
+ "@blackwell-systems/gcf": "2.4.0",
74
95
  "@toon-format/toon": "^2.1.0",
75
96
  "cockatiel": "^3.2.1",
76
97
  "compression": "^1.7.4",