lynkr 9.10.0 → 9.12.0

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 (50) hide show
  1. package/README.md +46 -11
  2. package/bin/lynkr-init.js +7 -0
  3. package/bin/statusline.js +80 -0
  4. package/bin/wrap.js +10 -6
  5. package/config/model-tiers.json +5 -5
  6. package/package.json +9 -4
  7. package/public/dashboard.html +354 -1
  8. package/src/api/openai-router.js +120 -2
  9. package/src/api/router.js +34 -0
  10. package/src/clients/databricks.js +148 -66
  11. package/src/clients/gpt-utils.js +8 -1
  12. package/src/clients/ollama-utils.js +19 -10
  13. package/src/clients/prompt-cache-injection.js +93 -30
  14. package/src/config/index.js +79 -0
  15. package/src/context/compression.js +27 -8
  16. package/src/context/distill.js +2 -1
  17. package/src/dashboard/api.js +82 -2
  18. package/src/dashboard/recommendations/cache-weakspots.js +88 -0
  19. package/src/dashboard/recommendations/deadweight.js +82 -0
  20. package/src/dashboard/recommendations/downsize.js +109 -0
  21. package/src/dashboard/recommendations/index.js +106 -0
  22. package/src/dashboard/recommendations/side-requests.js +68 -0
  23. package/src/dashboard/recommendations/wilson.js +24 -0
  24. package/src/dashboard/router.js +5 -0
  25. package/src/memory/distiller.js +350 -0
  26. package/src/memory/extractor.js +0 -8
  27. package/src/memory/format.js +17 -2
  28. package/src/memory/index.js +10 -0
  29. package/src/memory/retriever.js +7 -2
  30. package/src/memory/skills-cache.js +180 -0
  31. package/src/memory/tencentdb-launcher.js +459 -0
  32. package/src/memory/wiki.js +171 -0
  33. package/src/orchestrator/index.js +85 -2
  34. package/src/orchestrator/sse-transformer.js +9 -3
  35. package/src/prompts/system.js +20 -6
  36. package/src/routing/affinity-store.js +68 -1
  37. package/src/routing/agentic-detector.js +9 -1
  38. package/src/routing/cache-economics.js +123 -0
  39. package/src/routing/cache-switch-cost.js +194 -0
  40. package/src/routing/calibration.js +8 -1
  41. package/src/routing/complexity-analyzer.js +37 -10
  42. package/src/routing/context-compressor.js +170 -0
  43. package/src/routing/difficulty-classifier.js +115 -13
  44. package/src/routing/index.js +161 -53
  45. package/src/routing/model-registry.js +35 -10
  46. package/src/routing/session-affinity.js +60 -0
  47. package/src/routing/telemetry.js +343 -2
  48. package/src/routing/verifier.js +41 -2
  49. package/src/server.js +36 -3
  50. package/src/tools/code-graph.js +6 -4
@@ -50,6 +50,14 @@
50
50
  class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
51
51
  Logs
52
52
  </button>
53
+ <button onclick="App.navigate('insights')" data-page="insights"
54
+ class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
55
+ Insights
56
+ </button>
57
+ <button onclick="App.navigate('explore')" data-page="explore"
58
+ class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
59
+ Explore
60
+ </button>
53
61
  </nav>
54
62
 
55
63
  <div class="ml-auto flex items-center gap-3">
@@ -137,13 +145,18 @@ const App = {
137
145
  data: {},
138
146
  usageWindow: '7d',
139
147
  logFilters: { provider: '', tier: '', error: false },
148
+ insightOpen: null,
149
+ sessionId: null,
150
+ explore: { metric: 'spend', by: 'tier', stack: '', days: 7 },
140
151
  _refreshTimer: null,
141
152
  _countdownTimer: null,
142
153
  _countdown: 30,
143
154
  _charts: {},
144
155
 
145
156
  init() {
146
- const hash = location.hash.slice(1) || 'overview';
157
+ let hash = location.hash.slice(1) || 'overview';
158
+ // Session detail needs in-memory state — a cold load on that hash has none.
159
+ if (hash === 'session' && !this.sessionId) hash = 'logs';
147
160
  this.navigate(hash, true);
148
161
  this._startCountdown();
149
162
  },
@@ -198,6 +211,14 @@ const App = {
198
211
  if (this.logFilters.error) q.set('error', 'true');
199
212
  data = await this._fetch('/dashboard/api/logs?' + q);
200
213
  }
214
+ if (this.page === 'insights') data = await this._fetch('/dashboard/api/recommendations');
215
+ if (this.page === 'explore') {
216
+ const e = this.explore;
217
+ const q = new URLSearchParams({ metric: e.metric, by: e.by, days: e.days });
218
+ if (e.stack) q.set('stack', e.stack);
219
+ data = await this._fetch('/dashboard/api/analytics?' + q);
220
+ }
221
+ if (this.page === 'session') data = await this._fetch('/dashboard/api/sessions/' + encodeURIComponent(this.sessionId));
201
222
 
202
223
  this.data[this.page] = data;
203
224
  this._render(data);
@@ -228,6 +249,9 @@ const App = {
228
249
  usage: () => this._renderUsage(data),
229
250
  routing: () => this._renderRouting(data),
230
251
  logs: () => this._renderLogs(data),
252
+ insights: () => this._renderInsights(data),
253
+ explore: () => this._renderExplore(data),
254
+ session: () => this._renderSession(data),
231
255
  }[this.page]?.() || '';
232
256
  document.getElementById('content').innerHTML = `<div class="fade-in">${html}</div>`;
233
257
  this._afterRender(data);
@@ -493,6 +517,42 @@ const App = {
493
517
  ` : emptyState('No routing data for last 24h')}
494
518
  `, 'mb-6')}
495
519
 
520
+ <!-- Cache Economics (cache-aware routing receipt) -->
521
+ ${card(`
522
+ <h3 class="text-sm font-semibold text-slate-300 mb-4">Cache Economics (last ${d.window || '24h'})</h3>
523
+ ${d.cacheEconomics && d.cacheEconomics.decisions > 0 ? `
524
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
525
+ <div class="text-center">
526
+ <p class="text-2xl font-bold text-green-400">${fmt.usd(d.cacheEconomics.totalDollarsSaved)}</p>
527
+ <p class="text-xs text-slate-500 mt-1">Cache $ saved by routing</p>
528
+ <p class="text-xs text-slate-600">(holds + cleared switches)</p>
529
+ </div>
530
+ <div class="text-center">
531
+ <p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.holds)}</p>
532
+ <p class="text-xs text-slate-500 mt-1">Switches held</p>
533
+ <p class="text-xs text-slate-600">(warm prefix protected)</p>
534
+ </div>
535
+ <div class="text-center">
536
+ <p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.switches)}</p>
537
+ <p class="text-xs text-slate-500 mt-1">Switches cleared</p>
538
+ <p class="text-xs text-slate-600">(break-even ≤ remaining turns)</p>
539
+ </div>
540
+ <div class="text-center">
541
+ <p class="text-2xl font-bold text-amber-400">${fmt.usd(d.cacheEconomics.dollarsSavedByHolds)}</p>
542
+ <p class="text-xs text-slate-500 mt-1">Saved by holding pins</p>
543
+ <p class="text-xs text-slate-600">(cache re-writes avoided)</p>
544
+ </div>
545
+ </div>
546
+ ` : emptyState('No gated switch decisions yet — appears once a pinned session hits a downgrade decision')}
547
+ <div class="border-t border-slate-700/60 mt-4 pt-3 flex flex-wrap items-baseline gap-x-6 gap-y-1">
548
+ <span class="text-xs uppercase tracking-wide text-slate-500">Measured (past tense, auditable)</span>
549
+ ${d.cacheMeasured && d.cacheMeasured.rowsMeasured > 0 ? `
550
+ <span class="text-sm text-green-400 font-semibold">${fmt.usd2(d.cacheMeasured.measuredSavedUsd)} saved by cache reads actually served</span>
551
+ <span class="text-xs text-slate-500">${fmt.num(d.cacheMeasured.rowsMeasured)} requests measured · weakest: ${d.cacheMeasured.perModel[0] ? `${d.cacheMeasured.perModel[0].model} at ${d.cacheMeasured.perModel[0].hitRatio != null ? Math.round(d.cacheMeasured.perModel[0].hitRatio * 100) + '%' : '—'}` : '—'}</span>
552
+ ` : `<span class="text-xs text-slate-600">not measured yet — cache counters accrue from this version onward (not the same as zero savings)</span>`}
553
+ </div>
554
+ `, 'mb-6')}
555
+
496
556
  <!-- Provider Stats -->
497
557
  ${card(`
498
558
  <h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
@@ -560,6 +620,7 @@ const App = {
560
620
  <td class="py-2 px-3 text-xs ${r.error_type?'text-red-400':'text-slate-500'}">${r.error_type||'—'}</td>
561
621
  <td class="py-2 px-3 text-xs text-amber-300">${r.cost_usd!=null?fmt.usd(r.cost_usd):'—'}</td>
562
622
  <td class="py-2 px-3">${r.was_fallback?'<span class="badge bg-amber-900/40 text-amber-300">fallback</span>':'—'}</td>
623
+ <td class="py-2 px-3">${r.session_id ? `<button onclick="App.showSession('${String(r.session_id).replace(/'/g,'')}')" class="text-xs font-mono text-blue-400 hover:text-blue-200">${String(r.session_id).slice(0,10)}…</button>` : '—'}</td>
563
624
  </tr>`).join('');
564
625
 
565
626
  return `
@@ -603,6 +664,7 @@ const App = {
603
664
  <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Error</th>
604
665
  <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Cost</th>
605
666
  <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Flags</th>
667
+ <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Session</th>
606
668
  </tr></thead>
607
669
  <tbody>${tableRows}</tbody>
608
670
  </table>
@@ -616,6 +678,297 @@ const App = {
616
678
  if (this.page === 'usage' && data?.daily?.length && data.totals?.requests > 0) {
617
679
  this._drawUsageChart(data.daily);
618
680
  }
681
+ if (this.page === 'explore' && data?.rows?.length) this._drawExploreChart(data);
682
+ if (this.page === 'session' && data?.contextSeries?.length) this._drawContextChart(data.contextSeries);
683
+ },
684
+
685
+ /* ── INSIGHTS (Lens recommendations) ─────────── */
686
+ openInsight(id) {
687
+ this.insightOpen = this.insightOpen === id ? null : id;
688
+ this._render(this.data.insights);
689
+ },
690
+
691
+ showSession(id) {
692
+ if (!id) return;
693
+ this.sessionId = id;
694
+ this.navigate('session');
695
+ },
696
+
697
+ _renderInsights(d) {
698
+ if (!d) return emptyState('No data');
699
+ const f = d.findings || [];
700
+ const actionable = f.filter(x => x.state === 'actionable');
701
+ const sharePct = d.spendUsd > 0 ? Math.min(100, (d.totalRecoverableUsd / d.spendUsd) * 100) : 0;
702
+ const TONES = ['bg-amber-500','bg-blue-500','bg-purple-500','bg-green-500','bg-rose-500'];
703
+
704
+ // Hero — one netted, backward-looking figure. Past tense on purpose.
705
+ const segments = actionable.filter(x => x.pastOverspendUsd > 0);
706
+ const segTotal = segments.reduce((a, x) => a + x.pastOverspendUsd, 0) || 1;
707
+ const shareBar = segments.length ? `
708
+ <div class="flex h-2 rounded-full overflow-hidden bg-slate-700 mt-3">
709
+ ${segments.map((x, i) => `<div class="${TONES[i % TONES.length]}" style="width:${(x.pastOverspendUsd / segTotal) * 100}%" title="${x.title}"></div>`).join('')}
710
+ </div>
711
+ <div class="flex flex-wrap gap-3 mt-2 text-xs text-slate-500">
712
+ ${segments.map((x, i) => `<span><span class="inline-block w-2 h-2 rounded-full ${TONES[i % TONES.length]} mr-1"></span>${x.title}</span>`).join('')}
713
+ </div>` : '';
714
+
715
+ const hero = card(`
716
+ <p class="text-xs uppercase tracking-wide text-slate-500 mb-1">Past overspend found (last ${Math.round(d.windowMs / 86400000)}d)</p>
717
+ <div class="flex items-baseline gap-3">
718
+ <span class="text-3xl font-bold ${d.totalRecoverableUsd > 0 ? 'text-amber-300' : 'text-green-400'}">${fmt.usd2(d.totalRecoverableUsd)}</span>
719
+ <span class="text-sm text-slate-500">of ${fmt.usd2(d.spendUsd)} spent · ${sharePct.toFixed(1)}%</span>
720
+ </div>
721
+ ${shareBar}
722
+ <p class="text-xs text-slate-600 mt-3">Figures are backward-looking — checkable against spend already recorded. Ceilings are labeled in each finding's basis.</p>
723
+ `, 'mb-6');
724
+
725
+ const mechBadge = m => ({
726
+ auto: '<span class="badge bg-green-900/40 text-green-300">lynkr can act</span>',
727
+ snippet: '<span class="badge bg-blue-900/40 text-blue-300">client-side fix</span>',
728
+ info: '<span class="badge bg-slate-700 text-slate-300">diagnosis</span>',
729
+ }[m] || '');
730
+ const stateBadge = s => ({
731
+ actionable: '',
732
+ clean: '<span class="badge bg-green-900/30 text-green-400">ran — found nothing</span>',
733
+ not_measured: '<span class="badge bg-slate-700 text-slate-400">not measured yet</span>',
734
+ }[s] || '');
735
+
736
+ const tiles = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
737
+ ${f.map(x => `
738
+ <div onclick="${x.state === 'actionable' ? `App.openInsight('${x.id}')` : ''}"
739
+ class="border rounded-xl p-4 ${x.state === 'actionable' ? 'border-amber-700/50 bg-amber-900/10 cursor-pointer hover:bg-amber-900/20' : 'border-slate-700 bg-slate-800/60'} ${this.insightOpen === x.id ? 'ring-1 ring-amber-500' : ''}">
740
+ <div class="flex items-start justify-between gap-2 mb-2">
741
+ <span class="text-sm font-medium text-slate-200">${x.title}</span>
742
+ ${mechBadge(x.mechanism)}
743
+ </div>
744
+ ${x.state === 'actionable'
745
+ ? `<p class="text-2xl font-bold text-amber-300">${typeof x.pastOverspendUsd === 'number' ? fmt.usd2(x.pastOverspendUsd) : fmt.tok(x.pastOverspendTokens || 0) + ' tok'}</p>
746
+ <p class="text-xs text-slate-500 mt-1">click for evidence</p>`
747
+ : stateBadge(x.state)}
748
+ </div>`).join('')}
749
+ </div>`;
750
+
751
+ // Detail shell — Evidence / Fix / Caveat / How computed.
752
+ let detail = '';
753
+ const open = f.find(x => x.id === this.insightOpen);
754
+ if (open) {
755
+ const evid = open.evidence?.rows?.length ? `
756
+ <div class="overflow-x-auto">
757
+ <table class="w-full text-left">
758
+ <thead><tr class="border-b border-slate-700 text-xs text-slate-500">
759
+ ${open.evidence.columns.map(c => `<th class="py-2 px-3">${c}</th>`).join('')}
760
+ </tr></thead>
761
+ <tbody>${open.evidence.rows.map(r => `
762
+ <tr class="table-row border-b border-slate-700/40 text-sm">
763
+ ${r.map(v => `<td class="py-2 px-3 text-xs text-slate-300">${typeof v === 'number' && !Number.isInteger(v) ? fmt.usd(v) : v}</td>`).join('')}
764
+ </tr>`).join('')}
765
+ </tbody>
766
+ </table>
767
+ </div>` : emptyState('No evidence rows');
768
+ detail = card(`
769
+ <div class="flex items-center justify-between mb-4">
770
+ <h3 class="text-sm font-semibold text-slate-200">${open.title}</h3>
771
+ <div class="flex gap-4 text-xs text-slate-500">${(open.stats || []).map(s => `<span><span class="text-slate-300 font-semibold">${fmt.num(s.value)}</span> ${s.label}</span>`).join('')}</div>
772
+ </div>
773
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
774
+ <div class="lg:col-span-2">
775
+ <p class="text-xs uppercase tracking-wide text-slate-500 mb-2">Evidence — where this figure came from</p>
776
+ ${evid}
777
+ </div>
778
+ <div class="space-y-4">
779
+ ${open.fix ? `<div><p class="text-xs uppercase tracking-wide text-slate-500 mb-1">The fix</p><p class="text-sm text-slate-300">${open.fix}</p></div>` : ''}
780
+ ${open.caveat ? `<div class="border border-amber-800/40 bg-amber-900/10 rounded-lg p-3"><p class="text-xs uppercase tracking-wide text-amber-500 mb-1">Caveat</p><p class="text-xs text-slate-400">${open.caveat}</p></div>` : ''}
781
+ <div><p class="text-xs uppercase tracking-wide text-slate-500 mb-1">How this figure is computed</p><p class="text-xs text-slate-500">${open.estimateBasis || '—'}</p></div>
782
+ </div>
783
+ </div>
784
+ `, 'mb-6');
785
+ }
786
+
787
+ return `
788
+ <h2 class="text-lg font-semibold mb-5">Insights</h2>
789
+ ${hero}
790
+ ${tiles}
791
+ ${detail}
792
+ `;
793
+ },
794
+
795
+ /* ── EXPLORE (pivot explorer) ────────────────── */
796
+ setExplore(key, value) {
797
+ this.explore[key] = key === 'days' ? parseInt(value, 10) : value;
798
+ this.refresh();
799
+ },
800
+ applyPreset(metric, by, stack) {
801
+ this.explore = { ...this.explore, metric, by, stack };
802
+ this.refresh();
803
+ },
804
+ exportCsv() {
805
+ const d = this.data.explore;
806
+ if (!d?.rows?.length) return;
807
+ const header = d.stack ? 'dimension,stack,value' : 'dimension,value';
808
+ const lines = d.rows.map(r => d.stack
809
+ ? `"${r.dim}","${r.stack}",${r.value}`
810
+ : `"${r.dim}",${r.value}`);
811
+ const blob = new Blob([header + '\n' + lines.join('\n')], { type: 'text/csv' });
812
+ const a = document.createElement('a');
813
+ a.href = URL.createObjectURL(blob);
814
+ a.download = `lynkr-${d.metric}-by-${d.by}.csv`;
815
+ a.click();
816
+ },
817
+
818
+ _renderExplore(d) {
819
+ if (!d) return emptyState('No data');
820
+ const e = this.explore;
821
+ const METRICS = ['spend', 'tokens', 'requests', 'sessions'];
822
+ const DIMS = ['provider', 'model', 'tier', 'request_type', 'routing_method', 'switch_reason', 'day'];
823
+ const sel = (id, opts, val, allowEmpty) => `
824
+ <select onchange="App.setExplore('${id}', this.value)"
825
+ class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5">
826
+ ${allowEmpty ? `<option value="" ${!val ? 'selected' : ''}>no stack</option>` : ''}
827
+ ${opts.map(o => `<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`).join('')}
828
+ </select>`;
829
+
830
+ const kpiVal = { spend: fmt.usd2(d.kpis?.spend), tokens: fmt.tok(d.kpis?.tokens || 0), requests: fmt.num(d.kpis?.requests), sessions: fmt.num(d.kpis?.sessions) };
831
+ const kpiDelta = k => {
832
+ if (!d.kpiDeltas) return '<span class="text-xs text-slate-600">prior window too thin to compare</span>';
833
+ const v = d.kpiDeltas[k];
834
+ const sign = v > 0 ? '+' : '';
835
+ const cls = v > 0 ? 'text-amber-400' : 'text-green-400';
836
+ const fmtV = k === 'spend' ? fmt.usd2(Math.abs(v)) : k === 'tokens' ? fmt.tok(Math.abs(v)) : fmt.num(Math.abs(v));
837
+ return `<span class="text-xs ${cls}">${sign === '+' ? '▲' : '▼'} ${fmtV} vs prior</span>`;
838
+ };
839
+ const kpis = `<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
840
+ ${METRICS.map(k => `
841
+ <div class="border border-slate-700 bg-slate-800/60 rounded-xl p-4">
842
+ <p class="text-xs uppercase tracking-wide text-slate-500">${k}</p>
843
+ <p class="text-2xl font-bold mt-1">${kpiVal[k] ?? '—'}</p>
844
+ ${kpiDelta(k)}
845
+ </div>`).join('')}
846
+ </div>`;
847
+
848
+ const presets = [
849
+ ['Spend by tier', 'spend', 'tier', ''],
850
+ ['Tokens by model', 'tokens', 'model', ''],
851
+ ['Requests by type', 'requests', 'request_type', ''],
852
+ ['Spend over days by tier', 'spend', 'day', 'tier'],
853
+ ];
854
+
855
+ return `
856
+ <div class="flex items-center justify-between mb-5">
857
+ <h2 class="text-lg font-semibold">Explore</h2>
858
+ <button onclick="App.exportCsv()" class="text-xs text-slate-400 hover:text-white px-3 py-1.5 rounded-lg border border-slate-600 hover:bg-slate-700">Export CSV</button>
859
+ </div>
860
+ ${card(`
861
+ <div class="flex flex-wrap gap-3 items-center">
862
+ ${sel('metric', METRICS, e.metric)}
863
+ <span class="text-xs text-slate-500">by</span>
864
+ ${sel('by', DIMS, e.by)}
865
+ <span class="text-xs text-slate-500">stack</span>
866
+ ${sel('stack', DIMS, e.stack, true)}
867
+ ${sel('days', [1, 7, 30, 90], e.days)}
868
+ <span class="text-xs text-slate-600">days</span>
869
+ </div>
870
+ <div class="flex flex-wrap gap-2 mt-3">
871
+ ${presets.map(([label, m, b, s]) => `
872
+ <button onclick="App.applyPreset('${m}','${b}','${s}')"
873
+ class="text-xs px-2.5 py-1 rounded-full border border-slate-600 text-slate-400 hover:text-white hover:bg-slate-700">${label}</button>`).join('')}
874
+ </div>
875
+ `, 'mb-6')}
876
+ ${kpis}
877
+ ${d.rows?.length
878
+ ? card(`<div style="height:340px"><canvas id="explore-chart"></canvas></div>`)
879
+ : emptyState('No rows for this pivot in the window — not the same as “nothing happened”: check the window size.')}
880
+ `;
881
+ },
882
+
883
+ _drawExploreChart(d) {
884
+ const canvas = document.getElementById('explore-chart');
885
+ if (!canvas || typeof Chart === 'undefined') return;
886
+ const PALETTE = ['rgba(34,197,94,0.8)','rgba(59,130,246,0.8)','rgba(245,158,11,0.8)','rgba(168,85,247,0.8)','rgba(244,63,94,0.8)','rgba(14,165,233,0.8)','rgba(148,163,184,0.8)'];
887
+ let cfg;
888
+ if (d.stack) {
889
+ const dims = [...new Set(d.rows.map(r => r.dim))];
890
+ const stacks = [...new Set(d.rows.map(r => r.stack))];
891
+ cfg = {
892
+ type: 'bar',
893
+ data: {
894
+ labels: dims,
895
+ datasets: stacks.map((s, i) => ({
896
+ label: String(s ?? '—'),
897
+ data: dims.map(dim => d.rows.find(r => r.dim === dim && r.stack === s)?.value ?? 0),
898
+ backgroundColor: PALETTE[i % PALETTE.length],
899
+ })),
900
+ },
901
+ options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true }, y: { stacked: true } } },
902
+ };
903
+ } else {
904
+ const rows = d.rows.slice(0, 20);
905
+ cfg = {
906
+ type: 'bar',
907
+ data: {
908
+ labels: rows.map(r => String(r.dim ?? '—')),
909
+ datasets: [{ label: d.metric, data: rows.map(r => r.value), backgroundColor: PALETTE[1] }],
910
+ },
911
+ options: { responsive: true, maintainAspectRatio: false, indexAxis: rows.length > 8 ? 'y' : 'x', plugins: { legend: { display: false } } },
912
+ };
913
+ }
914
+ this._charts.explore = new Chart(canvas, cfg);
915
+ },
916
+
917
+ /* ── SESSION DETAIL ──────────────────────────── */
918
+ _renderSession(d) {
919
+ if (!d) return emptyState('Session not found');
920
+ const dur = d.lastSeen - d.firstSeen;
921
+ const modelRows = d.models.map(m => `
922
+ <tr class="table-row border-b border-slate-700/40 text-sm">
923
+ <td class="py-2 px-3 text-xs font-mono text-slate-300">${m.provider}</td>
924
+ <td class="py-2 px-3 text-xs text-slate-400">${m.model || '—'}</td>
925
+ <td class="py-2 px-3 text-xs">${fmt.num(m.requests)}</td>
926
+ <td class="py-2 px-3 text-xs">${fmt.tok(m.inputTokens)}</td>
927
+ <td class="py-2 px-3 text-xs">${fmt.tok(m.outputTokens)}</td>
928
+ <td class="py-2 px-3 text-xs text-green-400">${fmt.tok(m.cacheReadTokens)}</td>
929
+ <td class="py-2 px-3 text-xs text-amber-300">${fmt.usd(m.costUsd)}</td>
930
+ </tr>`).join('');
931
+
932
+ return `
933
+ <div class="flex items-center gap-3 mb-5">
934
+ <button onclick="App.navigate('logs')" class="text-xs text-slate-400 hover:text-white px-2 py-1 rounded border border-slate-600 hover:bg-slate-700">← logs</button>
935
+ <h2 class="text-lg font-semibold">Session</h2>
936
+ <span class="text-xs font-mono text-slate-500">${d.sessionId}</span>
937
+ </div>
938
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
939
+ ${[['requests', fmt.num(d.requests)], ['spend', fmt.usd(d.totalCostUsd)], ['tool calls', fmt.num(d.toolCalls)], ['duration', fmt.ms(dur)]]
940
+ .map(([l, v]) => `<div class="border border-slate-700 bg-slate-800/60 rounded-xl p-4"><p class="text-xs uppercase tracking-wide text-slate-500">${l}</p><p class="text-2xl font-bold mt-1">${v}</p></div>`).join('')}
941
+ </div>
942
+ ${card(`
943
+ <h3 class="text-sm font-semibold text-slate-300 mb-3">Input (context) tokens per call — distillation and cache behavior are visible here</h3>
944
+ <div style="height:220px"><canvas id="session-context-chart"></canvas></div>
945
+ `, 'mb-6')}
946
+ ${card(`
947
+ <h3 class="text-sm font-semibold text-slate-300 mb-3">Model mix</h3>
948
+ <div class="overflow-x-auto"><table class="w-full text-left">
949
+ <thead><tr class="border-b border-slate-700 text-xs text-slate-500">
950
+ <th class="py-2 px-3">provider</th><th class="py-2 px-3">model</th><th class="py-2 px-3">requests</th>
951
+ <th class="py-2 px-3">input</th><th class="py-2 px-3">output</th><th class="py-2 px-3">cache reads</th><th class="py-2 px-3">cost</th>
952
+ </tr></thead><tbody>${modelRows}</tbody>
953
+ </table></div>
954
+ `)}
955
+ `;
956
+ },
957
+
958
+ _drawContextChart(series) {
959
+ const canvas = document.getElementById('session-context-chart');
960
+ if (!canvas || typeof Chart === 'undefined') return;
961
+ this._charts.sessionCtx = new Chart(canvas, {
962
+ type: 'bar',
963
+ data: {
964
+ labels: series.map((_, i) => i + 1),
965
+ datasets: [
966
+ { label: 'input tokens', data: series.map(p => p.inputTokens), backgroundColor: 'rgba(59,130,246,0.8)' },
967
+ { label: 'cache reads', data: series.map(p => p.cacheReadTokens ?? 0), backgroundColor: 'rgba(34,197,94,0.6)' },
968
+ ],
969
+ },
970
+ options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'call #' } } } },
971
+ });
619
972
  },
620
973
 
621
974
  _drawUsageChart(daily) {
@@ -341,6 +341,111 @@ function lynkrBadge(resultBody) {
341
341
  return `*[Lynkr] ${m.tier} → ${m.model || "—"} (${m.provider || "—"})${score}*\n\n`;
342
342
  }
343
343
 
344
+ /**
345
+ * Phase 2b, OpenAI surface — reshape a live Anthropic Messages SSE stream
346
+ * (the orchestrator's transform output) into OpenAI chat.completion.chunk
347
+ * events in flight. Mirrors the buffered synthesis below in event structure:
348
+ * text streams as deltas; each tool_use is emitted as ONE complete tool_calls
349
+ * delta at its content_block_stop (clients never see partial JSON args).
350
+ * Thinking/reasoning deltas are dropped — OpenAI chunk format has no field
351
+ * for them and the buffered path is where thinking-lift lives.
352
+ */
353
+ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) {
354
+ const id = `chatcmpl-lynkr-${Date.now()}`;
355
+ const created = Math.floor(Date.now() / 1000);
356
+ const model = requestedModel || "lynkr";
357
+
358
+ const chunk = (delta, finish = null) => `data: ${JSON.stringify({
359
+ id, object: "chat.completion.chunk", created, model,
360
+ system_fingerprint: "fp_lynkr",
361
+ choices: [{ index: 0, delta, logprobs: null, finish_reason: finish }],
362
+ })}\n\n`;
363
+
364
+ const mapStop = (r) => r === "tool_use" ? "tool_calls" : r === "max_tokens" ? "length" : "stop";
365
+
366
+ // Normalize Node Readable / web ReadableStream / async iterable to chunks.
367
+ async function* iterate(s) {
368
+ if (s && typeof s[Symbol.asyncIterator] === "function") { yield* s; return; }
369
+ const reader = s.getReader();
370
+ for (;;) {
371
+ const { done, value } = await reader.read();
372
+ if (done) return;
373
+ yield value;
374
+ }
375
+ }
376
+
377
+ let buf = "";
378
+ const toolsByBlock = {}; // content-block index → {id, name, args}
379
+ let emittedTools = 0;
380
+ let finishReason = null;
381
+ let started = false;
382
+ const decoder = new TextDecoder();
383
+
384
+ try {
385
+ for await (const raw of iterate(stream)) {
386
+ buf += typeof raw === "string" ? raw : decoder.decode(raw, { stream: true });
387
+ let sep;
388
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
389
+ const block = buf.slice(0, sep); buf = buf.slice(sep + 2);
390
+ const dataLine = block.split("\n").find((l) => l.startsWith("data: "));
391
+ if (!dataLine) continue;
392
+ let ev;
393
+ try { ev = JSON.parse(dataLine.slice(6)); } catch { continue; }
394
+
395
+ switch (ev.type) {
396
+ case "message_start":
397
+ if (!started) { started = true; res.write(chunk({ role: "assistant", content: "" })); }
398
+ break;
399
+ case "content_block_start":
400
+ if (ev.content_block?.type === "tool_use") {
401
+ toolsByBlock[ev.index] = { id: ev.content_block.id, name: ev.content_block.name, args: "" };
402
+ }
403
+ break;
404
+ case "content_block_delta":
405
+ if (ev.delta?.type === "text_delta" && ev.delta.text) {
406
+ if (!started) { started = true; res.write(chunk({ role: "assistant", content: "" })); }
407
+ res.write(chunk({ content: ev.delta.text }));
408
+ } else if (ev.delta?.type === "input_json_delta" && toolsByBlock[ev.index]) {
409
+ toolsByBlock[ev.index].args += ev.delta.partial_json || "";
410
+ }
411
+ break;
412
+ case "content_block_stop": {
413
+ const t = toolsByBlock[ev.index];
414
+ if (t) {
415
+ res.write(chunk({ tool_calls: [{ index: emittedTools++, id: t.id, type: "function",
416
+ function: { name: t.name, arguments: t.args || "{}" } }] }));
417
+ delete toolsByBlock[ev.index];
418
+ }
419
+ break;
420
+ }
421
+ case "message_delta":
422
+ if (ev.delta?.stop_reason) finishReason = mapStop(ev.delta.stop_reason);
423
+ break;
424
+ case "message_stop":
425
+ res.write(chunk({}, finishReason || "stop"));
426
+ res.write("data: [DONE]\n\n");
427
+ res.end();
428
+ return;
429
+ default:
430
+ break; // ping, thinking deltas, unknown events — skip
431
+ }
432
+ if (res.writableEnded || res.destroyed) return;
433
+ }
434
+ }
435
+ // Upstream closed without message_stop — still terminate the client stream cleanly.
436
+ if (!res.writableEnded) {
437
+ res.write(chunk({}, finishReason || "stop"));
438
+ res.write("data: [DONE]\n\n");
439
+ res.end();
440
+ }
441
+ } catch (err) {
442
+ logger.warn({ err: err.message }, "OpenAI-surface stream forward failed mid-flight");
443
+ if (!res.writableEnded) {
444
+ try { res.write(chunk({}, "stop")); res.write("data: [DONE]\n\n"); res.end(); } catch { /* client gone */ }
445
+ }
446
+ }
447
+ }
448
+
344
449
  router.post("/chat/completions", async (req, res) => {
345
450
  const startTime = Date.now();
346
451
  const sessionId = req.headers["x-session-id"] || req.headers["authorization"]?.split(" ")[1] || "openai-session";
@@ -423,7 +528,11 @@ router.post("/chat/completions", async (req, res) => {
423
528
  res.flushHeaders();
424
529
 
425
530
  try {
426
- // For streaming, we need to handle it differently - convert to non-streaming temporarily
531
+ // The orchestrator decides the real upstream streaming mode: with
532
+ // clientWantsStream=true and a transform-eligible provider it returns
533
+ // a live Anthropic-shaped SSE stream (Phase 2b); otherwise it buffers
534
+ // and we synthesize chunks below, as before. Body-level stream stays
535
+ // false so buffered fallbacks parse cleanly.
427
536
  anthropicRequest.stream = false;
428
537
 
429
538
  const result = await orchestrator.processMessage({
@@ -431,10 +540,19 @@ router.post("/chat/completions", async (req, res) => {
431
540
  headers: req.headers,
432
541
  session: session,
433
542
  options: {
434
- maxSteps: req.body?.max_steps
543
+ maxSteps: req.body?.max_steps,
544
+ clientWantsStream: true
435
545
  }
436
546
  });
437
547
 
548
+ // Phase 2b live stream: reshape Anthropic SSE events into OpenAI
549
+ // chat.completion.chunk events in flight — first token reaches the
550
+ // client at upstream TTFB instead of after the full generation.
551
+ if (result && result.stream) {
552
+ await forwardAnthropicStreamAsOpenAIChunks(res, result.stream, req.body.model);
553
+ return;
554
+ }
555
+
438
556
  logger.debug({
439
557
  hasResult: !!result,
440
558
  resultKeys: result ? Object.keys(result) : null,
package/src/api/router.js CHANGED
@@ -579,6 +579,30 @@ function _buildStreamBadge(tier) {
579
579
  return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
580
580
  }
581
581
 
582
+ /**
583
+ * Terminal upstream errors carry no content array — without this, the synth
584
+ * SSE writers render an empty message and the turn goes silent in the TUI
585
+ * (only the routing badge shows). Returns visible-text content blocks for an
586
+ * error result, using the fallback chain's failure trail when the provider
587
+ * layer recorded one (so "moonshot: quota exhausted" isn't hidden behind the
588
+ * last rung's unrelated error), or null for normal responses.
589
+ */
590
+ function _buildFailureBlocks(result, msg) {
591
+ if (result.terminationReason !== "api_error" && Array.isArray(msg?.content)) return null;
592
+ const trail = Array.isArray(msg?._lynkr_failure_trail) ? msg._lynkr_failure_trail : null;
593
+ const lastErr = msg?.error?.message || msg?.message
594
+ || (typeof msg === "string" ? msg : JSON.stringify(msg ?? {}).slice(0, 200));
595
+ const trailText = (trail || [String(lastErr)]).map((t) => ` · ${t}`).join("\n");
596
+ const quotaHit = result.status === 429 || /rate.?limit|quota|TPD|TPM/i.test(trailText);
597
+ return [{
598
+ type: "text",
599
+ text:
600
+ `⚠ [Lynkr] ${quotaHit ? "Provider quota exhausted — no fallback could serve this request" : "No provider could serve this request"} (HTTP ${result.status ?? "?"}).\n\n` +
601
+ `${trailText}\n` +
602
+ (quotaHit ? `\nAll fallback candidates failed after the quota hit. Wait for the quota window to reset or repoint the affected tier in .env.\n` : ""),
603
+ }];
604
+ }
605
+
582
606
  /**
583
607
  * Scan the entire payload for tool_use or tool_result content blocks.
584
608
  * Unlike session-affinity.payloadHasToolHistory (which checks only the last
@@ -1611,6 +1635,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1611
1635
  // tool_use with no tool_use block).
1612
1636
  let contentBlocks = (msg.content || []).slice();
1613
1637
 
1638
+ const failureBlocks = _buildFailureBlocks(result, msg);
1639
+ if (failureBlocks) contentBlocks = failureBlocks;
1640
+
1614
1641
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1615
1642
  // users can see which tier/provider/model handled the request inside
1616
1643
  // Claude Code's TUI (TUI only renders content blocks; unknown top-level
@@ -1807,6 +1834,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1807
1834
  // tool_use with no tool_use block).
1808
1835
  let contentBlocks = (msg.content || []).slice();
1809
1836
 
1837
+ const failureBlocks = _buildFailureBlocks(result, msg);
1838
+ if (failureBlocks) contentBlocks = failureBlocks;
1839
+
1810
1840
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1811
1841
  // users can see which tier/provider/model handled the request inside
1812
1842
  // Claude Code's TUI (TUI only renders content blocks; unknown top-level
@@ -1969,6 +1999,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1969
1999
  }
1970
2000
 
1971
2001
  metrics.recordResponse(result.status);
2002
+ // finalBody is usually a JSON string; Express defaults string bodies to
2003
+ // text/html, which Claude Code rejects as a malformed 200. Only default —
2004
+ // an upstream content-type copied from result.headers above still wins.
2005
+ if (!res.getHeader("Content-Type")) res.type("application/json");
1972
2006
  res.status(result.status).send(finalBody);
1973
2007
  } catch (error) {
1974
2008
  next(error);