@flame0510/project-aether 1.2.0 → 1.4.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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Reading one file out of an agent container, running or not.
3
+ *
4
+ * `docker exec` cannot reach a stopped or paused container; `docker cp` can, and
5
+ * streams the file as a tar archive. Used by the config sync, which also writes
6
+ * back through `docker cp`, and by the model summary behind the agent cards.
7
+ */
8
+
9
+ /** The single regular file in a `docker cp … -` tar stream: its bytes and mode. */
10
+ export function singleFileFromTar(tar: Buffer): { body: Buffer; mode: number } {
11
+ if (tar.length < 512) throw new Error('docker cp returned an empty archive');
12
+ const header = tar.subarray(0, 512);
13
+ const field = (start: number, len: number) => {
14
+ const text = header.subarray(start, start + len).toString('ascii');
15
+ const nul = text.indexOf('\0');
16
+ return (nul === -1 ? text : text.slice(0, nul)).trim();
17
+ };
18
+ const type = field(156, 1);
19
+ // '0' (or NUL on old writers) is a regular file. Anything else — a pax header,
20
+ // a directory — is not the shape expected, and guessing past it could mean
21
+ // writing a config that was never read.
22
+ if (type !== '0' && type !== '') throw new Error(`docker cp returned a non-file entry (type ${type})`);
23
+ const size = parseInt(field(124, 12), 8);
24
+ if (!Number.isFinite(size) || tar.length < 512 + size) throw new Error('docker cp returned a truncated archive');
25
+ const mode = parseInt(field(100, 8), 8);
26
+ return { body: tar.subarray(512, 512 + size), mode: Number.isFinite(mode) ? mode & 0o777 : 0o600 };
27
+ }
@@ -149,7 +149,7 @@ async function deliverGithub(container: string, secret: Record<string, string>):
149
149
  if (!token) throw new Error('Missing token');
150
150
 
151
151
  // Require gh CLI preinstalled in the agent image
152
- if (!await cliPresent(container, 'gh')) throw new Error('gh CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
152
+ if (!await cliPresent(container, 'gh')) throw new Error('gh CLI not installed in this container. Download the current agent image from the Agents page and update this agent.');
153
153
 
154
154
  await containerExec(
155
155
  container,
@@ -162,7 +162,7 @@ async function deliverVercel(container: string, secret: Record<string, string>):
162
162
  if (!token) throw new Error('Missing token');
163
163
 
164
164
  // Require vercel CLI preinstalled in the agent image
165
- if (!await cliPresent(container, 'vercel')) throw new Error('vercel CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
165
+ if (!await cliPresent(container, 'vercel')) throw new Error('vercel CLI not installed in this container. Download the current agent image from the Agents page and update this agent.');
166
166
 
167
167
  // Validate token and discover team scope via Node fetch (no curl/exec)
168
168
  const scope = await discoverVercelScope(token);
@@ -191,7 +191,7 @@ async function deliverSupabase(container: string, secret: Record<string, string>
191
191
  if (!accessToken || !accessToken.trim()) throw new Error('Missing Supabase access token');
192
192
 
193
193
  // Check supabase CLI is available (installed globally in the image)
194
- if (!await cliPresent(container, 'supabase')) throw new Error('supabase CLI not installed in this container. Rebuild the base image: docker build -t openclaw-agent-base:latest agent-templates/base-image/');
194
+ if (!await cliPresent(container, 'supabase')) throw new Error('supabase CLI not installed in this container. Download the current agent image from the Agents page and update this agent.');
195
195
 
196
196
  // Login with access token — grants access to all projects on the account
197
197
  // supabase login --token supports non-interactive auth
@@ -139,6 +139,82 @@ export function initializeRev4aDb(dbPath) {
139
139
  ['model', 'TEXT'],
140
140
  ]);
141
141
 
142
+ // Agent updates: one row per attempt to move an agent to a newer OpenClaw version
143
+ // (lib/agent-update.ts). It answers, after any restart, where an update stopped and
144
+ // which archive restores the agent.
145
+ db.exec(`
146
+ CREATE TABLE IF NOT EXISTS agent_upgrades (
147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ agent_id TEXT NOT NULL,
149
+ status TEXT NOT NULL,
150
+ from_version TEXT,
151
+ to_version TEXT NOT NULL,
152
+ backup_file TEXT,
153
+ baseline_json TEXT,
154
+ verify_json TEXT,
155
+ error TEXT,
156
+ started_at INTEGER NOT NULL,
157
+ updated_at INTEGER NOT NULL,
158
+ finished_at INTEGER
159
+ );
160
+ CREATE INDEX IF NOT EXISTS idx_agent_upgrades_agent ON agent_upgrades(agent_id, started_at);
161
+ `);
162
+
163
+ // Recreate jobs (lib/agent-recreate.ts): same shape as an update minus the
164
+ // migration — a cold backup, the container rebuilt on the same version, the
165
+ // gateway started again. Persisted so a reload or a Rev4a restart mid-way is
166
+ // visible instead of lost, and so an interrupted job is marked at startup.
167
+ db.exec(`
168
+ CREATE TABLE IF NOT EXISTS agent_recreates (
169
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ agent_id TEXT NOT NULL,
171
+ status TEXT NOT NULL,
172
+ image TEXT,
173
+ backup_file TEXT,
174
+ error TEXT,
175
+ started_at INTEGER NOT NULL,
176
+ updated_at INTEGER NOT NULL,
177
+ finished_at INTEGER
178
+ );
179
+ CREATE INDEX IF NOT EXISTS idx_agent_recreates_agent ON agent_recreates(agent_id, started_at);
180
+ `);
181
+
182
+
183
+ // Restore jobs (lib/agent-restore.ts): the volume is replaced from an archive, so the
184
+ // job is visible and survives a reload or a Rev4a restart like a recreate does.
185
+ db.exec(`
186
+ CREATE TABLE IF NOT EXISTS agent_restores (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ agent_id TEXT NOT NULL,
189
+ status TEXT NOT NULL,
190
+ file TEXT,
191
+ error TEXT,
192
+ started_at INTEGER NOT NULL,
193
+ updated_at INTEGER NOT NULL,
194
+ finished_at INTEGER
195
+ );
196
+ CREATE INDEX IF NOT EXISTS idx_agent_restores_agent ON agent_restores(agent_id, started_at);
197
+ `);
198
+
199
+
200
+ // Edit jobs (lib/agent-edit.ts): the container is rebuilt with a new display name
201
+ // and/or port range, no volume backup involved. Persisted like the others so a
202
+ // reload or a Rev4a restart never loses track of it.
203
+ db.exec(`
204
+ CREATE TABLE IF NOT EXISTS agent_edits (
205
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
206
+ agent_id TEXT NOT NULL,
207
+ status TEXT NOT NULL,
208
+ display_name TEXT,
209
+ port_range TEXT,
210
+ error TEXT,
211
+ started_at INTEGER NOT NULL,
212
+ updated_at INTEGER NOT NULL,
213
+ finished_at INTEGER
214
+ );
215
+ CREATE INDEX IF NOT EXISTS idx_agent_edits_agent ON agent_edits(agent_id, started_at);
216
+ `);
217
+
142
218
 
143
219
  db.close();
144
220
  }
@@ -19,9 +19,9 @@
19
19
  * user's browser open", not "what is this machine's WAN address". Those
20
20
  * differ behind NAT, behind a reverse proxy, and over a tunnel.
21
21
  *
22
- * Agent URLs are now composed in the browser from the port returned by the API
23
- * plus `window.location.hostname` — a hostname already proven to reach this
24
- * server, since the dashboard is being served over it.
22
+ * Agent Control UI links are now built from the host the request reached Rev4a on
23
+ * (`hostnameFromHostHeader()` in `lib/agent-devices.ts`) — a hostname already
24
+ * proven to reach this server, since the dashboard is being served over it.
25
25
  */
26
26
 
27
27
  /**
@@ -2,14 +2,13 @@
2
2
  * Model catalogue — bundle-first + user-overrides.
3
3
  *
4
4
  * Reads the bundled models.config.json from the npm package and applies
5
- * user toggles from REV4A_DATA/model-overrides.json.
6
- *
7
- * Imported by both provider/route.ts and gateway/sync.ts — shared logic,
8
- * avoids circular dependencies.
5
+ * user toggles from REV4A_DATA/model-overrides.json. The only module that reads
6
+ * either file: routes, pages and the sync all go through it.
9
7
  */
10
8
  import * as fs from 'fs';
11
9
  import * as path from 'path';
12
10
  import { REV4A_DATA } from '@/lib/rev4a-paths';
11
+ import { readProviderKeys } from '@/app/api/gateway/provider/keys';
13
12
 
14
13
  /* ------------------------------------------------------------------ */
15
14
  /* Types */
@@ -21,6 +20,8 @@ export interface ModelConfigEntry {
21
20
  provider: string;
22
21
  enabled: boolean;
23
22
  modality?: string;
23
+ /** Upstream has retired this id. It still answers, but is redirected. */
24
+ deprecated?: boolean;
24
25
  }
25
26
 
26
27
  /* ------------------------------------------------------------------ */
@@ -45,21 +46,61 @@ function resolveBundledPath(): string {
45
46
  /* Loaders */
46
47
  /* ------------------------------------------------------------------ */
47
48
 
49
+ /** Last catalogue read successfully by this process. */
50
+ let lastGoodBundle: ModelConfigEntry[] | null = null;
51
+
52
+ /** How the most recent read of models.config.json went. */
53
+ let bundleStatus: 'ok' | 'stale' | 'unavailable' = 'ok';
54
+
55
+ /**
56
+ * Where the last catalogue read left us:
57
+ * - `ok` the file was read and parsed
58
+ * - `stale` the read failed; this process's last good copy was used
59
+ * - `unavailable` the read failed with no earlier copy, so the catalogue is empty
60
+ */
61
+ export function bundledCatalogueStatus(): 'ok' | 'stale' | 'unavailable' {
62
+ return bundleStatus;
63
+ }
64
+
65
+ /**
66
+ * The bundled catalogue.
67
+ *
68
+ * A failed read must not look like an empty catalogue. It used to: this returned
69
+ * `[]`, the loader then treated every user override as an orphan and deleted them
70
+ * all from disk, the sync pushed an empty model list into every agent, and the
71
+ * proxy refused every request. One unlucky read — the pricing script rewriting the
72
+ * file in place, a malformed release — was enough. A failed read now falls back to
73
+ * the last good copy and reports it through `bundledCatalogueStatus()`.
74
+ */
48
75
  function loadBundledModels(): ModelConfigEntry[] {
49
76
  try {
50
77
  const raw = fs.readFileSync(resolveBundledPath(), 'utf-8');
51
78
  const parsed = JSON.parse(raw);
52
- if (Array.isArray(parsed.models)) {
53
- return parsed.models.map((m: Record<string, unknown>) => ({
54
- id: String(m.id ?? ''),
55
- name: String(m.name ?? ''),
56
- provider: String(m.provider ?? ''),
57
- enabled: Boolean(m.enabled),
58
- modality: typeof m.modality === 'string' ? m.modality : undefined,
59
- }));
79
+ if (!Array.isArray(parsed.models) || parsed.models.length === 0) {
80
+ throw new Error('no models array');
60
81
  }
61
- } catch { /* fall through */ }
62
- return [];
82
+ const models: ModelConfigEntry[] = parsed.models.map((m: Record<string, unknown>) => ({
83
+ id: String(m.id ?? ''),
84
+ name: String(m.name ?? ''),
85
+ provider: String(m.provider ?? ''),
86
+ enabled: Boolean(m.enabled),
87
+ modality: typeof m.modality === 'string' ? m.modality : undefined,
88
+ deprecated: m.deprecated === true ? true : undefined,
89
+ }));
90
+ lastGoodBundle = models;
91
+ bundleStatus = 'ok';
92
+ return models;
93
+ } catch (e) {
94
+ const why = (e as Error).message;
95
+ if (lastGoodBundle) {
96
+ if (bundleStatus !== 'stale') console.warn(`[model-catalogue] models.config.json unreadable, using the last good copy: ${why}`);
97
+ bundleStatus = 'stale';
98
+ return lastGoodBundle;
99
+ }
100
+ if (bundleStatus !== 'unavailable') console.error(`[model-catalogue] models.config.json unreadable and no earlier copy: ${why}`);
101
+ bundleStatus = 'unavailable';
102
+ return [];
103
+ }
63
104
  }
64
105
 
65
106
  export function loadOverrides(): Record<string, boolean> {
@@ -90,23 +131,14 @@ export function writeOverrides(overrides: Record<string, boolean>): void {
90
131
  * 1. Read models.config.json from the npm package (bundle).
91
132
  * 2. Apply user overrides (enabled/disabled toggles).
92
133
  *
93
- * The bundle is read-only at runtime. User choices live in model-overrides.json.
134
+ * Read-only. Overrides for ids the bundle no longer has are ignored here and
135
+ * pruned only by `toggleModelOverride`, against a fresh read: this runs on every
136
+ * proxy request, and a read path that deletes user settings is one failed read
137
+ * away from deleting all of them.
94
138
  */
95
139
  export function loadModelsConfig(): ModelConfigEntry[] {
96
140
  const bundled = loadBundledModels();
97
141
  const overrides = loadOverrides();
98
-
99
- // Clean up orphan overrides (models removed from bundle)
100
- const bundledIds = new Set(bundled.map((m) => m.id));
101
- let dirty = false;
102
- for (const id of Object.keys(overrides)) {
103
- if (!bundledIds.has(id)) {
104
- delete overrides[id];
105
- dirty = true;
106
- }
107
- }
108
- if (dirty) writeOverrides(overrides);
109
-
110
142
  return bundled.map((m) => ({
111
143
  ...m,
112
144
  enabled: Object.prototype.hasOwnProperty.call(overrides, m.id) ? overrides[m.id] : m.enabled,
@@ -126,6 +158,87 @@ export function toggleModelOverride(modelId: string, enabled: boolean): boolean
126
158
  } else {
127
159
  overrides[modelId] = enabled;
128
160
  }
161
+
162
+ // Drop overrides for models the catalogue no longer has — but only against a
163
+ // catalogue read just now. Against a stale or missing copy every override
164
+ // would look orphaned.
165
+ if (bundledCatalogueStatus() === 'ok') {
166
+ const ids = new Set(bundled.map((m) => m.id));
167
+ for (const id of Object.keys(overrides)) if (!ids.has(id)) delete overrides[id];
168
+ }
169
+
129
170
  writeOverrides(overrides);
130
171
  return true;
131
172
  }
173
+
174
+ /**
175
+ * The models this deployment actually offers.
176
+ *
177
+ * Two conditions, and they are separate questions:
178
+ *
179
+ * - **enabled** — policy. What the operator wants offered, `models.config.json`
180
+ * as the default and `model-overrides.json` as their decision on top.
181
+ * - **provider has a key** — availability. Offering a model nobody can be billed
182
+ * for produces a runtime failure at the worst moment.
183
+ *
184
+ * This exists because four readers each answered it differently, and the answers
185
+ * mattered. Measured on the production host, which has keys for four providers
186
+ * and eight overrides that all diverge from the bundled default: the create
187
+ * wizard listed 58 models where the correct answer was 62, offering two the
188
+ * operator had switched off and hiding six they had switched on. It read the
189
+ * bundle directly and never opened the overrides file. Meanwhile
190
+ * `/api/provider/v1/models` applied the overrides but not the key filter, so it
191
+ * advertised models for providers with no credentials.
192
+ *
193
+ * Callers still shape the result themselves — the sync adds `input` modalities,
194
+ * the pickers add prices — but the *set* is decided here, once.
195
+ */
196
+ export function loadOfferedModels(): ModelConfigEntry[] {
197
+ const configured = new Set(Object.keys(readProviderKeys()));
198
+ return loadModelsConfig().filter((m) => {
199
+ if (!m.enabled) return false;
200
+ if (configured.has(m.provider)) return true;
201
+ // A `rev4a/*` alias bills through DeepSeek upstream, so a DeepSeek key is
202
+ // enough to offer one. The catalogue currently defines none.
203
+ if (m.provider === 'rev4a' && configured.has('deepseek')) return true;
204
+ return false;
205
+ });
206
+ }
207
+
208
+ /**
209
+ * May this deployment serve a request for this model?
210
+ *
211
+ * `loadOfferedModels` answers what to *show*; this answers what to *honour*. They
212
+ * were the same question asked in two places and only the first was ever asked:
213
+ * the proxy and the assistant took whatever model the caller named and billed it.
214
+ * Unchecking a model on the Gateway page removed it from every picker and stopped
215
+ * nothing, because a client that already held the id — a stale in-memory list, a
216
+ * saved preference, an agent configured before the change — kept working.
217
+ *
218
+ * Takes the *resolved* pair, not the raw string. Callers reach it through
219
+ * `extractProviderAndModel`, which collapses `rev4a/deepseek/deepseek-flash`,
220
+ * `deepseek/deepseek-flash` and the two-segment alias `rev4a/deepseek-flash` onto
221
+ * one provider and model, so all three are tested as the same catalogue id.
222
+ */
223
+ export function isModelOffered(provider: string, model: string): boolean {
224
+ const id = `${provider}/${model}`;
225
+ return loadOfferedModels().some((m) => m.id === id);
226
+ }
227
+
228
+ /**
229
+ * Where a model id stands against the catalogue, for explaining a failure.
230
+ *
231
+ * - `offered` — enabled and its provider has a key; the proxy serves it
232
+ * - `disabled` — in the catalogue but not offered: unchecked, or no key
233
+ * - `missing` — not in the catalogue at all, e.g. removed in a release
234
+ *
235
+ * Takes a bare id, without the `rev4a/` prefix. The Model panel used to infer the
236
+ * cause from the offered list alone, so a model removed from the catalogue was
237
+ * explained as "unchecked, or its provider has no key" while the card said
238
+ * `NOT IN CATALOGUE`. `models-summary` computes the same two sets once for the
239
+ * whole fleet instead of calling this per agent.
240
+ */
241
+ export function catalogueStatus(id: string): 'offered' | 'disabled' | 'missing' {
242
+ if (!loadModelsConfig().some((m) => m.id === id)) return 'missing';
243
+ return loadOfferedModels().some((m) => m.id === id) ? 'offered' : 'disabled';
244
+ }
@@ -27,6 +27,9 @@ interface BalanceInfo {
27
27
  granted_balance?: string;
28
28
  }
29
29
 
30
+ /** Thrown when the provider has no balance API for this account type (permanent, not transient). */
31
+ export class BalanceUnsupportedError extends Error {}
32
+
30
33
  /* ------------------------------------------------------------------ */
31
34
  /* DeepSeek */
32
35
  /* ------------------------------------------------------------------ */
@@ -99,7 +102,21 @@ async function fetchZAIBalance(apiKey: string): Promise<BalanceResult> {
99
102
  if (!resp.ok) {
100
103
  throw new Error(`Z.AI quota API returned ${resp.status}`);
101
104
  }
102
- const json = await resp.json() as { limits?: ZAILimit[] };
105
+ const json = await resp.json() as {
106
+ success?: boolean;
107
+ msg?: string;
108
+ limits?: ZAILimit[];
109
+ };
110
+
111
+ // The endpoint only serves GLM Coding Plan subscriptions. A pay-as-you-go
112
+ // account gets HTTP 200 with success:false and no limits; Z.AI exposes no
113
+ // balance API for it (no undocumented endpoint either, verified 2026-09-16),
114
+ // so this is permanent, not transient: the UI hides the badge entirely.
115
+ if (json.success === false) {
116
+ throw new BalanceUnsupportedError(
117
+ `Z.AI exposes no balance API for pay-as-you-go accounts (the token-quota endpoint requires a Coding Plan)`,
118
+ );
119
+ }
103
120
 
104
121
  const tokenLimit = json.limits?.find((l) => l.limitType === 'TOKENS_LIMIT');
105
122
  if (tokenLimit) {
@@ -116,17 +133,21 @@ async function fetchZAIBalance(apiKey: string): Promise<BalanceResult> {
116
133
  };
117
134
  }
118
135
 
119
- // Fallback: sum all limits
120
- const totalRemaining = (json.limits ?? []).reduce(
121
- (sum, l) => sum + (l.remaining ?? l.limitValue - l.usage),
122
- 0,
123
- );
124
- return {
125
- balance: `${(totalRemaining / 1_000_000).toFixed(1)}M tokens`,
126
- currency: 'TOKENS',
127
- value: totalRemaining,
128
- label: 'Remaining',
129
- };
136
+ // Fallback: sum all limits, only when the API actually returned some.
137
+ if ((json.limits ?? []).length > 0) {
138
+ const totalRemaining = (json.limits ?? []).reduce(
139
+ (sum, l) => sum + (l.remaining ?? l.limitValue - l.usage),
140
+ 0,
141
+ );
142
+ return {
143
+ balance: `${(totalRemaining / 1_000_000).toFixed(1)}M tokens`,
144
+ currency: 'TOKENS',
145
+ value: totalRemaining,
146
+ label: 'Remaining',
147
+ };
148
+ }
149
+
150
+ throw new Error('Z.AI returned no quota limits');
130
151
  }
131
152
 
132
153
  /* ------------------------------------------------------------------ */
@@ -58,27 +58,6 @@ export const SHARED_SKILLS_DIR = path.join(REV4A_DATA, 'shared', 'shared-skills'
58
58
  /** Rev4a system rules injected into every agent workspace, mounted read-only. */
59
59
  export const REV4A_RULES_DIR = path.join(REV4A_DATA, 'shared', 'rev4a-rules');
60
60
 
61
- /**
62
- * Model catalogue — read-only, shipped with the package.
63
- * Resolved at module load from the package root (alongside bin/rev4a.js).
64
- */
65
- function resolvePackageFile(filename: string): string {
66
- // cwd in dev (workspace checkout)
67
- const cwdPath = path.join(process.cwd(), filename);
68
- if (fs.existsSync(cwdPath)) return cwdPath;
69
-
70
- // Installed package: this file is at <pkgRoot>/lib/rev4a-paths.js
71
- const pkgRoot = path.resolve(__dirname, '..');
72
- const pkgPath = path.join(pkgRoot, filename);
73
- if (fs.existsSync(pkgPath)) return pkgPath;
74
-
75
- // Last resort: accept that it may not exist (will throw at read time)
76
- return cwdPath;
77
- }
78
-
79
- /** Static model catalogue — read-only, inside the package repo */
80
- export const MODELS_CONFIG_FILE = resolvePackageFile('models.config.json');
81
-
82
61
  /** Top-level .next build directory (kept inside npm package for now — built by rev4a serve) */
83
62
  export function nextDir(pkgRoot: string): string {
84
63
  return path.join(pkgRoot, '.next');