lynkr 9.11.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.
@@ -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);
@@ -520,6 +544,13 @@ const App = {
520
544
  </div>
521
545
  </div>
522
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>
523
554
  `, 'mb-6')}
524
555
 
525
556
  <!-- Provider Stats -->
@@ -589,6 +620,7 @@ const App = {
589
620
  <td class="py-2 px-3 text-xs ${r.error_type?'text-red-400':'text-slate-500'}">${r.error_type||'—'}</td>
590
621
  <td class="py-2 px-3 text-xs text-amber-300">${r.cost_usd!=null?fmt.usd(r.cost_usd):'—'}</td>
591
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>
592
624
  </tr>`).join('');
593
625
 
594
626
  return `
@@ -632,6 +664,7 @@ const App = {
632
664
  <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Error</th>
633
665
  <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Cost</th>
634
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>
635
668
  </tr></thead>
636
669
  <tbody>${tableRows}</tbody>
637
670
  </table>
@@ -645,6 +678,297 @@ const App = {
645
678
  if (this.page === 'usage' && data?.daily?.length && data.totals?.requests > 0) {
646
679
  this._drawUsageChart(data.daily);
647
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
+ });
648
972
  },
649
973
 
650
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,
@@ -310,6 +310,18 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
310
310
  incomingAuth = null;
311
311
  }
312
312
 
313
+ // Anthropic OAuth tokens are only meaningful at api.anthropic.com. When this
314
+ // provider points at any OTHER Anthropic-compatible host (Azure AI Foundry
315
+ // Claude, a corporate gateway), forwarding Claude Code's sk-ant-oat token
316
+ // earns a 401 there — and the configured x-api-key below is the right auth.
317
+ // This also means a subscription token never leaves the box unless the
318
+ // endpoint is Anthropic itself.
319
+ const isAnthropicDirect = String(config.azureAnthropic.endpoint || "").includes("api.anthropic.com");
320
+ if (incomingAuth && !isAnthropicDirect) {
321
+ logger.debug({ endpoint: config.azureAnthropic.endpoint }, "Azure Anthropic: non-Anthropic endpoint — using configured API key instead of client OAuth token");
322
+ incomingAuth = null;
323
+ }
324
+
313
325
  // Headers Anthropic uses to verify client identity for subscription OAuth tokens.
314
326
  // If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
315
327
  // headers (its terse anti-proxy response). Forward every Anthropic-relevant
@@ -2094,7 +2106,11 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2094
2106
  max_tokens: body.max_tokens || 16384,
2095
2107
  temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2096
2108
  top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2097
- stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
2109
+ // Streaming honored since the Phase-2b sse-transformer landed: the raw
2110
+ // OpenAI SSE stream is returned below and reshaped in flight by the
2111
+ // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
2112
+ // requests keep the Anthropic conversion path unchanged.
2113
+ stream: body.stream ?? false,
2098
2114
  };
2099
2115
 
2100
2116
  if (Array.isArray(body.tools) && body.tools.length > 0) {
@@ -2132,6 +2148,13 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2132
2148
  throw err;
2133
2149
  }
2134
2150
 
2151
+ // Streaming request: hand the raw OpenAI SSE stream to the orchestrator's
2152
+ // stream branch (sse-transformer reshapes it). The Anthropic conversion
2153
+ // below is buffered-only — it would consume the stream.
2154
+ if (response?.stream) {
2155
+ return response;
2156
+ }
2157
+
2135
2158
  const rawMsg = response?.json?.choices?.[0]?.message;
2136
2159
  logger.debug({
2137
2160
  responseOk: response?.ok,
@@ -3000,6 +3023,8 @@ async function invokeModel(body, options = {}) {
3000
3023
  pinned: routingResult.pinned ? 1 : 0,
3001
3024
  switch_reason: routingResult.switch_reason ?? null,
3002
3025
  cache_decision: routingResult._cacheDecision ?? null,
3026
+ cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3027
+ cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
3003
3028
  });
3004
3029
 
3005
3030
  // WS5.4 — feedback loop (success path).
@@ -3351,6 +3376,8 @@ async function invokeModel(body, options = {}) {
3351
3376
  pinned: routingResult.pinned ? 1 : 0,
3352
3377
  switch_reason: routingResult.switch_reason ?? null,
3353
3378
  cache_decision: routingResult._cacheDecision ?? null,
3379
+ cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
3380
+ cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
3354
3381
  });
3355
3382
 
3356
3383
  // WS5.4 — feedback loop (fallback success). The served provider