@flame0510/project-aether 1.1.11 → 1.1.13
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 +84 -22
- package/app/api/agents/create/route.ts +15 -3
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +2 -0
- package/app/api/agents/route.ts +1 -36
- 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/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +2 -0
- package/app/api/models/route.ts +2 -0
- 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 +1 -1
- 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/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -13
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +9 -4
- 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/db.ts +0 -10
- package/lib/docker-socket.ts +121 -0
- 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
package/docs/dev/GATEWAY.md
CHANGED
|
@@ -227,6 +227,11 @@ Updates model config for a single agent container (primary model only).
|
|
|
227
227
|
|
|
228
228
|
Returns current provider configuration state.
|
|
229
229
|
|
|
230
|
+
Add `?summary=1` for a cheap variant — `{ provider, label, configured }` only,
|
|
231
|
+
skipping the live-pricing fetch. Used by the agents page, which needs nothing
|
|
232
|
+
more than "is any provider configured" and shouldn't block on a network call to
|
|
233
|
+
OpenRouter to find out.
|
|
234
|
+
|
|
230
235
|
**Response:**
|
|
231
236
|
```json
|
|
232
237
|
{
|
|
@@ -237,13 +242,49 @@ Returns current provider configuration state.
|
|
|
237
242
|
"configured": true,
|
|
238
243
|
"baseUrl": "https://api.deepseek.com",
|
|
239
244
|
"models": [
|
|
240
|
-
{
|
|
245
|
+
{
|
|
246
|
+
"id": "deepseek/deepseek-v4-flash",
|
|
247
|
+
"name": "DeepSeek V4 Flash",
|
|
248
|
+
"enabled": true,
|
|
249
|
+
"pricing": { "input": 0.05, "output": 0.10 },
|
|
250
|
+
"pricingLive": true,
|
|
251
|
+
"modality": "text->text"
|
|
252
|
+
}
|
|
241
253
|
]
|
|
242
254
|
}
|
|
243
255
|
]
|
|
244
256
|
}
|
|
245
257
|
```
|
|
246
258
|
|
|
259
|
+
`pricing` is USD per 1M tokens. `-1 / -1` means the model routes dynamically
|
|
260
|
+
(OpenRouter Auto) — the UI renders "Dynamic" and shows "varies" for
|
|
261
|
+
capabilities rather than concrete icons.
|
|
262
|
+
|
|
263
|
+
#### Live pricing
|
|
264
|
+
|
|
265
|
+
`model-pricing.json` is maintained by hand and drifts — 16 of 79 OpenRouter
|
|
266
|
+
entries were wrong when this was added, one understating the real cost ~8x.
|
|
267
|
+
`lib/openrouter-pricing.ts` overlays the live catalogue on top of it and sets
|
|
268
|
+
`pricingLive: true` on the models it covers, which the UI marks with a `LIVE`
|
|
269
|
+
pill.
|
|
270
|
+
|
|
271
|
+
- Endpoint: `GET https://openrouter.ai/api/v1/models` — public, no API key,
|
|
272
|
+
CDN-cached, never billed.
|
|
273
|
+
- Scope: **only** models whose provider is `openrouter`. The same model sold
|
|
274
|
+
direct (openai, anthropic, google…) has different rates, so applying
|
|
275
|
+
OpenRouter's price there would show the wrong vendor's number.
|
|
276
|
+
- Cache mirrors what the endpoint itself declares: `max-age=300` for
|
|
277
|
+
freshness, `stale-if-error=3600` as the upper bound past which a stale
|
|
278
|
+
entry is dropped in favour of the static file. Failures back off for a
|
|
279
|
+
minute instead of retrying on every request.
|
|
280
|
+
- Skipped entirely when OpenRouter isn't configured, so callers that only
|
|
281
|
+
read `configured` (the agents page) don't wait on a network round trip.
|
|
282
|
+
- `modality` is **not** taken from upstream. `sync.ts` provisions agent
|
|
283
|
+
containers from the curated `models.config.json` value, so a different
|
|
284
|
+
capability set in the UI would advertise inputs the container never
|
|
285
|
+
accepted. (`moonshotai/kimi-k3` currently differs: `text+image` curated
|
|
286
|
+
vs `text+image+video` upstream.)
|
|
287
|
+
|
|
247
288
|
---
|
|
248
289
|
|
|
249
290
|
## Config Page (`/config`)
|
package/docs/dev/PROVIDERS.md
CHANGED
|
@@ -218,9 +218,10 @@ Key points:
|
|
|
218
218
|
|
|
219
219
|
## Security Notes
|
|
220
220
|
|
|
221
|
-
- The `GET /api/vault/provider/key` endpoint
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
- The `GET /api/vault/provider/key` endpoint calls `requireAuthJWT` directly
|
|
222
|
+
(browser cookie or bearer token) — API routes are never covered by `proxy.ts`
|
|
223
|
+
(it explicitly exempts `/api/*`), so each route must guard itself. Only
|
|
224
|
+
authenticated users can reveal keys.
|
|
224
225
|
- The UI **must never** log, screenshot, or otherwise persist the revealed key
|
|
225
226
|
outside the active screen view.
|
|
226
227
|
- All secrets in documentation, logs, or reports must be masked (e.g. `sk-...ast4`).
|
|
@@ -87,7 +87,7 @@ Browse installed OpenClaw plugins. Enable/disable toggle per plugin. Plugins ext
|
|
|
87
87
|
|
|
88
88
|
### Skills (`/skills`)
|
|
89
89
|
Skill registry showing all skills across three sources:
|
|
90
|
-
- **Shared** — global skills from
|
|
90
|
+
- **Shared** — global skills from `~/.config/rev4a/shared/shared-skills`, available to all agents
|
|
91
91
|
- **Per-agent** — skills in each agent's workspace (`<workspace>/skills`), shown with agent label
|
|
92
92
|
- **Bundled** — skills shipped with OpenClaw (read-only)
|
|
93
93
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js instrumentation hook — runs once at server startup.
|
|
3
|
+
*
|
|
4
|
+
* Syncs the provider gateway baseUrl to all agent containers on boot.
|
|
5
|
+
* This ensures agents pick up any domain/env changes after a restart,
|
|
6
|
+
* VPS reboot, or crash recovery — no manual Sync button needed.
|
|
7
|
+
*/
|
|
8
|
+
export async function register() {
|
|
9
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
10
|
+
try {
|
|
11
|
+
const { syncAllAgents } = await import('./app/api/gateway/sync');
|
|
12
|
+
syncAllAgents();
|
|
13
|
+
} catch {
|
|
14
|
+
// Best-effort — don't block startup if sync fails
|
|
15
|
+
console.warn('[rev4a] Failed to sync agents on startup');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/lib/agent-setup.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
12
|
import * as fs from 'fs';
|
|
13
|
+
import { SHARED_SKILLS_DIR, REV4A_RULES_DIR } from '@/lib/rev4a-paths';
|
|
13
14
|
|
|
14
15
|
// ── Volumes ──────────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -28,12 +29,12 @@ export interface Rev4aVolume {
|
|
|
28
29
|
*/
|
|
29
30
|
export const REV4A_VOLUMES: Rev4aVolume[] = [
|
|
30
31
|
{
|
|
31
|
-
source:
|
|
32
|
+
source: SHARED_SKILLS_DIR,
|
|
32
33
|
target: '/data/.openclaw/shared-skills',
|
|
33
34
|
mode: 'ro',
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
|
-
source:
|
|
37
|
+
source: REV4A_RULES_DIR,
|
|
37
38
|
target: '/root/.openclaw/workspace/.rev4a',
|
|
38
39
|
mode: 'ro',
|
|
39
40
|
},
|
|
@@ -60,14 +61,12 @@ export function getMountFlags(): string[] {
|
|
|
60
61
|
* multiple times.
|
|
61
62
|
*/
|
|
62
63
|
export function ensureHostDirs(): void {
|
|
64
|
+
// Plain mkdir: these now live under the user's own data directory, so no
|
|
65
|
+
// elevation is needed. The previous `sudo mkdir` with a plain fallback was
|
|
66
|
+
// only required because the paths sat at the filesystem root, and it could
|
|
67
|
+
// hang on a password prompt when run non-interactively.
|
|
63
68
|
for (const vol of REV4A_VOLUMES) {
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
execSync(`sudo mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
|
|
67
|
-
} catch {
|
|
68
|
-
execSync(`mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
69
|
+
fs.mkdirSync(vol.source, { recursive: true });
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
|
package/lib/buildAgentImage.ts
CHANGED
|
@@ -193,7 +193,11 @@ async function pullFromRegistry(
|
|
|
193
193
|
|
|
194
194
|
child.on('error', (err: Error) => {
|
|
195
195
|
appendLog(`Pull process error: ${err.message}`);
|
|
196
|
-
|
|
196
|
+
// Do NOT release the lock here: the caller falls through to
|
|
197
|
+
// localBuild(), which must stay protected. Releasing early let a
|
|
198
|
+
// concurrent request past getIsDownloading() and start a second
|
|
199
|
+
// `docker build` against the same tag. The lock is released by
|
|
200
|
+
// localBuild's own close/error handlers, or by the success path.
|
|
197
201
|
resolve(false);
|
|
198
202
|
});
|
|
199
203
|
});
|
package/lib/db.ts
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
// Shared DB helpers for API routes
|
|
2
2
|
import Database from 'better-sqlite3';
|
|
3
|
-
import type { NextRequest } from 'next/server';
|
|
4
|
-
import { NextResponse } from 'next/server';
|
|
5
3
|
import { initializeRev4aDb } from './db-bootstrap.mjs';
|
|
6
4
|
import { DB_FILE } from './rev4a-paths';
|
|
7
5
|
|
|
8
6
|
export const DB_PATH = process.env.REV4A_DB || DB_FILE;
|
|
9
|
-
export const TOKEN = process.env.REV4A_TOKEN ?? '';
|
|
10
|
-
|
|
11
|
-
export function requireAuth(request: NextRequest): NextResponse | null {
|
|
12
|
-
const token = new URL(request.url).searchParams.get('token');
|
|
13
|
-
const auth = request.headers.get('authorization');
|
|
14
|
-
const ok = token === TOKEN || auth === `Bearer ${TOKEN}`;
|
|
15
|
-
return ok ? null : NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
|
-
}
|
|
17
7
|
|
|
18
8
|
export function ensureDbReady(): void {
|
|
19
9
|
initializeRev4aDb(DB_PATH);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Engine API over its local unix socket.
|
|
3
|
+
*
|
|
4
|
+
* The socket path is NOT the same everywhere. `/var/run/docker.sock` is the
|
|
5
|
+
* Linux default (and what the VPS uses), but Docker Desktop on macOS puts it
|
|
6
|
+
* at `~/.docker/run/docker.sock` and does not create the /var/run symlink
|
|
7
|
+
* unless the user opts in. Hardcoding the Linux path made every read-only
|
|
8
|
+
* Docker route return an empty list on macOS — agents existed and ran, but
|
|
9
|
+
* the dashboard showed nothing, because creation shells out to the `docker`
|
|
10
|
+
* CLI (which reads the context) while listing went through this socket.
|
|
11
|
+
*
|
|
12
|
+
* Talking to the socket directly is preferred over shelling out for reads:
|
|
13
|
+
* no fork, no shell quoting, no `--format` template to parse — just JSON.
|
|
14
|
+
*/
|
|
15
|
+
import * as http from 'http';
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { execFileSync } from 'child_process';
|
|
20
|
+
|
|
21
|
+
let cached: string | null = null;
|
|
22
|
+
let lastMissAt = 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a failed resolution is remembered. Long enough that a Docker outage
|
|
26
|
+
* doesn't spawn a `docker context inspect` per request, short enough that a
|
|
27
|
+
* server which started before the Docker daemon picks it up on its own.
|
|
28
|
+
*/
|
|
29
|
+
const MISS_TTL_MS = 5000;
|
|
30
|
+
|
|
31
|
+
function fromDockerHostEnv(): string | null {
|
|
32
|
+
const raw = process.env.DOCKER_HOST;
|
|
33
|
+
if (!raw) return null;
|
|
34
|
+
// Only unix sockets are usable here; tcp:// would need a different client.
|
|
35
|
+
if (!raw.startsWith('unix://')) return null;
|
|
36
|
+
return raw.slice('unix://'.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fromDockerContext(): string | null {
|
|
40
|
+
try {
|
|
41
|
+
const out = execFileSync(
|
|
42
|
+
'docker',
|
|
43
|
+
['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'],
|
|
44
|
+
{ encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
45
|
+
).trim();
|
|
46
|
+
return out.startsWith('unix://') ? out.slice('unix://'.length) : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function usable(candidate: string | null): string | null {
|
|
53
|
+
if (!candidate) return null;
|
|
54
|
+
try {
|
|
55
|
+
fs.accessSync(candidate, fs.constants.R_OK | fs.constants.W_OK);
|
|
56
|
+
return candidate;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the Docker socket, most authoritative source first. Returns null when
|
|
64
|
+
* Docker isn't reachable at all.
|
|
65
|
+
*
|
|
66
|
+
* A success is cached for the life of the process — the path doesn't move. A
|
|
67
|
+
* failure is only cached for MISS_TTL_MS: Rev4a can legitimately start before
|
|
68
|
+
* the Docker daemon is up (systemd ordering, Docker Desktop still booting), and
|
|
69
|
+
* caching that failure permanently would leave every Docker route returning an
|
|
70
|
+
* empty list until someone restarted the server.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveDockerSocket(): string | null {
|
|
73
|
+
if (cached) return cached;
|
|
74
|
+
if (Date.now() - lastMissAt < MISS_TTL_MS) return null;
|
|
75
|
+
|
|
76
|
+
const found =
|
|
77
|
+
usable(fromDockerHostEnv()) ??
|
|
78
|
+
usable(fromDockerContext()) ??
|
|
79
|
+
// Docker Desktop (macOS, and Windows with WSL integration)
|
|
80
|
+
usable(path.join(os.homedir(), '.docker', 'run', 'docker.sock')) ??
|
|
81
|
+
// Linux default — the VPS lands here
|
|
82
|
+
usable('/var/run/docker.sock');
|
|
83
|
+
|
|
84
|
+
if (found) cached = found;
|
|
85
|
+
else lastMissAt = Date.now();
|
|
86
|
+
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when a usable Docker socket exists. */
|
|
91
|
+
export function dockerAvailable(): boolean {
|
|
92
|
+
return resolveDockerSocket() !== null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* GET/POST against the Docker Engine API, resolving the response as JSON.
|
|
97
|
+
* Rejects when Docker is unreachable or the payload isn't JSON.
|
|
98
|
+
*/
|
|
99
|
+
export function dockerFetch<T = any>(method: string, apiPath: string): Promise<T> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const socketPath = resolveDockerSocket();
|
|
102
|
+
if (!socketPath) {
|
|
103
|
+
reject(new Error('Docker socket not found'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const req = http.request(
|
|
108
|
+
{ socketPath, path: apiPath, method, headers: { Host: 'localhost' } },
|
|
109
|
+
(res) => {
|
|
110
|
+
let data = '';
|
|
111
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
112
|
+
res.on('end', () => {
|
|
113
|
+
try { resolve(JSON.parse(data) as T); }
|
|
114
|
+
catch { reject(new Error('Invalid JSON from Docker')); }
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
req.on('error', reject);
|
|
119
|
+
req.end();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live pricing for OpenRouter-hosted models.
|
|
3
|
+
*
|
|
4
|
+
* model-pricing.json is maintained by hand and drifts: at the time this was
|
|
5
|
+
* written, 16 of 79 OpenRouter entries disagreed with the live catalogue —
|
|
6
|
+
* one of them (google/gemini-2.5-flash) understated the real cost by ~8x.
|
|
7
|
+
* The static file stays as the offline fallback; this overlays it when the
|
|
8
|
+
* catalogue is reachable.
|
|
9
|
+
*
|
|
10
|
+
* Scope is deliberately narrow: only models whose provider is `openrouter`.
|
|
11
|
+
* OpenRouter resells models that we ALSO offer direct (openai, anthropic,
|
|
12
|
+
* google, …) at different rates, so applying these prices to a direct
|
|
13
|
+
* provider entry would show the wrong vendor's price.
|
|
14
|
+
*
|
|
15
|
+
* Only pricing is taken from upstream — NOT modality. app/api/gateway/sync.ts
|
|
16
|
+
* provisions agent containers from the curated models.config.json value, so
|
|
17
|
+
* showing a different capability set in the UI would advertise inputs the
|
|
18
|
+
* container was never configured to accept.
|
|
19
|
+
*
|
|
20
|
+
* The endpoint is public (no API key, served from CDN) — calling it costs
|
|
21
|
+
* nothing and is never billed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CATALOGUE_URL = 'https://openrouter.ai/api/v1/models';
|
|
25
|
+
// The call measures ~300ms against the CDN, so 3s is ~10x headroom. A longer
|
|
26
|
+
// limit wouldn't protect anything — it would just hold the response open when
|
|
27
|
+
// the catalogue is unreachable, and the static file is a fine stand-in.
|
|
28
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
29
|
+
|
|
30
|
+
// Mirrors the cache policy OpenRouter declares on the endpoint itself:
|
|
31
|
+
// cache-control: public, max-age=300, stale-while-revalidate=3600, stale-if-error=3600
|
|
32
|
+
const TTL_MS = 5 * 60 * 1000; // max-age=300
|
|
33
|
+
const MAX_STALE_MS = 60 * 60 * 1000; // stale-if-error=3600
|
|
34
|
+
// After a failure, don't retry on every single request — an unreachable
|
|
35
|
+
// catalogue would otherwise make each one pay the full abort timeout.
|
|
36
|
+
const RETRY_AFTER_FAILURE_MS = 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export interface LivePricing {
|
|
39
|
+
input: number; // USD per 1M prompt tokens
|
|
40
|
+
output: number; // USD per 1M completion tokens
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface OpenRouterModel {
|
|
44
|
+
id: string;
|
|
45
|
+
pricing?: { prompt?: string; completion?: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CacheEntry {
|
|
49
|
+
pricing: Map<string, LivePricing>;
|
|
50
|
+
fetchedAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let cache: CacheEntry | null = null;
|
|
54
|
+
let lastFailureAt = 0;
|
|
55
|
+
let inFlight: Promise<CacheEntry | null> | null = null;
|
|
56
|
+
|
|
57
|
+
/** OpenRouter quotes per-token; the UI works in per-million. */
|
|
58
|
+
function perMillion(raw: string | undefined): number | null {
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
const n = parseFloat(raw);
|
|
61
|
+
// -1 means "dynamic" (router models); not a real price, leave it to the
|
|
62
|
+
// static file which already encodes it as -1/-1 and renders as "Dynamic".
|
|
63
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
64
|
+
return n * 1_000_000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchCatalogue(): Promise<CacheEntry | null> {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(CATALOGUE_URL, {
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
headers: { accept: 'application/json' },
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
console.warn(`[openrouter-pricing] catalogue returned HTTP ${res.status}`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = (await res.json()) as { data?: OpenRouterModel[] };
|
|
81
|
+
if (!Array.isArray(body?.data)) {
|
|
82
|
+
console.warn('[openrouter-pricing] unexpected catalogue shape');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pricing = new Map<string, LivePricing>();
|
|
87
|
+
for (const m of body.data) {
|
|
88
|
+
if (!m?.id) continue;
|
|
89
|
+
const input = perMillion(m.pricing?.prompt);
|
|
90
|
+
const output = perMillion(m.pricing?.completion);
|
|
91
|
+
if (input !== null && output !== null) pricing.set(m.id, { input, output });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (pricing.size === 0) return null;
|
|
95
|
+
return { pricing, fetchedAt: Date.now() };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
const reason = e instanceof Error ? e.name : 'unknown';
|
|
98
|
+
console.warn(`[openrouter-pricing] catalogue unreachable (${reason}) — using static prices`);
|
|
99
|
+
return null;
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isUsable(entry: CacheEntry | null): entry is CacheEntry {
|
|
106
|
+
// Past stale-if-error we stop trusting it entirely and fall back to the
|
|
107
|
+
// static file, rather than labelling day-old numbers as live.
|
|
108
|
+
return !!entry && Date.now() - entry.fetchedAt < MAX_STALE_MS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cached catalogue, refreshed when stale. Concurrent callers share one
|
|
113
|
+
* in-flight request. A failure is remembered so an unreachable catalogue
|
|
114
|
+
* doesn't make every request pay the timeout; the previous entry keeps
|
|
115
|
+
* being served until it exceeds the stale-if-error window.
|
|
116
|
+
*/
|
|
117
|
+
async function getCatalogue(): Promise<CacheEntry | null> {
|
|
118
|
+
const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
|
|
119
|
+
if (fresh) return cache;
|
|
120
|
+
|
|
121
|
+
if (inFlight) return inFlight;
|
|
122
|
+
|
|
123
|
+
// Backing off after a recent failure — serve what we have, if still usable.
|
|
124
|
+
if (Date.now() - lastFailureAt < RETRY_AFTER_FAILURE_MS) {
|
|
125
|
+
return isUsable(cache) ? cache : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
inFlight = fetchCatalogue()
|
|
129
|
+
.then((result) => {
|
|
130
|
+
if (result) {
|
|
131
|
+
cache = result;
|
|
132
|
+
lastFailureAt = 0;
|
|
133
|
+
} else {
|
|
134
|
+
lastFailureAt = Date.now();
|
|
135
|
+
}
|
|
136
|
+
return isUsable(cache) ? cache : null;
|
|
137
|
+
})
|
|
138
|
+
.finally(() => {
|
|
139
|
+
inFlight = null;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return inFlight;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Map one of our ids to the upstream one.
|
|
147
|
+
*
|
|
148
|
+
* Our catalogue namespaces OpenRouter entries as `openrouter/<upstream id>`,
|
|
149
|
+
* but OpenRouter also publishes its own house models under a literal
|
|
150
|
+
* `openrouter/` vendor prefix (`openrouter/auto`, `openrouter/auto-beta`, …).
|
|
151
|
+
* Stripping unconditionally turns `openrouter/auto` into `auto`, which does
|
|
152
|
+
* not exist upstream — so try the stripped form first and fall back to the
|
|
153
|
+
* id as-is.
|
|
154
|
+
*/
|
|
155
|
+
function candidateUpstreamIds(modelId: string): string[] {
|
|
156
|
+
if (!modelId.startsWith('openrouter/')) return [];
|
|
157
|
+
const stripped = modelId.slice('openrouter/'.length);
|
|
158
|
+
return stripped === modelId ? [modelId] : [stripped, modelId];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pricing overlay keyed by OUR model ids, for the OpenRouter-hosted subset.
|
|
163
|
+
* Returns an empty map when the catalogue can't be reached — never throws.
|
|
164
|
+
*/
|
|
165
|
+
export async function getLivePricing(modelIds: string[]): Promise<Map<string, LivePricing>> {
|
|
166
|
+
const overlay = new Map<string, LivePricing>();
|
|
167
|
+
|
|
168
|
+
const wanted = modelIds.filter((id) => id.startsWith('openrouter/'));
|
|
169
|
+
if (wanted.length === 0) return overlay;
|
|
170
|
+
|
|
171
|
+
const catalogue = await getCatalogue();
|
|
172
|
+
if (!catalogue) return overlay;
|
|
173
|
+
|
|
174
|
+
for (const ourId of wanted) {
|
|
175
|
+
for (const upstreamId of candidateUpstreamIds(ourId)) {
|
|
176
|
+
const price = catalogue.pricing.get(upstreamId);
|
|
177
|
+
if (price) {
|
|
178
|
+
overlay.set(ourId, price);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return overlay;
|
|
184
|
+
}
|
package/lib/rev4a-auth.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
export function requireAuth(request: Request): NextResponse | null;
|
|
3
2
|
export function requireAuthJWT(request: Request): Promise<NextResponse | null>;
|
|
3
|
+
export function requireAuthIfConfigured(request: Request): Promise<NextResponse | null>;
|
|
4
|
+
export function getPassword(): string;
|
|
5
|
+
export function getJwtSecret(): string;
|
|
6
|
+
export function isConfigured(): boolean;
|
package/lib/rev4a-auth.js
CHANGED
|
@@ -1,36 +1,84 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { jwtVerify } from "jose";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import { ENV_FILE } from "@/lib/rev4a-paths";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Single source of truth for secret resolution. Every route and proxy.ts
|
|
8
|
+
// import these instead of keeping their own env+file-fallback copy — the
|
|
9
|
+
// duplication used to drift (proxy.ts had an env-only version because it
|
|
10
|
+
// used to run on Edge runtime, which can't use fs; now that it runs on
|
|
11
|
+
// Node.js runtime the split serves no purpose and only risks desync).
|
|
12
|
+
//
|
|
13
|
+
// In the supported `rev4a serve` flow the file fallback is rarely hit:
|
|
14
|
+
// bin/rev4a.js injects these into the Next.js child's env, and writeEnvVar()
|
|
15
|
+
// updates process.env in-process. It exists for setups that bypass the CLI
|
|
16
|
+
// (a hand-written systemd unit, `npm run dev` before .env exists).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const PASSWORD_RE = /^REV4A_PASSWORD=(.*)$/m;
|
|
19
|
+
const JWT_SECRET_RE = /^REV4A_JWT_SECRET=(.*)$/m;
|
|
20
|
+
|
|
21
|
+
function readEnvFileValue(re) {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
24
|
+
const match = fs.readFileSync(ENV_FILE, "utf-8").match(re);
|
|
25
|
+
if (match && match[1].trim()) return match[1].trim();
|
|
26
|
+
}
|
|
27
|
+
} catch { /* ignore */ }
|
|
28
|
+
return "";
|
|
8
29
|
}
|
|
9
30
|
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
if (!auth || auth !== `Bearer ${getExpectedToken()}`) {
|
|
13
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
14
|
-
}
|
|
15
|
-
return null;
|
|
31
|
+
export function getPassword() {
|
|
32
|
+
return process.env.REV4A_PASSWORD || readEnvFileValue(PASSWORD_RE);
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
process.env.REV4A_JWT_SECRET ||
|
|
20
|
-
|
|
35
|
+
export function getJwtSecret() {
|
|
36
|
+
return process.env.REV4A_JWT_SECRET || readEnvFileValue(JWT_SECRET_RE);
|
|
37
|
+
}
|
|
21
38
|
|
|
39
|
+
export function isConfigured() {
|
|
40
|
+
return getPassword().length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unauthorized() {
|
|
44
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cookie (JWT) auth, falling back to `Authorization: Bearer <REV4A_TOKEN>`.
|
|
49
|
+
* Returns null when authorized, or a 401 response to return as-is.
|
|
50
|
+
*/
|
|
22
51
|
export async function requireAuthJWT(request) {
|
|
23
52
|
const cookieToken = request.cookies?.get?.("rev4a_token")?.value;
|
|
24
|
-
|
|
53
|
+
const secret = getJwtSecret();
|
|
54
|
+
|
|
55
|
+
// An empty secret would mean verifying against a zero-length HMAC key.
|
|
56
|
+
// jose rejects that, but bail explicitly rather than relying on it.
|
|
57
|
+
if (cookieToken && secret) {
|
|
25
58
|
try {
|
|
26
|
-
await jwtVerify(cookieToken,
|
|
59
|
+
await jwtVerify(cookieToken, new TextEncoder().encode(secret));
|
|
27
60
|
return null;
|
|
28
|
-
} catch {}
|
|
61
|
+
} catch { /* fall through to bearer */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const expectedToken = process.env.REV4A_TOKEN || process.env.NEXT_PUBLIC_REV4A_TOKEN || "";
|
|
65
|
+
// Without this guard an unset token would make `Bearer ` match an empty
|
|
66
|
+
// expected value. HTTP header normalisation already strips the trailing
|
|
67
|
+
// space so it isn't reachable in practice — the guard makes that safety
|
|
68
|
+
// explicit instead of dependent on it.
|
|
69
|
+
if (expectedToken) {
|
|
70
|
+
const auth = request.headers.get("authorization");
|
|
71
|
+
if (auth === `Bearer ${expectedToken}`) return null;
|
|
29
72
|
}
|
|
30
73
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (auth === `Bearer ${expectedToken}`) return null;
|
|
74
|
+
return unauthorized();
|
|
75
|
+
}
|
|
34
76
|
|
|
35
|
-
|
|
77
|
+
/**
|
|
78
|
+
* For routes that must stay reachable during first-run setup (before any
|
|
79
|
+
* password exists) but must be protected once the instance is configured.
|
|
80
|
+
*/
|
|
81
|
+
export async function requireAuthIfConfigured(request) {
|
|
82
|
+
if (!isConfigured()) return null;
|
|
83
|
+
return requireAuthJWT(request);
|
|
36
84
|
}
|
package/lib/rev4a-paths.ts
CHANGED
|
@@ -39,6 +39,25 @@ export const AGENTS_TOKEN_FILE = path.join(REV4A_DATA, 'data', 'agents-token.jso
|
|
|
39
39
|
/** Provider API keys JSON */
|
|
40
40
|
export const PROVIDER_KEYS_FILE = path.join(REV4A_DATA, 'data', 'provider-keys.json');
|
|
41
41
|
|
|
42
|
+
/* ── Host directories bind-mounted into every agent container ───── */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* These used to live at `/docker/shared-skills` and `/docker/rev4a-rules`.
|
|
46
|
+
* That path can't work on macOS: the root volume is a sealed APFS system
|
|
47
|
+
* volume, so `/docker` cannot be created even as root. It also required
|
|
48
|
+
* `sudo mkdir` on Linux for no real benefit.
|
|
49
|
+
*
|
|
50
|
+
* Under REV4A_DATA they sit inside the user's home, which additionally
|
|
51
|
+
* falls within the directories Docker Desktop shares with its Linux VM by
|
|
52
|
+
* default — so a bind mount from here works with no extra configuration.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** Skills shared across all agents, mounted read-only. */
|
|
56
|
+
export const SHARED_SKILLS_DIR = path.join(REV4A_DATA, 'shared', 'shared-skills');
|
|
57
|
+
|
|
58
|
+
/** Rev4a system rules injected into every agent workspace, mounted read-only. */
|
|
59
|
+
export const REV4A_RULES_DIR = path.join(REV4A_DATA, 'shared', 'rev4a-rules');
|
|
60
|
+
|
|
42
61
|
/**
|
|
43
62
|
* Model catalogue — read-only, shipped with the package.
|
|
44
63
|
* Resolved at module load from the package root (alongside bin/rev4a.js).
|