@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
package/app/api/gateway/sync.ts
CHANGED
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
* Agent Gateway Sync
|
|
3
3
|
*
|
|
4
4
|
* Syncs the models.providers.rev4a config block to all agent containers
|
|
5
|
-
*
|
|
5
|
+
* without touching model references (primary/fallback).
|
|
6
6
|
*
|
|
7
7
|
* Import and call:
|
|
8
8
|
* syncAllAgents() — sync all containers with AGENT_ID label
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* A single-container `syncAgent()` used to be exported too. Nothing called it —
|
|
11
|
+
* the create route builds the block with `buildRev4aProviderConfig()` and writes
|
|
12
|
+
* it with `openclaw config patch` itself — so it was removed.
|
|
10
13
|
*/
|
|
11
|
-
import { execSync } from 'child_process';
|
|
14
|
+
import { execSync, execFileSync } from 'child_process';
|
|
12
15
|
import * as fs from 'fs';
|
|
16
|
+
import * as os from 'os';
|
|
17
|
+
import * as path from 'path';
|
|
13
18
|
import { readProviderKeys } from './provider/keys';
|
|
14
|
-
import {
|
|
19
|
+
import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
20
|
+
import { singleFileFromTar } from '@/lib/container-file';
|
|
15
21
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
16
22
|
|
|
17
23
|
const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
|
|
@@ -33,11 +39,8 @@ function modalityToInput(modality: string): string[] {
|
|
|
33
39
|
return left.split('+').map((s) => s.trim()).filter((s) => ALLOWED_INPUT.has(s));
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
function getActiveRev4aModels(
|
|
37
|
-
|
|
38
|
-
const configuredProviders = Object.keys(providerKeys);
|
|
39
|
-
return models
|
|
40
|
-
.filter((m) => m.enabled && configuredProviders.includes(m.provider))
|
|
42
|
+
function getActiveRev4aModels(): { id: string; name: string; input?: string[] }[] {
|
|
43
|
+
return loadOfferedModels()
|
|
41
44
|
.map((m) => {
|
|
42
45
|
const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
|
|
43
46
|
if (m.modality) entry.input = modalityToInput(m.modality);
|
|
@@ -45,54 +48,146 @@ function getActiveRev4aModels(models: ModelConfigEntry[]): { id: string; name: s
|
|
|
45
48
|
});
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
/** A readable cause from a failed child process: its stderr if any, else the message. */
|
|
52
|
+
function firstLine(e: unknown): string {
|
|
53
|
+
const err = e as { stderr?: string | Buffer; message?: string };
|
|
54
|
+
const text = (err.stderr ? err.stderr.toString() : '') || err.message || 'unknown error';
|
|
55
|
+
return text.trim().split('\n')[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A container carrying an AGENT_ID label, and whether it is running. */
|
|
59
|
+
interface AgentContainer { name: string; running: boolean }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every container carrying an AGENT_ID label, running or stopped.
|
|
63
|
+
*
|
|
64
|
+
* Throws when Docker cannot be asked. It used to catch that and return `[]`,
|
|
65
|
+
* which made "the Docker daemon is not running" indistinguishable from "there are
|
|
66
|
+
* no agents": a model toggle made while Docker was stopped synced to nobody and
|
|
67
|
+
* still reported success. An empty list now means only the second.
|
|
68
|
+
*
|
|
69
|
+
* Stopped agents are included on purpose. This used to run `docker ps` without
|
|
70
|
+
* `-a`, so a catalogue change made while an agent was stopped skipped it, and
|
|
71
|
+
* starting it later brought back the old catalogue until someone ran Sync All.
|
|
72
|
+
*/
|
|
73
|
+
function getAgentContainers(): AgentContainer[] {
|
|
74
|
+
let raw: string;
|
|
49
75
|
try {
|
|
50
|
-
|
|
51
|
-
`docker ps --filter "label=AGENT_ID" --format '{{.Names}}'`,
|
|
52
|
-
{ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8' },
|
|
76
|
+
raw = execSync(
|
|
77
|
+
`docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}'`,
|
|
78
|
+
{ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
53
79
|
).trim();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw new Error(firstLine(e));
|
|
82
|
+
}
|
|
83
|
+
if (!raw) return [];
|
|
84
|
+
return raw.split('\n')
|
|
85
|
+
.map((line) => {
|
|
86
|
+
const [name, state] = line.split('\t');
|
|
87
|
+
return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running' };
|
|
88
|
+
})
|
|
89
|
+
.filter((c) => c.name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Why Docker cannot be asked right now, or undefined when it can.
|
|
94
|
+
*
|
|
95
|
+
* For callers that must decide *before* committing a change whether its sync can
|
|
96
|
+
* happen at all. A model toggle made while Docker was unreachable used to be saved
|
|
97
|
+
* and then reach no container, leaving the Gateway's checkboxes out of step with
|
|
98
|
+
* what the agents hold; checking first lets it be refused cleanly instead.
|
|
99
|
+
*/
|
|
100
|
+
export function dockerUnreachableReason(): string | undefined {
|
|
101
|
+
try {
|
|
102
|
+
getAgentContainers();
|
|
103
|
+
return undefined;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return (e as Error).message;
|
|
58
106
|
}
|
|
59
107
|
}
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read and parse a container's JSON config, running or stopped.
|
|
112
|
+
*
|
|
113
|
+
* Throws on any failure. It used to return `{}`, and the caller wrote that back
|
|
114
|
+
* with only the provider block added — so a single timed-out read replaced an
|
|
115
|
+
* agent's whole config (agents, gateway, channels) with `{ models: { providers:
|
|
116
|
+
* { rev4a } } }`. An empty object is refused for the same reason. Also returns
|
|
117
|
+
* the file mode, so a stopped container's file is written back unchanged in that
|
|
118
|
+
* respect.
|
|
119
|
+
*/
|
|
120
|
+
function readContainerJson(container: AgentContainer, remotePath: string): { config: Record<string, unknown>; mode: number } {
|
|
121
|
+
let raw: string;
|
|
122
|
+
let mode = 0o600;
|
|
123
|
+
if (container.running) {
|
|
124
|
+
raw = execFileSync('docker', ['exec', container.name, 'cat', remotePath], {
|
|
125
|
+
timeout: 8000, maxBuffer: 512 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
const file = singleFileFromTar(execFileSync('docker', ['cp', `${container.name}:${remotePath}`, '-'], {
|
|
129
|
+
timeout: 8000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
+
}));
|
|
131
|
+
raw = file.body.toString('utf-8');
|
|
132
|
+
mode = file.mode;
|
|
133
|
+
}
|
|
134
|
+
let parsed: unknown;
|
|
62
135
|
try {
|
|
63
|
-
|
|
64
|
-
`docker exec ${container} cat ${remotePath}`,
|
|
65
|
-
{ timeout: 8000, maxBuffer: 512 * 1024, encoding: 'utf-8' },
|
|
66
|
-
);
|
|
67
|
-
return JSON.parse(raw);
|
|
136
|
+
parsed = JSON.parse(raw);
|
|
68
137
|
} catch {
|
|
69
|
-
|
|
138
|
+
// Not the parser's message: V8 quotes a slice of the input, this file holds
|
|
139
|
+
// tokens, and the error ends up in the sync summary shown in the browser.
|
|
140
|
+
throw new Error(`${remotePath} is not valid JSON; refusing to overwrite it`);
|
|
141
|
+
}
|
|
142
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
|
|
143
|
+
throw new Error(`${remotePath} is empty or not a JSON object; refusing to overwrite it`);
|
|
70
144
|
}
|
|
145
|
+
return { config: parsed as Record<string, unknown>, mode };
|
|
71
146
|
}
|
|
72
147
|
|
|
73
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Write a container's JSON config, running or stopped.
|
|
150
|
+
*
|
|
151
|
+
* Running: through `docker exec`, truncating the file in place so its mode and
|
|
152
|
+
* owner stay as they are, and OpenClaw hot-applies the change. Stopped: through
|
|
153
|
+
* `docker cp`, the only way to reach it; the file is staged on the host with the
|
|
154
|
+
* original mode, lands on the agent's volume, and is read at next start.
|
|
155
|
+
*/
|
|
156
|
+
function writeContainerJson(container: AgentContainer, remotePath: string, data: Record<string, unknown>, mode: number): void {
|
|
74
157
|
const jsonStr = JSON.stringify(data, null, 2);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
158
|
+
if (container.running) {
|
|
159
|
+
execFileSync('docker', ['exec', '-i', container.name, 'sh', '-c', 'cat > "$1"', 'sh', remotePath], {
|
|
160
|
+
timeout: 10000, maxBuffer: 1024 * 1024, input: jsonStr, stdio: ['pipe', 'pipe', 'pipe'],
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rev4a-sync-'));
|
|
165
|
+
const staged = path.join(dir, path.basename(remotePath));
|
|
166
|
+
try {
|
|
167
|
+
fs.writeFileSync(staged, jsonStr);
|
|
168
|
+
fs.chmodSync(staged, mode);
|
|
169
|
+
execFileSync('docker', ['cp', staged, `${container.name}:${remotePath}`], {
|
|
170
|
+
timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
171
|
+
});
|
|
172
|
+
} finally {
|
|
173
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
80
175
|
}
|
|
81
176
|
|
|
177
|
+
|
|
82
178
|
/* ------------------------------------------------------------------ */
|
|
83
179
|
/* Sync functions */
|
|
84
180
|
/* ------------------------------------------------------------------ */
|
|
85
181
|
|
|
86
182
|
/**
|
|
87
183
|
* Build the models.providers.rev4a config object from local state.
|
|
88
|
-
* Shared between the create
|
|
184
|
+
* Shared between the create route and syncAllAgents — single source of truth
|
|
89
185
|
* for the provider block injected into every agent container.
|
|
90
186
|
*/
|
|
91
187
|
export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
|
|
92
188
|
const providerKeys = readProviderKeys();
|
|
93
189
|
const rev4aApiKey = providerKeys['rev4a'] || '';
|
|
94
|
-
const
|
|
95
|
-
const activeModels = getActiveRev4aModels(allModels);
|
|
190
|
+
const activeModels = getActiveRev4aModels();
|
|
96
191
|
return {
|
|
97
192
|
baseUrl: PROVIDER_GATEWAY_BASE_URL,
|
|
98
193
|
apiKey: rev4aApiKey,
|
|
@@ -102,65 +197,146 @@ export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; a
|
|
|
102
197
|
}
|
|
103
198
|
|
|
104
199
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
200
|
+
* Write `models.providers.rev4a` into one running container.
|
|
201
|
+
*
|
|
202
|
+
* Throws instead of writing when the catalogue could not be read: an unreadable
|
|
203
|
+
* catalogue is not an empty one, and patching it in would strip every model from
|
|
204
|
+
* the agent. `syncAllAgents()` refuses for the same reason.
|
|
205
|
+
*
|
|
206
|
+
* No gateway restart: `config patch` reports "Change will apply without restarting
|
|
207
|
+
* the gateway" for a models-only patch, and the Gateway watches openclaw.json with
|
|
208
|
+
* gateway.reload defaulting to hybrid.
|
|
209
|
+
*/
|
|
210
|
+
export function patchRev4aProvider(container: string): void {
|
|
211
|
+
if (bundledCatalogueStatus() === 'unavailable') {
|
|
212
|
+
throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
|
|
213
|
+
}
|
|
214
|
+
const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig() } } });
|
|
215
|
+
execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
|
|
216
|
+
timeout: 15000,
|
|
217
|
+
stdio: 'pipe',
|
|
218
|
+
input,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What a sync did, container by container. */
|
|
223
|
+
export interface SyncOutcome {
|
|
224
|
+
/**
|
|
225
|
+
* True only when every agent container received the catalogue. Zero agents is
|
|
226
|
+
* ok — there is nothing to sync, which is the normal state on first run.
|
|
227
|
+
*/
|
|
228
|
+
ok: boolean;
|
|
229
|
+
/** Models written into each container. */
|
|
230
|
+
activeModels: number;
|
|
231
|
+
/** Agent containers found. */
|
|
232
|
+
total: number;
|
|
233
|
+
synced: string[];
|
|
234
|
+
/** Of `synced`, the ones not running (stopped, paused, created): written with `docker cp`. Applied when started, or resumed. */
|
|
235
|
+
stopped: string[];
|
|
236
|
+
failed: { container: string; error: string }[];
|
|
237
|
+
/** Docker could not list containers at all, so nothing was attempted. */
|
|
238
|
+
dockerError?: string;
|
|
239
|
+
/** The catalogue block could not be built, so nothing was attempted. */
|
|
240
|
+
configError?: string;
|
|
241
|
+
/** One line for a person. Built here so every caller says the same thing. */
|
|
242
|
+
summary: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** ", 2 of them not running" — those apply the change later, which is worth saying. */
|
|
246
|
+
function stoppedNote(outcome: SyncOutcome): string {
|
|
247
|
+
return outcome.stopped.length ? `, ${outcome.stopped.length} of them not running` : '';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Push the offered catalogue into every agent container, and say exactly what
|
|
252
|
+
* happened.
|
|
253
|
+
*
|
|
254
|
+
* Returns an outcome instead of throwing: callers run this *after* committing a
|
|
255
|
+
* change — a key saved, a model toggled — and a failed propagation must not be
|
|
256
|
+
* reported as a failed save, because the save stands. The old signature returned
|
|
257
|
+
* loose strings that no caller inspected, so a sync that reached no container
|
|
258
|
+
* surfaced as success.
|
|
107
259
|
*/
|
|
108
|
-
export function syncAllAgents():
|
|
260
|
+
export function syncAllAgents(): SyncOutcome {
|
|
109
261
|
const rev4aProviderConfig = buildRev4aProviderConfig();
|
|
262
|
+
const outcome: SyncOutcome = {
|
|
263
|
+
ok: false,
|
|
264
|
+
activeModels: rev4aProviderConfig.models.length,
|
|
265
|
+
total: 0,
|
|
266
|
+
synced: [],
|
|
267
|
+
stopped: [],
|
|
268
|
+
failed: [],
|
|
269
|
+
summary: '',
|
|
270
|
+
};
|
|
271
|
+
|
|
110
272
|
if (!rev4aProviderConfig.apiKey) {
|
|
111
|
-
|
|
273
|
+
outcome.configError = 'REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)';
|
|
274
|
+
outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
|
|
275
|
+
return outcome;
|
|
112
276
|
}
|
|
113
277
|
|
|
114
|
-
|
|
115
|
-
|
|
278
|
+
// A catalogue that could not be read is not an empty catalogue: pushing it would
|
|
279
|
+
// strip every model from every agent.
|
|
280
|
+
if (bundledCatalogueStatus() === 'unavailable') {
|
|
281
|
+
outcome.configError = 'models.config.json could not be read, so there is no catalogue to push';
|
|
282
|
+
outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
|
|
283
|
+
return outcome;
|
|
284
|
+
}
|
|
116
285
|
|
|
286
|
+
let containers: AgentContainer[];
|
|
287
|
+
try {
|
|
288
|
+
containers = getAgentContainers();
|
|
289
|
+
} catch (e) {
|
|
290
|
+
outcome.dockerError = (e as Error).message;
|
|
291
|
+
outcome.summary = `Not applied to any agent: Docker is unreachable (${outcome.dockerError}).`;
|
|
292
|
+
return outcome;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
outcome.total = containers.length;
|
|
117
296
|
for (const container of containers) {
|
|
118
297
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
298
|
+
syncContainer(container, rev4aProviderConfig);
|
|
299
|
+
outcome.synced.push(container.name);
|
|
300
|
+
if (!container.running) outcome.stopped.push(container.name);
|
|
121
301
|
} catch (e: unknown) {
|
|
122
|
-
|
|
123
|
-
results.push(`${container}: ERROR ${err.stderr || err.message || 'unknown'}`);
|
|
302
|
+
outcome.failed.push({ container: container.name, error: firstLine(e) });
|
|
124
303
|
}
|
|
125
304
|
}
|
|
126
305
|
|
|
127
|
-
|
|
128
|
-
|
|
306
|
+
outcome.ok = outcome.failed.length === 0;
|
|
307
|
+
if (outcome.total === 0) {
|
|
308
|
+
outcome.summary = 'No agent containers to sync.';
|
|
309
|
+
} else if (outcome.ok) {
|
|
310
|
+
outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}.`;
|
|
311
|
+
} else {
|
|
312
|
+
const names = outcome.failed.map((f) => `${f.container} (${f.error})`).join(', ');
|
|
313
|
+
outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}. Not applied to: ${names}.`;
|
|
129
314
|
}
|
|
130
|
-
|
|
131
|
-
return results;
|
|
315
|
+
return outcome;
|
|
132
316
|
}
|
|
133
317
|
|
|
134
318
|
/**
|
|
135
|
-
*
|
|
136
|
-
* Does NOT touch agents.defaults.model or agents.list[].model.
|
|
137
|
-
*/
|
|
138
|
-
export function syncAgent(containerName: string): string {
|
|
139
|
-
const rev4aProviderConfig = buildRev4aProviderConfig();
|
|
140
|
-
if (!rev4aProviderConfig.apiKey) {
|
|
141
|
-
throw new Error('REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)');
|
|
142
|
-
}
|
|
143
|
-
return syncContainer(containerName, rev4aProviderConfig);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Internal: write models.providers.rev4a to one container and restart its gateway.
|
|
319
|
+
* Internal: write models.providers.rev4a into one container's openclaw.json.
|
|
148
320
|
*/
|
|
149
321
|
function syncContainer(
|
|
150
|
-
container:
|
|
322
|
+
container: AgentContainer,
|
|
151
323
|
rev4aProviderConfig: { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string }[] },
|
|
152
324
|
): string {
|
|
153
325
|
const remotePath = '/root/.openclaw/openclaw.json';
|
|
154
|
-
|
|
326
|
+
// Read first. If this throws, the container is reported as failed and nothing
|
|
327
|
+
// below runs — in particular nothing is written.
|
|
328
|
+
const { config, mode } = readContainerJson(container, remotePath);
|
|
155
329
|
|
|
156
|
-
// Clean up stale files from old versions
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
330
|
+
// Clean up stale files from old versions. Needs exec, so running containers
|
|
331
|
+
// only; a stopped one is cleaned on the first sync after it starts.
|
|
332
|
+
if (container.running) {
|
|
333
|
+
try {
|
|
334
|
+
execFileSync('docker', ['exec', container.name, 'rm', '-f',
|
|
335
|
+
'/root/.openclaw/agents/main/agent/models.json',
|
|
336
|
+
'/root/.openclaw/agents/main/agent/auth-profiles.json'], { timeout: 5000, stdio: 'ignore' });
|
|
337
|
+
} catch {
|
|
338
|
+
// non-fatal
|
|
339
|
+
}
|
|
164
340
|
}
|
|
165
341
|
|
|
166
342
|
// Write/overwrite models.providers.rev4a — do NOT touch agents.*
|
|
@@ -169,7 +345,7 @@ function syncContainer(
|
|
|
169
345
|
(models.providers as Record<string, unknown>)['rev4a'] = rev4aProviderConfig;
|
|
170
346
|
config.models = models;
|
|
171
347
|
|
|
172
|
-
writeContainerJson(container, remotePath, config);
|
|
348
|
+
writeContainerJson(container, remotePath, config, mode);
|
|
173
349
|
|
|
174
350
|
// No gateway restart: this only writes models.providers.rev4a, and the Gateway
|
|
175
351
|
// watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
|
|
@@ -182,5 +358,5 @@ function syncContainer(
|
|
|
182
358
|
// The restart used to cost ~7.5s per container, serially — roughly 52s of
|
|
183
359
|
// fully blocked event loop on a seven-agent host, every time a provider key or
|
|
184
360
|
// model toggle was saved, to do something the Gateway had already done.
|
|
185
|
-
return `${container}: synced`;
|
|
361
|
+
return `${container.name}: synced`;
|
|
186
362
|
}
|
package/app/api/models/route.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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(
|