@flame0510/project-aether 1.5.1 → 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.
- package/app/agents/ChannelManager.tsx +17 -5
- package/app/agents/PageClient.tsx +13 -3
- package/app/api/agents/download-image/route.ts +8 -0
- package/app/api/gateway/provider/route.ts +16 -10
- package/app/api/models/details/route.ts +30 -0
- package/app/api/system-health/route.ts +3 -3
- package/app/api/update-check/route.ts +23 -10
- package/app/components/SessionDrawer.tsx +4 -4
- package/app/components/VersionBanner.tsx +50 -33
- package/app/components/ui/Metric.tsx +7 -2
- package/app/gateway/ModelDetailsModal.tsx +322 -0
- package/app/gateway/PageClient.tsx +28 -10
- package/app/globals.css +4 -1
- package/app/lib/model-format.ts +21 -0
- package/bin/rev4a.js +6 -3
- package/daemon.js +3 -3
- package/docs/ARCHITECTURE.md +25 -3
- package/docs/FRONTEND-ARCHITECTURE.md +5 -3
- package/docs/REV4A.md +17 -0
- package/docs/dev/API-REFERENCE.md +41 -3
- package/docs/dev/GATEWAY.md +58 -0
- package/docs/rag/REV4A-OVERVIEW.md +7 -1
- package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
- package/instrumentation.ts +11 -0
- package/lib/agent-edit-state.ts +25 -66
- package/lib/agent-job-state.ts +145 -0
- package/lib/agent-jobs-maintenance.ts +34 -0
- package/lib/agent-recreate-state.ts +24 -65
- package/lib/agent-restore-state.ts +25 -66
- package/lib/agent-restore.ts +28 -5
- package/lib/agent-update-state.ts +25 -61
- package/lib/channelManager.ts +12 -1
- package/lib/memory-context.ts +2 -2
- package/lib/model-catalogue.ts +17 -0
- package/lib/model-details.ts +124 -0
- package/lib/provider-labels.ts +21 -0
- package/model-details.json +16360 -0
- package/model-pricing.json +260 -24
- package/models.config.json +494 -10
- package/package.json +4 -2
- package/scripts/check-language.mjs +76 -0
- package/scripts/lib/model-upstream.mjs +61 -0
- package/scripts/model-info-suggest.mjs +149 -0
- package/scripts/refresh-model-pricing.mjs +180 -21
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Persisted state of agent restores: the `agent_restores` table (lib/db-bootstrap.mjs).
|
|
3
3
|
*
|
|
4
4
|
* Kept apart from lib/agent-restore.ts so busy checks (lib/agent-busy.ts) can ask which
|
|
5
|
-
* agents are being restored without importing the restore action.
|
|
5
|
+
* agents are being restored without importing the restore action. The machinery is
|
|
6
|
+
* lib/agent-job-state.ts; this file holds the table's types and names.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
+
import { createJobState } from '@/lib/agent-job-state';
|
|
8
9
|
|
|
9
10
|
export type RestoreStatus = 'restoring' | 'done' | 'failed' | 'interrupted';
|
|
10
11
|
|
|
@@ -24,84 +25,42 @@ export interface AgentRestoreRow {
|
|
|
24
25
|
|
|
25
26
|
const FINAL: readonly RestoreStatus[] = ['done', 'failed', 'interrupted'];
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return Number(info.lastInsertRowid);
|
|
36
|
-
} finally {
|
|
37
|
-
db.close();
|
|
38
|
-
}
|
|
39
|
-
}
|
|
28
|
+
const state = createJobState({
|
|
29
|
+
table: 'agent_restores',
|
|
30
|
+
initialStatus: 'restoring',
|
|
31
|
+
activeStatuses: ACTIVE_RESTORE_STATUSES,
|
|
32
|
+
finalStatuses: FINAL,
|
|
33
|
+
insertColumns: ['file'],
|
|
34
|
+
writableColumns: [],
|
|
35
|
+
});
|
|
40
36
|
|
|
41
37
|
type Writable = Partial<Pick<AgentRestoreRow, 'status' | 'error'>>;
|
|
42
38
|
|
|
39
|
+
export function insertRestore(agentId: string, file: string): number {
|
|
40
|
+
return state.insert(agentId, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
43
|
export function updateRestoreRow(id: number, fields: Writable): void {
|
|
44
|
-
|
|
45
|
-
const now = Date.now();
|
|
46
|
-
const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
|
|
47
|
-
const values: unknown[] = [...entries.map(([, v]) => v), now];
|
|
48
|
-
if (fields.status && FINAL.includes(fields.status)) {
|
|
49
|
-
sets.push('finished_at = ?');
|
|
50
|
-
values.push(now);
|
|
51
|
-
} else if (fields.status) {
|
|
52
|
-
sets.push('finished_at = NULL');
|
|
53
|
-
}
|
|
54
|
-
const db = openDb(false);
|
|
55
|
-
try {
|
|
56
|
-
db.prepare(`UPDATE agent_restores SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
57
|
-
} finally {
|
|
58
|
-
db.close();
|
|
59
|
-
}
|
|
44
|
+
state.update(id, fields);
|
|
60
45
|
}
|
|
61
46
|
|
|
62
47
|
export function latestRestore(agentId: string): AgentRestoreRow | null {
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
return (db.prepare('SELECT * FROM agent_restores WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRestoreRow | undefined) ?? null;
|
|
66
|
-
} finally {
|
|
67
|
-
db.close();
|
|
68
|
-
}
|
|
48
|
+
return state.latest<AgentRestoreRow>(agentId);
|
|
69
49
|
}
|
|
70
50
|
|
|
71
51
|
export function isRestoreActive(agentId: string): boolean {
|
|
72
|
-
|
|
73
|
-
return !!row && ACTIVE_RESTORE_STATUSES.includes(row.status);
|
|
52
|
+
return state.isActive(agentId);
|
|
74
53
|
}
|
|
75
54
|
|
|
76
|
-
/** AGENT_IDs with a restore in an active status. */
|
|
77
55
|
export function activeRestoreAgentIds(): Set<string> {
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const placeholders = ACTIVE_RESTORE_STATUSES.map(() => '?').join(', ');
|
|
81
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_restores WHERE status IN (${placeholders})`).all(...ACTIVE_RESTORE_STATUSES) as { agent_id: string }[];
|
|
82
|
-
return new Set(rows.map((r) => r.agent_id));
|
|
83
|
-
} finally {
|
|
84
|
-
db.close();
|
|
85
|
-
}
|
|
56
|
+
return state.activeAgentIds();
|
|
86
57
|
}
|
|
87
58
|
|
|
88
|
-
/**
|
|
89
|
-
* At startup no restore job can be running: a row still active was cut off by the
|
|
90
|
-
* restart. It becomes `interrupted`, keeping the archive it was restoring.
|
|
91
|
-
*/
|
|
92
59
|
export function markInterruptedRestores(): number {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
|
|
100
|
-
status = 'interrupted', updated_at = ?, finished_at = ?
|
|
101
|
-
WHERE status IN (${placeholders})`,
|
|
102
|
-
).run(now, now, ...ACTIVE_RESTORE_STATUSES);
|
|
103
|
-
return info.changes;
|
|
104
|
-
} finally {
|
|
105
|
-
db.close();
|
|
106
|
-
}
|
|
60
|
+
return state.markInterrupted();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Keep the newest `keepPerAgent` restores of each agent; the rest is history. */
|
|
64
|
+
export function pruneRestores(keepPerAgent: number): number {
|
|
65
|
+
return state.prune(keepPerAgent);
|
|
107
66
|
}
|
package/lib/agent-restore.ts
CHANGED
|
@@ -123,19 +123,42 @@ export function agentRestoreView(agentId: string): AgentRestoreView | null {
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* At startup no restore job can be running: rows still active were cut off by the
|
|
126
|
-
* restart.
|
|
127
|
-
*
|
|
126
|
+
* restart. A restore interrupted **mid-extract** leaves a half-written volume, so
|
|
127
|
+
* starting the container would present a partial workspace as if it were the restore: the
|
|
128
|
+
* extract is run again from the same archive (it is the source of truth and the file was
|
|
129
|
+
* checked before the job started), in the background, and the row lands in its final
|
|
130
|
+
* status. When the archive is gone the container is started on whatever the volume holds,
|
|
131
|
+
* and the row says so.
|
|
128
132
|
*/
|
|
129
133
|
export async function recoverInterruptedRestores(): Promise<number> {
|
|
130
134
|
const ids = activeRestoreAgentIds();
|
|
131
135
|
if (ids.size === 0) return 0;
|
|
132
136
|
const interrupted = markInterruptedRestores();
|
|
133
137
|
for (const agentId of ids) {
|
|
134
|
-
const
|
|
135
|
-
const name =
|
|
136
|
-
if (
|
|
138
|
+
const row = latestRestore(agentId);
|
|
139
|
+
const name = (await inspectAgentContainer(agentId).catch(() => null))?.Name?.replace(/^\//, '');
|
|
140
|
+
if (row?.file && (await backupFileExists(agentId, row.file))) {
|
|
141
|
+
console.log(`[agent:restore] ${agentId}: re-running the interrupted restore from ${row.file}`);
|
|
142
|
+
void runRestore(row.id, agentId, row.file).finally(() => undefined);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!name) continue;
|
|
137
146
|
const started = await startAndVerifyContainer(agentId, name);
|
|
138
147
|
if (!started.ok) console.warn(`[agent:restore] ${agentId} did not come back after an interrupted restore: ${started.error}`);
|
|
139
148
|
}
|
|
140
149
|
return interrupted;
|
|
141
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Whether the archive still exists in the backup volume. */
|
|
153
|
+
async function backupFileExists(agentId: string, file: string): Promise<boolean> {
|
|
154
|
+
if (!isValidBackupFile(agentId, file)) return false;
|
|
155
|
+
try {
|
|
156
|
+
await runDocker(
|
|
157
|
+
['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'test -f "/backup/$FILE"'],
|
|
158
|
+
{ timeoutMs: 30_000, env: { FILE: file } },
|
|
159
|
+
);
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Persisted state of agent updates: the `agent_upgrades` table (lib/db-bootstrap.mjs).
|
|
3
3
|
*
|
|
4
4
|
* Kept apart from lib/agent-update.ts so the provider sync can ask which agents are
|
|
5
|
-
* being updated without importing the update action, which imports the sync.
|
|
5
|
+
* being updated without importing the update action, which imports the sync. The
|
|
6
|
+
* machinery is lib/agent-job-state.ts; this file holds the table's types and names.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
+
import { createJobState } from '@/lib/agent-job-state';
|
|
8
9
|
|
|
9
10
|
export type UpdateStatus =
|
|
10
11
|
| 'pending'
|
|
@@ -38,65 +39,36 @@ export interface AgentUpdateRow {
|
|
|
38
39
|
|
|
39
40
|
const FINAL: readonly UpdateStatus[] = ['done', 'failed', 'interrupted', 'rolled_back', 'rollback_failed'];
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return Number(info.lastInsertRowid);
|
|
50
|
-
} finally {
|
|
51
|
-
db.close();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
42
|
+
const state = createJobState({
|
|
43
|
+
table: 'agent_upgrades',
|
|
44
|
+
initialStatus: 'pending',
|
|
45
|
+
activeStatuses: ACTIVE_STATUSES,
|
|
46
|
+
finalStatuses: FINAL,
|
|
47
|
+
insertColumns: ['from_version', 'to_version'],
|
|
48
|
+
writableColumns: ['backup_file', 'baseline_json', 'verify_json'],
|
|
49
|
+
});
|
|
54
50
|
|
|
55
51
|
type Writable = Partial<Pick<AgentUpdateRow, 'status' | 'backup_file' | 'baseline_json' | 'verify_json' | 'error'>>;
|
|
56
52
|
|
|
53
|
+
export function insertUpdate(agentId: string, fromVersion: string | null, toVersion: string): number {
|
|
54
|
+
return state.insert(agentId, fromVersion, toVersion);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
57
|
export function updateRow(id: number, fields: Writable): void {
|
|
58
|
-
|
|
59
|
-
const now = Date.now();
|
|
60
|
-
const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
|
|
61
|
-
const values: unknown[] = [...entries.map(([, v]) => v), now];
|
|
62
|
-
if (fields.status && FINAL.includes(fields.status)) {
|
|
63
|
-
sets.push('finished_at = ?');
|
|
64
|
-
values.push(now);
|
|
65
|
-
} else if (fields.status) {
|
|
66
|
-
sets.push('finished_at = NULL');
|
|
67
|
-
}
|
|
68
|
-
const db = openDb(false);
|
|
69
|
-
try {
|
|
70
|
-
db.prepare(`UPDATE agent_upgrades SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
71
|
-
} finally {
|
|
72
|
-
db.close();
|
|
73
|
-
}
|
|
58
|
+
state.update(id, fields);
|
|
74
59
|
}
|
|
75
60
|
|
|
76
61
|
export function latestUpdate(agentId: string): AgentUpdateRow | null {
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
return (db.prepare('SELECT * FROM agent_upgrades WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentUpdateRow | undefined) ?? null;
|
|
80
|
-
} finally {
|
|
81
|
-
db.close();
|
|
82
|
-
}
|
|
62
|
+
return state.latest<AgentUpdateRow>(agentId);
|
|
83
63
|
}
|
|
84
64
|
|
|
85
65
|
export function isUpdateActive(agentId: string): boolean {
|
|
86
|
-
|
|
87
|
-
return !!row && ACTIVE_STATUSES.includes(row.status);
|
|
66
|
+
return state.isActive(agentId);
|
|
88
67
|
}
|
|
89
68
|
|
|
90
69
|
/** AGENT_IDs with an update in an active status. */
|
|
91
70
|
export function activeUpdateAgentIds(): Set<string> {
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const placeholders = ACTIVE_STATUSES.map(() => '?').join(', ');
|
|
95
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_upgrades WHERE status IN (${placeholders})`).all(...ACTIVE_STATUSES) as { agent_id: string }[];
|
|
96
|
-
return new Set(rows.map((r) => r.agent_id));
|
|
97
|
-
} finally {
|
|
98
|
-
db.close();
|
|
99
|
-
}
|
|
71
|
+
return state.activeAgentIds();
|
|
100
72
|
}
|
|
101
73
|
|
|
102
74
|
/**
|
|
@@ -105,18 +77,10 @@ export function activeUpdateAgentIds(): Set<string> {
|
|
|
105
77
|
* between Rollback and a new Update.
|
|
106
78
|
*/
|
|
107
79
|
export function markInterruptedUpdates(): number {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
|
|
115
|
-
status = 'interrupted', updated_at = ?, finished_at = ?
|
|
116
|
-
WHERE status IN (${placeholders})`,
|
|
117
|
-
).run(now, now, ...ACTIVE_STATUSES);
|
|
118
|
-
return info.changes;
|
|
119
|
-
} finally {
|
|
120
|
-
db.close();
|
|
121
|
-
}
|
|
80
|
+
return state.markInterrupted();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Keep the newest `keepPerAgent` updates of each agent; the rest is history. */
|
|
84
|
+
export function pruneUpdates(keepPerAgent: number): number {
|
|
85
|
+
return state.prune(keepPerAgent);
|
|
122
86
|
}
|
package/lib/channelManager.ts
CHANGED
|
@@ -287,6 +287,12 @@ export interface ApprovedPairing {
|
|
|
287
287
|
export interface PairingState {
|
|
288
288
|
pending: PendingPairing[];
|
|
289
289
|
approved: ApprovedPairing[];
|
|
290
|
+
/**
|
|
291
|
+
* Why the approved list is empty, when that is not simply "none": the pairing store
|
|
292
|
+
* could not be read (the writer is not in this OpenClaw build, or the read failed).
|
|
293
|
+
* Callers must show it instead of "No approved senders", which would be a guess.
|
|
294
|
+
*/
|
|
295
|
+
error?: string | null;
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
/**
|
|
@@ -428,8 +434,13 @@ export async function getPairings(agentId: string, channel: string = 'telegram')
|
|
|
428
434
|
if (store.status === 'ok') {
|
|
429
435
|
for (const entry of store.entries ?? []) approved.push({ senderId: String(entry) });
|
|
430
436
|
}
|
|
437
|
+
// 'absent' is a real answer (no store yet, so no approved senders); anything else means
|
|
438
|
+
// the read did not succeed and an empty list must not be presented as "none".
|
|
439
|
+
const error = store.status === 'ok' || store.status === 'absent'
|
|
440
|
+
? null
|
|
441
|
+
: (store.error ?? 'The approved senders could not be read');
|
|
431
442
|
|
|
432
|
-
return { pending, approved };
|
|
443
|
+
return { pending, approved, error };
|
|
433
444
|
}
|
|
434
445
|
|
|
435
446
|
/** Approve a pending pairing code */
|
package/lib/memory-context.ts
CHANGED
|
@@ -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}
|
|
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
|
|
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;
|
package/lib/model-catalogue.ts
CHANGED
|
@@ -14,6 +14,20 @@ import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
|
14
14
|
/* Types */
|
|
15
15
|
/* ------------------------------------------------------------------ */
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The hand-written `info` block of a catalogue entry: what OpenRouter cannot tell us
|
|
19
|
+
* (parameter counts, the vendor's own docs, benchmarks published elsewhere). Every
|
|
20
|
+
* field is optional and every number carries its source — never an estimate.
|
|
21
|
+
*/
|
|
22
|
+
export interface ModelInfo {
|
|
23
|
+
params?: string;
|
|
24
|
+
released?: string;
|
|
25
|
+
docUrl?: string;
|
|
26
|
+
knowledgeCutoff?: string;
|
|
27
|
+
benchmarks?: { name: string; value: number; source: string; asOf?: string }[];
|
|
28
|
+
notes?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export interface ModelConfigEntry {
|
|
18
32
|
id: string;
|
|
19
33
|
name: string;
|
|
@@ -22,6 +36,8 @@ export interface ModelConfigEntry {
|
|
|
22
36
|
modality?: string;
|
|
23
37
|
/** Upstream has retired this id. It still answers, but is redirected. */
|
|
24
38
|
deprecated?: boolean;
|
|
39
|
+
/** Curated extras for the details modal (lib/model-details.ts). */
|
|
40
|
+
info?: ModelInfo;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
/* ------------------------------------------------------------------ */
|
|
@@ -86,6 +102,7 @@ function loadBundledModels(): ModelConfigEntry[] {
|
|
|
86
102
|
enabled: Boolean(m.enabled),
|
|
87
103
|
modality: typeof m.modality === 'string' ? m.modality : undefined,
|
|
88
104
|
deprecated: m.deprecated === true ? true : undefined,
|
|
105
|
+
info: m.info && typeof m.info === 'object' ? (m.info as ModelInfo) : undefined,
|
|
89
106
|
}));
|
|
90
107
|
lastGoodBundle = models;
|
|
91
108
|
bundleStatus = 'ok';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model details for the details modal: the curated catalogue entry
|
|
3
|
+
* (`models.config.json`), its price (`model-pricing.json`) and the generated specs,
|
|
4
|
+
* description and benchmarks (`model-details.json`, written by
|
|
5
|
+
* `scripts/refresh-model-pricing.mjs` from the OpenRouter catalogue).
|
|
6
|
+
*
|
|
7
|
+
* The generated file is optional on purpose: a checkout that never ran the script has
|
|
8
|
+
* fewer fields, never an error. Prices for direct providers come from their own price list
|
|
9
|
+
* (the script is scoped to `openrouter/*`), so the view labels where each number is from.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { loadModelsConfig, type ModelInfo } from '@/lib/model-catalogue';
|
|
14
|
+
import { providerLabel } from '@/lib/provider-labels';
|
|
15
|
+
import { getPricing } from '@/lib/model-pricing';
|
|
16
|
+
|
|
17
|
+
/** What `scripts/refresh-model-pricing.mjs` writes per id. All optional. */
|
|
18
|
+
export interface GeneratedDetails {
|
|
19
|
+
description?: string;
|
|
20
|
+
/** Release/listing date, `YYYY-MM-DD`. */
|
|
21
|
+
created?: string;
|
|
22
|
+
context?: number;
|
|
23
|
+
/** The provider's own context limit, when OpenRouter reports a different one. */
|
|
24
|
+
providerContext?: number;
|
|
25
|
+
maxOutput?: number;
|
|
26
|
+
tokenizer?: string;
|
|
27
|
+
instructType?: string;
|
|
28
|
+
/** Total parameter count and its source (Hugging Face, open weights only). */
|
|
29
|
+
params?: number;
|
|
30
|
+
paramsSource?: string;
|
|
31
|
+
hfArchitecture?: string;
|
|
32
|
+
knowledgeCutoff?: string;
|
|
33
|
+
huggingFaceId?: string;
|
|
34
|
+
canonicalSlug?: string;
|
|
35
|
+
url?: string;
|
|
36
|
+
reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string };
|
|
37
|
+
supportedParameters?: string[];
|
|
38
|
+
moderated?: boolean;
|
|
39
|
+
benchmarks?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ModelDetailsView {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
provider: string;
|
|
46
|
+
/** How the UI names that provider ("GLM (Z.AI)", "OpenRouter"). */
|
|
47
|
+
providerLabel: string;
|
|
48
|
+
modality?: string;
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
deprecated: boolean;
|
|
51
|
+
price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
|
|
52
|
+
details: GeneratedDetails | null;
|
|
53
|
+
info: ModelInfo | null;
|
|
54
|
+
/** When the generated part was produced. */
|
|
55
|
+
asOf: string | null;
|
|
56
|
+
detailsSource: 'openrouter' | 'none';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface DetailsFile {
|
|
60
|
+
source?: string;
|
|
61
|
+
generatedAt?: string;
|
|
62
|
+
models?: Record<string, GeneratedDetails>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveDetailsPath(): string {
|
|
66
|
+
const cwdPath = path.resolve(process.cwd(), 'model-details.json');
|
|
67
|
+
if (fs.existsSync(cwdPath)) return cwdPath;
|
|
68
|
+
// Fallback: relative to this module, for a global install whose cwd is elsewhere.
|
|
69
|
+
return path.resolve(__dirname, '..', 'model-details.json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cache: DetailsFile | null = null;
|
|
73
|
+
let cacheMtime = 0;
|
|
74
|
+
|
|
75
|
+
function loadDetails(): DetailsFile {
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.statSync(resolveDetailsPath());
|
|
78
|
+
if (cache && stat.mtimeMs <= cacheMtime) return cache;
|
|
79
|
+
cache = JSON.parse(fs.readFileSync(resolveDetailsPath(), 'utf-8')) as DetailsFile;
|
|
80
|
+
cacheMtime = stat.mtimeMs;
|
|
81
|
+
return cache ?? {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parameter count per model id, for the list views that show a size next to the price.
|
|
89
|
+
* One cached read of the generated file; a model without a Hugging Face card is simply
|
|
90
|
+
* absent — the row shows nothing rather than a guess.
|
|
91
|
+
*/
|
|
92
|
+
export function getModelParams(): Record<string, number> {
|
|
93
|
+
const out: Record<string, number> = {};
|
|
94
|
+
for (const [id, details] of Object.entries(loadDetails().models ?? {})) {
|
|
95
|
+
if (typeof details.params === 'number' && details.params > 0) out[id] = details.params;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The merged view for one model id, or null when the catalogue does not carry it. */
|
|
101
|
+
export function getModelDetails(id: string): ModelDetailsView | null {
|
|
102
|
+
const entry = loadModelsConfig().find((m) => m.id === id);
|
|
103
|
+
if (!entry) return null;
|
|
104
|
+
|
|
105
|
+
const file = loadDetails();
|
|
106
|
+
const details = file.models?.[id] ?? null;
|
|
107
|
+
const pricing = getPricing(id);
|
|
108
|
+
const info = entry.info ?? null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: entry.id,
|
|
112
|
+
name: entry.name,
|
|
113
|
+
provider: entry.provider,
|
|
114
|
+
providerLabel: providerLabel(entry.provider),
|
|
115
|
+
modality: entry.modality,
|
|
116
|
+
enabled: entry.enabled,
|
|
117
|
+
deprecated: entry.deprecated === true,
|
|
118
|
+
price: pricing ? { ...pricing, source: id.startsWith('openrouter/') ? 'openrouter' : 'vendor' } : null,
|
|
119
|
+
details,
|
|
120
|
+
info,
|
|
121
|
+
asOf: file.generatedAt ?? null,
|
|
122
|
+
detailsSource: details ? 'openrouter' : 'none',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the UI calls each provider key the model catalogue uses. One source: the Gateway's
|
|
3
|
+
* provider cards and the details modal both read it, so a provider is never named two ways.
|
|
4
|
+
*/
|
|
5
|
+
export const PROVIDER_LABELS: Record<string, string> = {
|
|
6
|
+
google: 'Google Gemini',
|
|
7
|
+
deepseek: 'DeepSeek',
|
|
8
|
+
openrouter: 'OpenRouter',
|
|
9
|
+
openai: 'OpenAI',
|
|
10
|
+
anthropic: 'Anthropic',
|
|
11
|
+
groq: 'Groq',
|
|
12
|
+
kimi: 'Kimi',
|
|
13
|
+
glm: 'GLM (Z.AI)',
|
|
14
|
+
qwen: 'Qwen',
|
|
15
|
+
rev4a: 'REV4A API KEY',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The label for a provider key, falling back to the key itself. */
|
|
19
|
+
export function providerLabel(key: string): string {
|
|
20
|
+
return PROVIDER_LABELS[key] ?? key;
|
|
21
|
+
}
|