@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.
Files changed (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. 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
+ }
@@ -1,54 +1,28 @@
1
- import { networkInterfaces } from 'os';
2
-
3
1
  /**
4
- * Detect the host's best public-facing IPv4 address for agent URLs.
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
- * Priority:
7
- * 1. External IP detection via icanhazip.com (works from Docker containers)
8
- * 2. Hostname resolution (filters out 172.x Docker internal IPs)
9
- * 3. First non-internal IPv4 network interface
10
- * 4. 'localhost' fallback
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
@@ -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
- const namesRaw = execFileSync('docker', [
81
- 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}'
82
- ], { encoding: 'utf8', timeout: 5000 }).trim();
83
- if (!namesRaw) return [];
84
-
85
- return namesRaw.split('\n').map(name => {
86
- const containerName = name.trim();
87
- if (!containerName) return null;
88
- let agentId = containerName;
89
- try {
90
- agentId = execFileSync('docker', [
91
- 'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
92
- ], { encoding: 'utf8', timeout: 5000 }).trim();
93
- } catch { /* fallback */ }
94
- return { containerName, agentId: agentId || containerName };
95
- }).filter(Boolean) as AgentInfo[];
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
- const raw = execFileSync('openclaw', ['cron', 'list', '--json', '--all'], {
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
- try {
137
- const raw = execFileSync('docker', [
138
- 'exec', '-e', `OPENCLAW_GATEWAY_URL=${gwUrl}`,
139
- containerName, 'openclaw', 'cron', 'list', '--json', '--all',
140
- ], { encoding: 'utf8', timeout: 15000 });
141
- const parsed = parseCronListOutput(raw);
142
- if (parsed) return parsed;
143
- } catch { /* try next gateway port */ }
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 agents = discoverAgentContainers();
200
- for (const agent of agents) {
201
- const containerJobs = listJobsInContainer(agent.containerName);
202
- if (containerJobs) {
203
- for (const job of containerJobs) {
204
- job.scheduleExpr = extractScheduleExpr(job);
205
- job.source = 'container';
206
- job.containerName = agent.containerName;
207
- if (!job.agentId) job.agentId = agent.agentId;
208
-
209
- if (job.id && seen.has(job.id)) continue;
210
-
211
- jobs.push(job);
212
- if (job.id) seen.add(job.id);
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
+ }
@@ -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;