lynkr 9.10.0 → 9.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -11
- package/bin/lynkr-init.js +7 -0
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/config/model-tiers.json +5 -5
- package/package.json +9 -4
- package/public/dashboard.html +354 -1
- package/src/api/openai-router.js +120 -2
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +148 -66
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +79 -0
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +82 -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/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +85 -2
- package/src/orchestrator/sse-transformer.js +9 -3
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +68 -1
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +37 -10
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +115 -13
- package/src/routing/index.js +161 -53
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +60 -0
- package/src/routing/telemetry.js +343 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- package/src/tools/code-graph.js +6 -4
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);
|
|
@@ -493,6 +517,42 @@ const App = {
|
|
|
493
517
|
` : emptyState('No routing data for last 24h')}
|
|
494
518
|
`, 'mb-6')}
|
|
495
519
|
|
|
520
|
+
<!-- Cache Economics (cache-aware routing receipt) -->
|
|
521
|
+
${card(`
|
|
522
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-4">Cache Economics (last ${d.window || '24h'})</h3>
|
|
523
|
+
${d.cacheEconomics && d.cacheEconomics.decisions > 0 ? `
|
|
524
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
525
|
+
<div class="text-center">
|
|
526
|
+
<p class="text-2xl font-bold text-green-400">${fmt.usd(d.cacheEconomics.totalDollarsSaved)}</p>
|
|
527
|
+
<p class="text-xs text-slate-500 mt-1">Cache $ saved by routing</p>
|
|
528
|
+
<p class="text-xs text-slate-600">(holds + cleared switches)</p>
|
|
529
|
+
</div>
|
|
530
|
+
<div class="text-center">
|
|
531
|
+
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.holds)}</p>
|
|
532
|
+
<p class="text-xs text-slate-500 mt-1">Switches held</p>
|
|
533
|
+
<p class="text-xs text-slate-600">(warm prefix protected)</p>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="text-center">
|
|
536
|
+
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.switches)}</p>
|
|
537
|
+
<p class="text-xs text-slate-500 mt-1">Switches cleared</p>
|
|
538
|
+
<p class="text-xs text-slate-600">(break-even ≤ remaining turns)</p>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="text-center">
|
|
541
|
+
<p class="text-2xl font-bold text-amber-400">${fmt.usd(d.cacheEconomics.dollarsSavedByHolds)}</p>
|
|
542
|
+
<p class="text-xs text-slate-500 mt-1">Saved by holding pins</p>
|
|
543
|
+
<p class="text-xs text-slate-600">(cache re-writes avoided)</p>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
` : emptyState('No gated switch decisions yet — appears once a pinned session hits a downgrade decision')}
|
|
547
|
+
<div class="border-t border-slate-700/60 mt-4 pt-3 flex flex-wrap items-baseline gap-x-6 gap-y-1">
|
|
548
|
+
<span class="text-xs uppercase tracking-wide text-slate-500">Measured (past tense, auditable)</span>
|
|
549
|
+
${d.cacheMeasured && d.cacheMeasured.rowsMeasured > 0 ? `
|
|
550
|
+
<span class="text-sm text-green-400 font-semibold">${fmt.usd2(d.cacheMeasured.measuredSavedUsd)} saved by cache reads actually served</span>
|
|
551
|
+
<span class="text-xs text-slate-500">${fmt.num(d.cacheMeasured.rowsMeasured)} requests measured · weakest: ${d.cacheMeasured.perModel[0] ? `${d.cacheMeasured.perModel[0].model} at ${d.cacheMeasured.perModel[0].hitRatio != null ? Math.round(d.cacheMeasured.perModel[0].hitRatio * 100) + '%' : '—'}` : '—'}</span>
|
|
552
|
+
` : `<span class="text-xs text-slate-600">not measured yet — cache counters accrue from this version onward (not the same as zero savings)</span>`}
|
|
553
|
+
</div>
|
|
554
|
+
`, 'mb-6')}
|
|
555
|
+
|
|
496
556
|
<!-- Provider Stats -->
|
|
497
557
|
${card(`
|
|
498
558
|
<h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
|
|
@@ -560,6 +620,7 @@ const App = {
|
|
|
560
620
|
<td class="py-2 px-3 text-xs ${r.error_type?'text-red-400':'text-slate-500'}">${r.error_type||'—'}</td>
|
|
561
621
|
<td class="py-2 px-3 text-xs text-amber-300">${r.cost_usd!=null?fmt.usd(r.cost_usd):'—'}</td>
|
|
562
622
|
<td class="py-2 px-3">${r.was_fallback?'<span class="badge bg-amber-900/40 text-amber-300">fallback</span>':'—'}</td>
|
|
623
|
+
<td class="py-2 px-3">${r.session_id ? `<button onclick="App.showSession('${String(r.session_id).replace(/'/g,'')}')" class="text-xs font-mono text-blue-400 hover:text-blue-200">${String(r.session_id).slice(0,10)}…</button>` : '—'}</td>
|
|
563
624
|
</tr>`).join('');
|
|
564
625
|
|
|
565
626
|
return `
|
|
@@ -603,6 +664,7 @@ const App = {
|
|
|
603
664
|
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Error</th>
|
|
604
665
|
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Cost</th>
|
|
605
666
|
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Flags</th>
|
|
667
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Session</th>
|
|
606
668
|
</tr></thead>
|
|
607
669
|
<tbody>${tableRows}</tbody>
|
|
608
670
|
</table>
|
|
@@ -616,6 +678,297 @@ const App = {
|
|
|
616
678
|
if (this.page === 'usage' && data?.daily?.length && data.totals?.requests > 0) {
|
|
617
679
|
this._drawUsageChart(data.daily);
|
|
618
680
|
}
|
|
681
|
+
if (this.page === 'explore' && data?.rows?.length) this._drawExploreChart(data);
|
|
682
|
+
if (this.page === 'session' && data?.contextSeries?.length) this._drawContextChart(data.contextSeries);
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
/* ── INSIGHTS (Lens recommendations) ─────────── */
|
|
686
|
+
openInsight(id) {
|
|
687
|
+
this.insightOpen = this.insightOpen === id ? null : id;
|
|
688
|
+
this._render(this.data.insights);
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
showSession(id) {
|
|
692
|
+
if (!id) return;
|
|
693
|
+
this.sessionId = id;
|
|
694
|
+
this.navigate('session');
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
_renderInsights(d) {
|
|
698
|
+
if (!d) return emptyState('No data');
|
|
699
|
+
const f = d.findings || [];
|
|
700
|
+
const actionable = f.filter(x => x.state === 'actionable');
|
|
701
|
+
const sharePct = d.spendUsd > 0 ? Math.min(100, (d.totalRecoverableUsd / d.spendUsd) * 100) : 0;
|
|
702
|
+
const TONES = ['bg-amber-500','bg-blue-500','bg-purple-500','bg-green-500','bg-rose-500'];
|
|
703
|
+
|
|
704
|
+
// Hero — one netted, backward-looking figure. Past tense on purpose.
|
|
705
|
+
const segments = actionable.filter(x => x.pastOverspendUsd > 0);
|
|
706
|
+
const segTotal = segments.reduce((a, x) => a + x.pastOverspendUsd, 0) || 1;
|
|
707
|
+
const shareBar = segments.length ? `
|
|
708
|
+
<div class="flex h-2 rounded-full overflow-hidden bg-slate-700 mt-3">
|
|
709
|
+
${segments.map((x, i) => `<div class="${TONES[i % TONES.length]}" style="width:${(x.pastOverspendUsd / segTotal) * 100}%" title="${x.title}"></div>`).join('')}
|
|
710
|
+
</div>
|
|
711
|
+
<div class="flex flex-wrap gap-3 mt-2 text-xs text-slate-500">
|
|
712
|
+
${segments.map((x, i) => `<span><span class="inline-block w-2 h-2 rounded-full ${TONES[i % TONES.length]} mr-1"></span>${x.title}</span>`).join('')}
|
|
713
|
+
</div>` : '';
|
|
714
|
+
|
|
715
|
+
const hero = card(`
|
|
716
|
+
<p class="text-xs uppercase tracking-wide text-slate-500 mb-1">Past overspend found (last ${Math.round(d.windowMs / 86400000)}d)</p>
|
|
717
|
+
<div class="flex items-baseline gap-3">
|
|
718
|
+
<span class="text-3xl font-bold ${d.totalRecoverableUsd > 0 ? 'text-amber-300' : 'text-green-400'}">${fmt.usd2(d.totalRecoverableUsd)}</span>
|
|
719
|
+
<span class="text-sm text-slate-500">of ${fmt.usd2(d.spendUsd)} spent · ${sharePct.toFixed(1)}%</span>
|
|
720
|
+
</div>
|
|
721
|
+
${shareBar}
|
|
722
|
+
<p class="text-xs text-slate-600 mt-3">Figures are backward-looking — checkable against spend already recorded. Ceilings are labeled in each finding's basis.</p>
|
|
723
|
+
`, 'mb-6');
|
|
724
|
+
|
|
725
|
+
const mechBadge = m => ({
|
|
726
|
+
auto: '<span class="badge bg-green-900/40 text-green-300">lynkr can act</span>',
|
|
727
|
+
snippet: '<span class="badge bg-blue-900/40 text-blue-300">client-side fix</span>',
|
|
728
|
+
info: '<span class="badge bg-slate-700 text-slate-300">diagnosis</span>',
|
|
729
|
+
}[m] || '');
|
|
730
|
+
const stateBadge = s => ({
|
|
731
|
+
actionable: '',
|
|
732
|
+
clean: '<span class="badge bg-green-900/30 text-green-400">ran — found nothing</span>',
|
|
733
|
+
not_measured: '<span class="badge bg-slate-700 text-slate-400">not measured yet</span>',
|
|
734
|
+
}[s] || '');
|
|
735
|
+
|
|
736
|
+
const tiles = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
737
|
+
${f.map(x => `
|
|
738
|
+
<div onclick="${x.state === 'actionable' ? `App.openInsight('${x.id}')` : ''}"
|
|
739
|
+
class="border rounded-xl p-4 ${x.state === 'actionable' ? 'border-amber-700/50 bg-amber-900/10 cursor-pointer hover:bg-amber-900/20' : 'border-slate-700 bg-slate-800/60'} ${this.insightOpen === x.id ? 'ring-1 ring-amber-500' : ''}">
|
|
740
|
+
<div class="flex items-start justify-between gap-2 mb-2">
|
|
741
|
+
<span class="text-sm font-medium text-slate-200">${x.title}</span>
|
|
742
|
+
${mechBadge(x.mechanism)}
|
|
743
|
+
</div>
|
|
744
|
+
${x.state === 'actionable'
|
|
745
|
+
? `<p class="text-2xl font-bold text-amber-300">${typeof x.pastOverspendUsd === 'number' ? fmt.usd2(x.pastOverspendUsd) : fmt.tok(x.pastOverspendTokens || 0) + ' tok'}</p>
|
|
746
|
+
<p class="text-xs text-slate-500 mt-1">click for evidence</p>`
|
|
747
|
+
: stateBadge(x.state)}
|
|
748
|
+
</div>`).join('')}
|
|
749
|
+
</div>`;
|
|
750
|
+
|
|
751
|
+
// Detail shell — Evidence / Fix / Caveat / How computed.
|
|
752
|
+
let detail = '';
|
|
753
|
+
const open = f.find(x => x.id === this.insightOpen);
|
|
754
|
+
if (open) {
|
|
755
|
+
const evid = open.evidence?.rows?.length ? `
|
|
756
|
+
<div class="overflow-x-auto">
|
|
757
|
+
<table class="w-full text-left">
|
|
758
|
+
<thead><tr class="border-b border-slate-700 text-xs text-slate-500">
|
|
759
|
+
${open.evidence.columns.map(c => `<th class="py-2 px-3">${c}</th>`).join('')}
|
|
760
|
+
</tr></thead>
|
|
761
|
+
<tbody>${open.evidence.rows.map(r => `
|
|
762
|
+
<tr class="table-row border-b border-slate-700/40 text-sm">
|
|
763
|
+
${r.map(v => `<td class="py-2 px-3 text-xs text-slate-300">${typeof v === 'number' && !Number.isInteger(v) ? fmt.usd(v) : v}</td>`).join('')}
|
|
764
|
+
</tr>`).join('')}
|
|
765
|
+
</tbody>
|
|
766
|
+
</table>
|
|
767
|
+
</div>` : emptyState('No evidence rows');
|
|
768
|
+
detail = card(`
|
|
769
|
+
<div class="flex items-center justify-between mb-4">
|
|
770
|
+
<h3 class="text-sm font-semibold text-slate-200">${open.title}</h3>
|
|
771
|
+
<div class="flex gap-4 text-xs text-slate-500">${(open.stats || []).map(s => `<span><span class="text-slate-300 font-semibold">${fmt.num(s.value)}</span> ${s.label}</span>`).join('')}</div>
|
|
772
|
+
</div>
|
|
773
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
|
774
|
+
<div class="lg:col-span-2">
|
|
775
|
+
<p class="text-xs uppercase tracking-wide text-slate-500 mb-2">Evidence — where this figure came from</p>
|
|
776
|
+
${evid}
|
|
777
|
+
</div>
|
|
778
|
+
<div class="space-y-4">
|
|
779
|
+
${open.fix ? `<div><p class="text-xs uppercase tracking-wide text-slate-500 mb-1">The fix</p><p class="text-sm text-slate-300">${open.fix}</p></div>` : ''}
|
|
780
|
+
${open.caveat ? `<div class="border border-amber-800/40 bg-amber-900/10 rounded-lg p-3"><p class="text-xs uppercase tracking-wide text-amber-500 mb-1">Caveat</p><p class="text-xs text-slate-400">${open.caveat}</p></div>` : ''}
|
|
781
|
+
<div><p class="text-xs uppercase tracking-wide text-slate-500 mb-1">How this figure is computed</p><p class="text-xs text-slate-500">${open.estimateBasis || '—'}</p></div>
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
`, 'mb-6');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return `
|
|
788
|
+
<h2 class="text-lg font-semibold mb-5">Insights</h2>
|
|
789
|
+
${hero}
|
|
790
|
+
${tiles}
|
|
791
|
+
${detail}
|
|
792
|
+
`;
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
/* ── EXPLORE (pivot explorer) ────────────────── */
|
|
796
|
+
setExplore(key, value) {
|
|
797
|
+
this.explore[key] = key === 'days' ? parseInt(value, 10) : value;
|
|
798
|
+
this.refresh();
|
|
799
|
+
},
|
|
800
|
+
applyPreset(metric, by, stack) {
|
|
801
|
+
this.explore = { ...this.explore, metric, by, stack };
|
|
802
|
+
this.refresh();
|
|
803
|
+
},
|
|
804
|
+
exportCsv() {
|
|
805
|
+
const d = this.data.explore;
|
|
806
|
+
if (!d?.rows?.length) return;
|
|
807
|
+
const header = d.stack ? 'dimension,stack,value' : 'dimension,value';
|
|
808
|
+
const lines = d.rows.map(r => d.stack
|
|
809
|
+
? `"${r.dim}","${r.stack}",${r.value}`
|
|
810
|
+
: `"${r.dim}",${r.value}`);
|
|
811
|
+
const blob = new Blob([header + '\n' + lines.join('\n')], { type: 'text/csv' });
|
|
812
|
+
const a = document.createElement('a');
|
|
813
|
+
a.href = URL.createObjectURL(blob);
|
|
814
|
+
a.download = `lynkr-${d.metric}-by-${d.by}.csv`;
|
|
815
|
+
a.click();
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
_renderExplore(d) {
|
|
819
|
+
if (!d) return emptyState('No data');
|
|
820
|
+
const e = this.explore;
|
|
821
|
+
const METRICS = ['spend', 'tokens', 'requests', 'sessions'];
|
|
822
|
+
const DIMS = ['provider', 'model', 'tier', 'request_type', 'routing_method', 'switch_reason', 'day'];
|
|
823
|
+
const sel = (id, opts, val, allowEmpty) => `
|
|
824
|
+
<select onchange="App.setExplore('${id}', this.value)"
|
|
825
|
+
class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5">
|
|
826
|
+
${allowEmpty ? `<option value="" ${!val ? 'selected' : ''}>no stack</option>` : ''}
|
|
827
|
+
${opts.map(o => `<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`).join('')}
|
|
828
|
+
</select>`;
|
|
829
|
+
|
|
830
|
+
const kpiVal = { spend: fmt.usd2(d.kpis?.spend), tokens: fmt.tok(d.kpis?.tokens || 0), requests: fmt.num(d.kpis?.requests), sessions: fmt.num(d.kpis?.sessions) };
|
|
831
|
+
const kpiDelta = k => {
|
|
832
|
+
if (!d.kpiDeltas) return '<span class="text-xs text-slate-600">prior window too thin to compare</span>';
|
|
833
|
+
const v = d.kpiDeltas[k];
|
|
834
|
+
const sign = v > 0 ? '+' : '';
|
|
835
|
+
const cls = v > 0 ? 'text-amber-400' : 'text-green-400';
|
|
836
|
+
const fmtV = k === 'spend' ? fmt.usd2(Math.abs(v)) : k === 'tokens' ? fmt.tok(Math.abs(v)) : fmt.num(Math.abs(v));
|
|
837
|
+
return `<span class="text-xs ${cls}">${sign === '+' ? '▲' : '▼'} ${fmtV} vs prior</span>`;
|
|
838
|
+
};
|
|
839
|
+
const kpis = `<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
840
|
+
${METRICS.map(k => `
|
|
841
|
+
<div class="border border-slate-700 bg-slate-800/60 rounded-xl p-4">
|
|
842
|
+
<p class="text-xs uppercase tracking-wide text-slate-500">${k}</p>
|
|
843
|
+
<p class="text-2xl font-bold mt-1">${kpiVal[k] ?? '—'}</p>
|
|
844
|
+
${kpiDelta(k)}
|
|
845
|
+
</div>`).join('')}
|
|
846
|
+
</div>`;
|
|
847
|
+
|
|
848
|
+
const presets = [
|
|
849
|
+
['Spend by tier', 'spend', 'tier', ''],
|
|
850
|
+
['Tokens by model', 'tokens', 'model', ''],
|
|
851
|
+
['Requests by type', 'requests', 'request_type', ''],
|
|
852
|
+
['Spend over days by tier', 'spend', 'day', 'tier'],
|
|
853
|
+
];
|
|
854
|
+
|
|
855
|
+
return `
|
|
856
|
+
<div class="flex items-center justify-between mb-5">
|
|
857
|
+
<h2 class="text-lg font-semibold">Explore</h2>
|
|
858
|
+
<button onclick="App.exportCsv()" class="text-xs text-slate-400 hover:text-white px-3 py-1.5 rounded-lg border border-slate-600 hover:bg-slate-700">Export CSV</button>
|
|
859
|
+
</div>
|
|
860
|
+
${card(`
|
|
861
|
+
<div class="flex flex-wrap gap-3 items-center">
|
|
862
|
+
${sel('metric', METRICS, e.metric)}
|
|
863
|
+
<span class="text-xs text-slate-500">by</span>
|
|
864
|
+
${sel('by', DIMS, e.by)}
|
|
865
|
+
<span class="text-xs text-slate-500">stack</span>
|
|
866
|
+
${sel('stack', DIMS, e.stack, true)}
|
|
867
|
+
${sel('days', [1, 7, 30, 90], e.days)}
|
|
868
|
+
<span class="text-xs text-slate-600">days</span>
|
|
869
|
+
</div>
|
|
870
|
+
<div class="flex flex-wrap gap-2 mt-3">
|
|
871
|
+
${presets.map(([label, m, b, s]) => `
|
|
872
|
+
<button onclick="App.applyPreset('${m}','${b}','${s}')"
|
|
873
|
+
class="text-xs px-2.5 py-1 rounded-full border border-slate-600 text-slate-400 hover:text-white hover:bg-slate-700">${label}</button>`).join('')}
|
|
874
|
+
</div>
|
|
875
|
+
`, 'mb-6')}
|
|
876
|
+
${kpis}
|
|
877
|
+
${d.rows?.length
|
|
878
|
+
? card(`<div style="height:340px"><canvas id="explore-chart"></canvas></div>`)
|
|
879
|
+
: emptyState('No rows for this pivot in the window — not the same as “nothing happened”: check the window size.')}
|
|
880
|
+
`;
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
_drawExploreChart(d) {
|
|
884
|
+
const canvas = document.getElementById('explore-chart');
|
|
885
|
+
if (!canvas || typeof Chart === 'undefined') return;
|
|
886
|
+
const PALETTE = ['rgba(34,197,94,0.8)','rgba(59,130,246,0.8)','rgba(245,158,11,0.8)','rgba(168,85,247,0.8)','rgba(244,63,94,0.8)','rgba(14,165,233,0.8)','rgba(148,163,184,0.8)'];
|
|
887
|
+
let cfg;
|
|
888
|
+
if (d.stack) {
|
|
889
|
+
const dims = [...new Set(d.rows.map(r => r.dim))];
|
|
890
|
+
const stacks = [...new Set(d.rows.map(r => r.stack))];
|
|
891
|
+
cfg = {
|
|
892
|
+
type: 'bar',
|
|
893
|
+
data: {
|
|
894
|
+
labels: dims,
|
|
895
|
+
datasets: stacks.map((s, i) => ({
|
|
896
|
+
label: String(s ?? '—'),
|
|
897
|
+
data: dims.map(dim => d.rows.find(r => r.dim === dim && r.stack === s)?.value ?? 0),
|
|
898
|
+
backgroundColor: PALETTE[i % PALETTE.length],
|
|
899
|
+
})),
|
|
900
|
+
},
|
|
901
|
+
options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true }, y: { stacked: true } } },
|
|
902
|
+
};
|
|
903
|
+
} else {
|
|
904
|
+
const rows = d.rows.slice(0, 20);
|
|
905
|
+
cfg = {
|
|
906
|
+
type: 'bar',
|
|
907
|
+
data: {
|
|
908
|
+
labels: rows.map(r => String(r.dim ?? '—')),
|
|
909
|
+
datasets: [{ label: d.metric, data: rows.map(r => r.value), backgroundColor: PALETTE[1] }],
|
|
910
|
+
},
|
|
911
|
+
options: { responsive: true, maintainAspectRatio: false, indexAxis: rows.length > 8 ? 'y' : 'x', plugins: { legend: { display: false } } },
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
this._charts.explore = new Chart(canvas, cfg);
|
|
915
|
+
},
|
|
916
|
+
|
|
917
|
+
/* ── SESSION DETAIL ──────────────────────────── */
|
|
918
|
+
_renderSession(d) {
|
|
919
|
+
if (!d) return emptyState('Session not found');
|
|
920
|
+
const dur = d.lastSeen - d.firstSeen;
|
|
921
|
+
const modelRows = d.models.map(m => `
|
|
922
|
+
<tr class="table-row border-b border-slate-700/40 text-sm">
|
|
923
|
+
<td class="py-2 px-3 text-xs font-mono text-slate-300">${m.provider}</td>
|
|
924
|
+
<td class="py-2 px-3 text-xs text-slate-400">${m.model || '—'}</td>
|
|
925
|
+
<td class="py-2 px-3 text-xs">${fmt.num(m.requests)}</td>
|
|
926
|
+
<td class="py-2 px-3 text-xs">${fmt.tok(m.inputTokens)}</td>
|
|
927
|
+
<td class="py-2 px-3 text-xs">${fmt.tok(m.outputTokens)}</td>
|
|
928
|
+
<td class="py-2 px-3 text-xs text-green-400">${fmt.tok(m.cacheReadTokens)}</td>
|
|
929
|
+
<td class="py-2 px-3 text-xs text-amber-300">${fmt.usd(m.costUsd)}</td>
|
|
930
|
+
</tr>`).join('');
|
|
931
|
+
|
|
932
|
+
return `
|
|
933
|
+
<div class="flex items-center gap-3 mb-5">
|
|
934
|
+
<button onclick="App.navigate('logs')" class="text-xs text-slate-400 hover:text-white px-2 py-1 rounded border border-slate-600 hover:bg-slate-700">← logs</button>
|
|
935
|
+
<h2 class="text-lg font-semibold">Session</h2>
|
|
936
|
+
<span class="text-xs font-mono text-slate-500">${d.sessionId}</span>
|
|
937
|
+
</div>
|
|
938
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
939
|
+
${[['requests', fmt.num(d.requests)], ['spend', fmt.usd(d.totalCostUsd)], ['tool calls', fmt.num(d.toolCalls)], ['duration', fmt.ms(dur)]]
|
|
940
|
+
.map(([l, v]) => `<div class="border border-slate-700 bg-slate-800/60 rounded-xl p-4"><p class="text-xs uppercase tracking-wide text-slate-500">${l}</p><p class="text-2xl font-bold mt-1">${v}</p></div>`).join('')}
|
|
941
|
+
</div>
|
|
942
|
+
${card(`
|
|
943
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Input (context) tokens per call — distillation and cache behavior are visible here</h3>
|
|
944
|
+
<div style="height:220px"><canvas id="session-context-chart"></canvas></div>
|
|
945
|
+
`, 'mb-6')}
|
|
946
|
+
${card(`
|
|
947
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Model mix</h3>
|
|
948
|
+
<div class="overflow-x-auto"><table class="w-full text-left">
|
|
949
|
+
<thead><tr class="border-b border-slate-700 text-xs text-slate-500">
|
|
950
|
+
<th class="py-2 px-3">provider</th><th class="py-2 px-3">model</th><th class="py-2 px-3">requests</th>
|
|
951
|
+
<th class="py-2 px-3">input</th><th class="py-2 px-3">output</th><th class="py-2 px-3">cache reads</th><th class="py-2 px-3">cost</th>
|
|
952
|
+
</tr></thead><tbody>${modelRows}</tbody>
|
|
953
|
+
</table></div>
|
|
954
|
+
`)}
|
|
955
|
+
`;
|
|
956
|
+
},
|
|
957
|
+
|
|
958
|
+
_drawContextChart(series) {
|
|
959
|
+
const canvas = document.getElementById('session-context-chart');
|
|
960
|
+
if (!canvas || typeof Chart === 'undefined') return;
|
|
961
|
+
this._charts.sessionCtx = new Chart(canvas, {
|
|
962
|
+
type: 'bar',
|
|
963
|
+
data: {
|
|
964
|
+
labels: series.map((_, i) => i + 1),
|
|
965
|
+
datasets: [
|
|
966
|
+
{ label: 'input tokens', data: series.map(p => p.inputTokens), backgroundColor: 'rgba(59,130,246,0.8)' },
|
|
967
|
+
{ label: 'cache reads', data: series.map(p => p.cacheReadTokens ?? 0), backgroundColor: 'rgba(34,197,94,0.6)' },
|
|
968
|
+
],
|
|
969
|
+
},
|
|
970
|
+
options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'call #' } } } },
|
|
971
|
+
});
|
|
619
972
|
},
|
|
620
973
|
|
|
621
974
|
_drawUsageChart(daily) {
|
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,
|
package/src/api/router.js
CHANGED
|
@@ -579,6 +579,30 @@ function _buildStreamBadge(tier) {
|
|
|
579
579
|
return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Terminal upstream errors carry no content array — without this, the synth
|
|
584
|
+
* SSE writers render an empty message and the turn goes silent in the TUI
|
|
585
|
+
* (only the routing badge shows). Returns visible-text content blocks for an
|
|
586
|
+
* error result, using the fallback chain's failure trail when the provider
|
|
587
|
+
* layer recorded one (so "moonshot: quota exhausted" isn't hidden behind the
|
|
588
|
+
* last rung's unrelated error), or null for normal responses.
|
|
589
|
+
*/
|
|
590
|
+
function _buildFailureBlocks(result, msg) {
|
|
591
|
+
if (result.terminationReason !== "api_error" && Array.isArray(msg?.content)) return null;
|
|
592
|
+
const trail = Array.isArray(msg?._lynkr_failure_trail) ? msg._lynkr_failure_trail : null;
|
|
593
|
+
const lastErr = msg?.error?.message || msg?.message
|
|
594
|
+
|| (typeof msg === "string" ? msg : JSON.stringify(msg ?? {}).slice(0, 200));
|
|
595
|
+
const trailText = (trail || [String(lastErr)]).map((t) => ` · ${t}`).join("\n");
|
|
596
|
+
const quotaHit = result.status === 429 || /rate.?limit|quota|TPD|TPM/i.test(trailText);
|
|
597
|
+
return [{
|
|
598
|
+
type: "text",
|
|
599
|
+
text:
|
|
600
|
+
`⚠ [Lynkr] ${quotaHit ? "Provider quota exhausted — no fallback could serve this request" : "No provider could serve this request"} (HTTP ${result.status ?? "?"}).\n\n` +
|
|
601
|
+
`${trailText}\n` +
|
|
602
|
+
(quotaHit ? `\nAll fallback candidates failed after the quota hit. Wait for the quota window to reset or repoint the affected tier in .env.\n` : ""),
|
|
603
|
+
}];
|
|
604
|
+
}
|
|
605
|
+
|
|
582
606
|
/**
|
|
583
607
|
* Scan the entire payload for tool_use or tool_result content blocks.
|
|
584
608
|
* Unlike session-affinity.payloadHasToolHistory (which checks only the last
|
|
@@ -1611,6 +1635,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1611
1635
|
// tool_use with no tool_use block).
|
|
1612
1636
|
let contentBlocks = (msg.content || []).slice();
|
|
1613
1637
|
|
|
1638
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1639
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1640
|
+
|
|
1614
1641
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1615
1642
|
// users can see which tier/provider/model handled the request inside
|
|
1616
1643
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
@@ -1807,6 +1834,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1807
1834
|
// tool_use with no tool_use block).
|
|
1808
1835
|
let contentBlocks = (msg.content || []).slice();
|
|
1809
1836
|
|
|
1837
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1838
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1839
|
+
|
|
1810
1840
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1811
1841
|
// users can see which tier/provider/model handled the request inside
|
|
1812
1842
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
@@ -1969,6 +1999,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1969
1999
|
}
|
|
1970
2000
|
|
|
1971
2001
|
metrics.recordResponse(result.status);
|
|
2002
|
+
// finalBody is usually a JSON string; Express defaults string bodies to
|
|
2003
|
+
// text/html, which Claude Code rejects as a malformed 200. Only default —
|
|
2004
|
+
// an upstream content-type copied from result.headers above still wins.
|
|
2005
|
+
if (!res.getHeader("Content-Type")) res.type("application/json");
|
|
1972
2006
|
res.status(result.status).send(finalBody);
|
|
1973
2007
|
} catch (error) {
|
|
1974
2008
|
next(error);
|