lynkr 9.11.0 → 9.13.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,
@@ -470,6 +588,22 @@ router.post("/chat/completions", async (req, res) => {
470
588
  const content = lynkrBadge(result.body) + (openaiResponse.choices[0].message.content || "");
471
589
  let toolCalls = openaiResponse.choices[0].message.tool_calls;
472
590
 
591
+ // Guard: never serve a clean empty completion. Upstream failures
592
+ // (e.g. exhausted 429 retries) can surface here as a contentless
593
+ // message with finish_reason "stop"; agent clients read that as
594
+ // "task complete" and terminate mid-task. Killing the stream without
595
+ // a finish chunk makes the client retry instead.
596
+ if (!content && (!toolCalls || toolCalls.length === 0)) {
597
+ logger.error({
598
+ finishReason: openaiResponse.choices[0]?.finish_reason,
599
+ terminationReason: result?.terminationReason,
600
+ status: result?.status,
601
+ }, "Empty completion reached serving path — aborting stream to force client retry");
602
+ res.write(`data: ${JSON.stringify({ error: { message: "Upstream returned an empty completion; retry.", type: "server_error", code: "empty_completion" } })}\n\n`);
603
+ res.destroy();
604
+ return;
605
+ }
606
+
473
607
  if (clientType !== "unknown" && toolCalls && toolCalls.length > 0) {
474
608
  toolCalls = toolCalls.map(tc => {
475
609
  const mapped = mapToolForClient(tc.function?.name || "", tc.function?.arguments || "{}", clientType);
@@ -646,6 +780,21 @@ router.post("/chat/completions", async (req, res) => {
646
780
  }, "Tool names mapped for non-streaming chat/completions");
647
781
  }
648
782
 
783
+ // Guard (mirrors the streaming path): never serve a clean empty
784
+ // completion — upstream failures can surface as contentless messages
785
+ // with finish_reason "stop", which agent clients read as "done" and
786
+ // terminate mid-task. A 502 is retryable; an empty 200 is a silent kill.
787
+ const _msg = openaiResponse.choices?.[0]?.message;
788
+ if (!_msg?.content && !(_msg?.tool_calls?.length > 0)) {
789
+ logger.error({
790
+ finishReason: openaiResponse.choices?.[0]?.finish_reason,
791
+ usage: openaiResponse.usage,
792
+ }, "Empty completion reached non-streaming serving path — returning 502");
793
+ return res.status(502).json({
794
+ error: { message: "Upstream returned an empty completion; retry.", type: "server_error", code: "empty_completion" }
795
+ });
796
+ }
797
+
649
798
  logger.info({
650
799
  duration: Date.now() - startTime,
651
800
  mode: "non-streaming",
@@ -821,6 +970,18 @@ function getConfiguredProviders() {
821
970
  });
822
971
  }
823
972
 
973
+ if (config.baidu?.apiKey) {
974
+ providers.push({
975
+ name: "baidu",
976
+ type: "baidu-qianfan",
977
+ models: [
978
+ config.baidu.model || "ernie-4.5-turbo-128k",
979
+ "ernie-4.5-turbo-128k",
980
+ "ernie-x1.1"
981
+ ]
982
+ });
983
+ }
984
+
824
985
  if (config.vertex?.projectId) {
825
986
  providers.push({
826
987
  name: "vertex",
@@ -107,6 +107,19 @@ function getConfiguredProviders() {
107
107
  });
108
108
  }
109
109
 
110
+ // Check Atlas Cloud (OpenAI-compatible Chat Completions)
111
+ if (config.atlas?.apiKey) {
112
+ providers.push({
113
+ name: "atlas",
114
+ type: "atlas-cloud",
115
+ baseUrl: config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions",
116
+ enabled: true,
117
+ models: [
118
+ { id: config.atlas.model || "qwen/qwen3.8-max", name: "Configured Model" },
119
+ ]
120
+ });
121
+ }
122
+
110
123
  // Check OpenRouter
111
124
  if (config.openrouter?.apiKey) {
112
125
  providers.push({
@@ -210,6 +223,21 @@ function getConfiguredProviders() {
210
223
  });
211
224
  }
212
225
 
226
+ // Check Baidu Qianfan (ERNIE)
227
+ if (config.baidu?.apiKey) {
228
+ providers.push({
229
+ name: "baidu",
230
+ type: "baidu-qianfan",
231
+ baseUrl: config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions",
232
+ enabled: true,
233
+ models: [
234
+ { id: config.baidu.model || "ernie-4.5-turbo-128k", name: "Configured Model" },
235
+ { id: "ernie-4.5-turbo-128k", name: "ERNIE 4.5 Turbo 128K" },
236
+ { id: "ernie-x1.1", name: "ERNIE X1.1 (reasoning)" },
237
+ ]
238
+ });
239
+ }
240
+
213
241
  // Check Vertex AI (Google Cloud)
214
242
  if (config.vertex?.projectId) {
215
243
  const region = config.vertex.region || "us-east5";