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.
- package/README.md +58 -9
- package/bin/lynkr-init.js +25 -2
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/package.json +8 -3
- package/public/dashboard.html +325 -1
- package/src/api/openai-router.js +163 -2
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +297 -30
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/dashboard/api.js +78 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- package/src/orchestrator/index.js +28 -5
- package/src/orchestrator/sse-transformer.js +10 -3
- package/src/routing/complexity-analyzer.js +30 -9
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
- package/src/routing/telemetry.js +206 -2
- package/scripts/convert-routellm.py +0 -105
package/public/dashboard.html
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/api/openai-router.js
CHANGED
|
@@ -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
|
-
//
|
|
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";
|