@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.
- package/README.md +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/Skeleton.tsx +132 -0
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +461 -140
- package/app/credentials/loading.tsx +19 -5
- package/app/gateway/PageClient.tsx +257 -673
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +73 -9
- package/docs/ARCHITECTURE.md +92 -33
- package/docs/FRONTEND-ARCHITECTURE.md +36 -6
- package/docs/REV4A.md +62 -30
- package/docs/dev/API-REFERENCE.md +490 -227
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +236 -92
- package/docs/dev/PROVIDERS.md +44 -44
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +20 -18
- package/docs/rag/REV4A-OVERVIEW.md +28 -32
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/lib/model-catalogue.ts +140 -27
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- 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 {
|
|
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) +
|
|
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
|
|
163
|
-
const ready =
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 {
|
|
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
|
|
251
|
-
const ready =
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 {
|
|
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 {
|
|
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) +
|
|
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
|
-
|
|
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 '${
|
|
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:
|
|
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
|
|
325
|
-
//
|
|
326
|
-
// a zombie agent with no config is worse than an explicit failure.
|
|
327
|
-
const ready =
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
491
|
-
return config.some((m) => m.id === modelId);
|
|
491
|
+
execFileSync('docker', ['network', 'inspect', name], { stdio: 'ignore', timeout: 5000 });
|
|
492
492
|
} catch {
|
|
493
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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: '
|
|
31
|
-
actions: ['Browse available agent templates', 'Name your agent and select its AI model', '
|
|
32
|
-
hints: 'Templates provide pre-built agent configurations.
|
|
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: '
|
|
37
|
-
actions: ['
|
|
38
|
-
hints: '
|
|
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
|
|
49
|
-
actions: ['View
|
|
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
|
|
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: '
|
|
85
|
-
actions: ['Browse the full tool catalog', 'See tool name,
|
|
86
|
-
hints: '
|
|
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
|
-
|
|
51
|
+
await cleanAgentProviders(container, profile.providerId);
|
|
52
|
+
markDesynced(id);
|
|
53
53
|
|
|
54
54
|
return NextResponse.json({ ok: true });
|
|
55
55
|
} catch (e: unknown) {
|