@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
|
@@ -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
|
-
|
|
6
|
-
cliInstalled,
|
|
5
|
+
probeContainer,
|
|
7
6
|
providerTokenHash,
|
|
8
7
|
profileTokenHash,
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
(
|
|
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
|
-
*
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
}
|