@flame0510/project-aether 1.1.15 → 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.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. package/app/api/gateway/route.ts +0 -191
@@ -6,7 +6,8 @@
6
6
  * thread does nothing else — no other request is served, no agent's stream
7
7
  * advances. Node's own guidance names `execSync` in a server as a
8
8
  * denial-of-service vector, and the numbers here bear that out: a bare
9
- * `docker exec … echo` costs ~260ms, and the OpenClaw CLI inside a container
9
+ * `docker exec … echo` costs ~90ms warm and ~225ms on the first call after an
10
+ * idle period, and the OpenClaw CLI inside a container
10
11
  * takes 2.5–6s per invocation because it boots a whole Node process.
11
12
  *
12
13
  * Routes that walk every agent multiplied that: `/api/crons` measured 19.2s on a
@@ -30,7 +31,12 @@ const DEFAULT_MAX_BUFFER = 1024 * 1024;
30
31
 
31
32
  export interface DockerExecOptions {
32
33
  timeoutMs?: number;
33
- /** Extra environment for the command, passed as `-e KEY=VALUE`. */
34
+ /**
35
+ * Extra environment for the command. Only the *names* reach argv, as `-e KEY`;
36
+ * docker then forwards each value from its own environment. A value passed as
37
+ * `-e KEY=VALUE` would sit in the process table for anyone on the host to read
38
+ * with `ps`, and in the text of any rejection Node builds from that argv.
39
+ */
34
40
  env?: Record<string, string>;
35
41
  maxBuffer?: number;
36
42
  signal?: AbortSignal;
@@ -38,11 +44,32 @@ export interface DockerExecOptions {
38
44
 
39
45
  function baseArgs(container: string, opts?: DockerExecOptions): string[] {
40
46
  const args = ['exec'];
41
- for (const [k, v] of Object.entries(opts?.env ?? {})) args.push('-e', `${k}=${v}`);
47
+ for (const k of Object.keys(opts?.env ?? {})) args.push('-e', k);
42
48
  args.push(container);
43
49
  return args;
44
50
  }
45
51
 
52
+ /**
53
+ * Rebuild a failure without the command that produced it.
54
+ *
55
+ * Node composes its own message as `Command failed: <argv…>\n<stderr>`, and the
56
+ * argv holds whatever the caller put in the script. For credential delivery that
57
+ * is the secret — base64 is encoding, not protection — and the message travels:
58
+ * `syncProfileToAgents` puts it in `SyncResult.error`, which the sync route
59
+ * returns to the browser. The most common failure of all, a revoked or expired
60
+ * token, is exactly the one that triggers it.
61
+ *
62
+ * `stderr` is kept because that is the part that actually explains the failure.
63
+ */
64
+ function execFailure(e: unknown, container: string): Error {
65
+ const err = e as { stderr?: string; code?: number; killed?: boolean; name?: string };
66
+ // An abort is the caller's own doing and is matched on elsewhere by name.
67
+ if (err.name === 'AbortError') return e as Error;
68
+ const cause = err.killed ? 'timed out' : err.code !== undefined ? `exit ${err.code}` : 'failed';
69
+ const detail = (err.stderr ?? '').trim();
70
+ return new Error(`docker exec ${container}: ${cause}${detail ? ` — ${detail}` : ''}`);
71
+ }
72
+
46
73
  /**
47
74
  * Run a command inside a container, passing argv directly.
48
75
  *
@@ -59,17 +86,22 @@ export async function dockerExec(
59
86
  ): Promise<string> {
60
87
  if (!resolveDockerSocket()) throw new Error('Docker is not available');
61
88
 
62
- const { stdout } = await execFileAsync(
63
- 'docker',
64
- [...baseArgs(container, opts), ...argv],
65
- {
66
- encoding: 'utf-8',
67
- timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
68
- maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
69
- signal: opts?.signal,
70
- },
71
- );
72
- return stdout;
89
+ try {
90
+ const { stdout } = await execFileAsync(
91
+ 'docker',
92
+ [...baseArgs(container, opts), ...argv],
93
+ {
94
+ encoding: 'utf-8',
95
+ timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
96
+ maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
97
+ signal: opts?.signal,
98
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
99
+ },
100
+ );
101
+ return stdout;
102
+ } catch (e) {
103
+ throw execFailure(e, container);
104
+ }
73
105
  }
74
106
 
75
107
  /**
@@ -132,6 +164,10 @@ export function dockerExecWithInput(
132
164
  return new Promise((resolve, reject) => {
133
165
  const child = spawn('docker', ['exec', '-i', ...baseArgs(container, opts).slice(1), ...argv], {
134
166
  stdio: ['pipe', 'pipe', 'pipe'],
167
+ // `baseArgs` names the variables but carries no values, so they have to
168
+ // reach docker through its own environment — without this the container
169
+ // would be handed empty variables and never say why.
170
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
135
171
  });
136
172
 
137
173
  let stdout = '';
@@ -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
- return parsed.models.map((m: Record<string, unknown>) => ({
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
- } catch { /* fall through */ }
62
- return [];
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
- * The bundle is read-only at runtime. User choices live in model-overrides.json.
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
+ }
@@ -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');