@flame0510/project-aether 1.1.15 → 1.3.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 (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. package/app/api/gateway/route.ts +0 -191
@@ -3,12 +3,13 @@ import { execSync } from 'child_process';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
6
- import { buildRev4aProviderConfig } from '@/app/api/gateway/sync';
6
+ import { patchRev4aProvider } from '@/app/api/gateway/sync';
7
7
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
8
8
  import { requireAuthJWT } from '@/lib/rev4a-auth';
9
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
9
10
 
10
11
  export const dynamic = 'force-dynamic';
11
- export const maxDuration = 180; // docker run (60s) + health check (60s) + config patch (60s)
12
+ export const maxDuration = 180; // docker run (60s) + startup wait (60s) + config patch (60s)
12
13
 
13
14
  const BACKUP_VOLUME = 'rev4a-backups';
14
15
 
@@ -20,23 +21,6 @@ function ts(): string {
20
21
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${ms}`;
21
22
  }
22
23
 
23
- /**
24
- * Wait for the container gateway to respond on port 3000.
25
- */
26
- function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
27
- const start = Date.now();
28
- while (Date.now() - start < timeoutMs) {
29
- try {
30
- execSync(
31
- `docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
32
- { timeout: 5000 },
33
- );
34
- return true;
35
- } catch { /* not ready */ }
36
- execSync('sleep 2', { timeout: 3000 });
37
- }
38
- return false;
39
- }
40
24
 
41
25
  /**
42
26
  * POST /api/agents/[id]/recreate
@@ -159,11 +143,11 @@ export async function POST(request: Request,
159
143
 
160
144
  execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
161
145
 
162
- // Wait for the new container to be healthy
163
- const ready = waitForContainerReady(id);
146
+ // Wait for the new container to finish starting — /startupz, not /health
147
+ const ready = await waitForGatewayReady(id);
164
148
  if (!ready) {
165
149
  return NextResponse.json(
166
- { success: false, error: 'Container started but gateway did not become healthy within 60s.' },
150
+ { success: false, error: 'Container started but its gateway did not finish starting within 60s.' },
167
151
  { status: 500 },
168
152
  );
169
153
  }
@@ -174,18 +158,10 @@ export async function POST(request: Request,
174
158
  applyRuntimeConfig(id);
175
159
 
176
160
  // Also guarantee models.providers.rev4a (host-dependent, not in base image)
177
- const providerConfig = buildRev4aProviderConfig();
178
- const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
179
- execSync(
180
- `docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
181
- { timeout: 15000, stdio: 'pipe', input: providerJson },
182
- );
183
-
184
- // No gateway restart: `config patch` reports "Change will apply without
185
- // restarting the gateway" for a models-only patch, and the Gateway watches
186
- // openclaw.json with gateway.reload defaulting to hybrid.
187
- } catch {
188
- // config patch is best-effort on recreate
161
+ patchRev4aProvider(id);
162
+ } catch (e) {
163
+ // best-effort on recreate, but a skipped patch must show up in the log
164
+ console.error('[agent:recreate] runtime config patch failed:', (e as Error).message);
189
165
  }
190
166
 
191
167
  return NextResponse.json({ success: true, name: id, image, network, backup: backupFile });
@@ -3,10 +3,11 @@ import { execSync } from 'child_process';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
6
- import { buildRev4aProviderConfig } from '@/app/api/gateway/sync';
6
+ import { patchRev4aProvider } from '@/app/api/gateway/sync';
7
7
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
8
8
  import { DB_FILE } from '@/lib/rev4a-paths';
9
9
  import { requireAuthJWT } from '@/lib/rev4a-auth';
10
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
10
11
 
11
12
  const BACKUP_VOLUME = 'rev4a-backups';
12
13
 
@@ -86,21 +87,6 @@ export async function DELETE(request: Request,
86
87
  }
87
88
  }
88
89
 
89
- function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
90
- const start = Date.now();
91
- while (Date.now() - start < timeoutMs) {
92
- try {
93
- execSync(
94
- `docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
95
- { timeout: 5000 },
96
- );
97
- return true;
98
- } catch { /* not ready */ }
99
- execSync('sleep 2', { timeout: 3000 });
100
- }
101
- return false;
102
- }
103
-
104
90
  function shellQuote(value: string): string {
105
91
  return `'${value.replace(/'/g, `'\\''`)}'`;
106
92
  }
@@ -247,11 +233,11 @@ export async function PATCH(
247
233
 
248
234
  execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
249
235
 
250
- // Wait for healthy
251
- const ready = waitForContainerReady(id);
236
+ // Wait for startup to complete — /startupz, not /health
237
+ const ready = await waitForGatewayReady(id);
252
238
  if (!ready) {
253
239
  return NextResponse.json(
254
- { error: 'Container started but gateway did not become healthy within 60s.' },
240
+ { error: 'Container started but its gateway did not finish starting within 60s.' },
255
241
  { status: 500 },
256
242
  );
257
243
  }
@@ -259,16 +245,11 @@ export async function PATCH(
259
245
  // Apply runtime config
260
246
  try {
261
247
  applyRuntimeConfig(id);
262
- const providerConfig = buildRev4aProviderConfig();
263
- const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
264
- execSync(
265
- `docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
266
- { timeout: 15000, stdio: 'pipe', input: providerJson },
267
- );
268
- // No gateway restart: `config patch` reports "Change will apply without
269
- // restarting the gateway" for a models-only patch, and the Gateway watches
270
- // openclaw.json with gateway.reload defaulting to hybrid.
271
- } catch { /* non-fatal */ }
248
+ patchRev4aProvider(id);
249
+ } catch (e) {
250
+ // non-fatal, but a skipped patch must show up in the log
251
+ console.error('[agent:update] runtime config patch failed:', (e as Error).message);
252
+ }
272
253
 
273
254
  return NextResponse.json({
274
255
  success: true,
@@ -1,37 +1,15 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { execSync } from 'child_process';
2
+ import { execSync, execFileSync } from 'child_process';
3
3
  import * as crypto from 'crypto';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
- import { buildRev4aProviderConfig } from '../../gateway/sync';
6
+ import { patchRev4aProvider } from '../../gateway/sync';
7
+ import { extractProviderAndModel } from '@/app/api/provider/upstream';
7
8
  import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
8
9
  import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
9
- import { loadModelsConfig } from '@/lib/model-catalogue';
10
+ import { isModelOffered } from '@/lib/model-catalogue';
10
11
  import { requireAuthJWT } from '@/lib/rev4a-auth';
11
-
12
- /**
13
- * Wait for a Docker container gateway to be ready.
14
- * Checks that the gateway is responding on port 3000 inside the container.
15
- * Polls every 2s, gives up after timeoutMs.
16
- * Returns true if the gateway is reachable, false on timeout.
17
- */
18
- function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
19
- const start = Date.now();
20
- while (Date.now() - start < timeoutMs) {
21
- try {
22
- execSync(
23
- `docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
24
- { timeout: 5000 },
25
- );
26
- return true;
27
- } catch {
28
- // gateway not ready yet
29
- }
30
- // Sleep 2s
31
- execSync('sleep 2', { timeout: 3000 });
32
- }
33
- return false;
34
- }
12
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
35
13
 
36
14
 
37
15
 
@@ -107,12 +85,18 @@ function validatePortInput(input: string): { valid: true; isBlock: boolean; star
107
85
  }
108
86
 
109
87
  export const dynamic = 'force-dynamic';
110
- export const maxDuration = 180; // docker run (60s) + health check (60s) + seeding (60s)
88
+ export const maxDuration = 180; // docker run (60s) + startup wait (60s) + seeding (60s)
111
89
 
112
90
  const TEMPLATES_DIR = templatesDir(process.cwd());
113
91
  const AGENTS_TOKEN_PATH = AGENTS_TOKEN_FILE;
114
92
  const DEFAULT_NETWORK = 'rev4a-network';
115
93
 
94
+ /**
95
+ * Used when the request names no model. The full id: the agent cards compare the
96
+ * primary against catalogue ids, and the two-segment alias matches none of them.
97
+ */
98
+ const DEFAULT_MODEL = 'rev4a/deepseek/deepseek-flash';
99
+
116
100
  function isDockerCompatibleName(name: string): boolean {
117
101
  return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
118
102
  }
@@ -191,9 +175,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
191
175
  }
192
176
 
193
177
  // Validate model (if provided) exists in models.config.json
194
- if (model && !isValidModel(model)) {
178
+ // A request without a model used to create an agent with no primary at all —
179
+ // the config write below was skipped — while the event log recorded a default
180
+ // the agent never had. The default is now applied for real, and validated like
181
+ // any other model, so it cannot produce an agent the proxy refuses.
182
+ const effectiveModel: string = typeof model === 'string' && model ? model : DEFAULT_MODEL;
183
+ if (!isValidModel(effectiveModel)) {
195
184
  return NextResponse.json(
196
- { success: false, error: `Model '${model}' not found in models.config.json` },
185
+ { success: false, error: `Model '${effectiveModel}' is not offered by this deployment: it is not in the catalogue, is disabled, or its provider has no API key.` },
197
186
  { status: 400 },
198
187
  );
199
188
  }
@@ -248,6 +237,10 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
248
237
  );
249
238
  }
250
239
 
240
+ // `rev4a serve` creates this network at start-up, but skips it when Docker was
241
+ // not ready yet; without it `docker run --network` fails. Idempotent.
242
+ ensureNetworkExists(network);
243
+
251
244
  // Every agent gets a host port range for direct access. The URL is composed
252
245
  // by the browser from `port` + `controlToken` in the response — only the
253
246
  // browser knows a hostname that actually reaches this machine.
@@ -310,7 +303,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
310
303
  port: portStart,
311
304
  portEnd,
312
305
  isBlock,
313
- model: model || 'rev4a/deepseek-v4-flash',
306
+ model: effectiveModel,
314
307
  fallbacks,
315
308
  containerId,
316
309
  containerName,
@@ -321,14 +314,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
321
314
  // DB logging is optional, ignore failures
322
315
  }
323
316
 
324
- // Wait for container to be ready (HTTP health check, up to 60s).
325
- // If it doesn't come up, remove the container and return an error —
326
- // a zombie agent with no config is worse than an explicit failure.
327
- const ready = waitForContainerReady(containerName);
317
+ // Wait for startup to complete (/startupz, up to 60s). Not /health, which
318
+ // answers as soon as the HTTP server listens. If it does not come up, remove
319
+ // the container: a zombie agent with no config is worse than an explicit failure.
320
+ const ready = await waitForGatewayReady(containerName);
328
321
  if (!ready) {
329
322
  try { execSync(`docker rm -f ${containerName}`, { timeout: 10000 }); } catch { /* best-effort */ }
330
323
  return NextResponse.json(
331
- { success: false, error: `Container started but gateway did not become healthy within 60s. Container removed — try again.` },
324
+ { success: false, error: `Container started but its gateway did not finish starting within 60s. Container removed — try again.` },
332
325
  { status: 500 },
333
326
  );
334
327
  }
@@ -388,11 +381,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
388
381
  config.gateway = gatewaySection;
389
382
 
390
383
  // Model reference (primary + fallbacks)
391
- if (model) {
392
- const modelRef = model.startsWith('rev4a/') ? model : `rev4a/${model}`;
393
- const fallbackRefs = (fallbacks || []).map((fb: string) =>
394
- fb.startsWith('rev4a/') ? fb : `rev4a/${fb}`,
395
- );
384
+ {
385
+ // Always the canonical `rev4a/<provider>/<model>`: the Agents chips and the
386
+ // Model panel look the primary up by catalogue id, so the alias form
387
+ // (`rev4a/deepseek-flash`) or a bare name would show as not in the catalogue.
388
+ const modelRef = toRev4aRef(effectiveModel);
389
+ const fallbackRefs = (fallbacks || []).map((fb: string) => toRev4aRef(fb));
396
390
  const agents = (config.agents || {}) as Record<string, unknown>;
397
391
  const defaults = (agents.defaults || {}) as Record<string, unknown>;
398
392
  defaults.model = {
@@ -434,19 +428,10 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
434
428
  try {
435
429
  applyRuntimeConfig(containerName);
436
430
 
437
- // Also guarantee models.providers.rev4a (host-dependent, not in base image)
438
- const providerConfig = buildRev4aProviderConfig();
439
- const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
440
- execSync(
441
- `docker exec -i ${containerName} sh -c 'openclaw config patch --stdin'`,
442
- { timeout: 15000, stdio: 'pipe', input: providerJson },
443
- );
444
-
445
- // No gateway restart: `config patch` reports "Change will apply without
446
- // restarting the gateway" for a models-only patch, and the Gateway watches
447
- // openclaw.json with gateway.reload defaulting to hybrid. The earlier
448
- // restart in this route stays — that one follows a full-file rewrite that
449
- // includes gateway.*, which is the one section that genuinely needs it.
431
+ // Also guarantee models.providers.rev4a (host-dependent, not in base image).
432
+ // This patch needs no restart; the earlier one follows a full-file rewrite
433
+ // that includes gateway.*, the one section that genuinely needs it.
434
+ patchRev4aProvider(containerName);
450
435
  } catch (e: unknown) {
451
436
  // runtime config patch is best-effort; log but don't fail the create
452
437
  console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
@@ -474,6 +459,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
474
459
  }
475
460
  }
476
461
 
462
+ /** `rev4a/<provider>/<model>`, whatever form the request used. */
463
+ function toRev4aRef(modelId: string): string {
464
+ const { provider, model } = extractProviderAndModel({ model: modelId });
465
+ return `rev4a/${provider}/${model}`;
466
+ }
467
+
477
468
  function getAvailableTemplates(): string[] {
478
469
  try {
479
470
  return fs.readdirSync(TEMPLATES_DIR).filter((f) => {
@@ -485,12 +476,23 @@ function getAvailableTemplates(): string[] {
485
476
  }
486
477
  }
487
478
 
479
+ /**
480
+ * The model must be one this deployment offers, in any form the proxy accepts.
481
+ * Checking the raw catalogue let the API create an agent on a disabled model, or
482
+ * one whose provider has no key, which the proxy then refuses on its first turn.
483
+ */
488
484
  function isValidModel(modelId: string): boolean {
485
+ const { provider, model } = extractProviderAndModel({ model: modelId });
486
+ return isModelOffered(provider, model);
487
+ }
488
+
489
+ function ensureNetworkExists(name: string): void {
489
490
  try {
490
- const config = loadModelsConfig();
491
- return config.some((m) => m.id === modelId);
491
+ execFileSync('docker', ['network', 'inspect', name], { stdio: 'ignore', timeout: 5000 });
492
492
  } catch {
493
- return true; // skip on errors
493
+ try {
494
+ execFileSync('docker', ['network', 'create', name], { stdio: 'ignore', timeout: 10000 });
495
+ } catch { /* let docker run report it */ }
494
496
  }
495
497
  }
496
498
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * GET /api/agents/models-summary
3
+ *
4
+ * One row per agent, running or not: which model it runs, and whether that model is in
5
+ * good standing. Feeds the model chip on the agent cards, so the list answers
6
+ * "what is this agent running, and is it fine?" without opening anything.
7
+ *
8
+ * That question had no cheap answer before: the model lived only inside each
9
+ * container's config, and the only route that read it walked the whole fleet.
10
+ * Three agents silently moved to DeepSeek V4.1, and one has been pointing at a
11
+ * model absent from its own catalogue, precisely because nothing surfaced it.
12
+ *
13
+ * Fan-out uses the NoFail variants: one unreachable container must degrade its
14
+ * own row, not blank the page. `mapWithConcurrency` is fail-fast, so a throwing
15
+ * mapper would discard every result already computed.
16
+ */
17
+ import { NextResponse } from 'next/server';
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
21
+ import { loadModelsConfig, loadOfferedModels } from '@/lib/model-catalogue';
22
+ import { singleFileFromTar } from '@/lib/container-file';
23
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ export const dynamic = 'force-dynamic';
28
+
29
+ /** Matches the per-agent query cap used elsewhere for fleet walks. */
30
+ const CONCURRENCY = 6;
31
+
32
+ const CONFIG_PATH = '/root/.openclaw/openclaw.json';
33
+
34
+ /** A not-running agent's config, through `docker cp`. Empty string when unreadable. */
35
+ async function readNotRunningConfig(container: string): Promise<string> {
36
+ try {
37
+ const { stdout } = await execFileAsync('docker', ['cp', `${container}:${CONFIG_PATH}`, '-'], {
38
+ encoding: 'buffer', timeout: 8000, maxBuffer: 1024 * 1024,
39
+ });
40
+ return singleFileFromTar(stdout as unknown as Buffer).body.toString('utf-8');
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * - `missing` the primary is not in the catalogue at all
48
+ * - `disabled` in the catalogue but not offered: unchecked, or its provider has no key
49
+ * - `out-of-sync` offered by the gateway, absent from this container's synced copy
50
+ * - `deprecated` works, on a name upstream has retired
51
+ */
52
+ export type ModelHealth = 'ok' | 'deprecated' | 'missing' | 'disabled' | 'out-of-sync' | 'unset' | 'unknown';
53
+
54
+ export interface AgentModelRow {
55
+ agentId: string;
56
+ containerName: string;
57
+ primary: string | null;
58
+ fallbackCount: number;
59
+ /** First fallback, for the one-line hint on the card. The rest stay in the panel. */
60
+ firstFallback: string | null;
61
+ health: ModelHealth;
62
+ }
63
+
64
+ export async function GET(request: Request): Promise<NextResponse> {
65
+ const denied = await requireAuthJWT(request);
66
+ if (denied) return denied as any;
67
+
68
+ let agents: { agentId: string; containerName: string; running: boolean }[] = [];
69
+ try {
70
+ // `-a`: a stopped agent is the likeliest to be sitting on a model that has
71
+ // since been removed or disabled, and it needs a chip as much as any.
72
+ const { stdout } = await execFileAsync(
73
+ 'docker',
74
+ ['ps', '-a', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}'],
75
+ { encoding: 'utf8', timeout: 5000 },
76
+ );
77
+ agents = stdout.trim().split('\n')
78
+ .map((line) => {
79
+ const [agentId, containerName, state] = line.split('\t');
80
+ if (!containerName?.trim()) return null;
81
+ return {
82
+ agentId: agentId?.trim() || containerName.trim(),
83
+ containerName: containerName.trim(),
84
+ running: state?.trim() === 'running',
85
+ };
86
+ })
87
+ .filter(Boolean) as { agentId: string; containerName: string; running: boolean }[];
88
+ } catch (e) {
89
+ // Not an empty list: that reads as "every agent is fine".
90
+ return NextResponse.json(
91
+ { agents: [], error: `Docker is unreachable: ${(e as Error).message.split('\n')[0]}` },
92
+ { status: 503 },
93
+ );
94
+ }
95
+
96
+ if (agents.length === 0) return NextResponse.json({ agents: [] });
97
+
98
+ // Deprecation is a property of the catalogue, not of the container: read it once.
99
+ const catalogue = new Map(loadModelsConfig().map((m) => [m.id, m]));
100
+ // What the gateway will actually serve. The proxy refuses anything outside it,
101
+ // so this set, not the container's copy, decides whether an agent can work.
102
+ const offered = new Set(loadOfferedModels().map((m) => m.id));
103
+
104
+ const rows = await mapWithConcurrency(agents, CONCURRENCY, async (agent): Promise<AgentModelRow> => {
105
+ const base: AgentModelRow = {
106
+ agentId: agent.agentId,
107
+ containerName: agent.containerName,
108
+ primary: null,
109
+ fallbackCount: 0,
110
+ firstFallback: null,
111
+ health: 'unknown',
112
+ };
113
+
114
+ const raw = agent.running
115
+ ? await dockerExecNoFail(agent.containerName, ['cat', CONFIG_PATH], { timeoutMs: 8000 })
116
+ : await readNotRunningConfig(agent.containerName);
117
+ // An empty body means unreachable *or* a read killed by the deadline —
118
+ // `docker exec` exits 0 either way, so both stay 'unknown' rather than
119
+ // being reported as a healthy agent with no model.
120
+ if (!raw) return base;
121
+
122
+ let config: Record<string, unknown>;
123
+ try { config = JSON.parse(raw); } catch { return base; }
124
+
125
+ const defaults = ((config.agents ?? {}) as Record<string, unknown>).defaults ?? {};
126
+ const model = (defaults as Record<string, unknown>).model;
127
+ const primary = typeof model === 'string'
128
+ ? model
129
+ : (model && typeof model === 'object' ? (model as Record<string, unknown>).primary : null);
130
+ const fallbacks = model && typeof model === 'object' && Array.isArray((model as Record<string, unknown>).fallbacks)
131
+ ? ((model as Record<string, unknown>).fallbacks as unknown[])
132
+ : [];
133
+
134
+ if (typeof primary !== 'string' || !primary) return { ...base, health: 'unset' };
135
+
136
+ const bare = primary.replace(/^rev4a\//, '');
137
+ const models = ((config.models ?? {}) as Record<string, unknown>).providers ?? {};
138
+ const rev4a = ((models as Record<string, unknown>).rev4a ?? {}) as Record<string, unknown>;
139
+ const ids = (Array.isArray(rev4a.models) ? rev4a.models : [])
140
+ .map((m) => (m && typeof m === 'object' ? (m as Record<string, unknown>).id : null))
141
+ .filter((id): id is string => typeof id === 'string');
142
+
143
+ const entry = catalogue.get(bare);
144
+ let health: ModelHealth;
145
+ // Ordered by remedy. Each case asks something different of the operator, and
146
+ // the single 'missing' this replaced pointed at the wrong fix half the time.
147
+ if (!entry) health = 'missing'; // gone from the catalogue: pick another model
148
+ else if (!offered.has(bare)) health = 'disabled'; // exists, not offered: the proxy refuses it
149
+ else if (!ids.includes(bare)) health = 'out-of-sync'; // offered, never reached this container: Sync All
150
+ else if (entry.deprecated) health = 'deprecated'; // works, on a retired name
151
+ else health = 'ok';
152
+
153
+ return {
154
+ ...base,
155
+ primary,
156
+ fallbackCount: fallbacks.length,
157
+ firstFallback: typeof fallbacks[0] === 'string' ? (fallbacks[0] as string) : null,
158
+ health,
159
+ };
160
+ });
161
+
162
+ return NextResponse.json({ agents: rows });
163
+ }
@@ -2,8 +2,11 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { readProviderKeys } from '@/app/api/gateway/provider/keys';
3
3
  import { extractProviderAndModel, getApiKey, callUpstream } from '@/app/api/provider/upstream';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+ import { isModelOffered, loadOfferedModels } from '@/lib/model-catalogue';
5
6
 
6
- const FALLBACK_MODEL = 'deepseek/deepseek-v4-flash';
7
+ // DeepSeek Flash (V4.1). Used only when the client names no model, and refused
8
+ // like any other if it has been unchecked on the Gateway page.
9
+ const FALLBACK_MODEL = 'deepseek/deepseek-flash';
7
10
 
8
11
  interface PageContext {
9
12
  title: string;
@@ -15,27 +18,27 @@ interface PageContext {
15
18
  const PAGE_CONTEXT: Record<string, PageContext> = {
16
19
  '/': {
17
20
  title: 'Dashboard',
18
- description: 'Main overview with live session list, cost summary (today, 7 days, 30 days by model), system health metrics (CPU, RAM, disk, load), and a real-time event feed.',
21
+ description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. It shows no CPU, RAM, disk or load average: those are collected by the daemon but no page displays them.',
19
22
  actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'View cost breakdown by model and time period', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
20
23
  hints: 'The Session Drawer shows you the complete tool call history for any session — just click a row.',
21
24
  },
22
25
  '/agents': {
23
26
  title: 'Agents',
24
27
  description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when outdated or missing, animated progress bar when downloading from registry.',
25
- actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, auth token, Traefik URL)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
28
+ actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, auth token, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
26
29
  hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when an updated version is available on the registry — download to pick up CLI updates.',
27
30
  },
28
31
  '/agents/create': {
29
32
  title: 'Create Agent',
30
- description: 'Wizard to create a new agent from a template. Choose a template, give it a name, select a model, set environment variables, and configure optional Traefik routing.',
31
- actions: ['Browse available agent templates', 'Name your agent and select its AI model', 'Add environment variables (KEY=*** pairs)', 'Optionally enable Traefik routing with a custom subdomain', 'Create the agent and start it immediately'],
32
- hints: 'Templates provide pre-built agent configurations. You can customize the image, model, and env vars after selecting a template.',
33
+ description: 'Two-step wizard to create a new agent from a template. Pick a template, then name it and optionally set a port range and a model.',
34
+ actions: ['Browse available agent templates', 'Name your agent and select its AI model', 'Set an optional port range (auto-assigned by default)', 'Create the agent and start it immediately'],
35
+ hints: 'Templates provide pre-built agent configurations. The model can be changed later from the agent\'s detail panel on the Agents page.',
33
36
  },
34
37
  '/gateway': {
35
38
  title: 'Gateway',
36
- description: 'Central gateway management page. Shows OpenClaw gateway status, all configured AI providers with their models, and each agent\'s model assignment with fallback models.',
37
- actions: ['Toggle models on/off per provider to control availability', 'View which model each agent uses as default', 'See fallback models configured for each agent', 'Check gateway health status and last sync time', 'Sync providers from the gateway configuration'],
38
- hints: 'The Gateway is the routing layer that connects agents to AI providers. Use this page to manage which models agents can access.',
39
+ description: 'Provider configuration: API keys, and which catalogue models this deployment offers. It does not assign models to agents.',
40
+ actions: ['Add, change or remove a provider API key', 'Toggle individual models on or off to control what the fleet is offered', 'Push the resulting catalogue to every agent container with Sync All Agents'],
41
+ hints: 'Unchecking a model here removes it everywhere, including for requests that name it directly. To change which model one agent runs, open that agent on the Agents page and use the Model section.',
39
42
  },
40
43
  '/crons': {
41
44
  title: 'Crons',
@@ -45,8 +48,8 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
45
48
  },
46
49
  '/containers': {
47
50
  title: 'Containers',
48
- description: 'List of all Docker containers running on the server (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
49
- actions: ['View all running containers with their details', 'Click "Terminal" on any container to open an interactive shell', 'Monitor container status and resource usage'],
51
+ description: 'List of all Docker containers on the server, running or stopped (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
52
+ actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports (no CPU or memory figures are shown)'],
50
53
  hints: 'Containers marked with an agentId belong to OpenClaw agents. Others are infrastructure containers like databases or reverse proxies.',
51
54
  },
52
55
  '/containers/terminal/[id]': {
@@ -64,7 +67,7 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
64
67
  '/lineage': {
65
68
  title: 'Lineage',
66
69
  description: 'Interactive graph showing parent-child relationships between agent sessions. Each node is a session, edges show who spawned whom.',
67
- actions: ['View the session hierarchy tree', 'Select a time period (1d/7d/30d) to filter the view', 'Click any session node to inspect its details', 'Zoom and pan through the graph'],
70
+ actions: ['View the session hierarchy tree', 'Select a time period (1d, 3d, 7d, 15d, 30d or all) to filter the view', 'Click any session node to inspect its details', 'Zoom and pan through the graph'],
68
71
  hints: 'When an agent spawns a child agent to do work, that relationship is shown here. Use the period selector to see recent activity.',
69
72
  },
70
73
  '/memory': {
@@ -81,9 +84,9 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
81
84
  },
82
85
  '/tools': {
83
86
  title: 'Tools',
84
- description: 'Catalog of all tools available to agents — built-in tools, MCP (Model Context Protocol) tools, plugin tools, and audio transcription tools.',
85
- actions: ['Browse the full tool catalog', 'See tool name, source package, and description', 'View audio/voice configuration settings', 'Check which MCP servers are connected'],
86
- hints: 'Each tool has a source (built-in, plugin, or MCP server). If a tool is missing, check the relevant plugin or MCP connection.',
87
+ description: 'Curated catalog of the OpenClaw built-in tools available to agents, grouped by area, plus audio and timezone settings. It does not list MCP servers or plugin tools.',
88
+ actions: ['Browse the full tool catalog', 'See each tool\'s name, description and status (Configured, Available, Coming soon)', 'View the audio transcription and time zone settings'],
89
+ hints: 'The catalog is a fixed list of OpenClaw built-in tools, not read from the agents. It shows no MCP servers or plugin tools, so a tool missing here says nothing about what an agent has.',
87
90
  },
88
91
  '/plugins': {
89
92
  title: 'Plugins',
@@ -177,6 +180,24 @@ export async function POST(request: NextRequest): Promise<Response> {
177
180
  ];
178
181
 
179
182
  const { provider, model: upstreamModel } = extractProviderAndModel({ model: effectiveModel });
183
+
184
+ // The chat panel loads its model list once, at mount, and keeps the choice in
185
+ // localStorage — so it can name a model that was unchecked on the Gateway page
186
+ // an hour ago, or one already gone when the preference was saved. Enforce the
187
+ // catalogue here rather than trusting the client to hold a current list.
188
+ if (!isModelOffered(provider, upstreamModel)) {
189
+ const alternative = loadOfferedModels()[0];
190
+ return NextResponse.json(
191
+ {
192
+ error: `Model '${provider}/${upstreamModel}' is not enabled for this deployment.`
193
+ + (alternative
194
+ ? ` Try ${alternative.id}, or enable it on the Gateway page.`
195
+ : ' No model is currently enabled — check the Gateway page.'),
196
+ },
197
+ { status: 400 },
198
+ );
199
+ }
200
+
180
201
  const providerKeys = readProviderKeys();
181
202
  const apiKey = getApiKey(provider, providerKeys);
182
203
 
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { getProfile, markSynced } from '@/lib/credentials/vault';
2
+ import { getProfile, markSynced, markDesynced } from '@/lib/credentials/vault';
3
3
  import { syncProfileToAgents, cleanAgentProviders } from '@/lib/credentials/delivery';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
5
 
@@ -48,8 +48,8 @@ export async function DELETE(request: Request,
48
48
  return NextResponse.json({ error: 'container is required' }, { status: 400 });
49
49
  }
50
50
 
51
- cleanAgentProviders(container, profile.providerId);
52
- markSynced(id, 'active');
51
+ await cleanAgentProviders(container, profile.providerId);
52
+ markDesynced(id);
53
53
 
54
54
  return NextResponse.json({ ok: true });
55
55
  } catch (e: unknown) {