@flame0510/project-aether 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
package/app/api/models/route.ts
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
1
|
import { type NextRequest } from "next/server";
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
|
-
import { MODELS_CONFIG_FILE } from '@/lib/rev4a-paths';
|
|
4
|
-
import * as fs from 'fs';
|
|
5
|
-
import * as path from 'path';
|
|
6
3
|
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
7
4
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
|
+
import { loadOfferedModels } from '@/lib/model-catalogue';
|
|
6
|
+
import { getPricing } from '@/lib/model-pricing';
|
|
8
7
|
|
|
9
8
|
export const dynamic = 'force-dynamic';
|
|
10
9
|
|
|
11
|
-
interface ModelsConfigEntry {
|
|
12
|
-
id: string;
|
|
13
|
-
name: string;
|
|
14
|
-
provider: string;
|
|
15
|
-
enabled: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const CONFIG_PATH = MODELS_CONFIG_FILE;
|
|
19
|
-
|
|
20
10
|
const PROVIDER_EMOJI: Record<string, string> = {
|
|
21
11
|
deepseek: '🧠',
|
|
22
12
|
openrouter: '🌐',
|
|
@@ -37,32 +27,27 @@ const PROVIDER_LABEL: Record<string, string> = {
|
|
|
37
27
|
rev4a: 'Rev4a',
|
|
38
28
|
};
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Model picker source. Uses the shared catalogue loader so the list offered here
|
|
32
|
+
* matches what the sync actually writes into agent containers — this route used
|
|
33
|
+
* to read models.config.json directly and ignore model-overrides.json, so models
|
|
34
|
+
* switched off on the Gateway page still showed up as selectable.
|
|
35
|
+
*/
|
|
49
36
|
export async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
50
37
|
const request = _req;
|
|
51
38
|
const denied = await requireAuthJWT(request);
|
|
52
39
|
if (denied) return denied;
|
|
53
|
-
const
|
|
54
|
-
const providerKeys = readProviderKeys();
|
|
55
|
-
const configured = new Set(Object.keys(providerKeys));
|
|
56
|
-
|
|
57
|
-
const eligible = allModels.filter((m) => {
|
|
58
|
-
if (!m.enabled) return false;
|
|
59
|
-
if (configured.has(m.provider)) return true;
|
|
60
|
-
// rev4a/* aliases resolve to deepseek upstream
|
|
61
|
-
if (m.provider === 'rev4a' && configured.has('deepseek')) return true;
|
|
62
|
-
return false;
|
|
63
|
-
});
|
|
40
|
+
const eligible = loadOfferedModels();
|
|
64
41
|
|
|
65
|
-
const grouped = new Map<string, {
|
|
42
|
+
const grouped = new Map<string, {
|
|
43
|
+
provider: string;
|
|
44
|
+
emoji: string;
|
|
45
|
+
models: {
|
|
46
|
+
id: string; label: string;
|
|
47
|
+
input: number | null; output: number | null;
|
|
48
|
+
deprecated: boolean;
|
|
49
|
+
}[];
|
|
50
|
+
}>();
|
|
66
51
|
|
|
67
52
|
for (const model of eligible) {
|
|
68
53
|
if (!grouped.has(model.provider)) {
|
|
@@ -72,7 +57,16 @@ export async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
|
72
57
|
models: [],
|
|
73
58
|
});
|
|
74
59
|
}
|
|
75
|
-
|
|
60
|
+
// Price travels with the option so the picker can show cost at the point of
|
|
61
|
+
// choice, instead of sending the operator to another page to look it up.
|
|
62
|
+
const price = getPricing(model.id);
|
|
63
|
+
grouped.get(model.provider)!.models.push({
|
|
64
|
+
id: model.id,
|
|
65
|
+
label: model.name,
|
|
66
|
+
input: price?.input ?? null,
|
|
67
|
+
output: price?.output ?? null,
|
|
68
|
+
deprecated: model.deprecated ?? false,
|
|
69
|
+
});
|
|
76
70
|
}
|
|
77
71
|
|
|
78
72
|
return NextResponse.json(Array.from(grouped.values()));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may call the Rev4a Provider Gateway.
|
|
3
|
+
*
|
|
4
|
+
* One credential opens this door: the `rev4a` entry of `provider-keys.json`.
|
|
5
|
+
* That is the value `buildRev4aProviderConfig()` writes into every agent
|
|
6
|
+
* container, and `syncAllAgents()` refuses to run without it, so it is the only
|
|
7
|
+
* token any legitimate client holds. `REV4A_API_KEY` is accepted alongside it
|
|
8
|
+
* for callers configured before the key moved into the file.
|
|
9
|
+
*
|
|
10
|
+
* **Why this is its own module.** The two v1 routes each carried their own copy
|
|
11
|
+
* of the check and the copies had drifted — one guarded the empty token, the
|
|
12
|
+
* other did not. Worse, both compared the incoming token against *every* value
|
|
13
|
+
* in `provider-keys.json`:
|
|
14
|
+
*
|
|
15
|
+
* Object.values(providerKeys).some((key) => token === key)
|
|
16
|
+
*
|
|
17
|
+
* `provider-keys.json` holds two different kinds of secret in one map. The
|
|
18
|
+
* `rev4a` entry is the password to this door. Every other entry is a credential
|
|
19
|
+
* Rev4a presents to an upstream provider. Iterating the map conflated them, so a
|
|
20
|
+
* DeepSeek API key authenticated here exactly like the gateway key — verified
|
|
21
|
+
* against a running instance, which answered `400 invalid_request_error` to a
|
|
22
|
+
* malformed body rather than `401`.
|
|
23
|
+
*
|
|
24
|
+
* With one upstream provider configured that granted nothing: the holder of a
|
|
25
|
+
* DeepSeek key can already spend at DeepSeek. It mattered as a latent fault. Add
|
|
26
|
+
* an OpenAI key and the old DeepSeek key silently becomes a way to spend at
|
|
27
|
+
* OpenAI, with nothing in the code or the UI to say so.
|
|
28
|
+
*/
|
|
29
|
+
import { timingSafeEqual } from 'crypto';
|
|
30
|
+
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
31
|
+
|
|
32
|
+
/** Compare without leaking length or position through timing. */
|
|
33
|
+
function secretEquals(a: string, b: string): boolean {
|
|
34
|
+
const ab = Buffer.from(a, 'utf8');
|
|
35
|
+
const bb = Buffer.from(b, 'utf8');
|
|
36
|
+
// timingSafeEqual throws on length mismatch, which is itself an early exit.
|
|
37
|
+
// Comparing digests of equal length would hide it; for a bearer token the
|
|
38
|
+
// length is not the secret, so a plain guard is enough.
|
|
39
|
+
if (ab.length !== bb.length) return false;
|
|
40
|
+
return timingSafeEqual(ab, bb);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the request carries the gateway credential.
|
|
45
|
+
*
|
|
46
|
+
* Never true for an absent or empty token, whatever the key file contains: an
|
|
47
|
+
* entry saved as `""` would otherwise match a request with no Authorization
|
|
48
|
+
* header at all.
|
|
49
|
+
*/
|
|
50
|
+
export function isGatewayClient(request: Request): boolean {
|
|
51
|
+
const token = (request.headers.get('Authorization') || '')
|
|
52
|
+
.replace(/^Bearer\s+/i, '')
|
|
53
|
+
.trim();
|
|
54
|
+
if (!token) return false;
|
|
55
|
+
|
|
56
|
+
// provider-keys.json is hand-editable: a non-string value must deny, not throw.
|
|
57
|
+
// Buffer.from on a number raises, and the models route has no catch.
|
|
58
|
+
const gatewayKey: unknown = readProviderKeys()['rev4a'];
|
|
59
|
+
if (typeof gatewayKey === 'string' && gatewayKey && secretEquals(token, gatewayKey)) return true;
|
|
60
|
+
|
|
61
|
+
const masterKey = process.env.REV4A_API_KEY;
|
|
62
|
+
if (masterKey && secretEquals(token, masterKey)) return true;
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
@@ -57,7 +57,7 @@ export const PROVIDER_ENVS: Record<string, ProviderEnvConfig> = {
|
|
|
57
57
|
|
|
58
58
|
/** Map rev4a/ model aliases to upstream provider/model pairs. */
|
|
59
59
|
export const MODEL_ALIASES: Record<string, { provider: string; model: string }> = {
|
|
60
|
-
'rev4a/deepseek-
|
|
60
|
+
'rev4a/deepseek-flash': { provider: 'deepseek', model: 'deepseek-flash' },
|
|
61
61
|
'rev4a/deepseek-v4-pro': { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
|
62
62
|
};
|
|
63
63
|
|
|
@@ -76,7 +76,7 @@ export function extractProviderAndModel(request: UpstreamRequest): { provider: s
|
|
|
76
76
|
if (request.provider) {
|
|
77
77
|
return { provider: request.provider, model: modelStr };
|
|
78
78
|
}
|
|
79
|
-
// Check aliases first (rev4a/deepseek-
|
|
79
|
+
// Check aliases first (rev4a/deepseek-flash -> deepseek/deepseek-flash)
|
|
80
80
|
if (modelStr && MODEL_ALIASES[modelStr]) {
|
|
81
81
|
return { ...MODEL_ALIASES[modelStr] };
|
|
82
82
|
}
|
|
@@ -114,6 +114,13 @@ export async function callUpstream(
|
|
|
114
114
|
|
|
115
115
|
const upstreamBody: Record<string, unknown> = { ...body, model: model || undefined };
|
|
116
116
|
delete upstreamBody.provider;
|
|
117
|
+
// `model` has been checked against the catalogue; these fields let the upstream
|
|
118
|
+
// pick a different one. OpenRouter serves a fallback from `models` when the
|
|
119
|
+
// primary fails, and `route`/`preset` can redirect the request, so forwarding them
|
|
120
|
+
// would let a client get a model the catalogue does not offer.
|
|
121
|
+
delete upstreamBody.models;
|
|
122
|
+
delete upstreamBody.route;
|
|
123
|
+
delete upstreamBody.preset;
|
|
117
124
|
|
|
118
125
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
119
126
|
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* OpenAI-compatible chat completions endpoint.
|
|
5
5
|
* POST /api/provider/v1/chat/completions
|
|
6
6
|
*
|
|
7
|
-
* Authentication
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Authentication is the `rev4a` entry of provider-keys.json, or REV4A_API_KEY.
|
|
8
|
+
* See `app/api/provider/auth.ts` — in particular why the other entries in that
|
|
9
|
+
* file, which are upstream credentials rather than passwords, must not be
|
|
10
|
+
* accepted here.
|
|
10
11
|
*/
|
|
11
12
|
import { NextRequest } from 'next/server';
|
|
13
|
+
import { isGatewayClient } from '@/app/api/provider/auth';
|
|
14
|
+
import { isModelOffered } from '@/lib/model-catalogue';
|
|
12
15
|
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
13
16
|
import {
|
|
14
17
|
extractProviderAndModel,
|
|
@@ -50,19 +53,7 @@ export async function POST(request: NextRequest) {
|
|
|
50
53
|
const start = Date.now();
|
|
51
54
|
|
|
52
55
|
try {
|
|
53
|
-
|
|
54
|
-
const authHeader = request.headers.get('Authorization') || '';
|
|
55
|
-
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
56
|
-
const providerKeys = readProviderKeys();
|
|
57
|
-
|
|
58
|
-
// Check if token matches any stored key
|
|
59
|
-
const isAuthenticated = Object.values(providerKeys).some((key) => token === key);
|
|
60
|
-
|
|
61
|
-
// Also accept the rev4a master API key env var
|
|
62
|
-
const rev4aMasterKey = process.env.REV4A_API_KEY;
|
|
63
|
-
const isMasterAuth = !!rev4aMasterKey && token === rev4aMasterKey;
|
|
64
|
-
|
|
65
|
-
if (!isAuthenticated && !isMasterAuth) {
|
|
56
|
+
if (!isGatewayClient(request)) {
|
|
66
57
|
return errorResponse(
|
|
67
58
|
'Invalid API key. Use your Rev4a API key from the Gateway page via Authorization: Bearer <key>.',
|
|
68
59
|
'authentication_error',
|
|
@@ -73,6 +64,21 @@ export async function POST(request: NextRequest) {
|
|
|
73
64
|
const body: ProviderV1Request = await request.json();
|
|
74
65
|
const { provider, model } = extractProviderAndModel(body);
|
|
75
66
|
|
|
67
|
+
// The catalogue is policy, and policy has to be enforced where the money is
|
|
68
|
+
// spent, not only where the pickers are drawn. Without this, unchecking a
|
|
69
|
+
// model on the Gateway page hid it from every list and still served every
|
|
70
|
+
// client that already held the id.
|
|
71
|
+
if (!isModelOffered(provider, model)) {
|
|
72
|
+
return errorResponse(
|
|
73
|
+
`Model '${provider}/${model}' is not enabled for this deployment. ` +
|
|
74
|
+
'Enable it on the Gateway page, or pick one that is.',
|
|
75
|
+
'invalid_request_error',
|
|
76
|
+
400,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Upstream credentials, distinct from the token that opened this door.
|
|
81
|
+
const providerKeys = readProviderKeys();
|
|
76
82
|
const apiKey = getApiKey(provider, providerKeys);
|
|
77
83
|
if (!apiKey) {
|
|
78
84
|
return errorResponse(
|
|
@@ -3,143 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/provider/v1/models
|
|
5
5
|
*
|
|
6
|
-
* Returns models available through the Rev4a Gateway
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Returns the models available through the Rev4a Gateway, in OpenAI-compatible
|
|
7
|
+
* format. This is the endpoint agent containers call to discover what they can
|
|
8
|
+
* ask for, so it must agree with what `gateway/sync.ts` writes into their config.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* It did not. This route used to carry its own copy of the catalogue and its own
|
|
11
|
+
* loader, and the two diverged in ways that mattered:
|
|
12
|
+
*
|
|
13
|
+
* - **User toggles were ignored here.** Disabling a model from the Gateway page
|
|
14
|
+
* writes to `model-overrides.json`, which `loadModelsConfig()` applies. This
|
|
15
|
+
* route read `models.config.json` raw, so a model switched off in the UI was
|
|
16
|
+
* still advertised to agents.
|
|
17
|
+
* - **A read failure overwrote the catalogue.** If the file was missing or
|
|
18
|
+
* momentarily unparseable, the `catch` wrote a hardcoded 30-model default over
|
|
19
|
+
* it — replacing the real catalogue, user overrides included.
|
|
20
|
+
*
|
|
21
|
+
* Both are gone: the catalogue now comes from `lib/model-catalogue`, the single
|
|
22
|
+
* loader shared with the create route, the provider page and the sync.
|
|
12
23
|
*/
|
|
13
24
|
import { NextResponse, NextRequest } from 'next/server';
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import * as path from 'path';
|
|
17
|
-
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
25
|
+
import { isGatewayClient } from '@/app/api/provider/auth';
|
|
26
|
+
import { loadOfferedModels } from '@/lib/model-catalogue';
|
|
18
27
|
|
|
19
28
|
export const dynamic = 'force-dynamic';
|
|
20
29
|
export const runtime = 'nodejs';
|
|
21
30
|
|
|
22
|
-
interface ModelEntry {
|
|
23
|
-
id: string;
|
|
24
|
-
object: string;
|
|
25
|
-
created: number;
|
|
26
|
-
owned_by: string;
|
|
27
|
-
permission: string[];
|
|
28
|
-
root: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ModelsConfigEntry {
|
|
32
|
-
id: string;
|
|
33
|
-
name: string;
|
|
34
|
-
provider: string;
|
|
35
|
-
enabled: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const CONFIG_PATH = MODELS_CONFIG_FILE;
|
|
39
|
-
|
|
40
|
-
/** Default model catalogue (known models for each provider) */
|
|
41
|
-
const DEFAULT_CATALOGUE: ModelsConfigEntry[] = [
|
|
42
|
-
// OpenAI
|
|
43
|
-
{ id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', provider: 'openai', enabled: true },
|
|
44
|
-
{ id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'openai', enabled: true },
|
|
45
|
-
{ id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'openai', enabled: true },
|
|
46
|
-
{ id: 'openai/gpt-5.5', name: 'GPT-5.5', provider: 'openai', enabled: true },
|
|
47
|
-
{ id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'openai', enabled: true },
|
|
48
|
-
{ id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', enabled: true },
|
|
49
|
-
// Anthropic
|
|
50
|
-
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', provider: 'anthropic', enabled: false },
|
|
51
|
-
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', provider: 'anthropic', enabled: false },
|
|
52
|
-
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', provider: 'anthropic', enabled: false },
|
|
53
|
-
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic', enabled: false },
|
|
54
|
-
// DeepSeek
|
|
55
|
-
{ id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'deepseek', enabled: true },
|
|
56
|
-
{ id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'deepseek', enabled: true },
|
|
57
|
-
// OpenRouter
|
|
58
|
-
{ id: 'openrouter/auto', name: 'OpenRouter Auto', provider: 'openrouter', enabled: true },
|
|
59
|
-
{ id: 'openrouter/deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash (OR)', provider: 'openrouter', enabled: true },
|
|
60
|
-
{ id: 'openrouter/google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (OR)', provider: 'openrouter', enabled: true },
|
|
61
|
-
{ id: 'openrouter/google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (OR)', provider: 'openrouter', enabled: true },
|
|
62
|
-
{ id: 'openrouter/anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6 (OR)', provider: 'openrouter', enabled: false },
|
|
63
|
-
{ id: 'openrouter/deepseek/deepseek-r1-0528', name: 'DeepSeek R1 0528 (OR)', provider: 'openrouter', enabled: false },
|
|
64
|
-
// Groq
|
|
65
|
-
{ id: 'groq/llama-3.1-8b-instant', name: 'Llama 3.1 8B (Groq)', provider: 'groq', enabled: false },
|
|
66
|
-
{ id: 'groq/llama-3.3-70b-versatile', name: 'Llama 3.3 70B (Groq)', provider: 'groq', enabled: false },
|
|
67
|
-
// Kimi
|
|
68
|
-
{ id: 'kimi/kimi-k3', name: 'Kimi K3', provider: 'kimi', enabled: true },
|
|
69
|
-
{ id: 'kimi/kimi-k2.7-code', name: 'Kimi K2.7 Code', provider: 'kimi', enabled: false },
|
|
70
|
-
{ id: 'kimi/kimi-k2.7-code-highspeed', name: 'Kimi K2.7 Code HighSpeed', provider: 'kimi', enabled: false },
|
|
71
|
-
{ id: 'kimi/kimi-k2.6', name: 'Kimi K2.6', provider: 'kimi', enabled: false },
|
|
72
|
-
// GLM
|
|
73
|
-
{ id: 'glm/glm-5.2', name: 'GLM-5.2', provider: 'glm', enabled: false },
|
|
74
|
-
{ id: 'glm/glm-5.1', name: 'GLM-5.1', provider: 'glm', enabled: false },
|
|
75
|
-
{ id: 'glm/glm-5', name: 'GLM-5', provider: 'glm', enabled: false },
|
|
76
|
-
{ id: 'glm/glm-4.7', name: 'GLM-4.7', provider: 'glm', enabled: false },
|
|
77
|
-
{ id: 'glm/glm-4.7-flash', name: 'GLM-4.7 Flash', provider: 'glm', enabled: false },
|
|
78
|
-
{ id: 'glm/glm-4.7-flashx', name: 'GLM-4.7 FlashX', provider: 'glm', enabled: false },
|
|
79
|
-
{ id: 'glm/glm-5v-turbo', name: 'GLM-5V Turbo', provider: 'glm', enabled: false },
|
|
80
|
-
{ id: 'glm/glm-4.6v-flash', name: 'GLM-4.6V Flash', provider: 'glm', enabled: false },
|
|
81
|
-
// Qwen
|
|
82
|
-
{ id: 'qwen/qwen3.7-max', name: 'Qwen 3.7 Max', provider: 'qwen', enabled: false },
|
|
83
|
-
{ id: 'qwen/qwen3.7-plus', name: 'Qwen 3.7 Plus', provider: 'qwen', enabled: true },
|
|
84
|
-
{ id: 'qwen/qwen3.7-flash', name: 'Qwen 3.7 Flash', provider: 'qwen', enabled: false },
|
|
85
|
-
{ id: 'qwen/qwen3-coder-plus', name: 'Qwen 3 Coder Plus', provider: 'qwen', enabled: false },
|
|
86
|
-
{ id: 'qwen/qwen-long', name: 'Qwen Long', provider: 'qwen', enabled: false },
|
|
87
|
-
// Rev4a aliases (convenience models for the Rev4a proxy)
|
|
88
|
-
{ id: 'rev4a/deepseek-v4-flash', name: 'DeepSeek V4 Flash (Rev4a)', provider: 'rev4a', enabled: true },
|
|
89
|
-
{ id: 'rev4a/deepseek-v4-pro', name: 'DeepSeek V4 Pro (Rev4a)', provider: 'rev4a', enabled: true },
|
|
90
|
-
{ id: 'rev4a/fast', name: 'Rev4a Fast', provider: 'rev4a', enabled: false },
|
|
91
|
-
{ id: 'rev4a/code', name: 'Rev4a Code', provider: 'rev4a', enabled: false },
|
|
92
|
-
{ id: 'rev4a/reason', name: 'Rev4a Reason', provider: 'rev4a', enabled: false },
|
|
93
|
-
{ id: 'rev4a/fallback-general', name: 'Rev4a Fallback General', provider: 'rev4a', enabled: false },
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
/** Load the models config from file, or create from defaults */
|
|
97
|
-
function loadConfig(): ModelsConfigEntry[] {
|
|
98
|
-
try {
|
|
99
|
-
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
100
|
-
const parsed = JSON.parse(raw);
|
|
101
|
-
if (Array.isArray(parsed.models)) {
|
|
102
|
-
return parsed.models as ModelsConfigEntry[];
|
|
103
|
-
}
|
|
104
|
-
return DEFAULT_CATALOGUE;
|
|
105
|
-
} catch {
|
|
106
|
-
// File doesn't exist or invalid — write defaults
|
|
107
|
-
try {
|
|
108
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ models: DEFAULT_CATALOGUE }, null, 2), 'utf-8');
|
|
109
|
-
} catch {
|
|
110
|
-
// Can't write, fall back to defaults in memory
|
|
111
|
-
}
|
|
112
|
-
return DEFAULT_CATALOGUE;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Resolve the provider from the Authorization token by matching against provider-keys.json */
|
|
117
|
-
function resolveProviderFromToken(authHeader: string): string | null {
|
|
118
|
-
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
119
|
-
if (!token) return null;
|
|
120
|
-
|
|
121
|
-
const providerKeys = readProviderKeys();
|
|
122
|
-
|
|
123
|
-
// Match the token value against stored provider keys
|
|
124
|
-
for (const [, key] of Object.entries(providerKeys)) {
|
|
125
|
-
if (token === key) return 'authenticated';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Also try matching the rev4a API key env var (legacy compat)
|
|
129
|
-
if (token === process.env.REV4A_API_KEY) return 'all';
|
|
130
|
-
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
31
|
export async function GET(request: NextRequest) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const provider = resolveProviderFromToken(authHeader);
|
|
140
|
-
|
|
141
|
-
if (!provider) {
|
|
142
|
-
// No valid token — return empty list to avoid leaking model info
|
|
32
|
+
if (!isGatewayClient(request)) {
|
|
33
|
+
// An empty list rather than a 401: this endpoint should not confirm the
|
|
34
|
+
// catalogue to a caller that cannot use it. The cost is that a container
|
|
35
|
+
// configured with the wrong key sees "no models" instead of an auth error.
|
|
143
36
|
return NextResponse.json({
|
|
144
37
|
object: 'list',
|
|
145
38
|
total: 0,
|
|
@@ -147,10 +40,10 @@ export async function GET(request: NextRequest) {
|
|
|
147
40
|
});
|
|
148
41
|
}
|
|
149
42
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
activeModels =
|
|
43
|
+
// Same set as gateway/sync.ts writes into the containers, so what an agent can
|
|
44
|
+
// discover matches what it was actually given. This route used to skip the
|
|
45
|
+
// provider-key half of that test and advertise models nobody could be billed for.
|
|
46
|
+
const activeModels = loadOfferedModels();
|
|
154
47
|
|
|
155
48
|
return NextResponse.json({
|
|
156
49
|
object: 'list',
|
|
@@ -1,60 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/setup/agent-image
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Returns
|
|
4
|
+
* Whether a supported OpenClaw version of the agent base image is downloaded.
|
|
5
|
+
* Returns `{ exists, version }`, the version being the newest one present.
|
|
6
6
|
*
|
|
7
7
|
* POST /api/setup/agent-image
|
|
8
8
|
*
|
|
9
|
-
* Downloads
|
|
10
|
-
*
|
|
9
|
+
* Downloads the newest supported version the registry publishes, and streams
|
|
10
|
+
* progress via SSE so the setup wizard can show a live progress bar. Nothing is
|
|
11
|
+
* downloaded when that version is already here.
|
|
11
12
|
*
|
|
12
13
|
* Only one download at a time — returns 409 if already running.
|
|
13
|
-
* Cleans up the docker process if the client disconnects.
|
|
14
14
|
*/
|
|
15
15
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
17
|
-
import { execSync } from 'child_process';
|
|
18
17
|
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
18
|
+
import { dockerAvailable } from '@/lib/docker-socket';
|
|
19
|
+
import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
|
|
19
20
|
|
|
20
21
|
export const dynamic = 'force-dynamic';
|
|
21
22
|
|
|
22
|
-
function dockerOk(): boolean {
|
|
23
|
-
try {
|
|
24
|
-
execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
|
|
25
|
-
encoding: 'utf-8',
|
|
26
|
-
timeout: 2000,
|
|
27
|
-
});
|
|
28
|
-
return true;
|
|
29
|
-
} catch {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
23
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
35
24
|
const denied = await requireAuthIfConfigured(request);
|
|
36
25
|
if (denied) return denied;
|
|
37
|
-
if (!
|
|
38
|
-
return NextResponse.json({ exists: false });
|
|
26
|
+
if (!dockerAvailable()) {
|
|
27
|
+
return NextResponse.json({ exists: false, version: null });
|
|
39
28
|
}
|
|
40
29
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
exec(
|
|
44
|
-
'docker image inspect openclaw-agent-base:latest --format "{{.ID}}" 2>/dev/null',
|
|
45
|
-
{ timeout: 5000 },
|
|
46
|
-
(err, stdout) => {
|
|
47
|
-
if (err) return reject(err);
|
|
48
|
-
resolve(stdout.trim());
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
if (result) {
|
|
53
|
-
return NextResponse.json({ exists: true, imageId: result });
|
|
54
|
-
}
|
|
55
|
-
return NextResponse.json({ exists: false });
|
|
30
|
+
const version = newestLocalSupportedVersion(await listLocalAgentImages());
|
|
31
|
+
return NextResponse.json({ exists: version !== null, version });
|
|
56
32
|
} catch {
|
|
57
|
-
return NextResponse.json({ exists: false });
|
|
33
|
+
return NextResponse.json({ exists: false, version: null });
|
|
58
34
|
}
|
|
59
35
|
}
|
|
60
36
|
|
|
@@ -81,11 +57,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
81
57
|
const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
|
|
82
58
|
|
|
83
59
|
try {
|
|
84
|
-
await downloadAgentImage({
|
|
85
|
-
force: false, // setup: skip if already exists
|
|
86
|
-
onEvent: send,
|
|
87
|
-
signal: ctrl.signal,
|
|
88
|
-
});
|
|
60
|
+
await downloadAgentImage({ onEvent: send, signal: ctrl.signal });
|
|
89
61
|
} catch (e: unknown) {
|
|
90
62
|
send(JSON.stringify({
|
|
91
63
|
type: 'error',
|
|
@@ -94,7 +66,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
94
66
|
}
|
|
95
67
|
controller.close();
|
|
96
68
|
},
|
|
97
|
-
cancel() { /* downloadAgentImage
|
|
69
|
+
cancel() { /* downloadAgentImage stops the docker process through the signal */ },
|
|
98
70
|
});
|
|
99
71
|
|
|
100
72
|
return new Response(stream, {
|
|
@@ -32,7 +32,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
|
|
|
32
32
|
className={`agent-btn ${filter.showOnlyActive ? 'active' : ''}`}
|
|
33
33
|
onClick={() => onChange({ showOnlyActive: !filter.showOnlyActive })}
|
|
34
34
|
>
|
|
35
|
-
|
|
35
|
+
active only
|
|
36
36
|
</button>
|
|
37
37
|
|
|
38
38
|
<button
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { useModels } from '../lib/models-context';
|
|
5
6
|
import Link from 'next/link';
|
|
6
7
|
|
|
7
8
|
const STORAGE_KEY = 'rev4a_pulse_chat';
|
|
@@ -79,51 +80,36 @@ export default function PulseChat() {
|
|
|
79
80
|
const [input, setInput] = useState('');
|
|
80
81
|
const [streaming, setStreaming] = useState(false);
|
|
81
82
|
const [selectedModel, setSelectedModel] = useState<string>('');
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// The shared catalogue, not a private copy. It refreshes when the Gateway page
|
|
84
|
+
// changes what is enabled, so this select cannot go stale mid-session.
|
|
85
|
+
const { providers: modelGroups, loaded: modelsLoaded } = useModels();
|
|
86
|
+
const modelsLoading = !modelsLoaded;
|
|
84
87
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
85
88
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
86
89
|
const abortRef = useRef<AbortController | null>(null);
|
|
87
90
|
|
|
88
|
-
//
|
|
91
|
+
// Keep the choice valid against the current catalogue.
|
|
92
|
+
//
|
|
93
|
+
// Runs whenever the shared list changes, which includes the moment a model is
|
|
94
|
+
// unchecked on the Gateway page: a selection that just became unavailable is
|
|
95
|
+
// replaced rather than left to fail at send time. The saved preference is only
|
|
96
|
+
// honoured while it is still on offer.
|
|
89
97
|
useEffect(() => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Restore saved model if valid, otherwise pick first
|
|
104
|
-
try {
|
|
105
|
-
const saved = localStorage.getItem(MODEL_STORAGE_KEY);
|
|
106
|
-
if (saved && allIds.includes(saved)) {
|
|
107
|
-
setSelectedModel(saved);
|
|
108
|
-
} else if (allIds.length > 0) {
|
|
109
|
-
setSelectedModel(allIds[0]);
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
if (allIds.length > 0) setSelectedModel(allIds[0]);
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
// API unavailable — leave selectedModel empty, select stays disabled
|
|
116
|
-
} finally {
|
|
117
|
-
if (!cancelled) setModelsLoading(false);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
fetchModels();
|
|
122
|
-
return () => { cancelled = true; };
|
|
123
|
-
}, []);
|
|
124
|
-
|
|
125
|
-
// Save model preference on change
|
|
98
|
+
if (!modelsLoaded) return;
|
|
99
|
+
const allIds = modelGroups.flatMap((g) => g.models.map((m) => m.id));
|
|
100
|
+
if (allIds.length === 0) { setSelectedModel(''); return; }
|
|
101
|
+
if (selectedModel && allIds.includes(selectedModel)) return;
|
|
102
|
+
|
|
103
|
+
let saved: string | null = null;
|
|
104
|
+
try { saved = localStorage.getItem(MODEL_STORAGE_KEY); } catch { /* blocked */ }
|
|
105
|
+
setSelectedModel(saved && allIds.includes(saved) ? saved : allIds[0]);
|
|
106
|
+
}, [modelGroups, modelsLoaded, selectedModel]);
|
|
107
|
+
|
|
108
|
+
// Save model preference on change. Not while empty: on mount the selection is ''
|
|
109
|
+
// until the list arrives, and saving that erased the preference before it could
|
|
110
|
+
// be read back.
|
|
126
111
|
useEffect(() => {
|
|
112
|
+
if (!selectedModel) return;
|
|
127
113
|
try {
|
|
128
114
|
localStorage.setItem(MODEL_STORAGE_KEY, selectedModel);
|
|
129
115
|
} catch {
|