@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
|
@@ -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
|
+
}
|
package/lib/docker-utils.ts
CHANGED
|
@@ -1,54 +1,28 @@
|
|
|
1
|
-
import { networkInterfaces } from 'os';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
2
|
+
* Docker-related host helpers.
|
|
3
|
+
*
|
|
4
|
+
* This module used to export `detectPublicHost()`, which shelled out to
|
|
5
|
+
* `curl icanhazip.com` via execSync to discover the machine's public IP, then
|
|
6
|
+
* fell back to `hostname -I` and finally the literal string 'localhost'. It was
|
|
7
|
+
* removed rather than optimised, because the caching it seemed to need would
|
|
8
|
+
* only have made a wrong answer stable:
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* - execSync blocks the entire event loop, so every call froze the whole
|
|
11
|
+
* server — not just its own request — for ~270ms, and up to the 5s timeout
|
|
12
|
+
* when the network hung. Node's own guidance names execSync in a server as
|
|
13
|
+
* a denial-of-service vector.
|
|
14
|
+
* - On macOS it never even worked: icanhazip answers with IPv6, which the
|
|
15
|
+
* IPv4-only validation discarded, and `hostname -I` is a GNU-only flag. The
|
|
16
|
+
* value that came back was the machine's `.local` mDNS name, reachable only
|
|
17
|
+
* from the same LAN.
|
|
18
|
+
* - It answered the wrong question. Callers needed "which URL should this
|
|
19
|
+
* user's browser open", not "what is this machine's WAN address". Those
|
|
20
|
+
* differ behind NAT, behind a reverse proxy, and over a tunnel.
|
|
21
|
+
*
|
|
22
|
+
* Agent URLs are now composed in the browser from the port returned by the API
|
|
23
|
+
* plus `window.location.hostname` — a hostname already proven to reach this
|
|
24
|
+
* server, since the dashboard is being served over it.
|
|
11
25
|
*/
|
|
12
|
-
export function detectPublicHost(): string {
|
|
13
|
-
// 1. External detection (works from inside Docker containers)
|
|
14
|
-
try {
|
|
15
|
-
const { execSync } = require('child_process');
|
|
16
|
-
const ip = execSync(
|
|
17
|
-
'curl -fs --max-time 3 icanhazip.com 2>/dev/null || curl -fs --max-time 3 ifconfig.me 2>/dev/null',
|
|
18
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
19
|
-
).trim();
|
|
20
|
-
if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !ip.startsWith('172.')) {
|
|
21
|
-
return ip;
|
|
22
|
-
}
|
|
23
|
-
} catch { /* ignore */ }
|
|
24
|
-
|
|
25
|
-
// 2. Hostname (often gives public IP on VPS, but Docker internal 172.x is filtered)
|
|
26
|
-
try {
|
|
27
|
-
const { execSync } = require('child_process');
|
|
28
|
-
const hostname = execSync('hostname -I 2>/dev/null || hostname 2>/dev/null', {
|
|
29
|
-
encoding: 'utf-8', timeout: 2000,
|
|
30
|
-
}).trim().split(/\s+/)[0];
|
|
31
|
-
if (hostname && hostname !== '127.0.0.1' && hostname !== '::1' && !hostname.startsWith('172.')) {
|
|
32
|
-
return hostname;
|
|
33
|
-
}
|
|
34
|
-
} catch { /* ignore */ }
|
|
35
|
-
|
|
36
|
-
// 3. Network interfaces (bare metal)
|
|
37
|
-
try {
|
|
38
|
-
const ifaces = networkInterfaces();
|
|
39
|
-
for (const [, addrs] of Object.entries(ifaces)) {
|
|
40
|
-
if (!addrs) continue;
|
|
41
|
-
for (const addr of addrs) {
|
|
42
|
-
if (addr.family === 'IPv4' && !addr.internal) {
|
|
43
|
-
return addr.address;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
} catch { /* fall through */ }
|
|
48
|
-
|
|
49
|
-
// 4. Fallback
|
|
50
|
-
return 'localhost';
|
|
51
|
-
}
|
|
52
26
|
|
|
53
27
|
/**
|
|
54
28
|
* Build the provider gateway base URL used to sync model config into agent
|
package/lib/openclaw-cron.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
1
|
+
import { execFileSync, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How many agents to query at once.
|
|
9
|
+
*
|
|
10
|
+
* Each one costs a `docker exec` plus an OpenClaw CLI boot — ~2.5-3s measured,
|
|
11
|
+
* and the route used to pay them in sequence: 19.2s on a seven-agent host, with
|
|
12
|
+
* the event loop blocked throughout. Running them concurrently makes the total
|
|
13
|
+
* the slowest agent rather than the sum. The cap is here because every one of
|
|
14
|
+
* these is a process, and the Docker daemon serialises past a point regardless.
|
|
15
|
+
*/
|
|
16
|
+
const AGENT_QUERY_CONCURRENCY = 6;
|
|
2
17
|
|
|
3
18
|
// ── Types ─────────────────────────────────────────────────────────────────
|
|
4
19
|
|
|
@@ -75,24 +90,26 @@ function extractScheduleExpr(job: OpenClawCronJob): string {
|
|
|
75
90
|
|
|
76
91
|
// ── Discover agent containers ────────────────────────────────────────────
|
|
77
92
|
|
|
78
|
-
function discoverAgentContainers(): AgentInfo[] {
|
|
93
|
+
async function discoverAgentContainers(): Promise<AgentInfo[]> {
|
|
79
94
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
agentId =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
// One `docker ps` for everything. This used to run an extra `docker inspect`
|
|
96
|
+
// per container just to read the AGENT_ID label — but `docker ps` prints
|
|
97
|
+
// labels itself, so that was N+1 process spawns (~260ms each) for data the
|
|
98
|
+
// first call already had.
|
|
99
|
+
const raw = await execFileAsync('docker', [
|
|
100
|
+
'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}',
|
|
101
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
102
|
+
|
|
103
|
+
return raw.stdout.trim().split('\n')
|
|
104
|
+
.map((line) => {
|
|
105
|
+
const [agentId, containerName] = line.split('\t');
|
|
106
|
+
if (!containerName?.trim()) return null;
|
|
107
|
+
return {
|
|
108
|
+
containerName: containerName.trim(),
|
|
109
|
+
agentId: agentId?.trim() || containerName.trim(),
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
.filter(Boolean) as AgentInfo[];
|
|
96
113
|
} catch {
|
|
97
114
|
return [];
|
|
98
115
|
}
|
|
@@ -113,34 +130,38 @@ function parseCronListOutput(raw: string): OpenClawCronJob[] | null {
|
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
function listJobsOnHost(): OpenClawCronJob[] | null {
|
|
133
|
+
async function listJobsOnHost(): Promise<OpenClawCronJob[] | null> {
|
|
117
134
|
try {
|
|
118
|
-
|
|
135
|
+
// Not a container command — this is the openclaw CLI on the host itself,
|
|
136
|
+
// so it goes through execFile directly rather than the docker-exec helper.
|
|
137
|
+
const raw = await execFileAsync('openclaw', ['cron', 'list', '--json', '--all'], {
|
|
119
138
|
encoding: 'utf8', timeout: 15000,
|
|
120
139
|
});
|
|
121
|
-
return parseCronListOutput(raw);
|
|
140
|
+
return parseCronListOutput(raw.stdout);
|
|
122
141
|
} catch {
|
|
123
142
|
return null;
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
function listJobsInContainer(containerName: string): OpenClawCronJob[] | null {
|
|
146
|
+
async function listJobsInContainer(containerName: string): Promise<OpenClawCronJob[] | null> {
|
|
128
147
|
// Try both common agent gateway ports. Agents default to 3000 (OpenClaw
|
|
129
148
|
// gateway --bind lan --port 3000) but may also run on 18789 (default).
|
|
149
|
+
// Kept sequential on purpose: the second is a fallback for the first, so
|
|
150
|
+
// firing both would double the load to discard one answer.
|
|
130
151
|
const gatewayUrls = [
|
|
131
152
|
'http://127.0.0.1:3000',
|
|
132
153
|
'http://127.0.0.1:18789',
|
|
133
154
|
];
|
|
134
155
|
|
|
135
156
|
for (const gwUrl of gatewayUrls) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
const raw = await dockerExecNoFail(
|
|
158
|
+
containerName,
|
|
159
|
+
['openclaw', 'cron', 'list', '--json', '--all'],
|
|
160
|
+
{ env: { OPENCLAW_GATEWAY_URL: gwUrl }, timeoutMs: 15000 },
|
|
161
|
+
);
|
|
162
|
+
if (!raw) continue;
|
|
163
|
+
const parsed = parseCronListOutput(raw);
|
|
164
|
+
if (parsed) return parsed;
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
return null;
|
|
@@ -180,12 +201,20 @@ function toggleInContainer(containerName: string, jobId: string, enabled: boolea
|
|
|
180
201
|
|
|
181
202
|
// ── List: host gateway + per-container cron ─────────────────────────────
|
|
182
203
|
|
|
183
|
-
export function listOpenClawCronJobs(): OpenClawCronListResult {
|
|
204
|
+
export async function listOpenClawCronJobs(): Promise<OpenClawCronListResult> {
|
|
184
205
|
const seen = new Set<string>();
|
|
185
206
|
const jobs: OpenClawCronJob[] = [];
|
|
186
207
|
|
|
208
|
+
// The host query and the agent discovery don't depend on each other, so they
|
|
209
|
+
// overlap. Everything below is ordered deterministically afterwards — host
|
|
210
|
+
// jobs first, then agents in discovery order — so the response doesn't shuffle
|
|
211
|
+
// between polls just because a container answered sooner this time.
|
|
212
|
+
const [hostJobs, agents] = await Promise.all([
|
|
213
|
+
listJobsOnHost(),
|
|
214
|
+
discoverAgentContainers(),
|
|
215
|
+
]);
|
|
216
|
+
|
|
187
217
|
// 1. Host gateway cron jobs
|
|
188
|
-
const hostJobs = listJobsOnHost();
|
|
189
218
|
if (hostJobs) {
|
|
190
219
|
for (const job of hostJobs) {
|
|
191
220
|
job.scheduleExpr = extractScheduleExpr(job);
|
|
@@ -195,22 +224,26 @@ export function listOpenClawCronJobs(): OpenClawCronListResult {
|
|
|
195
224
|
}
|
|
196
225
|
}
|
|
197
226
|
|
|
198
|
-
// 2. Agent container cron jobs
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
227
|
+
// 2. Agent container cron jobs — queried concurrently, merged in order.
|
|
228
|
+
const perAgent = await mapWithConcurrency(
|
|
229
|
+
agents,
|
|
230
|
+
AGENT_QUERY_CONCURRENCY,
|
|
231
|
+
(agent) => listJobsInContainer(agent.containerName),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
for (const [i, containerJobs] of perAgent.entries()) {
|
|
235
|
+
if (!containerJobs) continue;
|
|
236
|
+
const agent = agents[i];
|
|
237
|
+
for (const job of containerJobs) {
|
|
238
|
+
job.scheduleExpr = extractScheduleExpr(job);
|
|
239
|
+
job.source = 'container';
|
|
240
|
+
job.containerName = agent.containerName;
|
|
241
|
+
if (!job.agentId) job.agentId = agent.agentId;
|
|
242
|
+
|
|
243
|
+
if (job.id && seen.has(job.id)) continue;
|
|
244
|
+
|
|
245
|
+
jobs.push(job);
|
|
246
|
+
if (job.id) seen.add(job.id);
|
|
214
247
|
}
|
|
215
248
|
}
|
|
216
249
|
|
|
@@ -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;
|