@flame0510/project-aether 1.1.12 → 1.1.14
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 +4 -1
- package/app/agents/PageClient.tsx +140 -34
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +32 -13
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +94 -85
- package/app/api/agents/route.ts +10 -53
- package/app/api/agents/token/route.ts +4 -1
- package/app/api/auth/check/route.ts +2 -23
- package/app/api/auth/login/route.ts +3 -35
- package/app/api/auth/logout/route.ts +1 -1
- package/app/api/auth/status/route.ts +2 -21
- package/app/api/config/env/route.ts +7 -3
- package/app/api/config/restart/route.ts +2 -0
- package/app/api/containers/route.ts +3 -34
- package/app/api/crons/route.ts +2 -0
- package/app/api/debug/route.ts +2 -0
- package/app/api/envcheck/route.ts +3 -1
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +104 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/models/route.ts +2 -0
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/setup/agent-image/route.ts +8 -3
- package/app/api/setup/password/route.ts +32 -37
- package/app/api/setup/restart/route.ts +8 -2
- package/app/api/skills/delete/route.js +1 -1
- package/app/api/skills/promote/route.js +1 -1
- package/app/api/skills/route.js +89 -97
- package/app/api/skills/save/route.js +18 -5
- package/app/api/update-check/route.ts +4 -23
- package/app/api/wizard/complete/route.ts +7 -3
- package/app/api/wizard/reset/route.ts +8 -3
- package/app/api/wizard/status/route.ts +8 -5
- package/app/components/AuthGuard.tsx +29 -8
- package/app/components/Skeleton.tsx +71 -0
- package/app/components/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/crons/PageClient.tsx +32 -7
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -14
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +13 -8
- package/docs/dev/GATEWAY.md +42 -1
- package/docs/dev/PROVIDERS.md +4 -3
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/instrumentation.ts +18 -0
- package/lib/agent-setup.ts +8 -9
- package/lib/buildAgentImage.ts +5 -1
- package/lib/channelManager.ts +23 -16
- package/lib/db.ts +0 -10
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-socket.ts +121 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/lib/openrouter-pricing.ts +184 -0
- package/lib/rev4a-auth.d.ts +4 -1
- package/lib/rev4a-auth.js +68 -20
- package/lib/rev4a-paths.ts +19 -0
- package/model-pricing.json +50 -50
- package/models.config.json +2 -2
- package/package.json +4 -1
- package/proxy.ts +68 -0
- package/scripts/refresh-model-pricing.mjs +171 -0
- package/lib/auth.ts +0 -28
- package/lib/requireAuth.tsx +0 -38
|
@@ -12,6 +12,7 @@ import { syncAllAgents } 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';
|
|
15
|
+
import { getLivePricing } from '@/lib/openrouter-pricing';
|
|
15
16
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
16
17
|
|
|
17
18
|
export const dynamic = 'force-dynamic';
|
|
@@ -136,8 +137,37 @@ function restartService(): { stdout: string; stderr: string } {
|
|
|
136
137
|
/* ------------------------------------------------------------------ */
|
|
137
138
|
|
|
138
139
|
export async function GET(request: NextRequest) {
|
|
139
|
-
const
|
|
140
|
+
const denied = await requireAuthJWT(request);
|
|
141
|
+
if (denied) return denied;
|
|
140
142
|
const providerKeys = readProviderKeys();
|
|
143
|
+
|
|
144
|
+
// ?summary=1 — just "which providers have a key", no models, no pricing.
|
|
145
|
+
// The full response awaits live pricing from OpenRouter, a network round trip
|
|
146
|
+
// that costs ~2.4s cold. Callers that only need the boolean (the agents page,
|
|
147
|
+
// to decide whether to warn about a missing provider) must not pay for it, and
|
|
148
|
+
// must not be left guessing while it's in flight.
|
|
149
|
+
if (request.nextUrl.searchParams.get('summary') === '1') {
|
|
150
|
+
return NextResponse.json({
|
|
151
|
+
providers: Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => ({
|
|
152
|
+
provider: key,
|
|
153
|
+
label: cfg.label,
|
|
154
|
+
configured: !!providerKeys[key],
|
|
155
|
+
})),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allModels = loadModelsConfig();
|
|
160
|
+
|
|
161
|
+
// Live prices overlaid on the static file, which drifts. Fetched regardless
|
|
162
|
+
// of whether OpenRouter is configured: the catalogue is public, and prices
|
|
163
|
+
// are most useful precisely to someone still deciding whether to enable it.
|
|
164
|
+
// Best-effort — an empty map means the static values stand in.
|
|
165
|
+
//
|
|
166
|
+
// Modality deliberately stays on the curated value: sync.ts provisions
|
|
167
|
+
// containers from it, so taking a different one upstream would advertise
|
|
168
|
+
// inputs the agent was never configured to accept.
|
|
169
|
+
const livePricing = await getLivePricing(allModels.map((m) => m.id));
|
|
170
|
+
|
|
141
171
|
const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
|
|
142
172
|
const providerModels = allModels.filter((m) => m.provider === key);
|
|
143
173
|
return {
|
|
@@ -145,8 +175,14 @@ export async function GET(request: NextRequest) {
|
|
|
145
175
|
baseUrl: cfg.baseUrl, docsUrl: cfg.docsUrl,
|
|
146
176
|
models: providerModels.map((m) => {
|
|
147
177
|
const entry: Record<string, unknown> = { id: m.id, name: m.name, enabled: m.enabled };
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
178
|
+
const livePrice = livePricing.get(m.id);
|
|
179
|
+
if (livePrice) {
|
|
180
|
+
entry.pricing = livePrice;
|
|
181
|
+
entry.pricingLive = true;
|
|
182
|
+
} else {
|
|
183
|
+
const staticPrice = getPricing(m.id);
|
|
184
|
+
if (staticPrice) entry.pricing = staticPrice;
|
|
185
|
+
}
|
|
150
186
|
if (m.modality) entry.modality = m.modality;
|
|
151
187
|
return entry;
|
|
152
188
|
}),
|
|
@@ -157,7 +193,8 @@ export async function GET(request: NextRequest) {
|
|
|
157
193
|
}
|
|
158
194
|
|
|
159
195
|
export async function POST(request: NextRequest) {
|
|
160
|
-
const denied = await requireAuthJWT(request);
|
|
196
|
+
const denied = await requireAuthJWT(request);
|
|
197
|
+
if (denied) return denied;
|
|
161
198
|
try {
|
|
162
199
|
const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
|
|
163
200
|
|
|
@@ -189,7 +226,8 @@ export async function POST(request: NextRequest) {
|
|
|
189
226
|
}
|
|
190
227
|
|
|
191
228
|
export async function PUT(request: NextRequest) {
|
|
192
|
-
const denied = await requireAuthJWT(request);
|
|
229
|
+
const denied = await requireAuthJWT(request);
|
|
230
|
+
if (denied) return denied;
|
|
193
231
|
try {
|
|
194
232
|
const body: { modelId?: string; enabled?: boolean } = await request.json();
|
|
195
233
|
const { modelId, enabled } = body;
|
package/app/api/gateway/route.ts
CHANGED
|
@@ -2,23 +2,42 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* Gateway Status API
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Reports the per-agent model settings the Gateway page needs to render its
|
|
6
|
+
* agent cards and pre-fill the "edit default model" form. Only containers with
|
|
7
|
+
* an AGENT_ID label are considered — no hardcoded container names.
|
|
7
8
|
*
|
|
8
9
|
* GET /api/gateway
|
|
10
|
+
*
|
|
11
|
+
* This used to shell out to the OpenClaw CLI inside every container, twice:
|
|
12
|
+
* `openclaw models status --json` (~5.7s) and `openclaw models list --json`
|
|
13
|
+
* (~4.4s), serially, with execSync — so ~10s per agent with the event loop
|
|
14
|
+
* frozen throughout, and roughly 70s on a seven-agent host.
|
|
15
|
+
*
|
|
16
|
+
* Both were avoidable. `models list` produced a model catalogue and alias map
|
|
17
|
+
* that no line of the UI ever read: the page takes its model list from
|
|
18
|
+
* /api/gateway/provider, which reads local files. And `models status` returned,
|
|
19
|
+
* for the two fields actually used, exactly what sits in the agent's own
|
|
20
|
+
* openclaw.json — verified field by field against a live agent. So the CLI was
|
|
21
|
+
* being booted for five seconds to read a file that `cat` returns in 260ms.
|
|
22
|
+
*
|
|
23
|
+
* The response shape is unchanged so the page keeps working untouched; the
|
|
24
|
+
* fields that came only from the discarded CLI output are now reported empty
|
|
25
|
+
* rather than computed at great cost and thrown away.
|
|
9
26
|
*/
|
|
10
27
|
import { NextResponse } from 'next/server';
|
|
11
|
-
import {
|
|
28
|
+
import { execFile } from 'child_process';
|
|
29
|
+
import { promisify } from 'util';
|
|
12
30
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
31
|
+
import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
13
34
|
|
|
14
35
|
export const dynamic = 'force-dynamic';
|
|
15
36
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
available: boolean;
|
|
21
|
-
}
|
|
37
|
+
/** Agents queried at once. Each is a `docker exec … cat`, cheap but still a process. */
|
|
38
|
+
const AGENT_QUERY_CONCURRENCY = 8;
|
|
39
|
+
|
|
40
|
+
const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
|
|
22
41
|
|
|
23
42
|
interface GatewayProvider {
|
|
24
43
|
provider: string;
|
|
@@ -40,158 +59,105 @@ interface GatewayAgentStatus {
|
|
|
40
59
|
error?: string;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
interface
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/** Run a command inside a Docker container */
|
|
49
|
-
function dockerExec(container: string, cmd: string, timeout = 15000): string {
|
|
50
|
-
try {
|
|
51
|
-
return execSync(
|
|
52
|
-
`docker exec ${container} sh -c ${JSON.stringify(cmd)}`,
|
|
53
|
-
{ timeout, maxBuffer: 1024 * 1024, encoding: 'utf-8' },
|
|
54
|
-
);
|
|
55
|
-
} catch {
|
|
56
|
-
return '';
|
|
57
|
-
}
|
|
62
|
+
interface AgentRef {
|
|
63
|
+
agentId: string;
|
|
64
|
+
containerName: string;
|
|
65
|
+
state: string;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Containers carrying an AGENT_ID label, with the label read straight out of
|
|
70
|
+
* `docker ps` rather than a follow-up `docker inspect` per container.
|
|
71
|
+
*/
|
|
72
|
+
async function getAgents(): Promise<AgentRef[]> {
|
|
62
73
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
const { stdout } = await execFileAsync('docker', [
|
|
75
|
+
'ps', '--filter', 'label=AGENT_ID',
|
|
76
|
+
'--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}',
|
|
77
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
78
|
+
|
|
79
|
+
return stdout.trim().split('\n')
|
|
80
|
+
.map((line) => {
|
|
81
|
+
const [agentId, containerName, state] = line.split('\t');
|
|
82
|
+
if (!containerName?.trim()) return null;
|
|
83
|
+
return {
|
|
84
|
+
agentId: agentId?.trim() || containerName.trim(),
|
|
85
|
+
containerName: containerName.trim(),
|
|
86
|
+
state: state?.trim() || 'unknown',
|
|
87
|
+
};
|
|
88
|
+
})
|
|
89
|
+
.filter(Boolean) as AgentRef[];
|
|
72
90
|
} catch {
|
|
73
91
|
return [];
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Read an agent's model settings out of its openclaw.json.
|
|
97
|
+
*
|
|
98
|
+
* `agents.defaults.model` is the same value `openclaw models status` reports as
|
|
99
|
+
* `defaultModel`/`fallbacks`. The CLI additionally resolves an `imageModel`
|
|
100
|
+
* default when the config sets none, but the base image defines no such default
|
|
101
|
+
* (verified: `imageModel: null`), and agent creation always writes an explicit
|
|
102
|
+
* model — so there is no case where the file and the CLI would disagree.
|
|
103
|
+
*/
|
|
104
|
+
async function readAgentStatus(agent: AgentRef): Promise<GatewayAgentStatus> {
|
|
105
|
+
const raw = await dockerExecNoFail(
|
|
106
|
+
agent.containerName,
|
|
107
|
+
['cat', AGENT_CONFIG_PATH],
|
|
108
|
+
{ timeoutMs: 8000 },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const base: GatewayAgentStatus = {
|
|
112
|
+
agentId: agent.agentId,
|
|
113
|
+
containerName: agent.containerName,
|
|
114
|
+
state: agent.state,
|
|
115
|
+
defaultModel: '',
|
|
116
|
+
fallbacks: [],
|
|
117
|
+
allowed: [],
|
|
118
|
+
aliases: {},
|
|
119
|
+
providers: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!raw) return { ...base, error: 'config unreadable' };
|
|
97
123
|
|
|
124
|
+
try {
|
|
125
|
+
const config = JSON.parse(raw) as Record<string, any>;
|
|
126
|
+
const model = config?.agents?.defaults?.model ?? {};
|
|
98
127
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
defaultModel: String(data.defaultModel ?? ''),
|
|
103
|
-
fallbacks: (data.fallbacks ?? []).map(String),
|
|
104
|
-
allowed: (data.allowed ?? []).map(String),
|
|
105
|
-
aliases,
|
|
106
|
-
providers,
|
|
128
|
+
...base,
|
|
129
|
+
defaultModel: typeof model.primary === 'string' ? model.primary : '',
|
|
130
|
+
fallbacks: Array.isArray(model.fallbacks) ? model.fallbacks.map(String) : [],
|
|
107
131
|
};
|
|
108
132
|
} catch {
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Get available models from an agent */
|
|
114
|
-
function getAgentModels(container: string): GatewayModel[] {
|
|
115
|
-
const raw = dockerExec(container, 'openclaw models list --json', 8000);
|
|
116
|
-
if (!raw) return [];
|
|
117
|
-
try {
|
|
118
|
-
const parsed = JSON.parse(raw);
|
|
119
|
-
const models: Record<string, unknown>[] = parsed.models ?? parsed ?? [];
|
|
120
|
-
return models
|
|
121
|
-
.filter((m: Record<string, unknown>) => m.available !== false && !m.missing)
|
|
122
|
-
.map((m: Record<string, unknown>) => ({
|
|
123
|
-
key: String(m.key ?? ''),
|
|
124
|
-
provider: String(m.key ?? '').split('/')[0] || 'unknown',
|
|
125
|
-
model: String(m.name ?? String(m.key ?? '').split('/').slice(1).join('/')),
|
|
126
|
-
available: m.available !== false,
|
|
127
|
-
}));
|
|
128
|
-
} catch {
|
|
129
|
-
return [];
|
|
133
|
+
return { ...base, error: 'config unparseable' };
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
138
|
+
const denied = await requireAuthJWT(request);
|
|
139
|
+
if (denied) return denied;
|
|
134
140
|
try {
|
|
135
141
|
const timestamp = Date.now();
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const agents = agentsRaw
|
|
140
|
-
.map((a) => getAgentStatus(a))
|
|
141
|
-
.filter(Boolean) as GatewayAgentStatus[];
|
|
142
|
-
|
|
143
|
-
// Aggregate models from all agents (deduplicated by key)
|
|
144
|
-
const modelMap = new Map<string, GatewayModel>();
|
|
145
|
-
for (const agent of agents) {
|
|
146
|
-
for (const m of getAgentModels(agent.containerName)) {
|
|
147
|
-
if (!modelMap.has(m.key)) modelMap.set(m.key, m);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const allModels = Array.from(modelMap.values());
|
|
151
|
-
const availableModels = allModels.filter((m) => m.available);
|
|
152
|
-
|
|
153
|
-
// Aggregate aliases from all agents
|
|
154
|
-
const combinedAliases: Record<string, string[]> = {};
|
|
155
|
-
for (const agent of agents) {
|
|
156
|
-
for (const [alias, model] of Object.entries(agent.aliases)) {
|
|
157
|
-
if (!combinedAliases[alias]) combinedAliases[alias] = [];
|
|
158
|
-
if (!combinedAliases[alias].includes(model)) {
|
|
159
|
-
combinedAliases[alias].push(model);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
143
|
+
const refs = await getAgents();
|
|
144
|
+
const agents = await mapWithConcurrency(refs, AGENT_QUERY_CONCURRENCY, readAgentStatus);
|
|
163
145
|
|
|
164
146
|
// First agent is the "primary" for core model settings
|
|
165
147
|
const primaryAgent = agents[0] ?? null;
|
|
166
148
|
|
|
167
|
-
// API keys from the first agent's providers
|
|
168
|
-
const apiKeys: ApiKeyStatus[] = [];
|
|
169
|
-
if (primaryAgent) {
|
|
170
|
-
for (const p of primaryAgent.providers) {
|
|
171
|
-
// We can't query apiKey presence from openclaw models status (it's redacted).
|
|
172
|
-
// Mark as configured if the provider appears in the auth list.
|
|
173
|
-
apiKeys.push({
|
|
174
|
-
provider: p.provider,
|
|
175
|
-
configured: p.profiles > 0,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Models by provider
|
|
181
|
-
const byProvider = allModels.reduce<Record<string, number>>((acc, m) => {
|
|
182
|
-
acc[m.provider] = (acc[m.provider] ?? 0) + 1;
|
|
183
|
-
return acc;
|
|
184
|
-
}, {});
|
|
185
|
-
|
|
186
149
|
return NextResponse.json({
|
|
187
150
|
timestamp,
|
|
188
151
|
gateway: 'Rev4a Model Gateway',
|
|
189
152
|
status: 'online',
|
|
153
|
+
// The model catalogue lives in /api/gateway/provider, which reads the
|
|
154
|
+
// bundled config plus user overrides. Recomputing it per agent here cost
|
|
155
|
+
// 4.4s each and was never read.
|
|
190
156
|
models: {
|
|
191
|
-
total:
|
|
192
|
-
available:
|
|
193
|
-
byProvider:
|
|
194
|
-
list:
|
|
157
|
+
total: 0,
|
|
158
|
+
available: 0,
|
|
159
|
+
byProvider: [],
|
|
160
|
+
list: [],
|
|
195
161
|
},
|
|
196
162
|
agents: {
|
|
197
163
|
total: agents.length,
|
|
@@ -205,16 +171,18 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
205
171
|
providers: a.providers,
|
|
206
172
|
})),
|
|
207
173
|
},
|
|
208
|
-
aliases:
|
|
174
|
+
aliases: {},
|
|
209
175
|
coreModel: primaryAgent
|
|
210
176
|
? {
|
|
211
177
|
defaultModel: primaryAgent.defaultModel,
|
|
212
178
|
fallbacks: primaryAgent.fallbacks,
|
|
213
179
|
}
|
|
214
180
|
: null,
|
|
181
|
+
// Provider credential state comes from /api/gateway/provider (`configured`
|
|
182
|
+
// per provider), which reads provider-keys.json directly.
|
|
215
183
|
apiKeys: {
|
|
216
|
-
configured:
|
|
217
|
-
all:
|
|
184
|
+
configured: [],
|
|
185
|
+
all: [],
|
|
218
186
|
},
|
|
219
187
|
});
|
|
220
188
|
} catch (e: unknown) {
|
package/app/api/gateway/sync.ts
CHANGED
|
@@ -79,16 +79,6 @@ function writeContainerJson(container: string, remotePath: string, data: Record<
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function restartContainerGateway(container: string): string | null {
|
|
83
|
-
try {
|
|
84
|
-
execSync(`docker exec ${container} openclaw gateway restart`, { timeout: 15000, maxBuffer: 64 * 1024 });
|
|
85
|
-
return null;
|
|
86
|
-
} catch (e: unknown) {
|
|
87
|
-
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
88
|
-
return err.stderr?.toString().trim() || err.message || 'unknown error';
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
82
|
/* ------------------------------------------------------------------ */
|
|
93
83
|
/* Sync functions */
|
|
94
84
|
/* ------------------------------------------------------------------ */
|
|
@@ -181,9 +171,16 @@ function syncContainer(
|
|
|
181
171
|
|
|
182
172
|
writeContainerJson(container, remotePath, config);
|
|
183
173
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
174
|
+
// No gateway restart: this only writes models.providers.rev4a, and the Gateway
|
|
175
|
+
// watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
|
|
176
|
+
// so out loud — "Change will apply without restarting the gateway" — and its
|
|
177
|
+
// docs put `models` in the no-restart column, with `gateway.reload` defaulting
|
|
178
|
+
// to `hybrid`, which additionally restarts by itself for the changes that do
|
|
179
|
+
// need it. Verified against a live agent: every agent here leaves
|
|
180
|
+
// `gateway.reload` unset, so they all run that default.
|
|
181
|
+
//
|
|
182
|
+
// The restart used to cost ~7.5s per container, serially — roughly 52s of
|
|
183
|
+
// fully blocked event loop on a seven-agent host, every time a provider key or
|
|
184
|
+
// model toggle was saved, to do something the Gateway had already done.
|
|
185
|
+
return `${container}: synced`;
|
|
189
186
|
}
|
package/app/api/models/route.ts
CHANGED
|
@@ -48,6 +48,8 @@ function loadModelsConfig(): ModelsConfigEntry[] {
|
|
|
48
48
|
|
|
49
49
|
export async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
50
50
|
const request = _req;
|
|
51
|
+
const denied = await requireAuthJWT(request);
|
|
52
|
+
if (denied) return denied;
|
|
51
53
|
const allModels = loadModelsConfig();
|
|
52
54
|
const providerKeys = readProviderKeys();
|
|
53
55
|
const configured = new Set(Object.keys(providerKeys));
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Used by both /api/provider/v1/chat/completions and /api/assistant
|
|
6
6
|
* to avoid HTTP loopback (calling localhost from inside Next.js).
|
|
7
7
|
*/
|
|
8
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
9
|
-
|
|
10
8
|
export const dynamic = 'force-dynamic';
|
|
11
9
|
|
|
12
10
|
export interface ProviderEnvConfig {
|
|
@@ -127,7 +125,18 @@ export async function callUpstream(
|
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
if (provider === 'openrouter') {
|
|
130
|
-
|
|
128
|
+
// Attribution only: OpenRouter uses this pair to identify the app on its
|
|
129
|
+
// public leaderboard. It never resolves or contacts the URL, and it has no
|
|
130
|
+
// effect on routing, rate limits, pricing, or whether the call succeeds.
|
|
131
|
+
//
|
|
132
|
+
// This used to be `http://${detectPublicHost()}:3740`, which shelled out to
|
|
133
|
+
// curl synchronously — blocking the whole event loop for ~270ms before every
|
|
134
|
+
// OpenRouter call, and 55 of the 69 models enabled in the catalogue are
|
|
135
|
+
// OpenRouter ones. It also fragmented the attribution it was supposed to
|
|
136
|
+
// provide: every install reported a different host, so each one showed up as
|
|
137
|
+
// a separate app. A per-install address was never the right value here —
|
|
138
|
+
// X-Title, the other half of the same label, was always a constant.
|
|
139
|
+
headers['HTTP-Referer'] = 'https://rev4a.app';
|
|
131
140
|
headers['X-Title'] = 'Rev4a Gateway';
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* Only one download at a time — returns 409 if already running.
|
|
13
13
|
* Cleans up the docker process if the client disconnects.
|
|
14
14
|
*/
|
|
15
|
-
import { NextResponse } from 'next/server';
|
|
15
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
17
17
|
import { execSync } from 'child_process';
|
|
18
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
18
19
|
|
|
19
20
|
export const dynamic = 'force-dynamic';
|
|
20
21
|
|
|
@@ -30,7 +31,9 @@ function dockerOk(): boolean {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export async function GET(): Promise<NextResponse> {
|
|
34
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
35
|
+
const denied = await requireAuthIfConfigured(request);
|
|
36
|
+
if (denied) return denied;
|
|
34
37
|
if (!dockerOk()) {
|
|
35
38
|
return NextResponse.json({ exists: false });
|
|
36
39
|
}
|
|
@@ -55,7 +58,9 @@ export async function GET(): Promise<NextResponse> {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export async function POST(): Promise<Response> {
|
|
61
|
+
export async function POST(request: NextRequest): Promise<Response> {
|
|
62
|
+
const denied = await requireAuthIfConfigured(request);
|
|
63
|
+
if (denied) return denied;
|
|
59
64
|
if (getIsDownloading()) {
|
|
60
65
|
return new Response(
|
|
61
66
|
JSON.stringify({ error: 'A download is already in progress' }),
|
|
@@ -15,6 +15,7 @@ import * as fs from 'fs';
|
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import * as crypto from 'crypto';
|
|
17
17
|
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
18
|
+
import { isConfigured } from '@/lib/rev4a-auth';
|
|
18
19
|
|
|
19
20
|
export const dynamic = 'force-dynamic';
|
|
20
21
|
export const runtime = 'nodejs';
|
|
@@ -23,21 +24,33 @@ function generateSecret(len: number): string {
|
|
|
23
24
|
return crypto.randomBytes(len).toString('base64url').slice(0, len);
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* True only if an actual assignment line for `key` exists — a commented-out
|
|
29
|
+
* or incidental mention (`# old REV4A_TOKEN=…`) does not count. A substring
|
|
30
|
+
* check here would report the key as present and skip generating it, leaving
|
|
31
|
+
* the instance without a usable secret.
|
|
32
|
+
*/
|
|
33
|
+
function hasEnvKey(content: string, key: string): boolean {
|
|
34
|
+
return content.split('\n').some((line) => line.trimStart().startsWith(`${key}=`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Replace the assignment line for `key`, or append one if none exists. */
|
|
38
|
+
function upsertEnvLine(content: string, key: string, value: string): string {
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
let replaced = false;
|
|
41
|
+
const updated = lines.map((line) => {
|
|
42
|
+
if (!replaced && line.trimStart().startsWith(`${key}=`)) {
|
|
43
|
+
replaced = true;
|
|
44
|
+
return `${key}=${value}`;
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
});
|
|
48
|
+
if (!replaced) updated.push(`${key}=${value}`);
|
|
49
|
+
return updated.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
27
|
-
|
|
28
|
-
let currentPassword = process.env.REV4A_PASSWORD;
|
|
29
|
-
if (typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
|
30
|
-
try {
|
|
31
|
-
if (fs.existsSync(ENV_FILE)) {
|
|
32
|
-
const content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
33
|
-
const match = content.match(/^REV4A_PASSWORD=(.+)$/m);
|
|
34
|
-
if (match && match[1].trim()) {
|
|
35
|
-
currentPassword = match[1].trim();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch { /* ignore */ }
|
|
39
|
-
}
|
|
40
|
-
if (typeof currentPassword === 'string' && currentPassword.length > 0) {
|
|
53
|
+
if (isConfigured()) {
|
|
41
54
|
return NextResponse.json(
|
|
42
55
|
{ error: 'Password is already configured. Use the Config page to change it.' },
|
|
43
56
|
{ status: 409 },
|
|
@@ -72,30 +85,12 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|
|
72
85
|
content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
if (content
|
|
77
|
-
content = content
|
|
78
|
-
.split('\n')
|
|
79
|
-
.map((line) =>
|
|
80
|
-
line.startsWith('REV4A_PASSWORD=')
|
|
81
|
-
? 'REV4A_PASSWORD=' + body.password
|
|
82
|
-
: line,
|
|
83
|
-
)
|
|
84
|
-
.join('\n');
|
|
85
|
-
} else {
|
|
86
|
-
content += '\nREV4A_PASSWORD=' + body.password + '\n';
|
|
88
|
+
content = upsertEnvLine(content, 'REV4A_PASSWORD', body.password);
|
|
89
|
+
if (!hasEnvKey(content, 'REV4A_JWT_SECRET')) {
|
|
90
|
+
content = upsertEnvLine(content, 'REV4A_JWT_SECRET', generateSecret(64));
|
|
87
91
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!content.includes('REV4A_JWT_SECRET=')) {
|
|
91
|
-
const secret = generateSecret(64);
|
|
92
|
-
content += '\nREV4A_JWT_SECRET=' + secret + '\n';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Generate API token if missing
|
|
96
|
-
if (!content.includes('REV4A_TOKEN=')) {
|
|
97
|
-
const token = generateSecret(32);
|
|
98
|
-
content += '\nREV4A_TOKEN=' + token + '\n';
|
|
92
|
+
if (!hasEnvKey(content, 'REV4A_TOKEN')) {
|
|
93
|
+
content = upsertEnvLine(content, 'REV4A_TOKEN', generateSecret(32));
|
|
99
94
|
}
|
|
100
95
|
|
|
101
96
|
fs.writeFileSync(ENV_FILE, content.trim() + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
* 2. Otherwise send SIGTERM to the current process. The parent process
|
|
13
13
|
* manager (npx rev4a all or a shell wrapper) should restart it.
|
|
14
14
|
*/
|
|
15
|
-
import { NextResponse } from 'next/server';
|
|
15
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import { execSync } from 'child_process';
|
|
18
18
|
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
19
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
19
20
|
|
|
20
21
|
export const dynamic = 'force-dynamic';
|
|
21
22
|
export const runtime = 'nodejs';
|
|
22
23
|
|
|
23
|
-
export async function POST(): Promise<NextResponse> {
|
|
24
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
25
|
+
// Open only during initial setup (before a password exists). Once
|
|
26
|
+
// configured, this triggers a production service restart and must
|
|
27
|
+
// require a valid session like every other privileged route.
|
|
28
|
+
const denied = await requireAuthIfConfigured(request);
|
|
29
|
+
if (denied) return denied;
|
|
24
30
|
try {
|
|
25
31
|
if (!fs.existsSync(ENV_FILE)) {
|
|
26
32
|
return NextResponse.json({ error: '.env file not found' }, { status: 500 });
|
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* DELETE /api/skills/delete
|
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* POST /api/skills/promote
|