@flame0510/project-aether 1.5.2 → 1.6.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.
@@ -0,0 +1,322 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Everything known about one model: what it is, what it costs, what it can do and how it
5
+ * scores. Opened from the Gateway's model row and **fetched when it opens**
6
+ * (`GET /api/models/details`) — descriptions are long and one model is looked at at a
7
+ * time, so inlining them in `/api/models` would weigh down every page poll.
8
+ *
9
+ * Absent data is not shown at all: no "n/a" blocks, and no number without its source.
10
+ * Prices come from the owner of the model (`model-pricing.json`), specs, description and
11
+ * OpenRouter benchmarks from the generated `model-details.json`, and anything else from
12
+ * the catalogue's hand-written `info` block.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { Button, LoadingSpinner, Metric, Modal, ModalityIcons, Pill, PropertyList } from '../components/ui';
16
+ import { formatParams, formatPrice } from '../lib/model-format';
17
+
18
+ /** Mirrors lib/model-details.ts: a client component cannot import a module that reads files. */
19
+ interface DetailsView {
20
+ id: string;
21
+ name: string;
22
+ provider: string;
23
+ providerLabel: string;
24
+ modality?: string;
25
+ enabled: boolean;
26
+ deprecated: boolean;
27
+ price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
28
+ details: {
29
+ description?: string;
30
+ created?: string;
31
+ context?: number;
32
+ providerContext?: number;
33
+ maxOutput?: number;
34
+ tokenizer?: string;
35
+ instructType?: string;
36
+ /** Total parameter count, from the model's Hugging Face card (open weights only). */
37
+ params?: number;
38
+ paramsSource?: string;
39
+ /** Architecture facts from the model card (Hugging Face); open weights only. */
40
+ hf?: {
41
+ id: string;
42
+ total?: number;
43
+ byDtype?: Record<string, number>;
44
+ family?: string;
45
+ modelType?: string;
46
+ moe?: { experts?: number; perToken?: number; shared?: number };
47
+ layers?: number;
48
+ hidden?: number;
49
+ heads?: number;
50
+ kvHeads?: number;
51
+ vocab?: number;
52
+ context?: number;
53
+ vision?: boolean;
54
+ quantization?: string;
55
+ task?: string;
56
+ languages?: string[];
57
+ license?: string;
58
+ licenseName?: string;
59
+ published?: string;
60
+ updated?: string;
61
+ weightsBytes?: number;
62
+ tags?: string[];
63
+ downloads?: number;
64
+ likes?: number;
65
+ };
66
+ knowledgeCutoff?: string;
67
+ huggingFaceId?: string;
68
+ canonicalSlug?: string;
69
+ url?: string;
70
+ reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string };
71
+ supportedParameters?: string[];
72
+ moderated?: boolean;
73
+ benchmarks?: {
74
+ design_arena?: { arena?: string; category?: string; elo?: number; win_rate?: number; rank?: number }[];
75
+ artificial_analysis?: Record<string, number>;
76
+ };
77
+ } | null;
78
+ info: {
79
+ params?: string;
80
+ released?: string;
81
+ docUrl?: string;
82
+ knowledgeCutoff?: string;
83
+ benchmarks?: { name: string; value: number; source: string; asOf?: string }[];
84
+ notes?: string;
85
+ } | null;
86
+ asOf: string | null;
87
+ detailsSource: 'openrouter' | 'none';
88
+ }
89
+
90
+ const count = (n?: number): string | undefined => (typeof n === 'number' ? n.toLocaleString('en-US') : undefined);
91
+
92
+ /** "1,048,576" is unreadable in a small metric: 1.05M reads at a glance. */
93
+ function compactCount(n: number): string {
94
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
95
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 2)}M`;
96
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
97
+ return String(n);
98
+ }
99
+
100
+ /** "BF16 6.9B · FP8 314.4B" — the weights' precision mix, biggest first. */
101
+ function dtypeMix(byDtype: Record<string, number>): string {
102
+ return Object.entries(byDtype)
103
+ .filter(([, n]) => n > 1e6)
104
+ .sort((a, b) => b[1] - a[1])
105
+ .map(([k, n]) => `${k.replace('F8_E4M3', 'FP8').replace('F32', 'FP32')} ${formatParams(n)}`)
106
+ .join(' · ');
107
+ }
108
+ /** `parallel_tool_calls` reads better as words in a chip. */
109
+ const human = (p: string): string => p.replace(/_/g, ' ');
110
+
111
+ export default function ModelDetailsModal({ modelId, onClose }: { modelId: string; onClose: () => void }) {
112
+ const [view, setView] = useState<DetailsView | null>(null);
113
+ const [error, setError] = useState<string | null>(null);
114
+ const [fullDescription, setFullDescription] = useState(false);
115
+
116
+ useEffect(() => {
117
+ const controller = new AbortController();
118
+ setView(null);
119
+ setError(null);
120
+ fetch(`/api/models/details?id=${encodeURIComponent(modelId)}`, { signal: controller.signal })
121
+ .then(async (res) => {
122
+ const data = await res.json().catch(() => null);
123
+ if (!res.ok) throw new Error((data as { error?: string } | null)?.error || `HTTP ${res.status}`);
124
+ return data as DetailsView;
125
+ })
126
+ .then(setView)
127
+ .catch((e: unknown) => { if ((e as Error)?.name !== 'AbortError') setError((e as Error).message); });
128
+ return () => controller.abort();
129
+ }, [modelId]);
130
+
131
+ const d = view?.details ?? null;
132
+ const info = view?.info ?? null;
133
+ const hf = d?.hf ?? null;
134
+ const reasoning = d?.reasoning;
135
+ const arenas = d?.benchmarks?.design_arena ?? [];
136
+ const aa = d?.benchmarks?.artificial_analysis;
137
+ const released = info?.released ?? hf?.published ?? d?.created;
138
+
139
+ return (
140
+ <Modal open onClose={onClose} title={view ? view.name : modelId} maxWidth={720}>
141
+ {error ? (
142
+ <div style={{ fontSize: 12, color: 'var(--red)' }}>{error}</div>
143
+ ) : !view ? (
144
+ <LoadingSpinner label="Loading model details…" />
145
+ ) : (
146
+ <div style={{ display: 'grid', gap: 4, fontSize: 12 }}>
147
+ {/* Identity */}
148
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
149
+ <Pill>{view.provider}</Pill>
150
+ {view.deprecated && <Pill tone="warning">deprecated</Pill>}
151
+ {view.enabled ? <Pill tone="success">enabled</Pill> : <Pill>disabled</Pill>}
152
+ {view.modality && <ModalityIcons modality={view.modality} />}
153
+ </div>
154
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-dim)', overflowWrap: 'anywhere' }}>
155
+ {view.id}
156
+ </div>
157
+
158
+ {d?.description && (
159
+ <div style={{ margin: '8px 0 0' }}>
160
+ <p style={{
161
+ fontSize: 12, lineHeight: 1.5, color: 'var(--text)', margin: 0,
162
+ ...(fullDescription ? {} : { display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical' as const, overflow: 'hidden' }),
163
+ }}>{d.description}</p>
164
+ {d.description.length > 340 && (
165
+ <Button variant="ghost" size="sm" onClick={() => setFullDescription(!fullDescription)}>
166
+ {fullDescription ? 'Show less' : 'Show more'}
167
+ </Button>
168
+ )}
169
+ </div>
170
+ )}
171
+
172
+ {(info?.notes || view.deprecated) && (
173
+ <>
174
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Status</div>
175
+ <div style={{ fontSize: 11, color: 'var(--yellow)' }}>
176
+ {info?.notes ?? 'Upstream has retired this id: it still answers, but is redirected.'}
177
+ </div>
178
+ </>
179
+ )}
180
+
181
+ {/* Price */}
182
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Price</div>
183
+ <PropertyList rows={[
184
+ { label: 'Input / output', value: formatPrice(view.price) },
185
+ ...(view.price ? [{ label: 'Per 1M tokens, from', value: view.providerLabel, dim: true }] : []),
186
+ ]} />
187
+
188
+ {/* Architecture: the factory facts from the model card, open weights only */}
189
+ {hf && (hf.total || hf.moe || hf.layers) ? (
190
+ <>
191
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Architecture</div>
192
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(108px, 1fr))', gap: 8, marginBottom: 8 }}>
193
+ {hf.total ? <Metric size="sm" title="Parameters" value={formatParams(hf.total)} /> : null}
194
+ {hf.moe ? <Metric size="sm" title="Experts" value={`${count(hf.moe.experts) ?? '?'}${hf.moe.perToken ? ` · ${hf.moe.perToken}/tok` : ''}`} /> : null}
195
+ {hf.layers ? <Metric size="sm" title="Layers" value={hf.layers} /> : null}
196
+ {hf.context ? <Metric size="sm" title="Max context" value={`${compactCount(hf.context)} tok`} /> : null}
197
+ </div>
198
+ <PropertyList rows={[
199
+ ...(hf.family ? [{ label: 'Family', value: hf.family }] : []),
200
+ ...(hf.modelType ? [{ label: 'Model type', value: hf.modelType, dim: true }] : []),
201
+ ...(hf.hidden ? [{ label: 'Hidden size', value: count(hf.hidden) ?? '' }] : []),
202
+ ...(hf.heads ? [{ label: 'Attention heads', value: `${hf.heads}${hf.kvHeads && hf.kvHeads !== hf.heads ? ` (${hf.kvHeads} KV)` : ''}` }] : []),
203
+ ...(hf.vocab ? [{ label: 'Vocabulary', value: count(hf.vocab) ?? '' }] : []),
204
+ ...(hf.vision ? [{ label: 'Vision encoder', value: 'yes (multimodal)' }] : []),
205
+ ...(hf.quantization ? [{ label: 'Precision', value: hf.quantization.toUpperCase() }] : []),
206
+ ...(hf.weightsBytes ? [{ label: 'Weights on disk', value: `~${hf.weightsBytes >= 1e12 ? `${(hf.weightsBytes / 1e12).toFixed(1)} TB` : `${Math.round(hf.weightsBytes / 1e9)} GB`}` }] : []),
207
+ ...(hf.license ? [{ label: 'Licence', value: hf.license === 'other' ? 'custom — see the model card' : `${hf.license.toUpperCase()}${hf.licenseName ? ` (${hf.licenseName})` : ''}` }] : []),
208
+ ...(hf.byDtype ? [{ label: 'Weights by dtype', value: dtypeMix(hf.byDtype), dim: true }] : []),
209
+ ...(hf.task ? [{ label: 'Task', value: human(hf.task) }] : []),
210
+ ...(hf.languages?.length ? [{ label: 'Languages', value: hf.languages.join(', ') }] : []),
211
+ ...(hf.downloads || hf.likes ? [{ label: 'Popularity', value: `${count(hf.downloads) ?? '—'} downloads · ${count(hf.likes) ?? '—'} likes`, dim: true }] : []),
212
+ ]} />
213
+ <div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 4 }}>
214
+ From the model card on Hugging Face ({hf.id}).
215
+ </div>
216
+ </>
217
+ ) : null}
218
+
219
+ {/* Specs */}
220
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Specs</div>
221
+ <PropertyList rows={[
222
+ ...(released ? [{ label: 'Released', value: released }] : []),
223
+ ...(hf?.updated && hf.updated !== released ? [{ label: 'Card updated', value: hf.updated, dim: true }] : []),
224
+ ...(count(d?.context) ? [{ label: 'Context', value: `${count(d?.context)} tokens` }] : []),
225
+ ...(d?.providerContext && d.providerContext !== d.context ? [{ label: 'Provider limit', value: `${count(d.providerContext)} tokens`, dim: true }] : []),
226
+ ...(count(d?.maxOutput) ? [{ label: 'Max output', value: `${count(d?.maxOutput)} tokens` }] : []),
227
+ // The curated value can be more precise than the card (MoE total vs active);
228
+ // when there is none, the Hugging Face count is the honest number we have.
229
+ ...(info?.params
230
+ // The vendor's own framing (total/active), next to the card's total below:
231
+ // labelled, or the two numbers read as a contradiction.
232
+ ? [{ label: 'Parameters (vendor)', value: info.params }]
233
+ : []),
234
+ ...(info?.knowledgeCutoff || d?.knowledgeCutoff ? [{ label: 'Knowledge cutoff', value: info?.knowledgeCutoff ?? d?.knowledgeCutoff ?? '' }] : []),
235
+ ...(d?.tokenizer ? [{ label: 'Tokenizer', value: d.tokenizer, dim: true }] : []),
236
+ ...(d?.instructType ? [{ label: 'Instruct type', value: d.instructType, dim: true }] : []),
237
+ ...(d?.moderated ? [{ label: 'Moderated', value: 'yes' }] : []),
238
+ ]} />
239
+
240
+ {/* Reasoning */}
241
+ {reasoning && (
242
+ <>
243
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Reasoning</div>
244
+ <PropertyList rows={[
245
+ { label: 'Mode', value: reasoning.mandatory ? 'always reasons' : reasoning.default_enabled ? 'on by default' : 'optional' },
246
+ ...(reasoning.default_effort ? [{ label: 'Default effort', value: reasoning.default_effort }] : []),
247
+ ...(reasoning.supported_efforts?.length ? [{ label: 'Efforts', value: reasoning.supported_efforts.join(', ') }] : []),
248
+ ]} />
249
+ </>
250
+ )}
251
+
252
+ {/* Capabilities */}
253
+ {(d?.supportedParameters?.length || hf?.tags?.length) ? (
254
+ <>
255
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 6px' }}>Capabilities</div>
256
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
257
+ {[...new Set([...(d?.supportedParameters ?? []), ...(hf?.tags ?? [])])].map((c) => <Pill key={c}>{human(c)}</Pill>)}
258
+ </div>
259
+ </>
260
+ ) : null}
261
+
262
+ {/* Benchmarks: OpenRouter's, then the curated ones */}
263
+ {aa || arenas.length > 0 || info?.benchmarks?.length ? (
264
+ <>
265
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '14px 0 6px' }}>Benchmarks</div>
266
+ {aa && (
267
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 8, marginBottom: 8 }}>
268
+ {Object.entries(aa).map(([k, v]) => <Metric key={k} title={human(k)} value={v} />)}
269
+ </div>
270
+ )}
271
+ {arenas.length > 0 && (
272
+ <div style={{ display: 'grid', gap: 2 }}>
273
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 60px', gap: 6, fontSize: 10, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
274
+ <span>Design arena</span><span style={{ textAlign: 'right' }}>Elo</span><span style={{ textAlign: 'right' }}>Rank</span><span style={{ textAlign: 'right' }}>Win rate</span>
275
+ </div>
276
+ {arenas.map((a, i) => (
277
+ <div key={`${a.category ?? i}`} style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 60px', gap: 6, fontSize: 11 }}>
278
+ <span>{human(String(a.category ?? a.arena ?? '—'))}</span>
279
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.elo ?? '—'}</span>
280
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.rank ? `#${a.rank}` : '—'}</span>
281
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.win_rate !== undefined ? `${a.win_rate}%` : '—'}</span>
282
+ </div>
283
+ ))}
284
+ <div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>OpenRouter arenas (community votes), as of the file's date below.</div>
285
+ </div>
286
+ )}
287
+ {info?.benchmarks?.length ? (
288
+ <div style={{ display: 'grid', gap: 3, marginTop: 8 }}>
289
+ {info.benchmarks.map((b) => (
290
+ <div key={b.name} style={{ fontSize: 11 }}>
291
+ <span style={{ fontFamily: 'var(--font-mono)' }}>{b.value}</span>
292
+ <span style={{ marginLeft: 6 }}>{b.name}</span>
293
+ <a href={b.source} target="_blank" rel="noreferrer" style={{ marginLeft: 6, color: 'var(--violet)', fontSize: 10 }}>source</a>
294
+ {b.asOf && <span style={{ marginLeft: 6, color: 'var(--text-dim)', fontSize: 10 }}>({b.asOf})</span>}
295
+ </div>
296
+ ))}
297
+ </div>
298
+ ) : null}
299
+ </>
300
+ ) : null}
301
+
302
+ {/* Links + provenance */}
303
+ <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginTop: 14, fontSize: 10, color: 'var(--text-dim)' }}>
304
+ {d?.url && <a href={d.url} target="_blank" rel="noreferrer" style={{ color: 'var(--violet)' }}>OpenRouter page</a>}
305
+ {d?.huggingFaceId && <span>HF: {d.huggingFaceId}</span>}
306
+ {info?.docUrl && <a href={info.docUrl} target="_blank" rel="noreferrer" style={{ color: 'var(--violet)' }}>Vendor docs</a>}
307
+ </div>
308
+ <div style={{ marginTop: 6, fontSize: 10, color: 'var(--text-dim)' }}>
309
+ {view.detailsSource === 'openrouter'
310
+ ? `Specs and benchmarks via OpenRouter${d?.paramsSource === 'huggingface' ? ', size via Hugging Face' : ''}`
311
+ : 'No generated details for this model'}
312
+ {view.asOf ? ` · data as of ${view.asOf.slice(0, 10)}` : ''}
313
+ </div>
314
+
315
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
316
+ <Button variant="secondary" size="sm" onClick={onClose}>Close</Button>
317
+ </div>
318
+ </div>
319
+ )}
320
+ </Modal>
321
+ );
322
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface } from '../components/ui';
5
+ import ModelDetailsModal from './ModelDetailsModal';
6
+ import { formatParams, formatPrice } from '../lib/model-format';
5
7
  import PasswordInput from '../components/PasswordInput';
6
8
  import { Skeleton } from '../components/Skeleton';
7
9
  import { useModels } from '../lib/models-context';
@@ -19,6 +21,8 @@ interface ModelInfo {
19
21
  pricingLive?: boolean;
20
22
  /** e.g. "text+image+file->text" — curated, matches what sync.ts provisions. */
21
23
  modality?: string;
24
+ /** Total parameters, when the model's card publishes them (open weights only). */
25
+ params?: number;
22
26
  }
23
27
 
24
28
  interface GatewayProvider {
@@ -89,6 +93,8 @@ export default function GatewayPageClient() {
89
93
 
90
94
  // Balance fetch state (per provider)
91
95
  const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
96
+ /** Model whose details modal is open, null when none. */
97
+ const [detailsId, setDetailsId] = useState<string | null>(null);
92
98
  const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
93
99
 
94
100
  // Accordion state — which provider cards are expanded
@@ -155,14 +161,8 @@ export default function GatewayPageClient() {
155
161
 
156
162
  useEffect(() => { void loadAll(); }, [loadAll]);
157
163
 
158
- /** Format pricing for display: "$0.44 / $0.87" or "Free" or "Dynamic" or "—" */
159
- function fmtPrice(p: { input: number; output: number } | null | undefined): string {
160
- if (!p) return '—';
161
- if (p.input === -1 && p.output === -1) return 'Dynamic';
162
- if (p.input === 0 && p.output === 0) return 'Free';
163
- const f = (n: number) => n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`;
164
- return `${f(p.input)} / ${f(p.output)}`;
165
- }
164
+ /** Format pricing for display — shared with the details modal (app/lib/model-format.ts). */
165
+ const fmtPrice = formatPrice;
166
166
 
167
167
  /**
168
168
  * Local model filter. Matches against both the display name and the id,
@@ -430,7 +430,7 @@ export default function GatewayPageClient() {
430
430
  </section>
431
431
 
432
432
  {/* =========================================================== */}
433
- {/* REV4A API KEY — sezione speciale */}
433
+ {/* REV4A API KEY — special section */}
434
434
  {/* =========================================================== */}
435
435
  <div style={{ marginBottom: 20 }}>
436
436
  <Surface variant="panel">
@@ -658,6 +658,14 @@ export default function GatewayPageClient() {
658
658
  <span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
659
659
  {fmtPrice(pricing)}
660
660
  </span>
661
+ {typeof m.params === 'number' && (
662
+ <span
663
+ style={{ marginLeft: 8, color: 'var(--text-dim)' }}
664
+ title={`${m.params.toLocaleString('en-US')} parameters (Hugging Face)`}
665
+ >
666
+ {formatParams(m.params)}
667
+ </span>
668
+ )}
661
669
  {m.pricingLive && (
662
670
  <span
663
671
  title="Live price from the OpenRouter API"
@@ -668,6 +676,15 @@ export default function GatewayPageClient() {
668
676
  )}
669
677
  </div>
670
678
  </div>
679
+ <Button
680
+ variant="ghost"
681
+ size="sm"
682
+ aria-label={`Details for ${m.name}`}
683
+ title="Details"
684
+ onClick={() => setDetailsId(m.id)}
685
+ >
686
+ ⋯
687
+ </Button>
671
688
  {modelMsg && (
672
689
  // Its own full-width line under the name, not a third column. As an
673
690
  // unconstrained flex item a long sync summary took its natural width
@@ -710,6 +727,7 @@ export default function GatewayPageClient() {
710
727
  cancelLabel="Cancel"
711
728
  tone="danger"
712
729
  />
713
- </Page>
730
+ {detailsId && <ModelDetailsModal modelId={detailsId} onClose={() => setDetailsId(null)} />}
731
+ </Page>
714
732
  );
715
733
  }
package/app/globals.css CHANGED
@@ -615,6 +615,9 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
615
615
  .ui-tone--danger { border-color: #7f1d1d; }
616
616
  .ui-muted { color: var(--text-dim); }
617
617
  .ui-metric-value { margin-top: 14px; font-size: 26px; font-weight: 800; color: var(--text); }
618
+ /* Compact metric: for grids inside dialogs, where the card value would wrap. */
619
+ .ui-metric--sm .ui-metric-value { margin-top: 6px; font-size: 15px; font-weight: 700; line-height: 1.25; word-break: break-word; }
620
+ .ui-metric--sm .ui-kicker { font-size: 9px; }
618
621
  .ui-tone--danger .ui-metric-value { color: var(--red); }
619
622
  .ui-metric-subtitle { margin-top: 8px; font-size: 11px; }
620
623
  .ui-card-head { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
@@ -1386,7 +1389,7 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
1386
1389
 
1387
1390
  /* Padding bottom per non coprire contenuti — handled by .app-shell__content */
1388
1391
 
1389
- /* Chat button alzato sopra la bottom navbar */
1392
+ /* Chat button raised above the bottom navbar */
1390
1393
  .ochat__trigger {
1391
1394
  bottom: 70px !important;
1392
1395
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Formatting shared by the Gateway model row and the details modal, so the same number
3
+ * never reads two ways in the same page.
4
+ */
5
+
6
+ /** "1.60T", "321.3B", "9.7B", "300M" — the size people actually ask about. */
7
+ export function formatParams(n: number): string {
8
+ if (n >= 1e12) return `${(n / 1e12).toFixed(2)}T`;
9
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
10
+ if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`;
11
+ return String(n);
12
+ }
13
+
14
+ /** USD per 1M tokens: "$0.15 / $0.50", or "Free", "Dynamic", "—". */
15
+ export function formatPrice(p: { input: number; output: number } | null | undefined): string {
16
+ if (!p) return '—';
17
+ if (p.input === -1 && p.output === -1) return 'Dynamic';
18
+ if (p.input === 0 && p.output === 0) return 'Free';
19
+ const f = (n: number) => (n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`);
20
+ return `${f(p.input)} / ${f(p.output)}`;
21
+ }
@@ -36,7 +36,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
36
36
  | `TemplateOption` | `TemplateOption.tsx` | Agent template selection card with avatar, description, optional badge. Centralises create-agent card styling. |
37
37
  | `Pill` | `Pill.tsx` | Small status/attribute label. Variants: `default`, `accent`. |
38
38
  | `ModalityIcons` | `ModalityIcons.tsx` | Capability badges parsed from a model's `modality` string (`"text+image->text"`). Input types render as icons; a non-text **output** is called out separately, since reading an image and generating one are different capabilities. Pass `dynamic` for router models, which advertise the union of everything they might route to and so show "varies" instead. |
39
- | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone. |
39
+ | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone, and a `size` (`md` default, `sm` for a grid inside a dialog: smaller value, tighter spacing). |
40
40
  | `StatusCard` | `StatusCard.tsx` | Health/status report card. |
41
41
  | `Surface` | `Surface.tsx` | Shared panel/card surface, variant prop. |
42
42
  | `Page` / `PageHeader` | `Page.tsx` | Full-page layout shell. |
@@ -51,6 +51,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
51
51
  | `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). When that store cannot be read the panel shows the reason instead of "No approved senders" (`PairingState.error`), so an empty list is never a guess. Polls pairings every 5 s while open; one keyed busy state per action. |
52
52
  | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
53
53
  | `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
54
+ | `ModelDetailsModal` | `app/gateway/ModelDetailsModal.tsx` | Details for one model, opened by the `⋯` button on a Gateway model row (`aria-label="Details for <name>"`). Fetches `GET /api/models/details?id=` **when it opens** (AbortController, shared spinner, error text) instead of inlining descriptions in the polled list. Sections appear only when their source has data: identity (provider, modality through the shared `ModalityIcons`, enabled and deprecated chips, the id), price with its origin (the provider's display label, from `lib/provider-labels.ts`), specs (release date, context and the provider's own limit when it differs, max output, knowledge cutoff, tokenizer, instruct type, moderation, **Architecture** (a metric row — parameters, experts, layers, max context — over the facts from the model card: family, model type, hidden size, attention heads with KV heads, vocabulary, vision encoder, precision and the weight mix by dtype, task, languages, popularity, attributed to Hugging Face), the description truncated to four lines with a **Show more** toggle, reasoning (mode, default effort, efforts), capabilities as `Pill`s, benchmarks (Artificial Analysis indices as `Metric`s, the design-arena table, then the curated ones with source and date), status (deprecated + notes) and a footer with the generated part's date. Never shows "n/a": an absent field is an absent block. |
54
55
  | `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
55
56
  | `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
56
57
  | `useWizard` | `app/wizard/useWizard.ts` | Shared hook: wizard state, step transitions, provider save, restart. Receives server-side initial state to avoid a loading flash. |
@@ -316,6 +316,38 @@ Rev4a Provider Gateway — proxy chat completions to the correct upstream.
316
316
 
317
317
  ---
318
318
 
319
+ ### `GET /api/models/details?id=<model id>`
320
+ Everything the details modal shows for one model, and nothing more: fields the sources do
321
+ not have are **absent**, never `null` and never a guess.
322
+
323
+ **Auth:** browser cookie or bearer token
324
+
325
+ **Response:**
326
+ ```json
327
+ {
328
+ "id": "glm/glm-5.3-flash", "name": "GLM-5.3 Flash", "provider": "glm",
329
+ "modality": "text+image+video->text", "enabled": false, "deprecated": false,
330
+ "price": { "input": 0.15, "output": 0.5, "source": "vendor" },
331
+ "details": { "description": "…", "created": "2026-08-26", "context": 1310720,
332
+ "providerContext": 1048576, "maxOutput": 943718,
333
+ "reasoning": { "mandatory": true, "default_effort": "max", "supported_efforts": ["max","high","low"] },
334
+ "supportedParameters": ["tools","reasoning","…"],
335
+ "benchmarks": { "design_arena": [ … ], "artificial_analysis": { … } },
336
+ "huggingFaceId": "zai-org/GLM-5.3-Flash", "url": "https://openrouter.ai/…" },
337
+ "info": null,
338
+ "asOf": "2026-09-23T14:02:40.271Z",
339
+ "detailsSource": "openrouter"
340
+ }
341
+ ```
342
+
343
+ `price.source` is `openrouter` for an `openrouter/*` entry and `vendor` for a direct one,
344
+ which keeps prices honest: the refresh script never writes a vendor price. `details` comes
345
+ from the generated `model-details.json` (`npm run refresh:pricing`, see
346
+ `docs/dev/GATEWAY.md`), `info` from the catalogue's hand-written block, and `asOf` is when
347
+ the generated part was produced. An id the catalogue does not carry answers `404`.
348
+
349
+ **Errors:** `400` missing `id`, `404` unknown model.
350
+
319
351
  ### `GET /api/models`
320
352
  Models eligible to be used: the catalogue filtered down to models that are enabled
321
353
  (after overrides) and whose provider has a key configured, grouped by provider. Feeds the model pickers in the agent's
@@ -2564,8 +2596,11 @@ from local state only.
2564
2596
 
2565
2597
  **Response:** `{ "installed": "1.4.2", "latest": "1.5.0", "updateAvailable": true, "package": "@flame0510/project-aether", "log": "update.log" }`
2566
2598
 
2567
- `log` is the file name, in the Rev4a data directory, where the update's output is
2568
- written.
2599
+ `installed` is `null` when `package.json` cannot be read — during an update npm rewrites
2600
+ it, and a transient failure must not look like a version. With `installed` unknown there
2601
+ is nothing to compare, so `latest` is `null` and `updateAvailable` false. `installed` is
2602
+ never a placeholder like `0.0.0`. `log` is the file name, in the Rev4a data directory,
2603
+ where the update's output is written.
2569
2604
 
2570
2605
  ### `POST /api/update-check`
2571
2606
  Run `rev4a update` as a detached background process. Nothing here restarts anything:
@@ -148,6 +148,64 @@ for anyone cloning the project. It defines all known models across all providers
148
148
  Only enabled models for providers with a configured API key are synced to agent
149
149
  containers.
150
150
 
151
+ Provider display names come from `lib/provider-labels.ts`: the Gateway's cards and the
152
+ details modal read the same map, so a provider is never called two things in one page.
153
+
154
+ ### Model details (the details modal)
155
+
156
+ The Gateway model row has a **⋯ Details** button opening `ModelDetailsModal`
157
+ (`app/gateway/ModelDetailsModal.tsx`), fed by `GET /api/models/details?id=`. Three
158
+ sources, each one labelled in the modal:
159
+
160
+ | Source | File | Nature |
161
+ |---|---|---|
162
+ | Identity, enabled, modality, deprecation | `models.config.json` | curated by hand |
163
+ | Price | `model-pricing.json` | refreshed for `openrouter/*`, hand for direct vendors |
164
+ | Description, specs, benchmarks | `model-details.json` | **generated**, never hand-edited |
165
+
166
+ `npm run refresh:pricing` (script `scripts/refresh-model-pricing.mjs`) fetches the
167
+ OpenRouter catalogue once and writes both the prices and `model-details.json`:
168
+ description, release date (`created`), context and the provider's own limit, max output,
169
+ tokenizer, instruct type, knowledge cutoff, Hugging Face id, reasoning (mandatory, default
170
+ and supported efforts), supported parameters, and the benchmarks OpenRouter publishes
171
+ (design arenas with elo/rank/win-rate, Artificial Analysis indices).
172
+
173
+ **Size and architecture** come from a second source: for every entry with a
174
+ `hugging_face_id` the script asks the Hugging Face API (`safetensors.total` and the
175
+ per-dtype breakdown, the task, the languages, downloads and likes) **and the model's
176
+ `config.json`** — the API's summary config is reduced, the raw file is what carries the
177
+ architecture. That covers the **open-weight** models (DeepSeek, GLM, Kimi, Qwen, Llama,
178
+ StepFun…); a closed model has no card, so it gets no `params` and no `hf` block, and the
179
+ modal hides those rows rather than estimating. `--no-hf` skips the lookups.
180
+
181
+ Stored per model in an `hf` object: the architecture class and type, the mixture of
182
+ experts (`experts`, per token, shared), layers, hidden size, attention heads (and KV
183
+ heads, since MQA/GQA change what serving costs), vocabulary, the model's own max context,
184
+ whether it has a **vision encoder** (multimodal models nest their language model under
185
+ `text_config` and the encoder under `vision_config`), the precision (`fp8`,
186
+ `compressed-tensors`…) and the weight mix by dtype, the task, the languages and the
187
+ popularity. For a mixture-of-experts model the card's number is the **total**; an *active*
188
+ count is a derivation, so the modal shows the facts (experts, per token) and the curated
189
+ `info.params` free text can add the vendor's own "355B total, ~32B active". A direct entry is
190
+ matched through the vendor alias map (`glm` → `z-ai`, `kimi` → `moonshotai`) for the data
191
+ only — **its price stays the vendor's own**, and the modal says which is which.
192
+
193
+ Anything OpenRouter does not publish is curated in the catalogue as an optional `info`
194
+ block, and every number there carries its source:
195
+
196
+ ```json
197
+ "info": {
198
+ "params": "~1.8T MoE (active ~40B)",
199
+ "docUrl": "https://docs.z.ai/guides/vlm/glm-5.3-flash",
200
+ "knowledgeCutoff": "2026-03",
201
+ "benchmarks": [ { "name": "SWE-bench Verified", "value": 78.4, "source": "https://…", "asOf": "2026-08-26" } ],
202
+ "notes": "Native multimodal; 3x the coding-plan quota."
203
+ }
204
+ ```
205
+
206
+ No generated field ever replaces a curated one silently: prices are separate files, and the
207
+ modal shows the generated part's date (`asOf`).
208
+
151
209
  ---
152
210
 
153
211
  ## Per-agent model assignment
@@ -124,7 +124,7 @@ function formatBytesForWarning(value: number): string {
124
124
  function applyBootstrapSizeWarnings(file: FileSummary): void {
125
125
  const limit = BOOTSTRAP_FILE_WARN_BYTES[file.key as BootstrapFileName];
126
126
  if (!limit || file.size === null || file.size <= limit) return;
127
- file.warnings.push(`${file.key} sopra soglia: ${formatBytesForWarning(file.size)} / ${formatBytesForWarning(limit)}`);
127
+ file.warnings.push(`${file.key} above threshold: ${formatBytesForWarning(file.size)} / ${formatBytesForWarning(limit)}`);
128
128
  }
129
129
 
130
130
  export function inspectFile(filePath: string, key: string): FileSummary {
@@ -334,7 +334,7 @@ function inspectAgent(seed: AgentSeed): AgentMemorySummary {
334
334
 
335
335
  summary.strategy = classifyAgent(summary);
336
336
  if (bootstrapBytes > WORKSPACE_TOTAL_WARN_BYTES) {
337
- summary.warnings.push(`Bootstrap totale sopra budget: ${formatBytesForWarning(bootstrapBytes)} / ${formatBytesForWarning(WORKSPACE_TOTAL_WARN_BYTES)}`);
337
+ summary.warnings.push(`Bootstrap total above budget: ${formatBytesForWarning(bootstrapBytes)} / ${formatBytesForWarning(WORKSPACE_TOTAL_WARN_BYTES)}`);
338
338
  }
339
339
  summary.warnings = uniqueStrings(summary.warnings.concat(...Object.values(files).map((file) => file.warnings)));
340
340
  return summary;