@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.
Files changed (49) 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/gateway/agent/route.ts +23 -6
  12. package/app/api/gateway/provider/keys.ts +13 -1
  13. package/app/api/gateway/provider/route.ts +43 -12
  14. package/app/api/gateway/sync.ts +248 -72
  15. package/app/api/models/route.ts +28 -34
  16. package/app/api/provider/auth.ts +65 -0
  17. package/app/api/provider/upstream.ts +9 -2
  18. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  19. package/app/api/provider/v1/models/route.ts +26 -133
  20. package/app/components/PulseChat.tsx +25 -39
  21. package/app/components/ui/RemoveButton.tsx +46 -0
  22. package/app/components/ui/Select.tsx +3 -2
  23. package/app/components/ui/index.ts +1 -0
  24. package/app/credentials/PageClient.tsx +2 -2
  25. package/app/gateway/PageClient.tsx +257 -673
  26. package/app/globals.css +8 -0
  27. package/app/lib/models-context.tsx +43 -7
  28. package/app/wizard/useWizard.ts +6 -1
  29. package/bin/rev4a.js +73 -9
  30. package/docs/ARCHITECTURE.md +16 -4
  31. package/docs/FRONTEND-ARCHITECTURE.md +24 -2
  32. package/docs/REV4A.md +40 -17
  33. package/docs/dev/API-REFERENCE.md +170 -79
  34. package/docs/dev/GATEWAY.md +231 -89
  35. package/docs/dev/PROVIDERS.md +26 -13
  36. package/docs/rag/DATA-FRESHNESS.md +57 -28
  37. package/docs/rag/GLOSSARY.md +16 -14
  38. package/docs/rag/REV4A-OVERVIEW.md +23 -24
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +5 -7
  40. package/instrumentation.ts +9 -1
  41. package/lib/agent-readiness.ts +110 -0
  42. package/lib/channelManager.ts +64 -22
  43. package/lib/container-file.ts +27 -0
  44. package/lib/model-catalogue.ts +140 -27
  45. package/lib/rev4a-paths.ts +0 -21
  46. package/model-pricing.json +118 -110
  47. package/models.config.json +27 -12
  48. package/package.json +1 -1
  49. 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
+ }
@@ -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');