@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
@@ -2,17 +2,22 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { listProfiles, getSecret } from '@/lib/credentials/vault';
3
3
  import {
4
4
  listAgentContainers,
5
- execInContainer,
6
- cliInstalled,
5
+ probeContainer,
7
6
  providerTokenHash,
8
7
  profileTokenHash,
9
- detectTrello,
10
- detectNotion,
8
+ vercelCurrentTeam,
9
+ supabaseProjectRef,
10
+ TOKEN_FIELD,
11
+ type ContainerProbe,
11
12
  } from '@/lib/credentials/detect';
13
+ import { mapWithConcurrency } from '@/lib/docker-exec';
12
14
  import { requireAuthJWT } from '@/lib/rev4a-auth';
13
15
 
14
16
  export const dynamic = 'force-dynamic';
15
17
 
18
+ /** Containers probed at once. Each is a single `docker exec`. */
19
+ const AGENT_QUERY_CONCURRENCY = 6;
20
+
16
21
  interface DetectedStatus {
17
22
  container: string;
18
23
  agentId: string;
@@ -33,6 +38,12 @@ interface DetectedStatus {
33
38
  notionLoggedIn: boolean;
34
39
  toolsMdHasMarkers: boolean;
35
40
  matchedProfiles: { profileId: string; providerId: string; label: string }[];
41
+ /**
42
+ * Providers with a token present in the container, whether or not it belongs
43
+ * to a stored profile. A provider listed here but absent from
44
+ * `matchedProfiles` holds a credential nobody in the vault accounts for.
45
+ */
46
+ installedProviders: string[];
36
47
  }
37
48
 
38
49
  interface DetectionError {
@@ -41,125 +52,106 @@ interface DetectionError {
41
52
  error: string;
42
53
  }
43
54
 
44
- function detectSupabase(container: string): { loggedIn: boolean; linked: boolean; projectRef: string } {
45
- if (!cliInstalled(container, 'supabase')) {
46
- return { loggedIn: false, linked: false, projectRef: '' };
47
- }
48
-
49
- // Verify login by calling the Supabase API (analogous to gh auth status / vercel whoami)
50
- const raw = execInContainer(
51
- container,
52
- 'supabase projects list --output json 2>&1',
53
- (r) => r.trim(),
54
- '',
55
- );
56
-
57
- // Unauthorized → not logged in
58
- if (!raw || raw.includes('Unauthorized')) {
59
- return { loggedIn: false, linked: false, projectRef: '' };
60
- }
61
-
62
- // Check if a linked project exists in the workspace
63
- let linked = false;
64
- let projectRef = '';
65
- const linkedRef = execInContainer(
66
- container,
67
- 'cat /data/supabase/config.toml 2>/dev/null | grep project_id | head -1 | sed "s/.*= *\"//;s/\".*//"',
68
- (r) => r.trim(),
69
- '',
70
- );
71
- if (linkedRef && linkedRef.length > 0) {
72
- linked = true;
73
- projectRef = linkedRef;
74
- }
75
-
76
- return { loggedIn: true, linked, projectRef };
77
- }
78
-
79
- function detectGithub(container: string): { loggedIn: boolean; user: string } {
80
- if (!cliInstalled(container, 'gh')) {
81
- return { loggedIn: false, user: '' };
82
- }
83
-
84
- const raw = execInContainer(
85
- container,
86
- 'gh auth status 2>&1',
87
- (r) => r,
88
- '',
89
- );
90
- if (!raw || raw.includes('not logged into any GitHub hosts')) {
91
- return { loggedIn: false, user: '' };
92
- }
93
- const match = raw.match(/Logged in to github\.com as (\S+)/);
94
- return { loggedIn: !!match, user: match?.[1] ?? '' };
55
+ /** Last non-empty line of `vercel whoami` — the rest is header noise. */
56
+ function extractUsername(raw: string): string {
57
+ const lines = raw.split('\n').filter((l) => l.trim());
58
+ return lines[lines.length - 1]?.trim() || raw.trim();
95
59
  }
96
60
 
97
- function detectToolsMd(container: string): boolean {
98
- const raw = execInContainer(
99
- container,
100
- `grep -c 'REV4A:INTEGRATIONS:START' /root/.openclaw/workspace/TOOLS.md 2>/dev/null || echo 0`,
101
- (r) => r.trim(),
102
- '0',
103
- );
104
- return raw === '1' || parseInt(raw, 10) > 0;
61
+ /**
62
+ * Stored profiles with their token hashes, computed once per request.
63
+ *
64
+ * This used to run inside the per-container loop: `listProfiles()` plus one
65
+ * `getSecret()` per profile, each opening its own SQLite connection, repeated
66
+ * for every agent. Nothing in it depends on the container, so it is hoisted.
67
+ */
68
+ interface HashedProfile {
69
+ profileId: string;
70
+ providerId: string;
71
+ label: string;
72
+ tokenHash: string;
105
73
  }
106
74
 
107
- function detectVercel(container: string): { loggedIn: boolean; user: string; team: string } {
108
- if (!cliInstalled(container, 'vercel')) {
109
- return { loggedIn: false, user: '', team: '' };
110
- }
111
-
112
- const whoami = execInContainer(
113
- container,
114
- 'vercel whoami 2>&1 || true',
115
- (r) => r.trim(),
116
- '',
117
- );
118
- if (whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized')) {
119
- const teamRaw = execInContainer(
120
- container,
121
- "cat /root/.local/share/com.vercel.cli/config.json 2>/dev/null | grep 'currentTeam' | head -1 | sed 's/.*: *\"//;s/\".*//'",
122
- (r) => r.trim(),
123
- '',
124
- );
125
- return { loggedIn: true, user: extractUsername(whoami), team: teamRaw };
126
- }
127
-
128
- return { loggedIn: false, user: '', team: '' };
75
+ function loadHashedProfiles(): HashedProfile[] {
76
+ return listProfiles()
77
+ .map((p) => ({
78
+ profileId: p.id,
79
+ providerId: p.providerId,
80
+ label: p.label,
81
+ tokenHash: profileTokenHash(getSecret(p.id), p.providerId),
82
+ }))
83
+ .filter((p) => p.tokenHash);
129
84
  }
130
85
 
131
86
  /**
132
- * Extract just the username from 'vercel whoami' output (strip header lines).
87
+ * Hash of whatever token each provider currently has installed in the container.
88
+ *
89
+ * Computed for every provider, not only those with a stored profile. The probe
90
+ * has already fetched all of these files in its single exec, so the extra work
91
+ * is a handful of SHA-256 over short strings — and restricting it would hide the
92
+ * case that matters most: a token present in the container that matches nothing
93
+ * in the vault, which is what a credential edited after syncing looks like.
133
94
  */
134
- function extractUsername(raw: string): string {
135
- const lines = raw.split('\n').filter(Boolean);
136
- // Return the last non-empty line (the actual username)
137
- return lines[lines.length - 1]?.trim() || raw;
95
+ function installedHashes(probe: ContainerProbe): Map<string, string> {
96
+ const out = new Map<string, string>();
97
+ for (const providerId of Object.keys(TOKEN_FIELD)) {
98
+ const h = providerTokenHash(probe, providerId);
99
+ if (h) out.set(providerId, h);
100
+ }
101
+ return out;
138
102
  }
139
103
 
140
- /**
141
- * Match detected state to stored credential profiles by comparing token hashes.
142
- * `hashes` maps a providerId to the hash of the token currently installed in the
143
- * container. A profile matches when its own token hashes to the same value.
144
- * Providers whose token cannot be read yield '' and therefore never match.
145
- */
146
- function matchProfiles(
147
- hashes: Record<string, string>,
148
- ): { profileId: string; providerId: string; label: string }[] {
149
- const profiles = listProfiles();
150
- const matched: { profileId: string; providerId: string; label: string }[] = [];
151
-
152
- for (const profile of profiles) {
153
- const installedHash = hashes[profile.providerId];
154
- if (!installedHash) continue;
155
-
156
- const storedHash = profileTokenHash(getSecret(profile.id), profile.providerId);
157
- if (storedHash && storedHash === installedHash) {
158
- matched.push({ profileId: profile.id, providerId: profile.providerId, label: profile.label });
159
- }
160
- }
104
+ function matchProfiles(installed: Map<string, string>, profiles: HashedProfile[]) {
105
+ return profiles
106
+ .filter((p) => installed.get(p.providerId) === p.tokenHash)
107
+ .map(({ profileId, providerId, label }) => ({ profileId, providerId, label }));
108
+ }
161
109
 
162
- return matched;
110
+ function shape(container: string, agentId: string, probe: ContainerProbe, profiles: HashedProfile[]): DetectedStatus {
111
+ const installed = installedHashes(probe);
112
+ // `gh` changed the wording: 2.97 prints "…github.com account <user>", older
113
+ // builds print "…as <user>". The image installs it unpinned from apt, so both
114
+ // are accepted rather than tracking whichever one ships today.
115
+ const ghMatch = /Logged in to github\.com (?:as|account) (\S+)/.exec(probe.ghStatus);
116
+ const githubLoggedIn =
117
+ probe.cli.gh && !probe.ghStatus.includes('not logged into any GitHub hosts') && !!ghMatch;
118
+
119
+ const whoami = probe.vercelWhoami.trim();
120
+ const vercelLoggedIn =
121
+ probe.cli.vercel && !!whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized');
122
+
123
+ const projects = probe.supabaseProjects.trim();
124
+ // The exit status is the signal, not the wording. Matching on 'Unauthorized'
125
+ // recognised only the one failure whose text happened to say so; a missing
126
+ // token and an unreachable network both printed something else and passed.
127
+ const supabaseLoggedIn = probe.cli.supabase && probe.supabaseRc === 0 && !!projects;
128
+ const projectRef = supabaseLoggedIn ? supabaseProjectRef(probe) : '';
129
+
130
+ return {
131
+ container,
132
+ agentId,
133
+ displayName: probe.agentName || undefined,
134
+ githubLoggedIn,
135
+ githubUser: ghMatch?.[1] ?? '',
136
+ githubCliInstalled: probe.cli.gh,
137
+ vercelLoggedIn,
138
+ vercelUser: vercelLoggedIn ? extractUsername(probe.vercelWhoami) : '',
139
+ vercelTeam: vercelLoggedIn ? vercelCurrentTeam(probe) : '',
140
+ vercelCliInstalled: probe.cli.vercel,
141
+ supabaseLoggedIn,
142
+ supabaseLinked: !!projectRef,
143
+ supabaseProjectRef: projectRef,
144
+ supabaseCliInstalled: probe.cli.supabase,
145
+ // Presence of the config file is the login signal for both — neither ships a
146
+ // status command. An empty file now counts as absent, where `test -f` used
147
+ // to accept it.
148
+ trelloLoggedIn: !!probe.files.trelloConfig.trim(),
149
+ trelloCliInstalled: probe.cli.trello,
150
+ notionLoggedIn: !!probe.files.notionConfig.trim(),
151
+ toolsMdHasMarkers: probe.toolsMdMarkers > 0,
152
+ matchedProfiles: matchProfiles(installed, profiles),
153
+ installedProviders: [...installed.keys()],
154
+ };
163
155
  }
164
156
 
165
157
  export async function GET(request: NextRequest): Promise<NextResponse> {
@@ -167,8 +159,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
167
159
  try {
168
160
  const url = new URL(request.url);
169
161
  const filterAgent = url.searchParams.get('agent');
170
- let containers = listAgentContainers();
171
162
 
163
+ let containers = await listAgentContainers();
172
164
  if (filterAgent) {
173
165
  containers = containers.filter((c) => c.name === filterAgent);
174
166
  if (containers.length === 0) {
@@ -176,70 +168,28 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
176
168
  }
177
169
  }
178
170
 
179
- const results: (DetectedStatus | DetectionError)[] = [];
180
-
181
- for (const { name, agentId } of containers) {
182
- try {
183
- const { loggedIn: githubLoggedIn, user: githubUser } = detectGithub(name);
184
- const { loggedIn: trelloLoggedIn } = detectTrello(name);
185
- const { loggedIn: notionLoggedIn } = detectNotion(name);
186
- const { loggedIn: vercelLoggedIn, user: vercelUser, team: vercelTeam } = detectVercel(name);
187
- const { loggedIn: supabaseLoggedIn, linked: supabaseLinked, projectRef: supabaseProjectRef } = detectSupabase(name);
188
-
189
- const githubCliInstalled = cliInstalled(name, 'gh');
190
- const vercelCliInstalled = cliInstalled(name, 'vercel');
191
- const supabaseCliInstalled = cliInstalled(name, 'supabase');
192
- const trelloCliInstalled = cliInstalled(name, 'trello');
193
- const toolsMdHasMarkers = detectToolsMd(name);
194
-
195
- // Read AGENT_NAME env for display name (may differ from container name)
196
- const displayName = execInContainer(
197
- name,
198
- 'printenv AGENT_NAME',
199
- (r) => r.trim(),
200
- '',
201
- ) || undefined;
202
-
203
- // Precise profile matching by token hash — one source of truth for all providers.
204
- const matchedProfiles = matchProfiles({
205
- 'github-pat': providerTokenHash(name, 'github-pat'),
206
- vercel: providerTokenHash(name, 'vercel'),
207
- supabase: providerTokenHash(name, 'supabase'),
208
- trello: providerTokenHash(name, 'trello'),
209
- notion: providerTokenHash(name, 'notion'),
210
- });
211
-
212
- results.push({
213
- container: name,
214
- agentId,
215
- displayName,
216
- githubLoggedIn,
217
- githubUser,
218
- githubCliInstalled,
219
- vercelLoggedIn,
220
- vercelUser,
221
- vercelTeam,
222
- vercelCliInstalled,
223
- supabaseLoggedIn,
224
- supabaseLinked,
225
- supabaseProjectRef,
226
- supabaseCliInstalled,
227
- trelloLoggedIn,
228
- trelloCliInstalled,
229
- notionLoggedIn,
230
- toolsMdHasMarkers,
231
- matchedProfiles,
232
- });
233
- } catch (e: unknown) {
234
- results.push({
235
- container: name,
236
- agentId,
237
- error: (e as Error).message,
238
- });
239
- }
240
- }
171
+ const profiles = loadHashedProfiles();
172
+
173
+ // The callback swallows its own failures: mapWithConcurrency is fail-fast,
174
+ // so letting one unreachable container reject would discard the results
175
+ // already computed for every other agent.
176
+ const agents = await mapWithConcurrency<{ name: string; agentId: string }, DetectedStatus | DetectionError>(
177
+ containers,
178
+ AGENT_QUERY_CONCURRENCY,
179
+ async ({ name, agentId }) => {
180
+ try {
181
+ const probe = await probeContainer(name);
182
+ if (!probe.ok) {
183
+ return { container: name, agentId, error: 'Container did not respond' };
184
+ }
185
+ return shape(name, agentId, probe, profiles);
186
+ } catch (e: unknown) {
187
+ return { container: name, agentId, error: (e as Error).message };
188
+ }
189
+ },
190
+ );
241
191
 
242
- return NextResponse.json({ agents: results });
192
+ return NextResponse.json({ agents });
243
193
  } catch (e: unknown) {
244
194
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
245
195
  }
@@ -7,6 +7,9 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
7
7
  export const dynamic = 'force-dynamic';
8
8
 
9
9
  export async function GET(request: NextRequest): Promise<NextResponse> {
10
+ // requireAuthJWT was imported for the POST below and never applied here,
11
+ // leaving this the only unauthenticated route in the credentials feature.
12
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
10
13
  try {
11
14
  const profiles = listProfiles();
12
15
  return NextResponse.json({ profiles, providers: CREDENTIAL_PROVIDERS });
@@ -5,7 +5,7 @@
5
5
  * Uses base64-safe write — no heredoc shell escaping issues.
6
6
  *
7
7
  * Body:
8
- * { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-v4-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
8
+ * { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
9
9
  */
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
11
  import { execSync } from 'child_process';
@@ -56,6 +56,21 @@ function readFileFromContainer(container: string, remotePath: string): string {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Fallbacks already on a model entry, for a request that did not send any.
61
+ *
62
+ * `fallbacks` omitted and `fallbacks: []` are different requests. The first means
63
+ * "leave them alone", the second "clear them". This route used to treat both as
64
+ * `[]`, so any caller that sent only `model` silently erased the list.
65
+ */
66
+ function existingFallbacks(model: unknown): string[] {
67
+ if (model && typeof model === 'object') {
68
+ const f = (model as { fallbacks?: unknown }).fallbacks;
69
+ if (Array.isArray(f)) return f.filter((x): x is string => typeof x === 'string');
70
+ }
71
+ return [];
72
+ }
73
+
59
74
  export async function PUT(request: NextRequest) {
60
75
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
61
76
  try {
@@ -99,8 +114,8 @@ export async function PUT(request: NextRequest) {
99
114
  const mainAgent = list.find((a) => a.id === 'main');
100
115
  if (mainAgent) {
101
116
  const primary = ensureRev4aPrefix(body.model);
102
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
103
- // Remove the primary model dai fallback
117
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(mainAgent.model)));
118
+ // The primary never doubles as its own fallback.
104
119
  fallbacks = fallbacks.filter((fb) => fb !== primary);
105
120
  mainAgent.model = {
106
121
  primary,
@@ -111,7 +126,7 @@ export async function PUT(request: NextRequest) {
111
126
  const defaults = (agents.defaults || {}) as Record<string, unknown>;
112
127
  {
113
128
  const primary = ensureRev4aPrefix(body.model);
114
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
129
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(defaults.model)));
115
130
  fallbacks = fallbacks.filter((fb) => fb !== primary);
116
131
  defaults.model = {
117
132
  primary,
@@ -144,8 +159,10 @@ export async function PUT(request: NextRequest) {
144
159
  status: 'ok',
145
160
  agent: safeContainer,
146
161
  model: {
147
- primary: body.model,
148
- fallbacks: body.fallbacks || [],
162
+ // Report what was written, not what was asked: both prefixed, and the
163
+ // fallbacks as preserved or replaced.
164
+ primary: (defaults.model as { primary: string }).primary,
165
+ fallbacks: (defaults.model as { fallbacks: string[] }).fallbacks,
149
166
  },
150
167
  verify: { ok: verifyOk, bytes: verifyBytes },
151
168
  });
@@ -15,8 +15,16 @@ function ensureDir(): void {
15
15
  }
16
16
  }
17
17
 
18
+ /** Best-effort: files written before 0600 was enforced are world-readable. */
19
+ function tightenMode(): void {
20
+ try {
21
+ if ((fs.statSync(KEYS_PATH).mode & 0o077) !== 0) fs.chmodSync(KEYS_PATH, 0o600);
22
+ } catch { /* absent, or not ours to change */ }
23
+ }
24
+
18
25
  export function readProviderKeys(): ProviderKeys {
19
26
  ensureDir();
27
+ tightenMode();
20
28
  try {
21
29
  const raw = fs.readFileSync(KEYS_PATH, 'utf-8');
22
30
  return JSON.parse(raw);
@@ -34,5 +42,9 @@ export function readProviderKeys(): ProviderKeys {
34
42
 
35
43
  export function writeProviderKeys(keys: ProviderKeys): void {
36
44
  ensureDir();
37
- fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2), 'utf-8');
45
+ // Every upstream credential and the gateway password live here: owner-only, like
46
+ // the .env file. `mode` applies only when the file is created, so an existing
47
+ // file is tightened explicitly.
48
+ fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2), { encoding: 'utf-8', mode: 0o600 });
49
+ fs.chmodSync(KEYS_PATH, 0o600);
38
50
  }
@@ -8,7 +8,7 @@
8
8
  import { NextRequest, NextResponse } from 'next/server';
9
9
  import { execSync } from 'child_process';
10
10
  import { readProviderKeys, writeProviderKeys } from './keys';
11
- import { syncAllAgents } from '../sync';
11
+ import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
12
12
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
13
13
  import { getPricing } from '@/lib/model-pricing';
14
14
  import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
@@ -192,6 +192,20 @@ export async function GET(request: NextRequest) {
192
192
  return NextResponse.json({ providers });
193
193
  }
194
194
 
195
+ /**
196
+ * Run the sync without letting an unexpected throw turn a committed change into a
197
+ * 500. `syncAllAgents` already reports Docker and per-container failures as data;
198
+ * this only guards the rest.
199
+ */
200
+ function runSync(): SyncOutcome {
201
+ try {
202
+ return syncAllAgents();
203
+ } catch (e) {
204
+ const msg = e instanceof Error ? e.message : String(e);
205
+ return { ok: false, activeModels: 0, total: 0, synced: [], stopped: [], failed: [], configError: msg, summary: `Not applied to any agent: ${msg}.` };
206
+ }
207
+ }
208
+
195
209
  export async function POST(request: NextRequest) {
196
210
  const denied = await requireAuthJWT(request);
197
211
  if (denied) return denied;
@@ -199,8 +213,12 @@ export async function POST(request: NextRequest) {
199
213
  const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
200
214
 
201
215
  if (body._syncOnly) {
202
- const syncResults = syncAllAgents();
203
- return NextResponse.json({ status: 'ok', sync: syncResults });
216
+ // Here the sync *is* the operation, so its outcome is the status.
217
+ const sync = runSync();
218
+ return NextResponse.json(
219
+ sync.ok ? { status: 'ok', sync } : { status: 'error', error: sync.summary, sync },
220
+ { status: sync.ok ? 200 : 502 },
221
+ );
204
222
  }
205
223
 
206
224
  const providerName = body.provider;
@@ -210,15 +228,11 @@ export async function POST(request: NextRequest) {
210
228
  const apiKeyValue = body.apiKey;
211
229
  const isRemove = !apiKeyValue || apiKeyValue.trim() === '';
212
230
  setProviderApiKey(providerName, isRemove ? null : apiKeyValue!.trim());
213
- let syncResults: string[] = [];
214
- try {
215
- syncResults = syncAllAgents();
216
- } catch (se) {
217
- syncResults = [`Sync error: ${se instanceof Error ? se.message : String(se)}`];
218
- }
231
+ // The key is saved whatever the sync does next; report the two separately.
232
+ const sync = runSync();
219
233
  return NextResponse.json({
220
234
  status: 'ok', provider: providerName, configured: !isRemove,
221
- sync: syncResults,
235
+ sync,
222
236
  });
223
237
  } catch (e: unknown) {
224
238
  return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
@@ -238,9 +252,26 @@ export async function PUT(request: NextRequest) {
238
252
  const idx = models.findIndex((m) => m.id === modelId);
239
253
  if (idx === -1) return NextResponse.json({ status: 'error', error: `Model not found: ${modelId}` }, { status: 404 });
240
254
 
255
+ // Refuse up front when the sync cannot happen at all. Saving anyway would leave
256
+ // the checkbox on /gateway saying one thing and every agent holding another.
257
+ // Only a whole-fleet outage is refused this way: once Docker answers, a container
258
+ // that fails individually is reported in `sync`, because undoing the override
259
+ // would not undo the containers that did receive it. Provider key saves are not
260
+ // gated like this — the first-run wizard saves keys without reading the response
261
+ // and may run before Docker is up, so a refusal there would be silent.
262
+ const unreachable = dockerUnreachableReason();
263
+ if (unreachable) {
264
+ return NextResponse.json({
265
+ status: 'error',
266
+ error: `Not changed: Docker is unreachable (${unreachable}). Nothing was saved, so the Gateway still matches the agents.`,
267
+ }, { status: 503 });
268
+ }
269
+
241
270
  toggleModelOverride(modelId, enabled);
242
- const syncResults = syncAllAgents();
243
- return NextResponse.json({ status: 'ok', modelId, enabled, provider: models[idx].provider, sync: syncResults });
271
+ // `status` is the toggle, committed at this point. `sync` is whether the fleet
272
+ // got it — reported alongside, not folded into `status`.
273
+ const sync = runSync();
274
+ return NextResponse.json({ status: 'ok', modelId, enabled, provider: models[idx].provider, sync });
244
275
  } catch (e: unknown) {
245
276
  return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
246
277
  }