@aiwg/cli 2026.10.1 → 2026.10.3
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/agentic/code/providers/capability-matrix.yaml +270 -0
- package/agentic/code/providers/model-capabilities.v1.json +3091 -24
- package/bin/aiwg.mjs +86 -21
- package/dist/src/a2a/codecs.js +9 -0
- package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
- package/dist/src/artifacts/repair.js +9 -4
- package/dist/src/channel/manager.mjs +59 -10
- package/dist/src/cli/child-process-result.js +40 -0
- package/dist/src/cli/env.js +38 -16
- package/dist/src/cli/flag-parsing.js +236 -0
- package/dist/src/cli/handlers/decision.js +210 -4
- package/dist/src/cli/handlers/diagnose.js +9 -2
- package/dist/src/cli/handlers/feedback.js +14 -5
- package/dist/src/cli/handlers/init.js +245 -30
- package/dist/src/cli/handlers/mc.js +23 -0
- package/dist/src/cli/handlers/models.js +69 -6
- package/dist/src/cli/handlers/ralph.js +3 -0
- package/dist/src/cli/handlers/refresh.js +16 -0
- package/dist/src/cli/handlers/regenerate.js +14 -0
- package/dist/src/cli/handlers/runtime-info.js +24 -9
- package/dist/src/cli/handlers/script-runner.js +16 -6
- package/dist/src/cli/handlers/session.js +396 -62
- package/dist/src/cli/handlers/sessions.js +146 -40
- package/dist/src/cli/handlers/setup-manifest.js +439 -39
- package/dist/src/cli/handlers/setup.js +139 -39
- package/dist/src/cli/handlers/subcommands.js +3 -1
- package/dist/src/cli/handlers/team.js +18 -8
- package/dist/src/cli/handlers/use.js +13 -0
- package/dist/src/cli/handlers/utilities.js +106 -23
- package/dist/src/cli/prompt-utils.js +72 -0
- package/dist/src/cli/provider-resolution.js +11 -0
- package/dist/src/config/aiwg-config.js +268 -4
- package/dist/src/config/cli.js +146 -19
- package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
- package/dist/src/config/project-artifacts-health.mjs +14 -3
- package/dist/src/config/user-config.js +36 -10
- package/dist/src/decision/adapters/jev.js +75 -27
- package/dist/src/decision/driver.js +3 -1
- package/dist/src/decision/entry.js +38 -6
- package/dist/src/decision/heldout/collector.js +10 -1
- package/dist/src/decision/index.js +1 -0
- package/dist/src/decision/interop.js +60 -8
- package/dist/src/decision/ledger.js +839 -0
- package/dist/src/decision/validate.js +6 -3
- package/dist/src/effects/dispatch-verifier.js +252 -0
- package/dist/src/effects/index.js +1 -0
- package/dist/src/features/installer.js +2 -2
- package/dist/src/flow/graph-metadata.js +5 -0
- package/dist/src/gates/providers/loader.js +3 -1
- package/dist/src/installation/manager-command.mjs +1 -1
- package/dist/src/installation/manager.mjs +17 -2
- package/dist/src/lint/runner.js +2 -2
- package/dist/src/mcp/adapters/codex-runtime.js +360 -56
- package/dist/src/mcp/cli.mjs +587 -77
- package/dist/src/mcp/config-file.mjs +279 -0
- package/dist/src/mcp/config-layers.mjs +348 -0
- package/dist/src/mcp/credentials.mjs +484 -0
- package/dist/src/mcp/grok-build-config.mjs +16 -26
- package/dist/src/mcp/helpers.mjs +106 -2
- package/dist/src/mcp/omp-config.mjs +50 -29
- package/dist/src/mcp/profiles.js +97 -23
- package/dist/src/mcp/profiles.mjs +99 -27
- package/dist/src/mcp/registry.js +264 -57
- package/dist/src/mcp/registry.mjs +198 -45
- package/dist/src/mcp/server.mjs +403 -20
- package/dist/src/mcp/toml-string.mjs +9 -0
- package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
- package/dist/src/mcp/tool-filters.mjs +357 -0
- package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
- package/dist/src/mcp/tools/decision.mjs +21 -3
- package/dist/src/mcp/tools/orchestration.mjs +38 -0
- package/dist/src/mcp/tools/subsystems.mjs +39 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
- package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
- package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
- package/dist/src/memory/project-registry.js +2 -2
- package/dist/src/mission-protocol/codecs.js +639 -1
- package/dist/src/mission-protocol/types.js +110 -0
- package/dist/src/mission-protocol/worker-status.js +2 -0
- package/dist/src/models/config-loader.js +70 -13
- package/dist/src/models/grok-build-models.js +93 -41
- package/dist/src/models/model-capabilities.v1.json +3091 -24
- package/dist/src/models/model-discovery.js +862 -63
- package/dist/src/models/provider-policy.js +625 -11
- package/dist/src/models/qualification-digest.mjs +17 -0
- package/dist/src/models/resolver.js +259 -0
- package/dist/src/models/worker-direct-readiness.js +529 -0
- package/dist/src/models/worker-dispatch-offer.js +169 -0
- package/dist/src/models/worker-health-readiness.js +131 -0
- package/dist/src/models/worker-installation.mjs +879 -0
- package/dist/src/models/wrapper-route.js +18 -0
- package/dist/src/ops/registry.js +9 -14
- package/dist/src/plugins/standalone-packager.js +4 -3
- package/dist/src/process/bounded-child-process.mjs +387 -0
- package/dist/src/process/cli-command.mjs +75 -0
- package/dist/src/providers/capability-matrix.js +8 -0
- package/dist/src/providers/capability-matrix.yaml +270 -0
- package/dist/src/providers/grok-build-headless.js +43 -21
- package/dist/src/providers/provider-definitions.js +9 -1
- package/dist/src/providers/provider-definitions.mjs +9 -2
- package/dist/src/serve/executor-registry.js +16 -24
- package/dist/src/serve/fleet-mission-conductor.js +9 -0
- package/dist/src/serve/mission-conductor.js +9 -0
- package/dist/src/serve/shared-host-scheduler.js +2 -0
- package/dist/src/serve/worker-invocation.js +538 -0
- package/dist/src/serve/worker-stdin-frame.mjs +107 -0
- package/dist/src/sessions/adapter-registry.js +45 -0
- package/dist/src/sessions/adapters/generic.js +37 -3
- package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
- package/dist/src/sessions/adapters/grokbot.js +423 -24
- package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
- package/dist/src/sessions/adapters/muse.js +68 -29
- package/dist/src/sessions/adapters/pi.js +1 -0
- package/dist/src/sessions/batch-contracts.js +13 -1
- package/dist/src/sessions/batch-import.js +23 -42
- package/dist/src/sessions/candidates.js +47 -11
- package/dist/src/sessions/contracts.js +35 -0
- package/dist/src/sessions/coverage-preflight.js +134 -0
- package/dist/src/sessions/discovery.js +3 -0
- package/dist/src/sessions/grok-build-cli.js +67 -5
- package/dist/src/sessions/importer.js +24 -8
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/repository.js +54 -13
- package/dist/src/sessions/workspace-discovery.js +99 -37
- package/dist/src/skills/adapters/clawhub.js +10 -8
- package/dist/src/skills/adapters/openclaw.js +10 -7
- package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
- package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
- package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
- package/dist/src/smiths/mcpsmith/example.js +1 -1
- package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
- package/dist/src/storage/backends/logseq.js +68 -7
- package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
- package/dist/src/testing/generators/test-code-generator.js +57 -30
- package/dist/src/uhp/client.js +11 -1
- package/dist/src/uhp/mission.js +5 -0
- package/dist/src/update/checker.mjs +5 -5
- package/dist/src/update/notifier.mjs +54 -2
- package/dist/src/writing/fidelity.js +18 -1
- package/package.json +18 -1
- package/schemas/mission-v1.schema.json +2429 -0
- package/schemas/models/model-catalog.v1.schema.json +301 -0
- package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
- package/schemas/models/model-policy.v1.schema.json +14 -0
- package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
- package/tools/_resolve-impl.mjs +39 -0
- package/tools/agents/deploy-agents.mjs +15 -2
- package/tools/agents/providers/base.mjs +49 -32
- package/tools/agents/providers/grokbot.mjs +11 -12
- package/tools/agents/providers/hermes.mjs +6 -3
- package/tools/agents/providers/omp.mjs +5 -2
- package/tools/agents/providers/openclaw-translator.mjs +19 -5
- package/tools/providers/deepseek-harness-transport.mjs +358 -0
- package/tools/providers/omp-transport.mjs +192 -0
- package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
- package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
- package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
- package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
- package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
- package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
- package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
- package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
- package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
- package/tools/ralph-external/lib/worker-health.mjs +439 -0
- package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { hasSelectionRelevantConflicts, validateWorkerCatalogDiscovery } from './provider-policy.js';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
2
4
|
import { createInterface } from 'node:readline';
|
|
3
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, relative } from 'node:path';
|
|
7
|
+
import { constants } from 'node:fs';
|
|
5
8
|
import { homedir, tmpdir } from 'node:os';
|
|
6
9
|
export const PROVIDER_DISCOVERY_DECISIONS = {
|
|
7
10
|
claude: {
|
|
@@ -14,8 +17,8 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
|
|
|
14
17
|
codex: {
|
|
15
18
|
provider: 'codex',
|
|
16
19
|
status: 'native',
|
|
17
|
-
interface: 'codex app-server model/list',
|
|
18
|
-
reason: 'The app-server JSON-RPC model/list method returns models available to the current Codex account without running a model turn.',
|
|
20
|
+
interface: 'codex app-server model/list; codex debug models (version-scoped fixture)',
|
|
21
|
+
reason: 'The app-server JSON-RPC model/list method returns models available to the current Codex account without running a model turn. The version-scoped codex debug models JSON shape normalizes through the same observation contract.',
|
|
19
22
|
documentation: 'https://developers.openai.com/codex/app-server/',
|
|
20
23
|
},
|
|
21
24
|
copilot: {
|
|
@@ -62,9 +65,9 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
|
|
|
62
65
|
},
|
|
63
66
|
muse: {
|
|
64
67
|
provider: 'muse',
|
|
65
|
-
status: '
|
|
66
|
-
interface:
|
|
67
|
-
reason: '
|
|
68
|
+
status: 'native',
|
|
69
|
+
interface: 'model-catalog JSON rows (~/.local/share/muse/model-catalog/*.json)',
|
|
70
|
+
reason: 'Bounded model-catalog JSON rows report configured model facts for the current Muse installation without probing model APIs or consuming tokens. Entitlement stays account-scoped and contributor data-use facts remain config-scoped, never row-derived.',
|
|
68
71
|
documentation: 'https://github.com/jmagly/aiwg/blob/main/docs/architecture/adr-muse-provider-target.md',
|
|
69
72
|
},
|
|
70
73
|
opencode: {
|
|
@@ -88,6 +91,13 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
|
|
|
88
91
|
reason: 'OpenHuman profiles accept semantic model hints but expose no standardized local model-list command.',
|
|
89
92
|
documentation: 'https://github.com/roctinam/openhuman',
|
|
90
93
|
},
|
|
94
|
+
ollama: {
|
|
95
|
+
provider: 'ollama',
|
|
96
|
+
status: 'native',
|
|
97
|
+
interface: 'GET /api/tags on the configured local endpoint',
|
|
98
|
+
reason: 'The read-only HTTP model-list endpoint reports the locally present models and per-model digests for the configured Ollama server without probing a remote registry or consuming inference tokens. Declared here (roctinam/aiwg#2899, B-OG-09); no fetcher is implemented against this map by this task — see the local-http worker-adapter registry entry and its adapter docstring for what is actually dispatchable today.',
|
|
99
|
+
documentation: 'https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models',
|
|
100
|
+
},
|
|
91
101
|
omp: { provider: 'omp', status: 'native', interface: 'omp models --json --no-extensions',
|
|
92
102
|
reason: 'OMP JSON catalog reports credential-available LLM models; ambient extensions are disabled by default.',
|
|
93
103
|
documentation: 'https://github.com/can1357/oh-my-pi/blob/5964a0f7649275bcde818f20073193fd032451f2/packages/coding-agent/src/commands/models.ts' },
|
|
@@ -178,7 +188,7 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
|
|
|
178
188
|
runner(command, ['models', '--pure'], { cwd: tmpdir(), timeoutMs: 15_000 }),
|
|
179
189
|
]);
|
|
180
190
|
if (result.exitCode !== 0) {
|
|
181
|
-
const error = result.stderr.trim() || `OpenCode models exited ${result.exitCode}`;
|
|
191
|
+
const error = redactModelDiscoveryText(result.stderr.trim()) || `OpenCode models exited ${result.exitCode}`;
|
|
182
192
|
return {
|
|
183
193
|
provider: 'opencode',
|
|
184
194
|
source: 'native',
|
|
@@ -190,10 +200,16 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
|
|
|
190
200
|
error,
|
|
191
201
|
};
|
|
192
202
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
const diagnostics = [];
|
|
204
|
+
const models = [...new Set(result.stdout.split(/\r?\n/).flatMap((raw, index) => {
|
|
205
|
+
const line = raw.trim();
|
|
206
|
+
if (!line)
|
|
207
|
+
return [];
|
|
208
|
+
if (/^[a-z0-9][a-z0-9._-]*\/\S+$/i.test(line))
|
|
209
|
+
return [line];
|
|
210
|
+
diagnostics.push(`OpenCode models line ${index + 1}: malformed provider/model row`);
|
|
211
|
+
return [];
|
|
212
|
+
}))].map(id => ({ id }));
|
|
197
213
|
return {
|
|
198
214
|
provider: 'opencode',
|
|
199
215
|
source: 'native',
|
|
@@ -201,8 +217,8 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
|
|
|
201
217
|
...(version ? { runtimeVersion: version } : {}),
|
|
202
218
|
accountScope: 'local-runtime',
|
|
203
219
|
models,
|
|
204
|
-
...(models.length === 0
|
|
205
|
-
? { errorKind: 'invalid-output', error: 'OpenCode returned no normalized provider/model rows.' }
|
|
220
|
+
...(diagnostics.length || models.length === 0
|
|
221
|
+
? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'OpenCode returned no normalized provider/model rows.' }
|
|
206
222
|
: {}),
|
|
207
223
|
};
|
|
208
224
|
}
|
|
@@ -213,22 +229,33 @@ export async function discoverPiModels(command = 'pi', runner = runModelDiscover
|
|
|
213
229
|
runner(command, ['--list-models'], { cwd: tmpdir(), timeoutMs: 15_000 }),
|
|
214
230
|
]);
|
|
215
231
|
if (result.exitCode !== 0) {
|
|
216
|
-
const error = result.stderr.trim() || `Pi --list-models exited ${result.exitCode}`;
|
|
232
|
+
const error = redactModelDiscoveryText(result.stderr.trim()) || `Pi --list-models exited ${result.exitCode}`;
|
|
217
233
|
return { provider: 'pi', source: 'native', observedAt,
|
|
218
234
|
...(version ? { runtimeVersion: version } : {}), accountScope: 'local-runtime',
|
|
219
235
|
models: [], errorKind: classifyDiscoveryError(error), error };
|
|
220
236
|
}
|
|
221
|
-
const
|
|
222
|
-
const
|
|
237
|
+
const diagnostics = [];
|
|
238
|
+
const lines = result.stdout.split(/\r?\n/);
|
|
239
|
+
let headerSeen = false;
|
|
240
|
+
const models = lines.flatMap((raw, index) => {
|
|
241
|
+
const line = raw.trim();
|
|
242
|
+
if (!line)
|
|
243
|
+
return [];
|
|
244
|
+
if (!headerSeen && /^provider\s+model(?:\s|$)/i.test(line)) {
|
|
245
|
+
headerSeen = true;
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
223
248
|
const columns = line.split(/\s{2,}/);
|
|
224
|
-
if (columns.length < 2 || !/^[a-z0-9][a-z0-9._-]*$/i.test(columns[0]))
|
|
249
|
+
if (columns.length < 2 || !/^[a-z0-9][a-z0-9._-]*$/i.test(columns[0]) || !/^\S+$/.test(columns[1])) {
|
|
250
|
+
diagnostics.push(`Pi --list-models line ${index + 1}: malformed provider/model row`);
|
|
225
251
|
return [];
|
|
252
|
+
}
|
|
226
253
|
return [{ id: `${columns[0]}/${columns[1]}` }];
|
|
227
254
|
});
|
|
228
255
|
return { provider: 'pi', source: 'native', observedAt,
|
|
229
256
|
...(version ? { runtimeVersion: version } : {}), accountScope: 'local-runtime', models,
|
|
230
|
-
...(models.length === 0 ? { errorKind: 'invalid-output',
|
|
231
|
-
error: 'Pi returned no parseable provider/model rows.' } : {}) };
|
|
257
|
+
...(diagnostics.length || models.length === 0 ? { errorKind: 'invalid-output',
|
|
258
|
+
error: diagnostics.join('; ') || 'Pi returned no parseable provider/model rows.' } : {}) };
|
|
232
259
|
}
|
|
233
260
|
export async function discoverOpenClawModels(command = 'openclaw', runner = runModelDiscoveryCommand) {
|
|
234
261
|
const observedAt = new Date().toISOString();
|
|
@@ -237,7 +264,7 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
|
|
|
237
264
|
runner(command, ['models', 'list', '--json'], { timeoutMs: 15_000 }),
|
|
238
265
|
]);
|
|
239
266
|
if (result.exitCode !== 0) {
|
|
240
|
-
const error = result.stderr.trim() || `OpenClaw models list exited ${result.exitCode}`;
|
|
267
|
+
const error = redactModelDiscoveryText(result.stderr.trim()) || `OpenClaw models list exited ${result.exitCode}`;
|
|
241
268
|
return {
|
|
242
269
|
provider: 'openclaw',
|
|
243
270
|
source: 'native',
|
|
@@ -251,13 +278,25 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
|
|
|
251
278
|
}
|
|
252
279
|
try {
|
|
253
280
|
const payload = JSON.parse(result.stdout);
|
|
254
|
-
|
|
255
|
-
|
|
281
|
+
if (!Array.isArray(payload?.models))
|
|
282
|
+
throw new Error('expected /models array');
|
|
283
|
+
const diagnostics = [];
|
|
284
|
+
const models = payload.models.flatMap((model, index) => {
|
|
285
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)
|
|
286
|
+
|| typeof model.key !== 'string' || !/^[^\s/]+\/\S+$/.test(model.key)
|
|
287
|
+
|| (model.available !== undefined && typeof model.available !== 'boolean')
|
|
288
|
+
|| (model.name !== undefined && typeof model.name !== 'string')
|
|
289
|
+
|| (model.tags !== undefined && (!Array.isArray(model.tags) || model.tags.some((tag) => typeof tag !== 'string')))) {
|
|
290
|
+
diagnostics.push(`OpenClaw /models/${index}: malformed model row`);
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
return model.available !== false ? [{
|
|
256
294
|
id: model.key,
|
|
257
295
|
...(typeof model.name === 'string' ? { displayName: model.name } : {}),
|
|
258
296
|
isDefault: Array.isArray(model.tags) && model.tags.includes('default'),
|
|
259
297
|
}]
|
|
260
|
-
|
|
298
|
+
: [];
|
|
299
|
+
});
|
|
261
300
|
return {
|
|
262
301
|
provider: 'openclaw',
|
|
263
302
|
source: 'native',
|
|
@@ -265,8 +304,8 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
|
|
|
265
304
|
...(version ? { runtimeVersion: version } : {}),
|
|
266
305
|
accountScope: 'local-runtime',
|
|
267
306
|
models,
|
|
268
|
-
...(models.length === 0
|
|
269
|
-
? { errorKind: 'invalid-output', error: 'OpenClaw returned no normalized model keys.' }
|
|
307
|
+
...(diagnostics.length || models.length === 0
|
|
308
|
+
? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'OpenClaw returned no normalized model keys.' }
|
|
270
309
|
: {}),
|
|
271
310
|
};
|
|
272
311
|
}
|
|
@@ -294,6 +333,8 @@ async function readCatalog(file) {
|
|
|
294
333
|
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
295
334
|
if (!parsed.version || !parsed.providers || typeof parsed.providers !== 'object')
|
|
296
335
|
return null;
|
|
336
|
+
if (validateWorkerCatalogDiscovery(parsed.discovery).length)
|
|
337
|
+
return null;
|
|
297
338
|
return parsed;
|
|
298
339
|
}
|
|
299
340
|
catch {
|
|
@@ -328,7 +369,27 @@ const STANDARD_FAMILY = /(?:^|[/:._-])(?:sonnet|terra)(?:$|[/:._[-]|\d)/i;
|
|
|
328
369
|
export function selectRoleModels(models) {
|
|
329
370
|
// Flagship families are user-elected only (epic #2642): discovery never
|
|
330
371
|
// assigns them to a role, even when the provider marks one as its default.
|
|
331
|
-
const visible = models.filter(model =>
|
|
372
|
+
const visible = models.filter(model => {
|
|
373
|
+
// Keep the legacy name-based selection only for unenriched providers.
|
|
374
|
+
const worker = model.worker;
|
|
375
|
+
return !retirementGuard(null, model).blocked && !model.hidden && !FLAGSHIP_FAMILY.test(model.id)
|
|
376
|
+
&& (!worker || (worker.qualification.status === 'qualified'
|
|
377
|
+
&& worker.visibility === 'listed' && worker.availability === 'accessible'
|
|
378
|
+
&& (worker.lifecycle === 'active' || worker.lifecycle === 'deprecated')
|
|
379
|
+
&& worker.effortSupport !== 'unknown' && !hasSelectionRelevantConflicts(worker)));
|
|
380
|
+
});
|
|
381
|
+
if (visible.length === 0)
|
|
382
|
+
return {};
|
|
383
|
+
const selected = {};
|
|
384
|
+
for (const role of ['reasoning', 'coding', 'efficiency']) {
|
|
385
|
+
const candidates = visible.filter(model => !model.worker || model.worker.qualification.roles.includes(role));
|
|
386
|
+
const model = rankRoleModels(candidates)[role];
|
|
387
|
+
if (model)
|
|
388
|
+
selected[role] = model;
|
|
389
|
+
}
|
|
390
|
+
return selected;
|
|
391
|
+
}
|
|
392
|
+
function rankRoleModels(visible) {
|
|
332
393
|
if (visible.length === 0)
|
|
333
394
|
return {};
|
|
334
395
|
const coding = visible.find(model => STANDARD_FAMILY.test(model.id))
|
|
@@ -379,6 +440,8 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
379
440
|
const lines = createInterface({ input: child.stdout });
|
|
380
441
|
let settled = false;
|
|
381
442
|
let stderr = '';
|
|
443
|
+
const diagnostics = [];
|
|
444
|
+
let lineNumber = 0;
|
|
382
445
|
let detectedRuntimeVersion = version;
|
|
383
446
|
let timer;
|
|
384
447
|
const finish = (result) => {
|
|
@@ -390,7 +453,7 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
390
453
|
child.kill();
|
|
391
454
|
resolveDiscovery(result);
|
|
392
455
|
};
|
|
393
|
-
child.stderr.on('data', chunk => { stderr
|
|
456
|
+
child.stderr.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-4096); });
|
|
394
457
|
child.on('error', error => finish({
|
|
395
458
|
provider: 'codex',
|
|
396
459
|
source: 'native',
|
|
@@ -398,14 +461,20 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
398
461
|
accountScope: 'local-account',
|
|
399
462
|
models: [],
|
|
400
463
|
errorKind: classifyDiscoveryError(error.message),
|
|
401
|
-
error: error.message,
|
|
464
|
+
error: redactModelDiscoveryText(error.message),
|
|
402
465
|
}));
|
|
403
466
|
lines.on('line', line => {
|
|
467
|
+
lineNumber++;
|
|
404
468
|
let message;
|
|
405
469
|
try {
|
|
406
470
|
message = JSON.parse(line);
|
|
407
471
|
}
|
|
408
472
|
catch {
|
|
473
|
+
diagnostics.push(`Codex app-server line ${lineNumber}: invalid JSON`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!message || typeof message !== 'object') {
|
|
477
|
+
diagnostics.push(`Codex app-server line ${lineNumber}: expected JSON-RPC object`);
|
|
409
478
|
return;
|
|
410
479
|
}
|
|
411
480
|
if (message.id === 1 && message.result) {
|
|
@@ -425,16 +494,34 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
425
494
|
})}\n`);
|
|
426
495
|
}
|
|
427
496
|
if (message.id === 2) {
|
|
497
|
+
if (!Array.isArray(message.result?.data))
|
|
498
|
+
diagnostics.push('Codex model/list /result/data: expected array');
|
|
499
|
+
if (message.result?.nextCursor)
|
|
500
|
+
diagnostics.push('Codex model/list /result/nextCursor: truncated catalog (more pages remain)');
|
|
428
501
|
const models = Array.isArray(message.result?.data)
|
|
429
|
-
? message.result.data.
|
|
430
|
-
id
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
502
|
+
? message.result.data.flatMap((model, index) => {
|
|
503
|
+
const id = model?.model ?? model?.id;
|
|
504
|
+
const efforts = model?.supportedReasoningEfforts;
|
|
505
|
+
if (!model || typeof model !== 'object' || Array.isArray(model) || typeof id !== 'string' || !id.trim()
|
|
506
|
+
|| ['hidden', 'isDefault'].some(key => model[key] !== undefined && typeof model[key] !== 'boolean')
|
|
507
|
+
|| (model.displayName !== undefined && typeof model.displayName !== 'string')
|
|
508
|
+
|| (efforts !== undefined && (!Array.isArray(efforts) || efforts.some((effort) => {
|
|
509
|
+
const value = typeof effort === 'string' ? effort : effort?.reasoningEffort ?? effort?.effort;
|
|
510
|
+
return typeof value !== 'string' || !value.trim();
|
|
511
|
+
})))) {
|
|
512
|
+
diagnostics.push(`Codex model/list /result/data/${index}: malformed model row`);
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
return [{
|
|
516
|
+
id,
|
|
517
|
+
displayName: typeof model.displayName === 'string' ? model.displayName : undefined,
|
|
518
|
+
hidden: Boolean(model.hidden),
|
|
519
|
+
isDefault: Boolean(model.isDefault),
|
|
520
|
+
reasoningEfforts: Array.isArray(model.supportedReasoningEfforts)
|
|
521
|
+
? model.supportedReasoningEfforts.map((effort) => typeof effort === 'string' ? effort : String(effort.reasoningEffort ?? effort.effort ?? effort))
|
|
522
|
+
: undefined,
|
|
523
|
+
}];
|
|
524
|
+
})
|
|
438
525
|
: [];
|
|
439
526
|
finish({
|
|
440
527
|
provider: 'codex',
|
|
@@ -443,7 +530,8 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
443
530
|
...(detectedRuntimeVersion ? { runtimeVersion: detectedRuntimeVersion } : {}),
|
|
444
531
|
accountScope: 'local-account',
|
|
445
532
|
models,
|
|
446
|
-
...(message.error ? { error: JSON.stringify(message.error) }
|
|
533
|
+
...(message.error ? { errorKind: 'command', error: redactModelDiscoveryText(JSON.stringify(message.error)) }
|
|
534
|
+
: diagnostics.length ? { errorKind: 'invalid-output', error: diagnostics.join('; ') } : {}),
|
|
447
535
|
});
|
|
448
536
|
}
|
|
449
537
|
});
|
|
@@ -463,7 +551,7 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
|
|
|
463
551
|
accountScope: 'local-account',
|
|
464
552
|
models: [],
|
|
465
553
|
errorKind: 'timeout',
|
|
466
|
-
error: `Codex app-server model/list timed out${stderr ? `: ${stderr.trim()}` : ''}`,
|
|
554
|
+
error: `Codex app-server model/list timed out${stderr ? `: ${redactModelDiscoveryText(stderr.trim())}` : ''}`,
|
|
467
555
|
}), timeoutMs);
|
|
468
556
|
});
|
|
469
557
|
}
|
|
@@ -472,7 +560,7 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
472
560
|
const homeDir = options.homeDir ?? homedir();
|
|
473
561
|
const cacheFile = options.cacheFile ?? join(homeDir, '.cache/aiwg/model-catalog.v1.json');
|
|
474
562
|
const staticFile = join(options.aiwgRoot, 'agentic/code/providers/model-catalog.v1.json');
|
|
475
|
-
const signature = inventorySignature(options.inventory) + JSON.stringify({ omp: options.omp, profile: process.env.OMP_PROFILE ?? process.env.PI_PROFILE, config: process.env.PI_CONFIG_DIR, data: process.env.XDG_DATA_HOME, cache: process.env.XDG_CACHE_HOME, agent: process.env.PI_CODING_AGENT_DIR });
|
|
563
|
+
const signature = inventorySignature(options.inventory) + JSON.stringify({ codex: options.codex, muse: options.muse, omp: options.omp, profile: process.env.OMP_PROFILE ?? process.env.PI_PROFILE, config: process.env.PI_CONFIG_DIR, data: process.env.XDG_DATA_HOME, cache: process.env.XDG_CACHE_HOME, agent: process.env.PI_CODING_AGENT_DIR });
|
|
476
564
|
const staticCatalog = await readCatalog(staticFile);
|
|
477
565
|
if (!staticCatalog)
|
|
478
566
|
throw new Error(`Invalid or missing static model catalog: ${staticFile}`);
|
|
@@ -480,6 +568,8 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
480
568
|
const cached = useCache ? await readCatalog(cacheFile) : null;
|
|
481
569
|
if (!options.forceRefresh
|
|
482
570
|
&& cached
|
|
571
|
+
&& (!options.codex?.accountBinding || cached.discovery?.providers.codex?.accountBinding === options.codex.accountBinding)
|
|
572
|
+
&& (!options.muse?.accountBinding || cached.discovery?.providers.muse?.accountBinding === options.muse.accountBinding)
|
|
483
573
|
&& isFresh(cached, now, options.ttlMs ?? DEFAULT_TTL_MS, signature)) {
|
|
484
574
|
return {
|
|
485
575
|
...cached,
|
|
@@ -502,6 +592,7 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
502
592
|
fetchedAt: now.toISOString(),
|
|
503
593
|
inventorySignature: signature,
|
|
504
594
|
providers: {},
|
|
595
|
+
...(staticCatalog.discovery?.instances ? { instances: staticCatalog.discovery.instances } : {}),
|
|
505
596
|
},
|
|
506
597
|
};
|
|
507
598
|
}
|
|
@@ -518,7 +609,7 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
518
609
|
if (!response.ok)
|
|
519
610
|
throw new Error(`HTTP ${response.status}`);
|
|
520
611
|
const remote = await response.json();
|
|
521
|
-
if (!remote.version || !remote.providers)
|
|
612
|
+
if (!remote.version || !remote.providers || validateWorkerCatalogDiscovery(remote.discovery).length)
|
|
522
613
|
throw new Error('invalid catalog shape');
|
|
523
614
|
catalog = remote;
|
|
524
615
|
source = 'remote';
|
|
@@ -530,7 +621,12 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
530
621
|
}
|
|
531
622
|
const available = new Set(options.inventory.providers.filter(provider => provider.available).map(provider => provider.id));
|
|
532
623
|
const nativeDiscoverers = options.nativeDiscoverers ?? {
|
|
533
|
-
codex: () =>
|
|
624
|
+
codex: () => discoverCodexDebugModels(options.codex?.command ?? 'codex', options.runner, {
|
|
625
|
+
accountBinding: options.codex?.accountBinding, now: options.now,
|
|
626
|
+
}),
|
|
627
|
+
muse: () => discoverMuseCatalogModels({
|
|
628
|
+
...options.muse, catalogDir: options.muse?.catalogDir ?? join(homeDir, MUSE_CATALOG_DIR), now: options.now,
|
|
629
|
+
}),
|
|
534
630
|
opencode: () => discoverOpenCodeModels(),
|
|
535
631
|
openclaw: () => discoverOpenClawModels(),
|
|
536
632
|
pi: () => discoverPiModels(),
|
|
@@ -546,21 +642,6 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
546
642
|
if (discoverer) {
|
|
547
643
|
try {
|
|
548
644
|
providerDiscovery[provider] = await discoverer();
|
|
549
|
-
const selected = provider === 'omp' ? {} : selectRoleModels(providerDiscovery[provider].models);
|
|
550
|
-
const providerCatalog = catalog.providers[provider];
|
|
551
|
-
if (providerCatalog) {
|
|
552
|
-
for (const role of ['reasoning', 'coding', 'efficiency']) {
|
|
553
|
-
if (!selected[role])
|
|
554
|
-
continue;
|
|
555
|
-
providerCatalog.roles[role] = {
|
|
556
|
-
...(providerCatalog.roles[role] ?? {}),
|
|
557
|
-
id: selected[role].id,
|
|
558
|
-
status: 'active',
|
|
559
|
-
observed: true,
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
providerCatalog.verifiedAt = providerDiscovery[provider].observedAt.slice(0, 10);
|
|
563
|
-
}
|
|
564
645
|
}
|
|
565
646
|
catch (error) {
|
|
566
647
|
providerDiscovery[provider] = {
|
|
@@ -571,8 +652,65 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
571
652
|
models: [],
|
|
572
653
|
errorKind: classifyDiscoveryError(error.message),
|
|
573
654
|
error: error.message,
|
|
655
|
+
accountBinding: provider === 'codex' ? options.codex?.accountBinding : provider === 'muse' ? options.muse?.accountBinding : undefined,
|
|
574
656
|
};
|
|
575
657
|
}
|
|
658
|
+
const next = providerDiscovery[provider];
|
|
659
|
+
const instanceId = discoveryInstanceId(next);
|
|
660
|
+
const prior = cached?.discovery?.instances?.[instanceId]
|
|
661
|
+
?? cached?.discovery?.providers[provider];
|
|
662
|
+
if (prior)
|
|
663
|
+
providerDiscovery[provider] = mergeProviderDiscovery(prior, next);
|
|
664
|
+
const entry = providerDiscovery[provider];
|
|
665
|
+
if (entry.errorKind && prior && prior.accountBinding === next.accountBinding
|
|
666
|
+
&& cached?.providers[provider])
|
|
667
|
+
catalog.providers[provider] = structuredClone(cached.providers[provider]);
|
|
668
|
+
const providerCatalog = catalog.providers[provider];
|
|
669
|
+
const preservePins = provider === 'omp' || entry.models.some(model => model.worker)
|
|
670
|
+
|| prior?.models.some(model => model.worker);
|
|
671
|
+
if (!next.errorKind && prior && prior.accountBinding === next.accountBinding
|
|
672
|
+
&& prior.accountScope === next.accountScope) {
|
|
673
|
+
for (const role of Object.values(providerCatalog?.roles ?? {})) {
|
|
674
|
+
const alreadyUnavailable = Object.values(cached?.providers[provider]?.roles ?? {}).some(previous => previous.id === role.id && previous.status === 'unverified' && previous.observed === false);
|
|
675
|
+
if (!(alreadyUnavailable || (preservePins && prior.models.some(model => model.id === role.id)))
|
|
676
|
+
|| entry.models.some(model => model.id === role.id))
|
|
677
|
+
continue;
|
|
678
|
+
const guard = retirementGuard(role.id);
|
|
679
|
+
role.status = 'unverified';
|
|
680
|
+
role.observed = false;
|
|
681
|
+
entry.policy ??= { layers: [], selected: {}, constraints: [], diagnostics: [] };
|
|
682
|
+
if (!entry.policy.diagnostics.includes(guard.reason))
|
|
683
|
+
entry.policy.diagnostics.push(guard.reason);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
for (const model of entry.models) {
|
|
687
|
+
if (!model.worker)
|
|
688
|
+
continue;
|
|
689
|
+
const pinnedRoles = Object.values(providerCatalog?.roles ?? {}).filter(role => role.id === model.id);
|
|
690
|
+
const guard = retirementGuard(pinnedRoles.length ? model.id : null, model);
|
|
691
|
+
if (guard.blocked) {
|
|
692
|
+
for (const role of pinnedRoles)
|
|
693
|
+
role.status = 'unverified';
|
|
694
|
+
entry.policy ??= { layers: [], selected: {}, constraints: [], diagnostics: [] };
|
|
695
|
+
if (!entry.policy.diagnostics.includes(guard.reason))
|
|
696
|
+
entry.policy.diagnostics.push(guard.reason);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (providerCatalog) {
|
|
700
|
+
// Observations are facts, not approval to adopt a model. Preserve pins.
|
|
701
|
+
const selected = preservePins || entry.errorKind ? {}
|
|
702
|
+
: selectRoleModels(entry.models);
|
|
703
|
+
for (const role of ['reasoning', 'coding', 'efficiency']) {
|
|
704
|
+
if (!selected[role])
|
|
705
|
+
continue;
|
|
706
|
+
providerCatalog.roles[role] = {
|
|
707
|
+
...(providerCatalog.roles[role] ?? {}), id: selected[role].id,
|
|
708
|
+
status: 'active', observed: true,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
if (Object.keys(selected).length)
|
|
712
|
+
providerCatalog.verifiedAt = entry.observedAt.slice(0, 10);
|
|
713
|
+
}
|
|
576
714
|
}
|
|
577
715
|
else {
|
|
578
716
|
const decision = PROVIDER_DISCOVERY_DECISIONS[provider];
|
|
@@ -588,7 +726,7 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
588
726
|
};
|
|
589
727
|
}
|
|
590
728
|
}
|
|
591
|
-
|
|
729
|
+
let resolved = {
|
|
592
730
|
...catalog,
|
|
593
731
|
discovery: {
|
|
594
732
|
source,
|
|
@@ -597,8 +735,12 @@ export async function resolveDynamicModelCatalog(options) {
|
|
|
597
735
|
...(remoteError ? { remoteError } : {}),
|
|
598
736
|
inventorySignature: signature,
|
|
599
737
|
providers: providerDiscovery,
|
|
738
|
+
instances: { ...cached?.discovery?.instances, ...catalog.discovery?.instances },
|
|
600
739
|
},
|
|
601
740
|
};
|
|
741
|
+
for (const entry of Object.values(providerDiscovery)) {
|
|
742
|
+
resolved = recordAccountInstance(resolved, discoveryInstanceId(entry), entry);
|
|
743
|
+
}
|
|
602
744
|
await atomicWrite(cacheFile, `${JSON.stringify(resolved, null, 2)}\n`);
|
|
603
745
|
return resolved;
|
|
604
746
|
}
|
|
@@ -616,10 +758,19 @@ export async function discoverOmpModels(command = 'omp', runner = runModelDiscov
|
|
|
616
758
|
if (result.exitCode !== 0)
|
|
617
759
|
return { ...base, errorKind: result.exitCode === 124 ? 'timeout' : /unknown|unsupported|not found/i.test(result.stderr) ? 'unsupported' : classifyDiscoveryError(result.stderr), error: 'OMP model discovery failed; check CLI version, selected profile/config, and credential availability.' };
|
|
618
760
|
const payload = JSON.parse(result.stdout);
|
|
619
|
-
if (!Array.isArray(payload
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
761
|
+
if (!Array.isArray(payload?.models))
|
|
762
|
+
return { ...base, errorKind: 'invalid-output', error: 'OMP /models: expected array' };
|
|
763
|
+
const diagnostics = [];
|
|
764
|
+
base.models = payload.models.flatMap((m, index) => {
|
|
765
|
+
if (!m || typeof m.id !== 'string' || !m.id.trim() || typeof m.provider !== 'string' || !m.provider.trim()
|
|
766
|
+
|| (m.name !== undefined && typeof m.name !== 'string')
|
|
767
|
+
|| (m.thinking !== undefined && (!Array.isArray(m.thinking) || m.thinking.some((effort) => typeof effort !== 'string')))) {
|
|
768
|
+
diagnostics.push(`OMP /models/${index}: malformed model row`);
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
return [{ id: `${m.provider}/${m.id}`, llmProvider: m.provider, displayName: m.name, reasoningEfforts: m.thinking }];
|
|
772
|
+
});
|
|
773
|
+
return { ...base, ...(diagnostics.length ? { errorKind: 'invalid-output', error: diagnostics.join('; ') } : {}) };
|
|
623
774
|
}
|
|
624
775
|
catch {
|
|
625
776
|
return { ...base, errorKind: 'invalid-output', error: 'OMP returned invalid model catalog JSON or discovery could not run.' };
|
|
@@ -634,4 +785,652 @@ export function resolveOmpRoleModel(catalog, role, mappings, explicit) {
|
|
|
634
785
|
throw new Error(`OMP ${role} model unavailable; configure a model from omp models --json or supply an explicit model.`);
|
|
635
786
|
return id;
|
|
636
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* B-OG-03 shared discovery refinements (S1 direct/shared checkpoint).
|
|
790
|
+
*
|
|
791
|
+
* Codex `debug models` JSON and Muse model-catalog rows normalize through one
|
|
792
|
+
* observation contract: per-CLI sources, field provenance, coherent snapshots
|
|
793
|
+
* and freshness. Native and bridged MCP use identical facts. Synthetic fixture
|
|
794
|
+
* copies under test fixtures exercise these paths; synthetic seeds never ship
|
|
795
|
+
* as defaults.
|
|
796
|
+
*/
|
|
797
|
+
/** Evidence dated beyond this skew past now is rejected, never trusted. */
|
|
798
|
+
export const MAX_DISCOVERY_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
799
|
+
const MUSE_CATALOG_DIR = '.local/share/muse/model-catalog';
|
|
800
|
+
const MAX_MUSE_CATALOG_FILES = 64;
|
|
801
|
+
const MAX_MUSE_CATALOG_BYTES = 1024 * 1024;
|
|
802
|
+
/** Sanitized provenance digest for a normalized evidence entry. */
|
|
803
|
+
export function discoveryDigest(value) {
|
|
804
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
805
|
+
}
|
|
806
|
+
/** True when an evidence timestamp is materially in the future. Unknown/unparseable stays explicit, not future. */
|
|
807
|
+
export function isFutureDated(value, now = new Date(), skewMs = MAX_DISCOVERY_CLOCK_SKEW_MS) {
|
|
808
|
+
if (typeof value !== 'string')
|
|
809
|
+
return false;
|
|
810
|
+
const time = Date.parse(value);
|
|
811
|
+
return Number.isFinite(time) && time - now.getTime() > skewMs;
|
|
812
|
+
}
|
|
813
|
+
function normalizeVisibility(value) {
|
|
814
|
+
if (value === true || value === 'listed' || value === 'list' || value === 'visible' || value === 'shown')
|
|
815
|
+
return 'listed';
|
|
816
|
+
if (value === false || value === 'hidden')
|
|
817
|
+
return 'hidden';
|
|
818
|
+
return 'unknown';
|
|
819
|
+
}
|
|
820
|
+
function normalizeLifecycle(entry) {
|
|
821
|
+
const lifecycle = entry.lifecycle ?? entry.status ?? entry.state;
|
|
822
|
+
if (lifecycle === 'retired' || entry.retired === true) {
|
|
823
|
+
return {
|
|
824
|
+
lifecycle: 'retired',
|
|
825
|
+
sunsetAt: typeof entry.sunset_at === 'string' || entry.sunset_at === null ? entry.sunset_at : null,
|
|
826
|
+
...(typeof entry.replacement === 'string' && entry.replacement ? { replacementModelId: entry.replacement } : {}),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (lifecycle === 'deprecated' || entry.deprecated === true) {
|
|
830
|
+
return {
|
|
831
|
+
lifecycle: 'deprecated',
|
|
832
|
+
sunsetAt: typeof entry.sunset_at === 'string' || entry.sunset_at === null ? entry.sunset_at : null,
|
|
833
|
+
...(typeof entry.replacement === 'string' && entry.replacement ? { replacementModelId: entry.replacement } : {}),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return { lifecycle: 'active', sunsetAt: null };
|
|
837
|
+
}
|
|
838
|
+
function normalizePositiveInt(value) {
|
|
839
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 ? value : null;
|
|
840
|
+
}
|
|
841
|
+
function observationTimestamps(options) {
|
|
842
|
+
const observedAt = options.observedAt ?? (options.now ?? new Date()).toISOString();
|
|
843
|
+
return {
|
|
844
|
+
observedAt,
|
|
845
|
+
firstObservedAt: options.firstObservedAt ?? observedAt,
|
|
846
|
+
fetchedAt: options.fetchedAt ?? observedAt,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/** Build one explicit observation: unknowns stay unknown, every field carries provenance. */
|
|
850
|
+
export function buildWorkerObservation(options, input) {
|
|
851
|
+
const { observedAt, firstObservedAt, fetchedAt } = observationTimestamps(options);
|
|
852
|
+
const releaseDate = input.releaseDate ?? null;
|
|
853
|
+
const efforts = input.efforts ?? [];
|
|
854
|
+
const effortSupport = input.effortSupport
|
|
855
|
+
?? (efforts.length > 0 ? 'explicit' : 'unknown');
|
|
856
|
+
const lifecycle = input.lifecycle;
|
|
857
|
+
const availability = input.availability
|
|
858
|
+
?? (lifecycle === 'retired' ? 'unavailable' : 'accessible');
|
|
859
|
+
const provenanceFields = input.dataUseNote
|
|
860
|
+
? [...input.provenanceFields, '/dataUse:unknown(config-scoped)']
|
|
861
|
+
: input.provenanceFields;
|
|
862
|
+
const observation = {
|
|
863
|
+
version: 1,
|
|
864
|
+
recordId: `${options.cliId}:${input.modelId}:${options.accountBinding ?? options.accountScope}`,
|
|
865
|
+
cliId: options.cliId,
|
|
866
|
+
family: input.family,
|
|
867
|
+
runtimeVersion: options.runtimeVersion,
|
|
868
|
+
accountScope: options.accountBinding ?? options.accountScope,
|
|
869
|
+
endpointFingerprint: `local-cli:${options.cliId}`,
|
|
870
|
+
observedAt,
|
|
871
|
+
verifiedAt: observedAt,
|
|
872
|
+
releaseDate,
|
|
873
|
+
releaseDateKind: releaseDate === null ? 'unknown' : (input.releaseDateKind ?? 'reported'),
|
|
874
|
+
visibility: input.visibility,
|
|
875
|
+
availability,
|
|
876
|
+
lifecycle,
|
|
877
|
+
sunsetAt: input.sunsetAt ?? null,
|
|
878
|
+
...(input.replacementModelId ? { replacementModelId: input.replacementModelId } : {}),
|
|
879
|
+
contextTokens: input.contextTokens ?? null,
|
|
880
|
+
maxOutputTokens: input.maxOutputTokens ?? null,
|
|
881
|
+
modalities: ['text'],
|
|
882
|
+
efforts: effortSupport === 'explicit' ? efforts : [],
|
|
883
|
+
effortSupport,
|
|
884
|
+
pricing: { costClass: 'unknown', billing: 'unknown', currency: 'USD', inputPerMillion: null, outputPerMillion: null },
|
|
885
|
+
dataUse: { processing: 'unknown', productImprovement: null, retentionDays: null },
|
|
886
|
+
qualification: { status: 'unqualified', roles: [] },
|
|
887
|
+
provenance: [],
|
|
888
|
+
firstObservedAt,
|
|
889
|
+
fetchedAt,
|
|
890
|
+
retiredAt: lifecycle === 'retired' ? observedAt : null,
|
|
891
|
+
};
|
|
892
|
+
const { provenance: _provenance, observedAt: _observedAt, verifiedAt: _verifiedAt, firstObservedAt: _firstObservedAt, fetchedAt: _fetchedAt, retiredAt: _retiredAt, ...facts } = observation;
|
|
893
|
+
observation.evidenceDigest = discoveryDigest(JSON.stringify({
|
|
894
|
+
facts, modelId: input.modelId, source: options.source,
|
|
895
|
+
sourceEvidence: options.contentDigest ?? observationTimestamps(options),
|
|
896
|
+
}));
|
|
897
|
+
observation.provenance = [{
|
|
898
|
+
fields: [...new Set([...observationPointers(observation).filter(field => field !== '/provenance'),
|
|
899
|
+
'/id', '/hidden', '/isDefault', '/reasoningEfforts', ...provenanceFields])],
|
|
900
|
+
source: `${options.source}${options.pointer ? `#${options.pointer}` : ''}`,
|
|
901
|
+
kind: 'native', observedAt,
|
|
902
|
+
digest: discoveryDigest(JSON.stringify(observation)), confidence: 'reported',
|
|
903
|
+
}];
|
|
904
|
+
return observation;
|
|
905
|
+
}
|
|
906
|
+
/** Include container and leaf JSON Pointers so explicit unknown/default leaves stay traceable. */
|
|
907
|
+
function observationPointers(value, prefix = '') {
|
|
908
|
+
if (!value || typeof value !== 'object')
|
|
909
|
+
return [];
|
|
910
|
+
return Object.entries(value).flatMap(([key, entry]) => {
|
|
911
|
+
const pointer = `${prefix}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
912
|
+
return [pointer, ...observationPointers(entry, pointer)];
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
function stringList(value) {
|
|
916
|
+
return Array.isArray(value) ? value.flatMap(entry => {
|
|
917
|
+
if (typeof entry === 'string')
|
|
918
|
+
return entry ? [entry] : [];
|
|
919
|
+
if (entry && typeof entry === 'object' && typeof entry.effort === 'string' && entry.effort)
|
|
920
|
+
return [entry.effort];
|
|
921
|
+
return [];
|
|
922
|
+
}) : [];
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Normalize `codex debug models` JSON (`.models[]` with slug, visibility,
|
|
926
|
+
* supported_reasoning_levels, default_reasoning_level, context_window).
|
|
927
|
+
* Observed on Codex 0.160.0; hidden CLI defaults are never treated as eligible.
|
|
928
|
+
*/
|
|
929
|
+
export function normalizeCodexDebugModels(payload, options) {
|
|
930
|
+
const raw = payload?.models;
|
|
931
|
+
if (!Array.isArray(raw))
|
|
932
|
+
return { models: [], diagnostics: [`${options.source}: expected .models[] in codex debug models JSON`] };
|
|
933
|
+
return normalizeObservationRows(raw, options, 'codex');
|
|
934
|
+
}
|
|
935
|
+
function museEfforts(row) {
|
|
936
|
+
const variants = row.reasoning_effort_variants;
|
|
937
|
+
if (!Array.isArray(variants))
|
|
938
|
+
return [];
|
|
939
|
+
return [...new Set(variants.flatMap(variant => {
|
|
940
|
+
if (typeof variant === 'string')
|
|
941
|
+
return variant ? [variant] : [];
|
|
942
|
+
if (variant && typeof variant === 'object') {
|
|
943
|
+
const tier = variant.tier;
|
|
944
|
+
return typeof tier === 'string' && tier ? [tier] : [];
|
|
945
|
+
}
|
|
946
|
+
return [];
|
|
947
|
+
}))];
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Normalize Muse model-catalog rows (`.rows[]` with model_id, visibility,
|
|
951
|
+
* is_default, is_current, reasoning_effort_variants[].tier, context_limit,
|
|
952
|
+
* output_limit, release_date). Contributor data-use facts stay config-scoped
|
|
953
|
+
* and are never derived from a row's name or default bit.
|
|
954
|
+
*/
|
|
955
|
+
export function normalizeMuseCatalogRows(payload, options) {
|
|
956
|
+
const rows = Array.isArray(payload) ? payload
|
|
957
|
+
: Array.isArray(payload?.rows) ? payload.rows : [];
|
|
958
|
+
if (!rows.length)
|
|
959
|
+
return { models: [], diagnostics: [`${options.source}: expected .rows[] in Muse model-catalog JSON`] };
|
|
960
|
+
return normalizeObservationRows(rows, options, 'muse');
|
|
961
|
+
}
|
|
962
|
+
function validDate(value) {
|
|
963
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
964
|
+
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
965
|
+
}
|
|
966
|
+
function validTimestamp(value) {
|
|
967
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value)
|
|
968
|
+
&& validDate(value.slice(0, 10)) && Number.isFinite(Date.parse(value));
|
|
969
|
+
}
|
|
970
|
+
function normalizeObservationRows(rows, options, cli) {
|
|
971
|
+
const diagnostics = [];
|
|
972
|
+
const models = [];
|
|
973
|
+
const sourcePointers = new Map();
|
|
974
|
+
for (const [index, entry] of rows.entries()) {
|
|
975
|
+
const rowOptions = options.rowOptions?.[index] ?? options;
|
|
976
|
+
const now = rowOptions.now ?? new Date();
|
|
977
|
+
const sourceOptions = { ...rowOptions, pointer: rowOptions.pointer ?? `/${cli === 'muse' ? 'rows' : 'models'}/${index}` };
|
|
978
|
+
const source = `${sourceOptions.source}#${sourceOptions.pointer}`;
|
|
979
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
980
|
+
diagnostics.push(`${source}: skipped non-object model entry`);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const row = entry;
|
|
984
|
+
const id = cli === 'muse' ? row.model_id : row.slug;
|
|
985
|
+
if (typeof id !== 'string' || !id.trim()) {
|
|
986
|
+
diagnostics.push(`${source}: skipped entry without a ${cli === 'muse' ? 'model_id' : 'slug'}`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const times = observationTimestamps(sourceOptions);
|
|
990
|
+
for (const field of [cli === 'muse' ? 'context_limit' : 'context_window', 'output_limit']) {
|
|
991
|
+
if (row[field] != null && (typeof row[field] !== 'number' || !Number.isSafeInteger(row[field]) || row[field] < 1)) {
|
|
992
|
+
diagnostics.push(`${source}: malformed ${field} for ${id}; retained as unknown`);
|
|
993
|
+
// Do not round or coerce malformed evidence into a valid observation.
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
for (const field of ['is_default', 'is_current', 'retired', 'deprecated']) {
|
|
997
|
+
if (row[field] != null && typeof row[field] !== 'boolean')
|
|
998
|
+
diagnostics.push(`${source}: malformed ${field} for ${id}`);
|
|
999
|
+
}
|
|
1000
|
+
const effortField = cli === 'muse' ? 'reasoning_effort_variants' : 'supported_reasoning_levels';
|
|
1001
|
+
const rawEfforts = row[effortField];
|
|
1002
|
+
if (rawEfforts != null) {
|
|
1003
|
+
if (!Array.isArray(rawEfforts))
|
|
1004
|
+
diagnostics.push(`${source}/${effortField}: expected effort array`);
|
|
1005
|
+
else
|
|
1006
|
+
for (const [effortIndex, effort] of rawEfforts.entries()) {
|
|
1007
|
+
const value = typeof effort === 'string' ? effort : effort?.[cli === 'muse' ? 'tier' : 'effort'];
|
|
1008
|
+
if (typeof value !== 'string' || !value.trim())
|
|
1009
|
+
diagnostics.push(`${source}/${effortField}/${effortIndex}: malformed effort`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (row.default_reasoning_level != null && (typeof row.default_reasoning_level !== 'string' || !row.default_reasoning_level.trim())) {
|
|
1013
|
+
diagnostics.push(`${source}: malformed default_reasoning_level for ${id}`);
|
|
1014
|
+
}
|
|
1015
|
+
let invalidTime = false;
|
|
1016
|
+
for (const [field, value] of Object.entries(times)) {
|
|
1017
|
+
if (!validTimestamp(value) || isFutureDated(value, now)) {
|
|
1018
|
+
diagnostics.push(`${source}: ${field} is unknown (invalid or future-dated evidence); rejected ${id}`);
|
|
1019
|
+
invalidTime = true;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (invalidTime)
|
|
1023
|
+
continue;
|
|
1024
|
+
let releaseDate = null;
|
|
1025
|
+
if (row.release_date !== null && row.release_date !== undefined) {
|
|
1026
|
+
if (!validDate(row.release_date))
|
|
1027
|
+
diagnostics.push(`${source}: malformed release_date for ${id}; retained as unknown`);
|
|
1028
|
+
else if (isFutureDated(row.release_date, now)) {
|
|
1029
|
+
diagnostics.push(`${source}: rejected future-dated release_date for ${id}; date is unknown`);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
else
|
|
1033
|
+
releaseDate = row.release_date;
|
|
1034
|
+
}
|
|
1035
|
+
const visibility = normalizeVisibility(row.visibility);
|
|
1036
|
+
const lifecycle = normalizeLifecycle(row);
|
|
1037
|
+
if (row.sunset_at != null && !validTimestamp(row.sunset_at)) {
|
|
1038
|
+
diagnostics.push(`${source}: malformed sunset_at for ${id}; retained as unknown`);
|
|
1039
|
+
lifecycle.sunsetAt = null;
|
|
1040
|
+
}
|
|
1041
|
+
const efforts = cli === 'muse' ? museEfforts(row) : [...new Set([...stringList(row.supported_reasoning_levels),
|
|
1042
|
+
...(typeof row.default_reasoning_level === 'string' && row.default_reasoning_level ? [row.default_reasoning_level] : [])])];
|
|
1043
|
+
const model = {
|
|
1044
|
+
id, ...(visibility === 'unknown' ? {} : { hidden: visibility === 'hidden' }),
|
|
1045
|
+
...(cli === 'muse' ? { isDefault: row.is_default === true } : {}),
|
|
1046
|
+
...(efforts.length ? { reasoningEfforts: efforts } : {}),
|
|
1047
|
+
worker: buildWorkerObservation(sourceOptions, {
|
|
1048
|
+
modelId: id, family: typeof row.family === 'string' && row.family ? row.family : cli,
|
|
1049
|
+
visibility, ...lifecycle, releaseDate,
|
|
1050
|
+
availability: lifecycle.lifecycle === 'retired' ? 'unavailable'
|
|
1051
|
+
: cli === 'muse' ? (row.is_current === true ? 'accessible' : 'unknown') : 'accessible',
|
|
1052
|
+
contextTokens: normalizePositiveInt(cli === 'muse' ? row.context_limit : row.context_window),
|
|
1053
|
+
maxOutputTokens: normalizePositiveInt(row.output_limit),
|
|
1054
|
+
efforts: efforts.map(id => ({ id, selfDelegates: null })),
|
|
1055
|
+
effortSupport: efforts.length ? 'explicit' : 'unknown',
|
|
1056
|
+
...(cli === 'muse' ? { dataUseNote: 'contributor policy is config-scoped' } : {}),
|
|
1057
|
+
provenanceFields: cli === 'muse'
|
|
1058
|
+
? ['/id:model_id', '/isDefault:is_default', '/availability:is_current', '/efforts:reasoning_effort_variants[].tier', '/contextTokens:context_limit', '/maxOutputTokens:output_limit', '/releaseDate:release_date']
|
|
1059
|
+
: ['/id:slug', '/efforts:supported_reasoning_levels+default_reasoning_level', '/contextTokens:context_window'],
|
|
1060
|
+
}),
|
|
1061
|
+
};
|
|
1062
|
+
// Bind compatibility-row values without including unrelated credential-bearing row keys.
|
|
1063
|
+
const compatibility = { id: model.id, hidden: model.hidden, isDefault: model.isDefault, reasoningEfforts: model.reasoningEfforts };
|
|
1064
|
+
model.worker.evidenceDigest = discoveryDigest(JSON.stringify({
|
|
1065
|
+
workerDigest: model.worker.evidenceDigest, ...compatibility,
|
|
1066
|
+
}));
|
|
1067
|
+
const { provenance: _provenance, ...values } = model.worker;
|
|
1068
|
+
model.worker.provenance[0].digest = discoveryDigest(JSON.stringify({ worker: values, ...compatibility }));
|
|
1069
|
+
sourcePointers.set(model, {
|
|
1070
|
+
visibility: '/visibility', releaseDate: '/release_date', sunsetAt: '/sunset_at', replacementModelId: '/replacement',
|
|
1071
|
+
lifecycle: row.retired === true ? '/retired' : row.deprecated === true ? '/deprecated'
|
|
1072
|
+
: row.lifecycle !== undefined ? '/lifecycle' : row.status !== undefined ? '/status' : '/state',
|
|
1073
|
+
availability: cli === 'muse' ? '/is_current' : '/visibility',
|
|
1074
|
+
efforts: cli === 'muse' ? '/reasoning_effort_variants' : '/supported_reasoning_levels',
|
|
1075
|
+
effortSupport: cli === 'muse' ? '/reasoning_effort_variants' : '/supported_reasoning_levels',
|
|
1076
|
+
contextTokens: cli === 'muse' ? '/context_limit' : '/context_window', maxOutputTokens: '/output_limit', family: '/family',
|
|
1077
|
+
});
|
|
1078
|
+
models.push(model);
|
|
1079
|
+
}
|
|
1080
|
+
return { models: reconcileDuplicateObservations(models, diagnostics, sourcePointers), diagnostics };
|
|
1081
|
+
}
|
|
1082
|
+
/** Retain every conflicting value and source; never let the first row erase retirement. */
|
|
1083
|
+
function reconcileDuplicateObservations(models, diagnostics, sourcePointers) {
|
|
1084
|
+
const groups = new Map();
|
|
1085
|
+
for (const model of models)
|
|
1086
|
+
groups.set(model.id, [...(groups.get(model.id) ?? []), model]);
|
|
1087
|
+
return [...groups.values()].map(group => {
|
|
1088
|
+
if (group.length === 1)
|
|
1089
|
+
return group[0];
|
|
1090
|
+
const model = { ...group[0], worker: { ...group[0].worker } };
|
|
1091
|
+
const worker = model.worker;
|
|
1092
|
+
const fields = ['family', 'visibility', 'lifecycle', 'availability', 'sunsetAt', 'replacementModelId',
|
|
1093
|
+
'efforts', 'effortSupport', 'releaseDate', 'contextTokens', 'maxOutputTokens'];
|
|
1094
|
+
const conflicts = [];
|
|
1095
|
+
for (const field of fields) {
|
|
1096
|
+
const evidence = group.map(m => ({ value: m.worker[field] ?? null,
|
|
1097
|
+
source: m.worker.provenance[0].source, pointer: sourcePointers.get(m)?.[field] ?? `/${field}`,
|
|
1098
|
+
observedAt: m.worker.observedAt, digest: m.worker.provenance[0].digest }));
|
|
1099
|
+
if (new Set(evidence.map(e => JSON.stringify(e.value))).size < 2)
|
|
1100
|
+
continue;
|
|
1101
|
+
conflicts.push({ field: `/${field}`, evidence });
|
|
1102
|
+
diagnostics.push(`${model.id}: conflicting ${field === 'releaseDate' ? 'release_date' : field} values; retained values with individual sources: ${evidence.map(item => item.source).join(', ')}`);
|
|
1103
|
+
switch (field) {
|
|
1104
|
+
case 'lifecycle':
|
|
1105
|
+
worker.lifecycle = group.some(m => m.worker.lifecycle === 'retired') ? 'retired' : 'unverified';
|
|
1106
|
+
break;
|
|
1107
|
+
case 'visibility':
|
|
1108
|
+
worker.visibility = group.some(m => m.worker.visibility === 'hidden') ? 'hidden' : 'unknown';
|
|
1109
|
+
break;
|
|
1110
|
+
case 'availability':
|
|
1111
|
+
worker.availability = 'unknown';
|
|
1112
|
+
break;
|
|
1113
|
+
case 'efforts':
|
|
1114
|
+
case 'effortSupport':
|
|
1115
|
+
worker.efforts = [];
|
|
1116
|
+
worker.effortSupport = 'unknown';
|
|
1117
|
+
break;
|
|
1118
|
+
case 'releaseDate':
|
|
1119
|
+
worker.releaseDate = null;
|
|
1120
|
+
worker.releaseDateKind = 'unknown';
|
|
1121
|
+
break;
|
|
1122
|
+
case 'sunsetAt':
|
|
1123
|
+
case 'contextTokens':
|
|
1124
|
+
case 'maxOutputTokens':
|
|
1125
|
+
worker[field] = null;
|
|
1126
|
+
break;
|
|
1127
|
+
case 'replacementModelId':
|
|
1128
|
+
delete worker.replacementModelId;
|
|
1129
|
+
break;
|
|
1130
|
+
case 'family':
|
|
1131
|
+
worker.family = 'unknown';
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Compatibility defaults also remain evidence rather than silently keeping the first.
|
|
1136
|
+
if (new Set(group.map(m => m.isDefault)).size > 1) {
|
|
1137
|
+
diagnostics.push(`${model.id}: conflicting isDefault values from ${group.map(m => m.worker.provenance[0].source).join(', ')}`);
|
|
1138
|
+
conflicts.push({ field: '/isDefault', evidence: group.map(m => ({ value: m.isDefault ?? null,
|
|
1139
|
+
source: m.worker.provenance[0].source, pointer: '/is_default',
|
|
1140
|
+
observedAt: m.worker.observedAt, digest: m.worker.provenance[0].digest })) });
|
|
1141
|
+
model.isDefault = false;
|
|
1142
|
+
}
|
|
1143
|
+
if (worker.lifecycle === 'retired') {
|
|
1144
|
+
worker.availability = 'unavailable';
|
|
1145
|
+
worker.retiredAt = group.find(m => m.worker.lifecycle === 'retired').worker.retiredAt;
|
|
1146
|
+
}
|
|
1147
|
+
model.hidden = worker.visibility === 'hidden';
|
|
1148
|
+
if (worker.visibility === 'unknown')
|
|
1149
|
+
delete model.hidden;
|
|
1150
|
+
model.reasoningEfforts = worker.efforts.map(e => e.id);
|
|
1151
|
+
worker.provenance = group.flatMap(m => m.worker.provenance);
|
|
1152
|
+
worker.evidenceDigest = discoveryDigest(JSON.stringify(group.map(m => m.worker.evidenceDigest)));
|
|
1153
|
+
if (conflicts.length)
|
|
1154
|
+
worker.conflicts = conflicts;
|
|
1155
|
+
const { provenance: _provenance, ...resolved } = worker;
|
|
1156
|
+
worker.provenance.push({ fields: observationPointers(resolved), source: 'src/models/model-discovery.ts#reconcileDuplicateObservations',
|
|
1157
|
+
kind: 'derived', observedAt: worker.observedAt,
|
|
1158
|
+
digest: discoveryDigest(JSON.stringify(resolved)), confidence: 'derived' });
|
|
1159
|
+
return model;
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Merge a refresh into its prior snapshot. A failed refresh never renews age:
|
|
1164
|
+
* prior models and prior observation time are kept while the new error and
|
|
1165
|
+
* fetch time stay visible. A partial success retains prior-only rows with
|
|
1166
|
+
* their original age; per-model firstObservedAt is the earliest evidence.
|
|
1167
|
+
* A complete success contains exactly the returned identities.
|
|
1168
|
+
*/
|
|
1169
|
+
export function mergeProviderDiscovery(prior, next) {
|
|
1170
|
+
if (prior.provider !== next.provider || prior.accountBinding !== next.accountBinding
|
|
1171
|
+
|| prior.accountScope !== next.accountScope)
|
|
1172
|
+
return next;
|
|
1173
|
+
if (next.errorKind && next.models.length === 0) {
|
|
1174
|
+
return {
|
|
1175
|
+
...next,
|
|
1176
|
+
models: prior.models,
|
|
1177
|
+
observedAt: prior.observedAt,
|
|
1178
|
+
...(prior.accountBinding !== undefined ? { accountBinding: prior.accountBinding } : {}),
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const priorById = new Map(prior.models.map(model => [model.id, model]));
|
|
1182
|
+
const seen = new Set();
|
|
1183
|
+
const merged = next.models.map((model) => {
|
|
1184
|
+
seen.add(model.id);
|
|
1185
|
+
const previous = priorById.get(model.id);
|
|
1186
|
+
if (!previous?.worker || !model.worker)
|
|
1187
|
+
return model;
|
|
1188
|
+
if (next.provider === 'muse' && previous.worker.evidenceDigest
|
|
1189
|
+
&& previous.worker.evidenceDigest === model.worker.evidenceDigest)
|
|
1190
|
+
return previous;
|
|
1191
|
+
const firstObservedAt = Date.parse(previous.worker.firstObservedAt) < Date.parse(model.worker.firstObservedAt)
|
|
1192
|
+
? previous.worker.firstObservedAt
|
|
1193
|
+
: model.worker.firstObservedAt;
|
|
1194
|
+
if (firstObservedAt === model.worker.firstObservedAt)
|
|
1195
|
+
return model;
|
|
1196
|
+
return { ...model, worker: {
|
|
1197
|
+
...model.worker, firstObservedAt,
|
|
1198
|
+
provenance: [
|
|
1199
|
+
...model.worker.provenance.map(p => ({ ...p, fields: p.fields.filter(field => field !== '/firstObservedAt') })),
|
|
1200
|
+
{ fields: ['/firstObservedAt'], source: previous.worker.provenance[0].source,
|
|
1201
|
+
kind: 'derived', observedAt: previous.worker.observedAt,
|
|
1202
|
+
digest: discoveryDigest(JSON.stringify({ firstObservedAt, evidence: previous.worker.evidenceDigest })), confidence: 'derived' },
|
|
1203
|
+
],
|
|
1204
|
+
} };
|
|
1205
|
+
});
|
|
1206
|
+
if (next.errorKind) {
|
|
1207
|
+
for (const model of prior.models) {
|
|
1208
|
+
if (!seen.has(model.id))
|
|
1209
|
+
merged.push(model);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const observedAt = next.errorKind ? prior.observedAt
|
|
1213
|
+
: next.provider === 'muse' && merged.some(model => model.worker)
|
|
1214
|
+
? merged.flatMap(model => model.worker ? [model.worker.observedAt] : []).sort((a, b) => Date.parse(a) - Date.parse(b))[0]
|
|
1215
|
+
: next.observedAt;
|
|
1216
|
+
return { ...next, observedAt, models: merged };
|
|
1217
|
+
}
|
|
1218
|
+
/** Retirement blocks dispatch without rewriting pins; replacement is a proposal, never substitution. */
|
|
1219
|
+
export function retirementGuard(pinnedId, model) {
|
|
1220
|
+
if (!model && pinnedId)
|
|
1221
|
+
return {
|
|
1222
|
+
blocked: true, pin: pinnedId,
|
|
1223
|
+
reason: `Unavailable model ${pinnedId} is absent from the complete catalog and blocks dispatch; pin is preserved, never rewritten.`,
|
|
1224
|
+
};
|
|
1225
|
+
if (model?.worker?.lifecycle === 'retired') {
|
|
1226
|
+
const replacement = model.worker.supersededBy ?? model.worker.replacementModelId;
|
|
1227
|
+
return {
|
|
1228
|
+
blocked: true,
|
|
1229
|
+
pin: pinnedId,
|
|
1230
|
+
reason: `Retired model ${model.id} blocks dispatch; pin ${pinnedId ?? '(none)'} is preserved, never rewritten`
|
|
1231
|
+
+ `${replacement ? ` (suggested replacement ${replacement} requires operator approval)` : ''}.`,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
return { blocked: false, pin: pinnedId };
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Record an account-scoped discovery under `discovery.instances` without
|
|
1238
|
+
* merging account entitlements into the provider projection.
|
|
1239
|
+
*/
|
|
1240
|
+
function discoveryInstanceId(entry) {
|
|
1241
|
+
return `${entry.provider}:${entry.accountBinding ?? entry.accountScope}`;
|
|
1242
|
+
}
|
|
1243
|
+
export function recordAccountInstance(catalog, instanceId, entry) {
|
|
1244
|
+
const discovery = catalog.discovery ?? {
|
|
1245
|
+
source: 'static',
|
|
1246
|
+
fetchedAt: entry.observedAt,
|
|
1247
|
+
providers: {},
|
|
1248
|
+
};
|
|
1249
|
+
return {
|
|
1250
|
+
...catalog,
|
|
1251
|
+
discovery: {
|
|
1252
|
+
...discovery,
|
|
1253
|
+
instances: { ...(discovery.instances ?? {}), [instanceId]: entry },
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
/** Run the version-scoped `codex debug models` strategy and normalize its JSON. */
|
|
1258
|
+
export async function discoverCodexDebugModels(command = 'codex', runner = runModelDiscoveryCommand, options = {}) {
|
|
1259
|
+
const observedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
1260
|
+
const version = options.runtimeVersion ?? await runtimeVersion(command, runner);
|
|
1261
|
+
const base = {
|
|
1262
|
+
provider: 'codex',
|
|
1263
|
+
source: 'native',
|
|
1264
|
+
observedAt,
|
|
1265
|
+
...(version ? { runtimeVersion: version } : {}),
|
|
1266
|
+
accountScope: 'local-account',
|
|
1267
|
+
...(options.accountBinding ? { accountBinding: options.accountBinding } : {}),
|
|
1268
|
+
models: [],
|
|
1269
|
+
};
|
|
1270
|
+
const result = await runner(command, ['debug', 'models'], { cwd: tmpdir(), timeoutMs: 10_000 });
|
|
1271
|
+
if (result.exitCode !== 0) {
|
|
1272
|
+
const error = redactModelDiscoveryText(result.stderr.trim()) || `codex debug models exited ${result.exitCode}`;
|
|
1273
|
+
return { ...base, errorKind: classifyDiscoveryError(error), error };
|
|
1274
|
+
}
|
|
1275
|
+
let payload;
|
|
1276
|
+
try {
|
|
1277
|
+
payload = JSON.parse(result.stdout);
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
return { ...base, errorKind: 'invalid-output', error: `Invalid codex debug models JSON: ${error.message}` };
|
|
1281
|
+
}
|
|
1282
|
+
const normalized = normalizeCodexDebugModels(payload, {
|
|
1283
|
+
cliId: 'codex',
|
|
1284
|
+
source: `${command} debug models`,
|
|
1285
|
+
runtimeVersion: version ?? 'unknown',
|
|
1286
|
+
accountScope: 'local-account',
|
|
1287
|
+
...(options.accountBinding ? { accountBinding: options.accountBinding } : {}),
|
|
1288
|
+
observedAt,
|
|
1289
|
+
now: (options.now ?? (() => new Date()))(),
|
|
1290
|
+
});
|
|
1291
|
+
return {
|
|
1292
|
+
...base,
|
|
1293
|
+
models: normalized.models,
|
|
1294
|
+
...(normalized.diagnostics.length > 0 || normalized.models.length === 0
|
|
1295
|
+
? {
|
|
1296
|
+
errorKind: 'invalid-output',
|
|
1297
|
+
error: normalized.diagnostics.join('; ')
|
|
1298
|
+
|| 'codex debug models returned no normalized model rows.',
|
|
1299
|
+
}
|
|
1300
|
+
: {}),
|
|
1301
|
+
policy: { layers: [], selected: {}, constraints: [], diagnostics: normalized.diagnostics },
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
/** Read bounded Muse model-catalog rows. Reads only declared catalog keys, never credential files. */
|
|
1305
|
+
export async function discoverMuseCatalogModels(options = {}) {
|
|
1306
|
+
const now = (options.now ?? (() => new Date()))();
|
|
1307
|
+
const catalogDir = options.catalogDir ?? join(homedir(), MUSE_CATALOG_DIR);
|
|
1308
|
+
const base = {
|
|
1309
|
+
provider: 'muse', source: 'native', observedAt: now.toISOString(),
|
|
1310
|
+
...(options.runtimeVersion ? { runtimeVersion: options.runtimeVersion } : {}),
|
|
1311
|
+
accountScope: 'local-account', ...(options.accountBinding ? { accountBinding: options.accountBinding } : {}), models: [],
|
|
1312
|
+
};
|
|
1313
|
+
const diagnostics = [];
|
|
1314
|
+
const rows = [];
|
|
1315
|
+
const rowOptions = [];
|
|
1316
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
1317
|
+
const bounded = async (operation) => {
|
|
1318
|
+
let timer;
|
|
1319
|
+
try {
|
|
1320
|
+
return await Promise.race([operation, new Promise((_resolve, reject) => {
|
|
1321
|
+
timer = setTimeout(() => reject(new Error('Muse catalog discovery deadline exceeded')), Math.max(0, deadline - Date.now()));
|
|
1322
|
+
})]);
|
|
1323
|
+
}
|
|
1324
|
+
finally {
|
|
1325
|
+
clearTimeout(timer);
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
try {
|
|
1329
|
+
const root = await bounded(realpath(catalogDir));
|
|
1330
|
+
const files = (await bounded(readdir(root))).filter(file => file.endsWith('.json')).sort();
|
|
1331
|
+
if (!files.length)
|
|
1332
|
+
diagnostics.push(`${root}: holds no JSON rows`);
|
|
1333
|
+
if (files.length > MAX_MUSE_CATALOG_FILES)
|
|
1334
|
+
diagnostics.push(`${join(root, files[MAX_MUSE_CATALOG_FILES])}: exceeds bounded file count; this and remaining files skipped`);
|
|
1335
|
+
for (const file of files.slice(0, MAX_MUSE_CATALOG_FILES)) {
|
|
1336
|
+
if (Date.now() >= deadline) {
|
|
1337
|
+
diagnostics.push(`${join(root, file)}: discovery deadline exceeded; this and remaining files skipped`);
|
|
1338
|
+
break;
|
|
1339
|
+
}
|
|
1340
|
+
const path = join(root, file);
|
|
1341
|
+
try {
|
|
1342
|
+
const link = await bounded(lstat(path));
|
|
1343
|
+
let target = path;
|
|
1344
|
+
if (link.isSymbolicLink()) {
|
|
1345
|
+
target = await bounded(realpath(path));
|
|
1346
|
+
const subpath = relative(root, target);
|
|
1347
|
+
if (subpath === '..' || subpath.startsWith('../'))
|
|
1348
|
+
throw new Error('symlink escapes catalog directory; regular file required');
|
|
1349
|
+
}
|
|
1350
|
+
else if (!link.isFile())
|
|
1351
|
+
throw new Error('not a regular file; skipped');
|
|
1352
|
+
const stat = link.isSymbolicLink() ? await bounded(lstat(target)) : link;
|
|
1353
|
+
if (!stat.isFile())
|
|
1354
|
+
throw new Error('not a regular file; skipped');
|
|
1355
|
+
if (stat.size > MAX_MUSE_CATALOG_BYTES)
|
|
1356
|
+
throw new Error('exceeds bounded catalog size; skipped before reading');
|
|
1357
|
+
// O_NONBLOCK prevents a stat/open race to a FIFO from blocking. O_NOFOLLOW
|
|
1358
|
+
// rejects a replacement symlink; fstat rechecks the opened descriptor.
|
|
1359
|
+
const pending = open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1360
|
+
// If open itself outlives the deadline, close any late descriptor.
|
|
1361
|
+
let expired = false;
|
|
1362
|
+
let lateHandle;
|
|
1363
|
+
void pending.then(handle => {
|
|
1364
|
+
lateHandle = handle;
|
|
1365
|
+
if (expired)
|
|
1366
|
+
void handle.close().catch(() => { });
|
|
1367
|
+
}, () => { });
|
|
1368
|
+
const handle = await bounded(pending).catch(error => {
|
|
1369
|
+
expired = true;
|
|
1370
|
+
if (lateHandle)
|
|
1371
|
+
void lateHandle.close().catch(() => { });
|
|
1372
|
+
throw error;
|
|
1373
|
+
});
|
|
1374
|
+
try {
|
|
1375
|
+
const opened = await bounded(handle.stat());
|
|
1376
|
+
if (!opened.isFile())
|
|
1377
|
+
throw new Error('not a regular file; skipped');
|
|
1378
|
+
if (opened.size > MAX_MUSE_CATALOG_BYTES)
|
|
1379
|
+
throw new Error('exceeds bounded catalog size; skipped before reading');
|
|
1380
|
+
const buffer = Buffer.alloc(MAX_MUSE_CATALOG_BYTES + 1);
|
|
1381
|
+
let bytes = 0;
|
|
1382
|
+
while (bytes < buffer.length) {
|
|
1383
|
+
const result = await bounded(handle.read(buffer, bytes, buffer.length - bytes, bytes));
|
|
1384
|
+
if (!result.bytesRead)
|
|
1385
|
+
break;
|
|
1386
|
+
bytes += result.bytesRead;
|
|
1387
|
+
}
|
|
1388
|
+
if (bytes > MAX_MUSE_CATALOG_BYTES)
|
|
1389
|
+
throw new Error('exceeds bounded catalog size; skipped');
|
|
1390
|
+
const payload = JSON.parse(buffer.subarray(0, bytes).toString('utf8'));
|
|
1391
|
+
const fileRows = Array.isArray(payload) ? payload : payload?.rows;
|
|
1392
|
+
if (!Array.isArray(fileRows))
|
|
1393
|
+
throw new Error('expected .rows[]; rejected');
|
|
1394
|
+
const metadata = Array.isArray(payload) ? {} : payload;
|
|
1395
|
+
// Re-reading a cache does not create new evidence. Prefer its provider
|
|
1396
|
+
// timestamp, falling back to the file's own modification time.
|
|
1397
|
+
const evidenceTime = metadata.fetchedAt ?? metadata.fetched_at ?? metadata.refreshedAt ?? metadata.refreshed_at ?? opened.mtime.toISOString();
|
|
1398
|
+
const normalizedOptions = {
|
|
1399
|
+
cliId: 'muse', source: path, contentDigest: discoveryDigest(buffer.subarray(0, bytes).toString('utf8')), runtimeVersion: options.runtimeVersion ?? 'unknown',
|
|
1400
|
+
accountScope: 'local-account', accountBinding: options.accountBinding, now,
|
|
1401
|
+
observedAt: metadata.observedAt ?? evidenceTime,
|
|
1402
|
+
fetchedAt: evidenceTime,
|
|
1403
|
+
firstObservedAt: metadata.firstObservedAt ?? evidenceTime,
|
|
1404
|
+
};
|
|
1405
|
+
for (const [index, row] of fileRows.entries()) {
|
|
1406
|
+
rows.push(row);
|
|
1407
|
+
rowOptions.push({ ...normalizedOptions, pointer: `/${Array.isArray(payload) ? '' : 'rows/'}${index}` });
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
finally {
|
|
1411
|
+
await bounded(handle.close());
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
diagnostics.push(`${path}: ${error.message}; rejected`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
catch (error) {
|
|
1420
|
+
diagnostics.push(`Muse model catalog is unavailable at ${catalogDir}: ${error.message}`);
|
|
1421
|
+
}
|
|
1422
|
+
const normalized = normalizeMuseCatalogRows(rows, {
|
|
1423
|
+
cliId: 'muse', source: catalogDir, runtimeVersion: options.runtimeVersion ?? 'unknown',
|
|
1424
|
+
accountScope: 'local-account', now, rowOptions,
|
|
1425
|
+
});
|
|
1426
|
+
diagnostics.push(...normalized.diagnostics);
|
|
1427
|
+
const observedAt = normalized.models.length
|
|
1428
|
+
? normalized.models.map(model => model.worker.observedAt).sort((a, b) => Date.parse(a) - Date.parse(b))[0] : base.observedAt;
|
|
1429
|
+
return {
|
|
1430
|
+
...base, observedAt, models: normalized.models,
|
|
1431
|
+
...(diagnostics.length || normalized.models.length === 0
|
|
1432
|
+
? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'Muse model catalog returned no usable rows.' } : {}),
|
|
1433
|
+
policy: { layers: [], selected: {}, constraints: [], diagnostics },
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
637
1436
|
//# sourceMappingURL=model-discovery.js.map
|