@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
@@ -1,22 +1,12 @@
1
1
  import { type NextRequest } from "next/server";
2
2
  import { NextResponse } from 'next/server';
3
- import { MODELS_CONFIG_FILE } from '@/lib/rev4a-paths';
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
3
  import { readProviderKeys } from '@/app/api/gateway/provider/keys';
7
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+ import { loadOfferedModels } from '@/lib/model-catalogue';
6
+ import { getPricing } from '@/lib/model-pricing';
8
7
 
9
8
  export const dynamic = 'force-dynamic';
10
9
 
11
- interface ModelsConfigEntry {
12
- id: string;
13
- name: string;
14
- provider: string;
15
- enabled: boolean;
16
- }
17
-
18
- const CONFIG_PATH = MODELS_CONFIG_FILE;
19
-
20
10
  const PROVIDER_EMOJI: Record<string, string> = {
21
11
  deepseek: '🧠',
22
12
  openrouter: '🌐',
@@ -37,32 +27,27 @@ const PROVIDER_LABEL: Record<string, string> = {
37
27
  rev4a: 'Rev4a',
38
28
  };
39
29
 
40
- function loadModelsConfig(): ModelsConfigEntry[] {
41
- try {
42
- const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
43
- const parsed = JSON.parse(raw);
44
- if (Array.isArray(parsed.models)) return parsed.models as ModelsConfigEntry[];
45
- } catch {}
46
- return [];
47
- }
48
-
30
+ /**
31
+ * Model picker source. Uses the shared catalogue loader so the list offered here
32
+ * matches what the sync actually writes into agent containers — this route used
33
+ * to read models.config.json directly and ignore model-overrides.json, so models
34
+ * switched off on the Gateway page still showed up as selectable.
35
+ */
49
36
  export async function GET(_req: NextRequest): Promise<NextResponse> {
50
37
  const request = _req;
51
38
  const denied = await requireAuthJWT(request);
52
39
  if (denied) return denied;
53
- const allModels = loadModelsConfig();
54
- const providerKeys = readProviderKeys();
55
- const configured = new Set(Object.keys(providerKeys));
56
-
57
- const eligible = allModels.filter((m) => {
58
- if (!m.enabled) return false;
59
- if (configured.has(m.provider)) return true;
60
- // rev4a/* aliases resolve to deepseek upstream
61
- if (m.provider === 'rev4a' && configured.has('deepseek')) return true;
62
- return false;
63
- });
40
+ const eligible = loadOfferedModels();
64
41
 
65
- const grouped = new Map<string, { provider: string; emoji: string; models: { id: string; label: string }[] }>();
42
+ const grouped = new Map<string, {
43
+ provider: string;
44
+ emoji: string;
45
+ models: {
46
+ id: string; label: string;
47
+ input: number | null; output: number | null;
48
+ deprecated: boolean;
49
+ }[];
50
+ }>();
66
51
 
67
52
  for (const model of eligible) {
68
53
  if (!grouped.has(model.provider)) {
@@ -72,7 +57,16 @@ export async function GET(_req: NextRequest): Promise<NextResponse> {
72
57
  models: [],
73
58
  });
74
59
  }
75
- grouped.get(model.provider)!.models.push({ id: model.id, label: model.name });
60
+ // Price travels with the option so the picker can show cost at the point of
61
+ // choice, instead of sending the operator to another page to look it up.
62
+ const price = getPricing(model.id);
63
+ grouped.get(model.provider)!.models.push({
64
+ id: model.id,
65
+ label: model.name,
66
+ input: price?.input ?? null,
67
+ output: price?.output ?? null,
68
+ deprecated: model.deprecated ?? false,
69
+ });
76
70
  }
77
71
 
78
72
  return NextResponse.json(Array.from(grouped.values()));
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Who may call the Rev4a Provider Gateway.
3
+ *
4
+ * One credential opens this door: the `rev4a` entry of `provider-keys.json`.
5
+ * That is the value `buildRev4aProviderConfig()` writes into every agent
6
+ * container, and `syncAllAgents()` refuses to run without it, so it is the only
7
+ * token any legitimate client holds. `REV4A_API_KEY` is accepted alongside it
8
+ * for callers configured before the key moved into the file.
9
+ *
10
+ * **Why this is its own module.** The two v1 routes each carried their own copy
11
+ * of the check and the copies had drifted — one guarded the empty token, the
12
+ * other did not. Worse, both compared the incoming token against *every* value
13
+ * in `provider-keys.json`:
14
+ *
15
+ * Object.values(providerKeys).some((key) => token === key)
16
+ *
17
+ * `provider-keys.json` holds two different kinds of secret in one map. The
18
+ * `rev4a` entry is the password to this door. Every other entry is a credential
19
+ * Rev4a presents to an upstream provider. Iterating the map conflated them, so a
20
+ * DeepSeek API key authenticated here exactly like the gateway key — verified
21
+ * against a running instance, which answered `400 invalid_request_error` to a
22
+ * malformed body rather than `401`.
23
+ *
24
+ * With one upstream provider configured that granted nothing: the holder of a
25
+ * DeepSeek key can already spend at DeepSeek. It mattered as a latent fault. Add
26
+ * an OpenAI key and the old DeepSeek key silently becomes a way to spend at
27
+ * OpenAI, with nothing in the code or the UI to say so.
28
+ */
29
+ import { timingSafeEqual } from 'crypto';
30
+ import { readProviderKeys } from '@/app/api/gateway/provider/keys';
31
+
32
+ /** Compare without leaking length or position through timing. */
33
+ function secretEquals(a: string, b: string): boolean {
34
+ const ab = Buffer.from(a, 'utf8');
35
+ const bb = Buffer.from(b, 'utf8');
36
+ // timingSafeEqual throws on length mismatch, which is itself an early exit.
37
+ // Comparing digests of equal length would hide it; for a bearer token the
38
+ // length is not the secret, so a plain guard is enough.
39
+ if (ab.length !== bb.length) return false;
40
+ return timingSafeEqual(ab, bb);
41
+ }
42
+
43
+ /**
44
+ * True when the request carries the gateway credential.
45
+ *
46
+ * Never true for an absent or empty token, whatever the key file contains: an
47
+ * entry saved as `""` would otherwise match a request with no Authorization
48
+ * header at all.
49
+ */
50
+ export function isGatewayClient(request: Request): boolean {
51
+ const token = (request.headers.get('Authorization') || '')
52
+ .replace(/^Bearer\s+/i, '')
53
+ .trim();
54
+ if (!token) return false;
55
+
56
+ // provider-keys.json is hand-editable: a non-string value must deny, not throw.
57
+ // Buffer.from on a number raises, and the models route has no catch.
58
+ const gatewayKey: unknown = readProviderKeys()['rev4a'];
59
+ if (typeof gatewayKey === 'string' && gatewayKey && secretEquals(token, gatewayKey)) return true;
60
+
61
+ const masterKey = process.env.REV4A_API_KEY;
62
+ if (masterKey && secretEquals(token, masterKey)) return true;
63
+
64
+ return false;
65
+ }
@@ -57,7 +57,7 @@ export const PROVIDER_ENVS: Record<string, ProviderEnvConfig> = {
57
57
 
58
58
  /** Map rev4a/ model aliases to upstream provider/model pairs. */
59
59
  export const MODEL_ALIASES: Record<string, { provider: string; model: string }> = {
60
- 'rev4a/deepseek-v4-flash': { provider: 'deepseek', model: 'deepseek-v4-flash' },
60
+ 'rev4a/deepseek-flash': { provider: 'deepseek', model: 'deepseek-flash' },
61
61
  'rev4a/deepseek-v4-pro': { provider: 'deepseek', model: 'deepseek-v4-pro' },
62
62
  };
63
63
 
@@ -76,7 +76,7 @@ export function extractProviderAndModel(request: UpstreamRequest): { provider: s
76
76
  if (request.provider) {
77
77
  return { provider: request.provider, model: modelStr };
78
78
  }
79
- // Check aliases first (rev4a/deepseek-v4-flash -> deepseek/deepseek-v4-flash)
79
+ // Check aliases first (rev4a/deepseek-flash -> deepseek/deepseek-flash)
80
80
  if (modelStr && MODEL_ALIASES[modelStr]) {
81
81
  return { ...MODEL_ALIASES[modelStr] };
82
82
  }
@@ -114,6 +114,13 @@ export async function callUpstream(
114
114
 
115
115
  const upstreamBody: Record<string, unknown> = { ...body, model: model || undefined };
116
116
  delete upstreamBody.provider;
117
+ // `model` has been checked against the catalogue; these fields let the upstream
118
+ // pick a different one. OpenRouter serves a fallback from `models` when the
119
+ // primary fails, and `route`/`preset` can redirect the request, so forwarding them
120
+ // would let a client get a model the catalogue does not offer.
121
+ delete upstreamBody.models;
122
+ delete upstreamBody.route;
123
+ delete upstreamBody.preset;
117
124
 
118
125
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
119
126
 
@@ -4,11 +4,14 @@
4
4
  * OpenAI-compatible chat completions endpoint.
5
5
  * POST /api/provider/v1/chat/completions
6
6
  *
7
- * Authentication uses provider keys stored in data/provider-keys.json.
8
- * The Authorization Bearer token must match a configured provider key.
9
- * The rev4a master key (REV4A_API_KEY env var) unlocks all providers.
7
+ * Authentication is the `rev4a` entry of provider-keys.json, or REV4A_API_KEY.
8
+ * See `app/api/provider/auth.ts` — in particular why the other entries in that
9
+ * file, which are upstream credentials rather than passwords, must not be
10
+ * accepted here.
10
11
  */
11
12
  import { NextRequest } from 'next/server';
13
+ import { isGatewayClient } from '@/app/api/provider/auth';
14
+ import { isModelOffered } from '@/lib/model-catalogue';
12
15
  import { readProviderKeys } from '@/app/api/gateway/provider/keys';
13
16
  import {
14
17
  extractProviderAndModel,
@@ -50,19 +53,7 @@ export async function POST(request: NextRequest) {
50
53
  const start = Date.now();
51
54
 
52
55
  try {
53
- // Resolve provider from Authorization token
54
- const authHeader = request.headers.get('Authorization') || '';
55
- const token = authHeader.replace(/^Bearer\s+/i, '').trim();
56
- const providerKeys = readProviderKeys();
57
-
58
- // Check if token matches any stored key
59
- const isAuthenticated = Object.values(providerKeys).some((key) => token === key);
60
-
61
- // Also accept the rev4a master API key env var
62
- const rev4aMasterKey = process.env.REV4A_API_KEY;
63
- const isMasterAuth = !!rev4aMasterKey && token === rev4aMasterKey;
64
-
65
- if (!isAuthenticated && !isMasterAuth) {
56
+ if (!isGatewayClient(request)) {
66
57
  return errorResponse(
67
58
  'Invalid API key. Use your Rev4a API key from the Gateway page via Authorization: Bearer <key>.',
68
59
  'authentication_error',
@@ -73,6 +64,21 @@ export async function POST(request: NextRequest) {
73
64
  const body: ProviderV1Request = await request.json();
74
65
  const { provider, model } = extractProviderAndModel(body);
75
66
 
67
+ // The catalogue is policy, and policy has to be enforced where the money is
68
+ // spent, not only where the pickers are drawn. Without this, unchecking a
69
+ // model on the Gateway page hid it from every list and still served every
70
+ // client that already held the id.
71
+ if (!isModelOffered(provider, model)) {
72
+ return errorResponse(
73
+ `Model '${provider}/${model}' is not enabled for this deployment. ` +
74
+ 'Enable it on the Gateway page, or pick one that is.',
75
+ 'invalid_request_error',
76
+ 400,
77
+ );
78
+ }
79
+
80
+ // Upstream credentials, distinct from the token that opened this door.
81
+ const providerKeys = readProviderKeys();
76
82
  const apiKey = getApiKey(provider, providerKeys);
77
83
  if (!apiKey) {
78
84
  return errorResponse(
@@ -3,143 +3,36 @@
3
3
  *
4
4
  * GET /api/provider/v1/models
5
5
  *
6
- * Returns models available through the Rev4a Gateway
7
- * in OpenAI-compatible format, filtered by the provider
8
- * identified from the Authorization token.
6
+ * Returns the models available through the Rev4a Gateway, in OpenAI-compatible
7
+ * format. This is the endpoint agent containers call to discover what they can
8
+ * ask for, so it must agree with what `gateway/sync.ts` writes into their config.
9
9
  *
10
- * The model list is read from models.config.json in the project root.
11
- * If the file doesn't exist, falls back to provider env config + static defaults.
10
+ * It did not. This route used to carry its own copy of the catalogue and its own
11
+ * loader, and the two diverged in ways that mattered:
12
+ *
13
+ * - **User toggles were ignored here.** Disabling a model from the Gateway page
14
+ * writes to `model-overrides.json`, which `loadModelsConfig()` applies. This
15
+ * route read `models.config.json` raw, so a model switched off in the UI was
16
+ * still advertised to agents.
17
+ * - **A read failure overwrote the catalogue.** If the file was missing or
18
+ * momentarily unparseable, the `catch` wrote a hardcoded 30-model default over
19
+ * it — replacing the real catalogue, user overrides included.
20
+ *
21
+ * Both are gone: the catalogue now comes from `lib/model-catalogue`, the single
22
+ * loader shared with the create route, the provider page and the sync.
12
23
  */
13
24
  import { NextResponse, NextRequest } from 'next/server';
14
- import { MODELS_CONFIG_FILE } from '@/lib/rev4a-paths';
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
- import { readProviderKeys } from '@/app/api/gateway/provider/keys';
25
+ import { isGatewayClient } from '@/app/api/provider/auth';
26
+ import { loadOfferedModels } from '@/lib/model-catalogue';
18
27
 
19
28
  export const dynamic = 'force-dynamic';
20
29
  export const runtime = 'nodejs';
21
30
 
22
- interface ModelEntry {
23
- id: string;
24
- object: string;
25
- created: number;
26
- owned_by: string;
27
- permission: string[];
28
- root: string;
29
- }
30
-
31
- interface ModelsConfigEntry {
32
- id: string;
33
- name: string;
34
- provider: string;
35
- enabled: boolean;
36
- }
37
-
38
- const CONFIG_PATH = MODELS_CONFIG_FILE;
39
-
40
- /** Default model catalogue (known models for each provider) */
41
- const DEFAULT_CATALOGUE: ModelsConfigEntry[] = [
42
- // OpenAI
43
- { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', provider: 'openai', enabled: true },
44
- { id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'openai', enabled: true },
45
- { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'openai', enabled: true },
46
- { id: 'openai/gpt-5.5', name: 'GPT-5.5', provider: 'openai', enabled: true },
47
- { id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'openai', enabled: true },
48
- { id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', enabled: true },
49
- // Anthropic
50
- { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', provider: 'anthropic', enabled: false },
51
- { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', provider: 'anthropic', enabled: false },
52
- { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', provider: 'anthropic', enabled: false },
53
- { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic', enabled: false },
54
- // DeepSeek
55
- { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'deepseek', enabled: true },
56
- { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'deepseek', enabled: true },
57
- // OpenRouter
58
- { id: 'openrouter/auto', name: 'OpenRouter Auto', provider: 'openrouter', enabled: true },
59
- { id: 'openrouter/deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash (OR)', provider: 'openrouter', enabled: true },
60
- { id: 'openrouter/google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (OR)', provider: 'openrouter', enabled: true },
61
- { id: 'openrouter/google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (OR)', provider: 'openrouter', enabled: true },
62
- { id: 'openrouter/anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6 (OR)', provider: 'openrouter', enabled: false },
63
- { id: 'openrouter/deepseek/deepseek-r1-0528', name: 'DeepSeek R1 0528 (OR)', provider: 'openrouter', enabled: false },
64
- // Groq
65
- { id: 'groq/llama-3.1-8b-instant', name: 'Llama 3.1 8B (Groq)', provider: 'groq', enabled: false },
66
- { id: 'groq/llama-3.3-70b-versatile', name: 'Llama 3.3 70B (Groq)', provider: 'groq', enabled: false },
67
- // Kimi
68
- { id: 'kimi/kimi-k3', name: 'Kimi K3', provider: 'kimi', enabled: true },
69
- { id: 'kimi/kimi-k2.7-code', name: 'Kimi K2.7 Code', provider: 'kimi', enabled: false },
70
- { id: 'kimi/kimi-k2.7-code-highspeed', name: 'Kimi K2.7 Code HighSpeed', provider: 'kimi', enabled: false },
71
- { id: 'kimi/kimi-k2.6', name: 'Kimi K2.6', provider: 'kimi', enabled: false },
72
- // GLM
73
- { id: 'glm/glm-5.2', name: 'GLM-5.2', provider: 'glm', enabled: false },
74
- { id: 'glm/glm-5.1', name: 'GLM-5.1', provider: 'glm', enabled: false },
75
- { id: 'glm/glm-5', name: 'GLM-5', provider: 'glm', enabled: false },
76
- { id: 'glm/glm-4.7', name: 'GLM-4.7', provider: 'glm', enabled: false },
77
- { id: 'glm/glm-4.7-flash', name: 'GLM-4.7 Flash', provider: 'glm', enabled: false },
78
- { id: 'glm/glm-4.7-flashx', name: 'GLM-4.7 FlashX', provider: 'glm', enabled: false },
79
- { id: 'glm/glm-5v-turbo', name: 'GLM-5V Turbo', provider: 'glm', enabled: false },
80
- { id: 'glm/glm-4.6v-flash', name: 'GLM-4.6V Flash', provider: 'glm', enabled: false },
81
- // Qwen
82
- { id: 'qwen/qwen3.7-max', name: 'Qwen 3.7 Max', provider: 'qwen', enabled: false },
83
- { id: 'qwen/qwen3.7-plus', name: 'Qwen 3.7 Plus', provider: 'qwen', enabled: true },
84
- { id: 'qwen/qwen3.7-flash', name: 'Qwen 3.7 Flash', provider: 'qwen', enabled: false },
85
- { id: 'qwen/qwen3-coder-plus', name: 'Qwen 3 Coder Plus', provider: 'qwen', enabled: false },
86
- { id: 'qwen/qwen-long', name: 'Qwen Long', provider: 'qwen', enabled: false },
87
- // Rev4a aliases (convenience models for the Rev4a proxy)
88
- { id: 'rev4a/deepseek-v4-flash', name: 'DeepSeek V4 Flash (Rev4a)', provider: 'rev4a', enabled: true },
89
- { id: 'rev4a/deepseek-v4-pro', name: 'DeepSeek V4 Pro (Rev4a)', provider: 'rev4a', enabled: true },
90
- { id: 'rev4a/fast', name: 'Rev4a Fast', provider: 'rev4a', enabled: false },
91
- { id: 'rev4a/code', name: 'Rev4a Code', provider: 'rev4a', enabled: false },
92
- { id: 'rev4a/reason', name: 'Rev4a Reason', provider: 'rev4a', enabled: false },
93
- { id: 'rev4a/fallback-general', name: 'Rev4a Fallback General', provider: 'rev4a', enabled: false },
94
- ];
95
-
96
- /** Load the models config from file, or create from defaults */
97
- function loadConfig(): ModelsConfigEntry[] {
98
- try {
99
- const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
100
- const parsed = JSON.parse(raw);
101
- if (Array.isArray(parsed.models)) {
102
- return parsed.models as ModelsConfigEntry[];
103
- }
104
- return DEFAULT_CATALOGUE;
105
- } catch {
106
- // File doesn't exist or invalid — write defaults
107
- try {
108
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({ models: DEFAULT_CATALOGUE }, null, 2), 'utf-8');
109
- } catch {
110
- // Can't write, fall back to defaults in memory
111
- }
112
- return DEFAULT_CATALOGUE;
113
- }
114
- }
115
-
116
- /** Resolve the provider from the Authorization token by matching against provider-keys.json */
117
- function resolveProviderFromToken(authHeader: string): string | null {
118
- const token = authHeader.replace(/^Bearer\s+/i, '').trim();
119
- if (!token) return null;
120
-
121
- const providerKeys = readProviderKeys();
122
-
123
- // Match the token value against stored provider keys
124
- for (const [, key] of Object.entries(providerKeys)) {
125
- if (token === key) return 'authenticated';
126
- }
127
-
128
- // Also try matching the rev4a API key env var (legacy compat)
129
- if (token === process.env.REV4A_API_KEY) return 'all';
130
-
131
- return null;
132
- }
133
-
134
31
  export async function GET(request: NextRequest) {
135
- const allModels = loadConfig();
136
-
137
- // Determine provider from Authorization token
138
- const authHeader = request.headers.get('Authorization') || '';
139
- const provider = resolveProviderFromToken(authHeader);
140
-
141
- if (!provider) {
142
- // No valid token — return empty list to avoid leaking model info
32
+ if (!isGatewayClient(request)) {
33
+ // An empty list rather than a 401: this endpoint should not confirm the
34
+ // catalogue to a caller that cannot use it. The cost is that a container
35
+ // configured with the wrong key sees "no models" instead of an auth error.
143
36
  return NextResponse.json({
144
37
  object: 'list',
145
38
  total: 0,
@@ -147,10 +40,10 @@ export async function GET(request: NextRequest) {
147
40
  });
148
41
  }
149
42
 
150
- let activeModels: ModelsConfigEntry[];
151
-
152
- // Token authenticated — return all enabled models
153
- activeModels = allModels.filter((m) => m.enabled);
43
+ // Same set as gateway/sync.ts writes into the containers, so what an agent can
44
+ // discover matches what it was actually given. This route used to skip the
45
+ // provider-key half of that test and advertise models nobody could be billed for.
46
+ const activeModels = loadOfferedModels();
154
47
 
155
48
  return NextResponse.json({
156
49
  object: 'list',
@@ -1,60 +1,36 @@
1
1
  /**
2
2
  * GET /api/setup/agent-image
3
3
  *
4
- * Checks if openclaw-agent-base:latest exists locally or needs downloading.
5
- * Returns status and a hint for the UI.
4
+ * Whether a supported OpenClaw version of the agent base image is downloaded.
5
+ * Returns `{ exists, version }`, the version being the newest one present.
6
6
  *
7
7
  * POST /api/setup/agent-image
8
8
  *
9
- * Downloads openclaw-agent-base:latest from ghcr.io (public registry).
10
- * Streams progress via SSE so the setup wizard can show a live progress bar.
9
+ * Downloads the newest supported version the registry publishes, and streams
10
+ * progress via SSE so the setup wizard can show a live progress bar. Nothing is
11
+ * downloaded when that version is already here.
11
12
  *
12
13
  * Only one download at a time — returns 409 if already running.
13
- * Cleans up the docker process if the client disconnects.
14
14
  */
15
15
  import { NextResponse, type NextRequest } from 'next/server';
16
16
  import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
17
- import { execSync } from 'child_process';
18
17
  import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
18
+ import { dockerAvailable } from '@/lib/docker-socket';
19
+ import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
19
20
 
20
21
  export const dynamic = 'force-dynamic';
21
22
 
22
- function dockerOk(): boolean {
23
- try {
24
- execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
25
- encoding: 'utf-8',
26
- timeout: 2000,
27
- });
28
- return true;
29
- } catch {
30
- return false;
31
- }
32
- }
33
-
34
23
  export async function GET(request: NextRequest): Promise<NextResponse> {
35
24
  const denied = await requireAuthIfConfigured(request);
36
25
  if (denied) return denied;
37
- if (!dockerOk()) {
38
- return NextResponse.json({ exists: false });
26
+ if (!dockerAvailable()) {
27
+ return NextResponse.json({ exists: false, version: null });
39
28
  }
40
29
  try {
41
- const { exec } = await import('child_process');
42
- const result = await new Promise<string>((resolve, reject) => {
43
- exec(
44
- 'docker image inspect openclaw-agent-base:latest --format "{{.ID}}" 2>/dev/null',
45
- { timeout: 5000 },
46
- (err, stdout) => {
47
- if (err) return reject(err);
48
- resolve(stdout.trim());
49
- },
50
- );
51
- });
52
- if (result) {
53
- return NextResponse.json({ exists: true, imageId: result });
54
- }
55
- return NextResponse.json({ exists: false });
30
+ const version = newestLocalSupportedVersion(await listLocalAgentImages());
31
+ return NextResponse.json({ exists: version !== null, version });
56
32
  } catch {
57
- return NextResponse.json({ exists: false });
33
+ return NextResponse.json({ exists: false, version: null });
58
34
  }
59
35
  }
60
36
 
@@ -81,11 +57,7 @@ export async function POST(request: NextRequest): Promise<Response> {
81
57
  const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
82
58
 
83
59
  try {
84
- await downloadAgentImage({
85
- force: false, // setup: skip if already exists
86
- onEvent: send,
87
- signal: ctrl.signal,
88
- });
60
+ await downloadAgentImage({ onEvent: send, signal: ctrl.signal });
89
61
  } catch (e: unknown) {
90
62
  send(JSON.stringify({
91
63
  type: 'error',
@@ -94,7 +66,7 @@ export async function POST(request: NextRequest): Promise<Response> {
94
66
  }
95
67
  controller.close();
96
68
  },
97
- cancel() { /* downloadAgentImage handles cleanup via signal */ },
69
+ cancel() { /* downloadAgentImage stops the docker process through the signal */ },
98
70
  });
99
71
 
100
72
  return new Response(stream, {
@@ -32,7 +32,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
32
32
  className={`agent-btn ${filter.showOnlyActive ? 'active' : ''}`}
33
33
  onClick={() => onChange({ showOnlyActive: !filter.showOnlyActive })}
34
34
  >
35
- solo attivi
35
+ active only
36
36
  </button>
37
37
 
38
38
  <button
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useRef, useEffect, useCallback } from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
+ import { useModels } from '../lib/models-context';
5
6
  import Link from 'next/link';
6
7
 
7
8
  const STORAGE_KEY = 'rev4a_pulse_chat';
@@ -79,51 +80,36 @@ export default function PulseChat() {
79
80
  const [input, setInput] = useState('');
80
81
  const [streaming, setStreaming] = useState(false);
81
82
  const [selectedModel, setSelectedModel] = useState<string>('');
82
- const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
83
- const [modelsLoading, setModelsLoading] = useState(true);
83
+ // The shared catalogue, not a private copy. It refreshes when the Gateway page
84
+ // changes what is enabled, so this select cannot go stale mid-session.
85
+ const { providers: modelGroups, loaded: modelsLoaded } = useModels();
86
+ const modelsLoading = !modelsLoaded;
84
87
  const bottomRef = useRef<HTMLDivElement>(null);
85
88
  const inputRef = useRef<HTMLTextAreaElement>(null);
86
89
  const abortRef = useRef<AbortController | null>(null);
87
90
 
88
- // Fetch available models from backend on mount
91
+ // Keep the choice valid against the current catalogue.
92
+ //
93
+ // Runs whenever the shared list changes, which includes the moment a model is
94
+ // unchecked on the Gateway page: a selection that just became unavailable is
95
+ // replaced rather than left to fail at send time. The saved preference is only
96
+ // honoured while it is still on offer.
89
97
  useEffect(() => {
90
- let cancelled = false;
91
-
92
- async function fetchModels() {
93
- try {
94
- const res = await fetch('/api/models');
95
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
96
- const data: ModelGroup[] = await res.json();
97
- if (cancelled) return;
98
-
99
- setModelGroups(data);
100
-
101
- const allIds = data.flatMap((g) => g.models.map((m) => m.id));
102
-
103
- // Restore saved model if valid, otherwise pick first
104
- try {
105
- const saved = localStorage.getItem(MODEL_STORAGE_KEY);
106
- if (saved && allIds.includes(saved)) {
107
- setSelectedModel(saved);
108
- } else if (allIds.length > 0) {
109
- setSelectedModel(allIds[0]);
110
- }
111
- } catch {
112
- if (allIds.length > 0) setSelectedModel(allIds[0]);
113
- }
114
- } catch {
115
- // API unavailable — leave selectedModel empty, select stays disabled
116
- } finally {
117
- if (!cancelled) setModelsLoading(false);
118
- }
119
- }
120
-
121
- fetchModels();
122
- return () => { cancelled = true; };
123
- }, []);
124
-
125
- // Save model preference on change
98
+ if (!modelsLoaded) return;
99
+ const allIds = modelGroups.flatMap((g) => g.models.map((m) => m.id));
100
+ if (allIds.length === 0) { setSelectedModel(''); return; }
101
+ if (selectedModel && allIds.includes(selectedModel)) return;
102
+
103
+ let saved: string | null = null;
104
+ try { saved = localStorage.getItem(MODEL_STORAGE_KEY); } catch { /* blocked */ }
105
+ setSelectedModel(saved && allIds.includes(saved) ? saved : allIds[0]);
106
+ }, [modelGroups, modelsLoaded, selectedModel]);
107
+
108
+ // Save model preference on change. Not while empty: on mount the selection is ''
109
+ // until the list arrives, and saving that erased the preference before it could
110
+ // be read back.
126
111
  useEffect(() => {
112
+ if (!selectedModel) return;
127
113
  try {
128
114
  localStorage.setItem(MODEL_STORAGE_KEY, selectedModel);
129
115
  } catch {