@flame0510/project-aether 1.2.0 → 1.3.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 +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- 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/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 +257 -673
- 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 +73 -9
- package/docs/ARCHITECTURE.md +16 -4
- package/docs/FRONTEND-ARCHITECTURE.md +24 -2
- package/docs/REV4A.md +40 -17
- package/docs/dev/API-REFERENCE.md +170 -79
- package/docs/dev/GATEWAY.md +231 -89
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +16 -14
- package/docs/rag/REV4A-OVERVIEW.md +23 -24
- package/docs/rag/WHAT-I-CAN-ANSWER.md +5 -7
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/model-catalogue.ts +140 -27
- 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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading one file out of an agent container, running or not.
|
|
3
|
+
*
|
|
4
|
+
* `docker exec` cannot reach a stopped or paused container; `docker cp` can, and
|
|
5
|
+
* streams the file as a tar archive. Used by the config sync, which also writes
|
|
6
|
+
* back through `docker cp`, and by the model summary behind the agent cards.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The single regular file in a `docker cp … -` tar stream: its bytes and mode. */
|
|
10
|
+
export function singleFileFromTar(tar: Buffer): { body: Buffer; mode: number } {
|
|
11
|
+
if (tar.length < 512) throw new Error('docker cp returned an empty archive');
|
|
12
|
+
const header = tar.subarray(0, 512);
|
|
13
|
+
const field = (start: number, len: number) => {
|
|
14
|
+
const text = header.subarray(start, start + len).toString('ascii');
|
|
15
|
+
const nul = text.indexOf('\0');
|
|
16
|
+
return (nul === -1 ? text : text.slice(0, nul)).trim();
|
|
17
|
+
};
|
|
18
|
+
const type = field(156, 1);
|
|
19
|
+
// '0' (or NUL on old writers) is a regular file. Anything else — a pax header,
|
|
20
|
+
// a directory — is not the shape expected, and guessing past it could mean
|
|
21
|
+
// writing a config that was never read.
|
|
22
|
+
if (type !== '0' && type !== '') throw new Error(`docker cp returned a non-file entry (type ${type})`);
|
|
23
|
+
const size = parseInt(field(124, 12), 8);
|
|
24
|
+
if (!Number.isFinite(size) || tar.length < 512 + size) throw new Error('docker cp returned a truncated archive');
|
|
25
|
+
const mode = parseInt(field(100, 8), 8);
|
|
26
|
+
return { body: tar.subarray(512, 512 + size), mode: Number.isFinite(mode) ? mode & 0o777 : 0o600 };
|
|
27
|
+
}
|
package/lib/model-catalogue.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* Model catalogue — bundle-first + user-overrides.
|
|
3
3
|
*
|
|
4
4
|
* Reads the bundled models.config.json from the npm package and applies
|
|
5
|
-
* user toggles from REV4A_DATA/model-overrides.json.
|
|
6
|
-
*
|
|
7
|
-
* Imported by both provider/route.ts and gateway/sync.ts — shared logic,
|
|
8
|
-
* avoids circular dependencies.
|
|
5
|
+
* user toggles from REV4A_DATA/model-overrides.json. The only module that reads
|
|
6
|
+
* either file: routes, pages and the sync all go through it.
|
|
9
7
|
*/
|
|
10
8
|
import * as fs from 'fs';
|
|
11
9
|
import * as path from 'path';
|
|
12
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
13
12
|
|
|
14
13
|
/* ------------------------------------------------------------------ */
|
|
15
14
|
/* Types */
|
|
@@ -21,6 +20,8 @@ export interface ModelConfigEntry {
|
|
|
21
20
|
provider: string;
|
|
22
21
|
enabled: boolean;
|
|
23
22
|
modality?: string;
|
|
23
|
+
/** Upstream has retired this id. It still answers, but is redirected. */
|
|
24
|
+
deprecated?: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/* ------------------------------------------------------------------ */
|
|
@@ -45,21 +46,61 @@ function resolveBundledPath(): string {
|
|
|
45
46
|
/* Loaders */
|
|
46
47
|
/* ------------------------------------------------------------------ */
|
|
47
48
|
|
|
49
|
+
/** Last catalogue read successfully by this process. */
|
|
50
|
+
let lastGoodBundle: ModelConfigEntry[] | null = null;
|
|
51
|
+
|
|
52
|
+
/** How the most recent read of models.config.json went. */
|
|
53
|
+
let bundleStatus: 'ok' | 'stale' | 'unavailable' = 'ok';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the last catalogue read left us:
|
|
57
|
+
* - `ok` the file was read and parsed
|
|
58
|
+
* - `stale` the read failed; this process's last good copy was used
|
|
59
|
+
* - `unavailable` the read failed with no earlier copy, so the catalogue is empty
|
|
60
|
+
*/
|
|
61
|
+
export function bundledCatalogueStatus(): 'ok' | 'stale' | 'unavailable' {
|
|
62
|
+
return bundleStatus;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The bundled catalogue.
|
|
67
|
+
*
|
|
68
|
+
* A failed read must not look like an empty catalogue. It used to: this returned
|
|
69
|
+
* `[]`, the loader then treated every user override as an orphan and deleted them
|
|
70
|
+
* all from disk, the sync pushed an empty model list into every agent, and the
|
|
71
|
+
* proxy refused every request. One unlucky read — the pricing script rewriting the
|
|
72
|
+
* file in place, a malformed release — was enough. A failed read now falls back to
|
|
73
|
+
* the last good copy and reports it through `bundledCatalogueStatus()`.
|
|
74
|
+
*/
|
|
48
75
|
function loadBundledModels(): ModelConfigEntry[] {
|
|
49
76
|
try {
|
|
50
77
|
const raw = fs.readFileSync(resolveBundledPath(), 'utf-8');
|
|
51
78
|
const parsed = JSON.parse(raw);
|
|
52
|
-
if (Array.isArray(parsed.models)) {
|
|
53
|
-
|
|
54
|
-
id: String(m.id ?? ''),
|
|
55
|
-
name: String(m.name ?? ''),
|
|
56
|
-
provider: String(m.provider ?? ''),
|
|
57
|
-
enabled: Boolean(m.enabled),
|
|
58
|
-
modality: typeof m.modality === 'string' ? m.modality : undefined,
|
|
59
|
-
}));
|
|
79
|
+
if (!Array.isArray(parsed.models) || parsed.models.length === 0) {
|
|
80
|
+
throw new Error('no models array');
|
|
60
81
|
}
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
const models: ModelConfigEntry[] = parsed.models.map((m: Record<string, unknown>) => ({
|
|
83
|
+
id: String(m.id ?? ''),
|
|
84
|
+
name: String(m.name ?? ''),
|
|
85
|
+
provider: String(m.provider ?? ''),
|
|
86
|
+
enabled: Boolean(m.enabled),
|
|
87
|
+
modality: typeof m.modality === 'string' ? m.modality : undefined,
|
|
88
|
+
deprecated: m.deprecated === true ? true : undefined,
|
|
89
|
+
}));
|
|
90
|
+
lastGoodBundle = models;
|
|
91
|
+
bundleStatus = 'ok';
|
|
92
|
+
return models;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const why = (e as Error).message;
|
|
95
|
+
if (lastGoodBundle) {
|
|
96
|
+
if (bundleStatus !== 'stale') console.warn(`[model-catalogue] models.config.json unreadable, using the last good copy: ${why}`);
|
|
97
|
+
bundleStatus = 'stale';
|
|
98
|
+
return lastGoodBundle;
|
|
99
|
+
}
|
|
100
|
+
if (bundleStatus !== 'unavailable') console.error(`[model-catalogue] models.config.json unreadable and no earlier copy: ${why}`);
|
|
101
|
+
bundleStatus = 'unavailable';
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
63
104
|
}
|
|
64
105
|
|
|
65
106
|
export function loadOverrides(): Record<string, boolean> {
|
|
@@ -90,23 +131,14 @@ export function writeOverrides(overrides: Record<string, boolean>): void {
|
|
|
90
131
|
* 1. Read models.config.json from the npm package (bundle).
|
|
91
132
|
* 2. Apply user overrides (enabled/disabled toggles).
|
|
92
133
|
*
|
|
93
|
-
*
|
|
134
|
+
* Read-only. Overrides for ids the bundle no longer has are ignored here and
|
|
135
|
+
* pruned only by `toggleModelOverride`, against a fresh read: this runs on every
|
|
136
|
+
* proxy request, and a read path that deletes user settings is one failed read
|
|
137
|
+
* away from deleting all of them.
|
|
94
138
|
*/
|
|
95
139
|
export function loadModelsConfig(): ModelConfigEntry[] {
|
|
96
140
|
const bundled = loadBundledModels();
|
|
97
141
|
const overrides = loadOverrides();
|
|
98
|
-
|
|
99
|
-
// Clean up orphan overrides (models removed from bundle)
|
|
100
|
-
const bundledIds = new Set(bundled.map((m) => m.id));
|
|
101
|
-
let dirty = false;
|
|
102
|
-
for (const id of Object.keys(overrides)) {
|
|
103
|
-
if (!bundledIds.has(id)) {
|
|
104
|
-
delete overrides[id];
|
|
105
|
-
dirty = true;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (dirty) writeOverrides(overrides);
|
|
109
|
-
|
|
110
142
|
return bundled.map((m) => ({
|
|
111
143
|
...m,
|
|
112
144
|
enabled: Object.prototype.hasOwnProperty.call(overrides, m.id) ? overrides[m.id] : m.enabled,
|
|
@@ -126,6 +158,87 @@ export function toggleModelOverride(modelId: string, enabled: boolean): boolean
|
|
|
126
158
|
} else {
|
|
127
159
|
overrides[modelId] = enabled;
|
|
128
160
|
}
|
|
161
|
+
|
|
162
|
+
// Drop overrides for models the catalogue no longer has — but only against a
|
|
163
|
+
// catalogue read just now. Against a stale or missing copy every override
|
|
164
|
+
// would look orphaned.
|
|
165
|
+
if (bundledCatalogueStatus() === 'ok') {
|
|
166
|
+
const ids = new Set(bundled.map((m) => m.id));
|
|
167
|
+
for (const id of Object.keys(overrides)) if (!ids.has(id)) delete overrides[id];
|
|
168
|
+
}
|
|
169
|
+
|
|
129
170
|
writeOverrides(overrides);
|
|
130
171
|
return true;
|
|
131
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The models this deployment actually offers.
|
|
176
|
+
*
|
|
177
|
+
* Two conditions, and they are separate questions:
|
|
178
|
+
*
|
|
179
|
+
* - **enabled** — policy. What the operator wants offered, `models.config.json`
|
|
180
|
+
* as the default and `model-overrides.json` as their decision on top.
|
|
181
|
+
* - **provider has a key** — availability. Offering a model nobody can be billed
|
|
182
|
+
* for produces a runtime failure at the worst moment.
|
|
183
|
+
*
|
|
184
|
+
* This exists because four readers each answered it differently, and the answers
|
|
185
|
+
* mattered. Measured on the production host, which has keys for four providers
|
|
186
|
+
* and eight overrides that all diverge from the bundled default: the create
|
|
187
|
+
* wizard listed 58 models where the correct answer was 62, offering two the
|
|
188
|
+
* operator had switched off and hiding six they had switched on. It read the
|
|
189
|
+
* bundle directly and never opened the overrides file. Meanwhile
|
|
190
|
+
* `/api/provider/v1/models` applied the overrides but not the key filter, so it
|
|
191
|
+
* advertised models for providers with no credentials.
|
|
192
|
+
*
|
|
193
|
+
* Callers still shape the result themselves — the sync adds `input` modalities,
|
|
194
|
+
* the pickers add prices — but the *set* is decided here, once.
|
|
195
|
+
*/
|
|
196
|
+
export function loadOfferedModels(): ModelConfigEntry[] {
|
|
197
|
+
const configured = new Set(Object.keys(readProviderKeys()));
|
|
198
|
+
return loadModelsConfig().filter((m) => {
|
|
199
|
+
if (!m.enabled) return false;
|
|
200
|
+
if (configured.has(m.provider)) return true;
|
|
201
|
+
// A `rev4a/*` alias bills through DeepSeek upstream, so a DeepSeek key is
|
|
202
|
+
// enough to offer one. The catalogue currently defines none.
|
|
203
|
+
if (m.provider === 'rev4a' && configured.has('deepseek')) return true;
|
|
204
|
+
return false;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* May this deployment serve a request for this model?
|
|
210
|
+
*
|
|
211
|
+
* `loadOfferedModels` answers what to *show*; this answers what to *honour*. They
|
|
212
|
+
* were the same question asked in two places and only the first was ever asked:
|
|
213
|
+
* the proxy and the assistant took whatever model the caller named and billed it.
|
|
214
|
+
* Unchecking a model on the Gateway page removed it from every picker and stopped
|
|
215
|
+
* nothing, because a client that already held the id — a stale in-memory list, a
|
|
216
|
+
* saved preference, an agent configured before the change — kept working.
|
|
217
|
+
*
|
|
218
|
+
* Takes the *resolved* pair, not the raw string. Callers reach it through
|
|
219
|
+
* `extractProviderAndModel`, which collapses `rev4a/deepseek/deepseek-flash`,
|
|
220
|
+
* `deepseek/deepseek-flash` and the two-segment alias `rev4a/deepseek-flash` onto
|
|
221
|
+
* one provider and model, so all three are tested as the same catalogue id.
|
|
222
|
+
*/
|
|
223
|
+
export function isModelOffered(provider: string, model: string): boolean {
|
|
224
|
+
const id = `${provider}/${model}`;
|
|
225
|
+
return loadOfferedModels().some((m) => m.id === id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Where a model id stands against the catalogue, for explaining a failure.
|
|
230
|
+
*
|
|
231
|
+
* - `offered` — enabled and its provider has a key; the proxy serves it
|
|
232
|
+
* - `disabled` — in the catalogue but not offered: unchecked, or no key
|
|
233
|
+
* - `missing` — not in the catalogue at all, e.g. removed in a release
|
|
234
|
+
*
|
|
235
|
+
* Takes a bare id, without the `rev4a/` prefix. The Model panel used to infer the
|
|
236
|
+
* cause from the offered list alone, so a model removed from the catalogue was
|
|
237
|
+
* explained as "unchecked, or its provider has no key" while the card said
|
|
238
|
+
* `NOT IN CATALOGUE`. `models-summary` computes the same two sets once for the
|
|
239
|
+
* whole fleet instead of calling this per agent.
|
|
240
|
+
*/
|
|
241
|
+
export function catalogueStatus(id: string): 'offered' | 'disabled' | 'missing' {
|
|
242
|
+
if (!loadModelsConfig().some((m) => m.id === id)) return 'missing';
|
|
243
|
+
return loadOfferedModels().some((m) => m.id === id) ? 'offered' : 'disabled';
|
|
244
|
+
}
|
package/lib/rev4a-paths.ts
CHANGED
|
@@ -58,27 +58,6 @@ export const SHARED_SKILLS_DIR = path.join(REV4A_DATA, 'shared', 'shared-skills'
|
|
|
58
58
|
/** Rev4a system rules injected into every agent workspace, mounted read-only. */
|
|
59
59
|
export const REV4A_RULES_DIR = path.join(REV4A_DATA, 'shared', 'rev4a-rules');
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* Model catalogue — read-only, shipped with the package.
|
|
63
|
-
* Resolved at module load from the package root (alongside bin/rev4a.js).
|
|
64
|
-
*/
|
|
65
|
-
function resolvePackageFile(filename: string): string {
|
|
66
|
-
// cwd in dev (workspace checkout)
|
|
67
|
-
const cwdPath = path.join(process.cwd(), filename);
|
|
68
|
-
if (fs.existsSync(cwdPath)) return cwdPath;
|
|
69
|
-
|
|
70
|
-
// Installed package: this file is at <pkgRoot>/lib/rev4a-paths.js
|
|
71
|
-
const pkgRoot = path.resolve(__dirname, '..');
|
|
72
|
-
const pkgPath = path.join(pkgRoot, filename);
|
|
73
|
-
if (fs.existsSync(pkgPath)) return pkgPath;
|
|
74
|
-
|
|
75
|
-
// Last resort: accept that it may not exist (will throw at read time)
|
|
76
|
-
return cwdPath;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Static model catalogue — read-only, inside the package repo */
|
|
80
|
-
export const MODELS_CONFIG_FILE = resolvePackageFile('models.config.json');
|
|
81
|
-
|
|
82
61
|
/** Top-level .next build directory (kept inside npm package for now — built by rev4a serve) */
|
|
83
62
|
export function nextDir(pkgRoot: string): string {
|
|
84
63
|
return path.join(pkgRoot, '.next');
|