@ludi-uni/ludi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Phase 2: maintenance execution tiers + cheapest-sufficient selection.
|
|
2
|
+
// Cases A-G from the Phase 2 spec. Selection is a dry-run decision layer: no model
|
|
3
|
+
// is invoked and no config is written.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
selectTierModel, buildMonitorOutput, escalationDecision, runMaintenancePlan,
|
|
10
|
+
localElectricityCost, estimatedCostPerRun, validateExecPolicy, loadExecPolicy, DEFAULT_POLICY,
|
|
11
|
+
} from '../lib/maintenance-exec.mjs';
|
|
12
|
+
import { effectiveCatalog, evaluateMaintenance, loadCatalog, validateCatalog } from '../lib/maintenance.mjs';
|
|
13
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
14
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
15
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
16
|
+
|
|
17
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
19
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
20
|
+
|
|
21
|
+
// --- fixture helpers -------------------------------------------------------
|
|
22
|
+
const m = (provider, model, over = {}) => ({
|
|
23
|
+
provider, model, status: 'active', cost: { usdPerMInput: 0.5, usdPerMOutput: 2 },
|
|
24
|
+
contextK: 256, vision: false, toolUse: 'good', location: 'cloud',
|
|
25
|
+
scores: { coding: 60, reasoning: 55, speed: 70 }, ...over,
|
|
26
|
+
});
|
|
27
|
+
const cat = models => ({ version: 1, updatedAt: '2026-03-01', models });
|
|
28
|
+
const registryWith = backends => mergeRegistries(null, { version: 1, backends });
|
|
29
|
+
const sel = (models, tier, opts) => selectTierModel(models, tier, DEFAULT_POLICY, opts);
|
|
30
|
+
|
|
31
|
+
test('SWE-2 campaign cutoff changes free-capacity advice without live rebinding', () => {
|
|
32
|
+
const catalog = loadCatalog(join(kit, 'adapters/pi/model-catalog.json'));
|
|
33
|
+
const policy = loadExecPolicy(join(kit, 'adapters/pi/maintenance-policy.json'));
|
|
34
|
+
const reg = registryWith({ devin: { provider: 'devin', model: 'swe-2-high' } });
|
|
35
|
+
const before = runMaintenancePlan({ routing, registry: reg, agents, catalog, policy, asOf: '2026-10-10T14:59:59Z' });
|
|
36
|
+
const beforeCode = before.freeCapacityPlan.find(p => p.capability === 'strong-code');
|
|
37
|
+
assert.equal(beforeCode.recommendedFree, 'devin/swe-2-high');
|
|
38
|
+
assert.equal(before.freeCapacityPlan.find(p => p.capability === 'deep-review').recommendedFree, 'devin/swe-2-high');
|
|
39
|
+
assert.equal(before.freeCapacityPlan.find(p => p.capability === 'cheap-code').recommendedFree, 'qoder/Qwen3.8-Flash');
|
|
40
|
+
assert.equal(before.freeCapacityPlan.find(p => p.capability === 'vision-reasoning').recommendedFree, null);
|
|
41
|
+
assert.equal(beforeCode.freeCandidates[0].freeUntil, '2026-10-10T15:00:00Z');
|
|
42
|
+
assert.equal(before.outcome, 'no-change');
|
|
43
|
+
|
|
44
|
+
const after = runMaintenancePlan({ routing, registry: reg, agents, catalog, policy, asOf: '2026-10-10T15:00:00Z' });
|
|
45
|
+
assert.equal(after.freeCapacityPlan.find(p => p.capability === 'strong-code').recommendedFree, null);
|
|
46
|
+
assert.equal(after.monitor.changed, true);
|
|
47
|
+
assert.match(after.monitor.reasons.join(' '), /free campaign cutoff reached/);
|
|
48
|
+
assert.equal(after.proposal.decisions.find(d => d.backend === 'devin').decision === 'keep' || after.proposal.decisions.find(d => d.backend === 'devin').decision === 'propose', true);
|
|
49
|
+
assert.equal(effectiveCatalog(catalog, [], '2026-10-10T15:00:00Z').find(m => m.provider === 'devin').cost.free, false);
|
|
50
|
+
assert.deepEqual(validateCatalog({ version: 1, models: [{ ...catalog.models.find(m => m.provider === 'devin'), freeUntil: 'tomorrow' }] }).some(e => e.includes('freeUntil')), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const FREE_STRONG = m('freep', 'free-strong', { cost: { free: true }, scores: { coding: 78, reasoning: 70, speed: 80 } });
|
|
54
|
+
const FREE_WEAK = m('freep', 'free-weak', { cost: { free: true }, scores: { coding: 30, reasoning: 25, speed: 60 } });
|
|
55
|
+
const CHEAP_OK = m('cloudp', 'cheap-ok', { cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, scores: { coding: 68, reasoning: 62, speed: 75 } });
|
|
56
|
+
const EXPENSIVE_STRONG = m('cloudp', 'pro-strong', { cost: { usdPerMInput: 5, usdPerMOutput: 20 }, scores: { coding: 90, reasoning: 88, speed: 60 } });
|
|
57
|
+
const LOCAL = m('localp', 'local-mid', { location: 'local', cost: { free: true }, local: { powerWatts: 400, taskMinutes: 10 }, scores: { coding: 45, reasoning: 40, speed: 50 }, toolUse: 'basic' });
|
|
58
|
+
|
|
59
|
+
// --- A: strong free model -> monitor and evaluate both pick it --------------
|
|
60
|
+
test('Case A: a strong free model is selected for monitor and evaluate', () => {
|
|
61
|
+
const models = [FREE_STRONG, CHEAP_OK, EXPENSIVE_STRONG];
|
|
62
|
+
for (const tier of ['monitor', 'evaluate']) {
|
|
63
|
+
const r = sel(models, tier);
|
|
64
|
+
assert.equal(r.selected.model, 'freep/free-strong', `${tier}: ${r.selectionReason}`);
|
|
65
|
+
assert.equal(r.selectionPath, 'free-cloud');
|
|
66
|
+
assert.equal(r.selected.effectiveCostUsd < 0.02, true); // only the speed penalty
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- B: free ends, cheap sufficient cloud -> cheapest-sufficient, no premium
|
|
71
|
+
test('Case B: free campaign end -> cheapest-sufficient cloud, no reconfigure escalation', () => {
|
|
72
|
+
const catalog = cat([
|
|
73
|
+
m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 62, reasoning: 55, speed: 85 } }),
|
|
74
|
+
CHEAP_OK, EXPENSIVE_STRONG,
|
|
75
|
+
]);
|
|
76
|
+
const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }];
|
|
77
|
+
const effective = effectiveCatalog(catalog, events);
|
|
78
|
+
const r = sel(effective, 'evaluate');
|
|
79
|
+
assert.equal(r.selected.model, 'cloudp/cheap-ok', r.selectionReason);
|
|
80
|
+
assert.equal(r.selectionPath, 'cheapest-sufficient-cloud');
|
|
81
|
+
// and the pipeline: monitor detects, evaluate decides, no premium escalation
|
|
82
|
+
const registry = registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } });
|
|
83
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
|
|
84
|
+
assert.equal(run.monitor.changed, true);
|
|
85
|
+
assert.equal(run.escalation, null);
|
|
86
|
+
assert.equal(run.outcome, 'evaluated-no-change'); // advantage below margin -> keep
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --- C: cheap models below requiredQuality -> escalate to a stronger model -
|
|
90
|
+
test('Case C: models below requiredQuality are rejected; a stronger candidate is selected', () => {
|
|
91
|
+
const weak = m('cloudp', 'too-weak', { cost: { usdPerMInput: 0.05, usdPerMOutput: 0.2 }, scores: { coding: 30, reasoning: 25, speed: 80 } });
|
|
92
|
+
const r = sel([weak, EXPENSIVE_STRONG], 'evaluate'); // requiredQuality 65
|
|
93
|
+
assert.equal(r.selected.model, 'cloudp/pro-strong');
|
|
94
|
+
const rejected = r.candidates.find(c => c.model === 'cloudp/too-weak');
|
|
95
|
+
assert.equal(rejected.eligible, false);
|
|
96
|
+
assert.ok(rejected.rejectedReasons.some(x => x.includes('requiredQuality')));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- D: no eligible cloud -> local fallback --------------------------------
|
|
100
|
+
test('Case D: cloud unusable -> local fallback with electricity cost', () => {
|
|
101
|
+
const gone = m('cloudp', 'gone', { status: 'removed' });
|
|
102
|
+
const r = sel([gone, LOCAL], 'monitor'); // requiredQuality 40; local quality ~44
|
|
103
|
+
assert.equal(r.selectionPath, 'local-fallback');
|
|
104
|
+
assert.equal(r.selected.model, 'localp/local-mid');
|
|
105
|
+
assert.equal(r.fallbackOccurred, true);
|
|
106
|
+
assert.equal(r.selected.location, 'local');
|
|
107
|
+
assert.ok(r.selected.costDetail.electricityUsd > 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- E: cloud API cheaper than local electricity -> cloud chosen ------------
|
|
111
|
+
test('Case E: cheaper cloud API beats local electricity estimate', () => {
|
|
112
|
+
const powerHungry = structuredClone(LOCAL);
|
|
113
|
+
powerHungry.local = { powerWatts: 900, taskMinutes: 30 }; // 0.45kWh * $0.30 = $0.135/run
|
|
114
|
+
const cheapCloud = m('cloudp', 'tiny', { cost: { usdPerMInput: 0.05, usdPerMOutput: 0.1 }, scores: { coding: 55, reasoning: 50, speed: 90 } });
|
|
115
|
+
const r = sel([powerHungry, cheapCloud], 'monitor');
|
|
116
|
+
assert.equal(r.selected.location, 'cloud');
|
|
117
|
+
assert.equal(r.selected.model, 'cloudp/tiny');
|
|
118
|
+
// sanity: the local run really would cost more
|
|
119
|
+
assert.ok(localElectricityCost(powerHungry.local, 0.30) > estimatedCostPerRun(cheapCloud, DEFAULT_POLICY, [powerHungry, cheapCloud], 'monitor').total);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- F: multi-capability change -> reconfigure escalation -------------------
|
|
123
|
+
test('Case F: changes spanning multiple capabilities escalate to reconfigure', () => {
|
|
124
|
+
const catalog = cat([
|
|
125
|
+
m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 30, reasoning: 25, speed: 85 } }),
|
|
126
|
+
CHEAP_OK, EXPENSIVE_STRONG,
|
|
127
|
+
m('cloudp', 'vis-strong', { vision: true, cost: { usdPerMInput: 3, usdPerMOutput: 12 }, scores: { coding: 85, reasoning: 88, speed: 55 } }),
|
|
128
|
+
]);
|
|
129
|
+
const registry = registryWith({
|
|
130
|
+
cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' },
|
|
131
|
+
sol: { provider: 'qoder', model: 'Qwen3.8-Flash' }, // two primaries on the same dying model
|
|
132
|
+
astra: { provider: 'qoder', model: 'Qwen3.8-Flash' }, // vision caps too
|
|
133
|
+
});
|
|
134
|
+
const events = [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }];
|
|
135
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
|
|
136
|
+
assert.ok(run.escalation, 'expected escalation');
|
|
137
|
+
assert.equal(run.escalation.targetTier, 'reconfigure');
|
|
138
|
+
assert.equal(run.escalation.sourceTier, 'evaluate');
|
|
139
|
+
assert.ok(run.escalation.affectedCapabilities.length >= 2);
|
|
140
|
+
assert.ok(run.tiers.some(t => t.role === 'reconfigure'));
|
|
141
|
+
assert.ok(run.escalation.estimatedDecisionCostUsd >= 0);
|
|
142
|
+
// proposal changes carry the escalation record
|
|
143
|
+
assert.ok(run.proposal.changes.every(c => c.escalation?.targetTier === 'reconfigure'));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// --- G: availability probe failure -> unknown, never "gone" -----------------
|
|
147
|
+
test('Case G: failed availability probe is unknown, not unavailable', () => {
|
|
148
|
+
const monitor = buildMonitorOutput({ events: [], catalog: cat([CHEAP_OK]), availability: null, availabilitySource: 'pi --list-models (failed)' });
|
|
149
|
+
assert.equal(monitor.infoStatus.probeFailed, true);
|
|
150
|
+
assert.equal(monitor.changed, false); // nothing declared missing
|
|
151
|
+
const r = sel([CHEAP_OK], 'monitor', { availability: null });
|
|
152
|
+
assert.equal(r.selected.model, 'cloudp/cheap-ok'); // still eligible
|
|
153
|
+
assert.equal(r.candidates[0].availability, 'unknown');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- supporting behaviour ---------------------------------------------------
|
|
157
|
+
test('free below requiredQuality is not used (free never overrides the quality bar)', () => {
|
|
158
|
+
const r = sel([FREE_WEAK, CHEAP_OK], 'evaluate');
|
|
159
|
+
assert.equal(r.selected.model, 'cloudp/cheap-ok');
|
|
160
|
+
assert.equal(r.selectionPath, 'cheapest-sufficient-cloud');
|
|
161
|
+
const rejected = r.candidates.find(c => c.model === 'freep/free-weak');
|
|
162
|
+
assert.equal(rejected.eligible, false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('localElectricityCost: watts x minutes x price', () => {
|
|
166
|
+
assert.ok(Math.abs(localElectricityCost({ powerWatts: 450, taskMinutes: 4 }, 0.30) - 0.009) < 1e-9);
|
|
167
|
+
assert.equal(localElectricityCost({ powerWatts: 450 }, 0.30), null);
|
|
168
|
+
assert.equal(localElectricityCost(null, 0.30), null);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('monitor output shape: changed/reasons/affectedModels/severity/escalationRequired', () => {
|
|
172
|
+
const out = buildMonitorOutput({ events: [{ type: 'price-changed', provider: 'p', model: 'x' }], catalog: cat([m('p', 'x')]) });
|
|
173
|
+
assert.equal(out.changed, true);
|
|
174
|
+
assert.deepEqual(out.affectedModels, ['p/x']);
|
|
175
|
+
assert.equal(out.severity, 'low');
|
|
176
|
+
assert.equal(out.escalationRequired, false);
|
|
177
|
+
const quiet = buildMonitorOutput({ events: [], catalog: cat([m('p', 'x')]) });
|
|
178
|
+
assert.equal(quiet.changed, false);
|
|
179
|
+
assert.equal(quiet.severity, 'none');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('runMaintenancePlan short-circuits on no change (only monitor tier runs)', () => {
|
|
183
|
+
const registry = registryWith({ cheap: { provider: 'cloudp', model: 'cheap-ok' } });
|
|
184
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog: cat([CHEAP_OK]), events: [] });
|
|
185
|
+
assert.equal(run.outcome, 'no-change');
|
|
186
|
+
assert.deepEqual(run.tiers.map(t => t.role), ['monitor']);
|
|
187
|
+
assert.equal(run.proposal, null);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('escalationDecision returns null for small single-capability changes', () => {
|
|
191
|
+
const result = { changes: [{ affected: { capabilities: ['cheap-code'], agents: ['scout'] }, scores: { current: 50, proposed: 62, delta: 12 }, confidence: 'high' }], decisions: [], infoStatus: { eventsApplied: [] } };
|
|
192
|
+
assert.equal(escalationDecision(result, { severity: 'low' }, DEFAULT_POLICY), null);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('exec policy validates and merges over defaults', () => {
|
|
196
|
+
assert.ok(validateExecPolicy({ version: 1, requiredQuality: { monitor: 200 } }).some(e => e.includes('requiredQuality')));
|
|
197
|
+
const p = loadExecPolicy(null);
|
|
198
|
+
assert.equal(p.requiredQuality.reconfigure, 80);
|
|
199
|
+
assert.equal(p.electricityPricePerKwh, 0.30);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('run report records tier selections, candidates, costs and flags', () => {
|
|
203
|
+
const registry = registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } });
|
|
204
|
+
const catalog = cat([
|
|
205
|
+
m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 30, reasoning: 25, speed: 85 } }),
|
|
206
|
+
CHEAP_OK,
|
|
207
|
+
]);
|
|
208
|
+
const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash' }];
|
|
209
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
|
|
210
|
+
for (const t of run.tiers) {
|
|
211
|
+
assert.ok(t.selected?.model, `${t.role} selected`);
|
|
212
|
+
assert.ok(t.selectionReason);
|
|
213
|
+
assert.ok(Array.isArray(t.candidates) && t.candidates.length);
|
|
214
|
+
assert.ok(typeof t.selected.effectiveCostUsd === 'number');
|
|
215
|
+
assert.ok(typeof t.fallbackOccurred === 'boolean');
|
|
216
|
+
}
|
|
217
|
+
assert.ok(run.estimatedDecisionCostUsd > 0);
|
|
218
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Phase 3: maintenance runner — real (stubbed) tier invocation, schema validation,
|
|
2
|
+
// one retry, ordered fallback, deterministic authority, and per-run cost profiles.
|
|
3
|
+
// Cases A–J from the Phase 3 spec. No real model is invoked; `invoke` is scripted.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
invokeTier, runMaintenanceLive, validateStructuredOutput, extractJson,
|
|
10
|
+
buildTierPrompt, TIER_SCHEMAS, MONITOR_SCHEMA,
|
|
11
|
+
} from '../lib/maintenance-runner.mjs';
|
|
12
|
+
import { selectTierModel, estimatedCostPerRun, DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
|
|
13
|
+
import { effectiveCatalog } from '../lib/maintenance.mjs';
|
|
14
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
15
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
16
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
17
|
+
|
|
18
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
20
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
21
|
+
|
|
22
|
+
const m = (provider, model, over = {}) => ({
|
|
23
|
+
provider, model, status: 'active', cost: { usdPerMInput: 0.5, usdPerMOutput: 2 },
|
|
24
|
+
contextK: 256, vision: false, toolUse: 'good', location: 'cloud',
|
|
25
|
+
scores: { coding: 60, reasoning: 55, speed: 70 }, ...over,
|
|
26
|
+
});
|
|
27
|
+
const cat = models => ({ version: 1, updatedAt: '2026-03-01', models });
|
|
28
|
+
const registryWith = backends => mergeRegistries(null, { version: 1, backends });
|
|
29
|
+
|
|
30
|
+
const FREE_GOOD = m('freep', 'free-good', { cost: { free: true }, scores: { coding: 75, reasoning: 68, speed: 80 } });
|
|
31
|
+
const CHEAP2 = m('cloudp', 'cheap-2', { cost: { usdPerMInput: 0.1, usdPerMOutput: 0.4 }, scores: { coding: 66, reasoning: 60, speed: 78 } });
|
|
32
|
+
const PREMIUM = m('cloudp', 'premium-huge', { premium: true, cost: { usdPerMInput: 10, usdPerMOutput: 40 }, scores: { coding: 95, reasoning: 95, speed: 40 } });
|
|
33
|
+
const LOCAL = m('localp', 'local-mid', { location: 'local', cost: { free: true }, local: { powerWatts: 400, taskMinutes: 10 }, scores: { coding: 45, reasoning: 40, speed: 50 }, toolUse: 'basic' });
|
|
34
|
+
|
|
35
|
+
const json = o => JSON.stringify(o);
|
|
36
|
+
const ok = text => ({ ok: true, text, durationMs: 5 });
|
|
37
|
+
const fail = error => ({ ok: false, error, durationMs: 3 });
|
|
38
|
+
const scriptInvoker = (script, calls = []) => async req => {
|
|
39
|
+
calls.push(req.modelId);
|
|
40
|
+
const v = typeof script === 'function' ? script(req) : script[req.modelId.split(':')[0]] ?? script['*'];
|
|
41
|
+
return typeof v === 'function' ? v(req) : v;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MON_OK = () => json({ decision: 'changed', confidence: 0.9, reasoningSummary: 'event diffs found', affectedModels: ['qoder/Qwen3.8-Flash'], severity: 'medium', evaluateNeeded: true, recommendedActions: [] });
|
|
45
|
+
const EVAL_OK = (c = 0.85) => json({ decision: 'keep', confidence: c, reasoningSummary: 'within margin', affectedCapabilities: ['cheap-code'], recommendedActions: [], recommendedModels: [] });
|
|
46
|
+
const REC_OK = () => json({ decision: 'propose', confidence: 0.8, reasoningSummary: 'structural', affectedCapabilities: ['cheap-code', 'strong-code'], recommendedActions: [], recommendedModels: [], routingNotes: [] });
|
|
47
|
+
|
|
48
|
+
// Post-campaign Qwen3.8-Flash stays mid-quality: the switch is simple, no escalation.
|
|
49
|
+
const dyingCatalog = () => cat([
|
|
50
|
+
m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 62, reasoning: 55, speed: 85 } }),
|
|
51
|
+
CHEAP2, FREE_GOOD,
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// --- A: no change -> monitor only ------------------------------------------
|
|
55
|
+
test('A: no-change -> only monitor is invoked, run stops', async () => {
|
|
56
|
+
const calls = [];
|
|
57
|
+
const run = await runMaintenanceLive({
|
|
58
|
+
routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
|
|
59
|
+
agents, catalog: cat([CHEAP2, FREE_GOOD]), events: [],
|
|
60
|
+
invoke: scriptInvoker({ '*': () => ok(json({ decision: 'no-change', confidence: 0.95, reasoningSummary: 'quiet', severity: 'none', evaluateNeeded: false })) }, calls),
|
|
61
|
+
});
|
|
62
|
+
assert.equal(run.outcome, 'no-change');
|
|
63
|
+
assert.equal(calls.length, 1);
|
|
64
|
+
assert.equal(run.invocations.length, 1);
|
|
65
|
+
assert.equal(run.invocations[0].tier, 'monitor');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// --- B: simple change -> monitor + evaluate, no reconfigure -----------------
|
|
69
|
+
test('B: simple change -> monitor and evaluate invoked, no reconfigure', async () => {
|
|
70
|
+
const calls = [];
|
|
71
|
+
const run = await runMaintenanceLive({
|
|
72
|
+
routing, registry: registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } }),
|
|
73
|
+
agents, catalog: dyingCatalog(),
|
|
74
|
+
events: [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash' }],
|
|
75
|
+
invoke: scriptInvoker({ '*': req => ok(req.prompt.includes('STAGE: monitor') ? MON_OK() : EVAL_OK()) }, calls),
|
|
76
|
+
});
|
|
77
|
+
assert.equal(calls.length, 2);
|
|
78
|
+
assert.deepEqual(run.invocations.map(i => i.tier), ['monitor', 'evaluate']);
|
|
79
|
+
assert.equal(run.escalation, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- C: complex change -> all three tiers -----------------------------------
|
|
83
|
+
test('C: multi-capability structural change -> reconfigure is invoked', async () => {
|
|
84
|
+
const calls = [];
|
|
85
|
+
const catalog = cat([
|
|
86
|
+
m('qoder', 'Qwen3.8-Flash', { scores: { coding: 30, reasoning: 25, speed: 85 } }),
|
|
87
|
+
CHEAP2, FREE_GOOD, m('cloudp', 'vis', { vision: true, cost: { usdPerMInput: 3, usdPerMOutput: 12 }, scores: { coding: 85, reasoning: 88, speed: 55 } }),
|
|
88
|
+
]);
|
|
89
|
+
const registry = registryWith({
|
|
90
|
+
cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' },
|
|
91
|
+
sol: { provider: 'qoder', model: 'Qwen3.8-Flash' },
|
|
92
|
+
astra: { provider: 'qoder', model: 'Qwen3.8-Flash' },
|
|
93
|
+
});
|
|
94
|
+
const run = await runMaintenanceLive({
|
|
95
|
+
routing, registry, agents, catalog,
|
|
96
|
+
events: [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash' }],
|
|
97
|
+
invoke: scriptInvoker({ '*': req => ok(req.prompt.includes('STAGE: monitor') ? MON_OK() : req.prompt.includes('STAGE: evaluate') ? EVAL_OK() : REC_OK()) }, calls),
|
|
98
|
+
});
|
|
99
|
+
assert.deepEqual(run.invocations.map(i => i.tier), ['monitor', 'evaluate', 'reconfigure']);
|
|
100
|
+
assert.ok(run.escalation.escalationReason.length > 0);
|
|
101
|
+
assert.equal(run.escalation.targetTier, 'reconfigure');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// --- D: monitor model fails -> next cheap candidate --------------------------
|
|
105
|
+
test('D: monitor invocation failure falls back to the next ordered candidate', async () => {
|
|
106
|
+
const calls = [];
|
|
107
|
+
const run = await runMaintenanceLive({
|
|
108
|
+
routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
|
|
109
|
+
agents, catalog: cat([FREE_GOOD, CHEAP2]), events: [],
|
|
110
|
+
invoke: scriptInvoker(req => req.modelId.startsWith('freep/') ? fail('rate limit exceeded') : ok(json({ decision: 'no-change', confidence: 0.9, reasoningSummary: 'ok', severity: 'none', evaluateNeeded: false })), calls),
|
|
111
|
+
});
|
|
112
|
+
assert.deepEqual(calls.map(c => c.split('/')[0]), ['freep', 'cloudp']);
|
|
113
|
+
const inv = run.invocations[0];
|
|
114
|
+
assert.equal(inv.fallbackOccurred, true);
|
|
115
|
+
assert.equal(inv.selectedModel, 'cloudp/cheap-2');
|
|
116
|
+
assert.equal(inv.invocations[0].failureClass, 'invoke');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- E: malformed JSON -> one retry -> success -------------------------------
|
|
120
|
+
test('E: malformed structured output retries once then succeeds', async () => {
|
|
121
|
+
let n = 0;
|
|
122
|
+
const inv = await invokeTier('monitor', selectTierModel(effectiveCatalog(cat([FREE_GOOD])), 'monitor', DEFAULT_POLICY), {
|
|
123
|
+
invoke: async () => (++n === 1 ? ok('not json at all') : ok(MON_OK())),
|
|
124
|
+
catalogKeys: new Set(['freep/free-good']),
|
|
125
|
+
});
|
|
126
|
+
assert.equal(n, 2);
|
|
127
|
+
assert.equal(inv.output.decision, 'changed');
|
|
128
|
+
assert.equal(inv.attempts.length, 2);
|
|
129
|
+
assert.equal(inv.attempts[0].schemaValid, false);
|
|
130
|
+
assert.equal(inv.attempts[1].schemaValid, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- F: still invalid after retry -> deterministic continues -----------------
|
|
134
|
+
test('F: invalid output after retry -> tier degrades to deterministic result', async () => {
|
|
135
|
+
const calls = [];
|
|
136
|
+
const run = await runMaintenanceLive({
|
|
137
|
+
routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
|
|
138
|
+
agents, catalog: cat([FREE_GOOD]), events: [],
|
|
139
|
+
invoke: scriptInvoker({ '*': () => ok('garbage {no json') }, calls),
|
|
140
|
+
});
|
|
141
|
+
const inv = run.invocations[0];
|
|
142
|
+
assert.equal(inv.degradedToDeterministic, true);
|
|
143
|
+
assert.equal(calls.length, 2); // initial + one retry, then stop
|
|
144
|
+
assert.equal(run.outcome, 'no-change'); // deterministic monitor still decides
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// --- G/H: per-run cost comparison --------------------------------------------
|
|
148
|
+
test('G: short profile -> cloud cheaper than local electricity -> cloud selected', () => {
|
|
149
|
+
const models = [LOCAL, CHEAP2];
|
|
150
|
+
const r = selectTierModel(models, 'monitor', DEFAULT_POLICY); // 8K in / 800 out
|
|
151
|
+
assert.equal(r.selected.location, 'cloud');
|
|
152
|
+
const localCost = estimatedCostPerRun(LOCAL, DEFAULT_POLICY, models, 'monitor');
|
|
153
|
+
const cloudCost = estimatedCostPerRun(CHEAP2, DEFAULT_POLICY, models, 'monitor');
|
|
154
|
+
assert.ok(cloudCost.api < localCost.electricity, `api ${cloudCost.api} vs elec ${localCost.electricity}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('H: long coding profile -> token cost grows, local becomes cheaper', () => {
|
|
158
|
+
const expensiveCloud = m('cloudp', 'pricey', { cost: { usdPerMInput: 8, usdPerMOutput: 30 }, scores: { coding: 66, reasoning: 60, speed: 70 } });
|
|
159
|
+
const local = m('localp', 'local-ok', { location: 'local', cost: { free: true }, local: { powerWatts: 300, taskMinutes: 2 }, scores: { coding: 45, reasoning: 40, speed: 60 }, toolUse: 'basic' });
|
|
160
|
+
const policy = structuredClone(DEFAULT_POLICY);
|
|
161
|
+
policy.taskProfiles.monitor = { estimatedInputTokens: 500000, estimatedOutputTokens: 50000, estimatedTaskMinutes: 20 };
|
|
162
|
+
const models = [expensiveCloud, local];
|
|
163
|
+
const r = selectTierModel(models, 'monitor', policy);
|
|
164
|
+
const cloudApi = estimatedCostPerRun(expensiveCloud, policy, models, 'monitor').api; // ~$5.5
|
|
165
|
+
const localElec = estimatedCostPerRun(local, policy, models, 'monitor').electricity; // 0.3kWh*0.3=$0.03
|
|
166
|
+
assert.ok(cloudApi > localElec);
|
|
167
|
+
assert.equal(r.selected.location, 'local');
|
|
168
|
+
assert.equal(r.selectionPath, 'cheapest-sufficient-local'); // local wins on cost, not as a fallback
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- I: premium not used for monitor -----------------------------------------
|
|
172
|
+
test('I: premium models are excluded from monitor/evaluate selection', () => {
|
|
173
|
+
const r = selectTierModel([PREMIUM, CHEAP2], 'monitor', DEFAULT_POLICY);
|
|
174
|
+
assert.equal(r.selected.model, 'cloudp/cheap-2');
|
|
175
|
+
assert.ok(!r.candidates.some(c => c.model === 'cloudp/premium-huge'));
|
|
176
|
+
const rec = selectTierModel([PREMIUM, CHEAP2], 'reconfigure', DEFAULT_POLICY); // reconfigure may see it
|
|
177
|
+
assert.ok(rec.candidates.some(c => c.model === 'cloudp/premium-huge'));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- J: LLM recommends a non-catalog model -> rejected ------------------------
|
|
181
|
+
test('J: recommendedModels outside the catalog are rejected and recorded', async () => {
|
|
182
|
+
const out = json({ decision: 'keep', confidence: 0.8, reasoningSummary: 'x', affectedCapabilities: [], recommendedActions: [], recommendedModels: ['evil/ghost-model', 'freep/free-good'] });
|
|
183
|
+
const inv = await invokeTier('evaluate', selectTierModel(effectiveCatalog(cat([FREE_GOOD, CHEAP2])), 'evaluate', DEFAULT_POLICY), {
|
|
184
|
+
invoke: async () => ok(out),
|
|
185
|
+
catalogKeys: new Set(['freep/free-good', 'cloudp/cheap-2']),
|
|
186
|
+
});
|
|
187
|
+
assert.deepEqual(inv.output.recommendedModels, ['freep/free-good']);
|
|
188
|
+
assert.deepEqual(inv.output.rejectedRecommendations, ['evil/ghost-model']);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// --- schema / parsing --------------------------------------------------------
|
|
192
|
+
test('schema validation: required keys, enums, ranges', () => {
|
|
193
|
+
assert.deepEqual(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'changed', confidence: 0.5, reasoningSummary: 'x' }), []);
|
|
194
|
+
assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'maybe', confidence: 0.5, reasoningSummary: 'x' }).some(e => e.includes('decision')));
|
|
195
|
+
assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'changed', confidence: 2, reasoningSummary: 'x' }).some(e => e.includes('confidence')));
|
|
196
|
+
assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { confidence: 0.5 }).some(e => e.includes('decision')));
|
|
197
|
+
assert.ok(validateStructuredOutput(MONITOR_SCHEMA, 'text').some(e => e.includes('object')));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('extractJson tolerates fences and surrounding prose', () => {
|
|
201
|
+
assert.equal(extractJson('```json\n{"a":1}\n```').a, 1);
|
|
202
|
+
assert.equal(extractJson('here is the result: {"a":{"b":2}} done').a.b, 2);
|
|
203
|
+
assert.equal(extractJson('no json'), null);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('tier prompts embed deterministic facts and forbid invented models', () => {
|
|
207
|
+
const p = buildTierPrompt('evaluate', { evaluation: { changes: 1 }, catalogKeys: ['a/b'] });
|
|
208
|
+
assert.ok(p.includes('STAGE: evaluate'));
|
|
209
|
+
assert.ok(p.includes('a/b'));
|
|
210
|
+
assert.ok(p.includes('Never invent provider/model ids'));
|
|
211
|
+
const mp = buildTierPrompt('monitor', { monitor: { changed: true, reasons: ['e'], affectedModels: ['p/m'], severity: 'low' } });
|
|
212
|
+
assert.ok(mp.includes('Do NOT propose routing changes'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('quota failure is classified distinctly', async () => {
|
|
216
|
+
const inv = await invokeTier('monitor', selectTierModel(effectiveCatalog(cat([FREE_GOOD, CHEAP2])), 'monitor', DEFAULT_POLICY), {
|
|
217
|
+
invoke: async req => req.modelId.startsWith('freep/') ? fail('Error: usage limit has been reached') : ok(MON_OK()),
|
|
218
|
+
catalogKeys: new Set(),
|
|
219
|
+
});
|
|
220
|
+
assert.equal(inv.attempts[0].failureClass, 'quota');
|
|
221
|
+
assert.equal(inv.selected.model, 'cloudp/cheap-2');
|
|
222
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Model-provisioning maintenance: catalog/events validation, scoring, and the
|
|
2
|
+
// Qoder/Qwen3.8-Flash free-campaign-end scenario. Proposals only — nothing is applied.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { resolve, dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
evaluateMaintenance, loadCatalog, loadEvents, loadAvailabilityFile,
|
|
9
|
+
validateCatalog, validateEvents, scoreModel, DEFAULT_MARGIN,
|
|
10
|
+
} from '../lib/maintenance.mjs';
|
|
11
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
12
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
13
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
14
|
+
import { parseModelList } from '../adapters/pi/lib/list-models.mjs';
|
|
15
|
+
|
|
16
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const fx = join(kit, 'tests/fixtures/maintenance');
|
|
18
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
19
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
20
|
+
const catalog = loadCatalog(join(fx, 'catalog.json'));
|
|
21
|
+
const events = loadEvents(join(fx, 'events.json')); // qoder/Qwen3.8-Flash free-campaign-ended
|
|
22
|
+
|
|
23
|
+
const baseRegistry = () => mergeRegistries(null, {
|
|
24
|
+
version: 1,
|
|
25
|
+
backends: {
|
|
26
|
+
local: { provider: 'freetoken', model: 'Qwen3.6-35B-A3B-NVFP4', thinking: 'off' },
|
|
27
|
+
cheap: { provider: 'openai-codex', model: 'gpt-5.6-luna', thinking: 'low' },
|
|
28
|
+
qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
|
|
29
|
+
devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
|
|
30
|
+
sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
|
|
31
|
+
astra: { provider: 'openai-codex', model: 'gpt-6-astra', thinking: 'medium', vision: true },
|
|
32
|
+
codex: { provider: 'openai-codex', model: 'gpt-5.5', thinking: 'high' },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const decide = (report, backend) => report.decisions.find(d => d.backend === backend);
|
|
36
|
+
|
|
37
|
+
test('catalog and events validators reject malformed input', () => {
|
|
38
|
+
assert.ok(validateCatalog({ version: 1, models: [{ provider: 'p', model: 'm', status: 'gone' }] }).some(e => e.includes('status')));
|
|
39
|
+
assert.ok(validateCatalog({ version: 1, models: [{ provider: 'p', model: 'm', status: 'active', cost: { usdPerMInput: -1 } }] }).some(e => e.includes('usdPerMInput')));
|
|
40
|
+
assert.ok(validateCatalog({ version: 1, models: [
|
|
41
|
+
{ provider: 'p', model: 'm', status: 'active' }, { provider: 'p', model: 'm', status: 'active' },
|
|
42
|
+
] }).some(e => e.includes('duplicates')));
|
|
43
|
+
assert.ok(validateEvents({ version: 1, events: [{ type: 'exploded', provider: 'p', model: 'm' }] }).some(e => e.includes('type')));
|
|
44
|
+
assert.ok(validateEvents({ version: 1, events: [{ type: 'price-changed', provider: 'p' }] }).some(e => e.includes('provider and model')));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('scoring is tier-weighted: the high tier values quality far more than the low tier', () => {
|
|
48
|
+
const free = { cost: { free: true }, contextK: 64, toolUse: 'basic', scores: { coding: 40, reasoning: 35, speed: 70 } };
|
|
49
|
+
const strong = { cost: { usdPerMInput: 1.5, usdPerMOutput: 6 }, contextK: 400, toolUse: 'good', scores: { coding: 85, reasoning: 82, speed: 55 } };
|
|
50
|
+
const lowDelta = scoreModel(strong, 'low').score - scoreModel(free, 'low').score;
|
|
51
|
+
const highDelta = scoreModel(strong, 'high').score - scoreModel(free, 'high').score;
|
|
52
|
+
assert.ok(highDelta > lowDelta, `high ${highDelta} should exceed low ${lowDelta}`);
|
|
53
|
+
assert.ok(scoreModel(strong, 'high').score > scoreModel(free, 'high').score);
|
|
54
|
+
assert.equal(scoreModel({ status: 'active' }, 'low').score, null); // nothing known
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('free-campaign end alone does not force a change: current model is re-scored and compared', () => {
|
|
58
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events });
|
|
59
|
+
const cheap = decide(report, 'qoder');
|
|
60
|
+
assert.equal(cheap.current.model, 'Qwen3.8-Flash');
|
|
61
|
+
assert.ok(cheap.trigger.some(t => t.includes('free-campaign-ended')), JSON.stringify(cheap.trigger));
|
|
62
|
+
// Post-campaign Qwen3.8-Flash (~$0.75/M) scores close to the paid alternatives under
|
|
63
|
+
// low-tier weights: advantage < margin -> keep. The event alone changed nothing.
|
|
64
|
+
assert.equal(cheap.decision, 'keep', cheap.reason);
|
|
65
|
+
assert.match(cheap.reason, /below margin/);
|
|
66
|
+
assert.equal(report.changes.length, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a forced migration (removed model) produces a full proposal with rollback', () => {
|
|
70
|
+
const removed = [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-15' }];
|
|
71
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: removed });
|
|
72
|
+
const change = report.changes.find(c => c.affected.backend === 'qoder');
|
|
73
|
+
assert.ok(change.changeReason.includes('removed'));
|
|
74
|
+
assert.deepEqual(change.affected.capabilities, ['cheap-code', 'orchestration']);
|
|
75
|
+
assert.deepEqual(change.affected.agents, ['orchestrator', 'scout', 'tester']);
|
|
76
|
+
assert.equal(change.currentModel.provider, 'qoder');
|
|
77
|
+
assert.equal(change.proposedModel.model, 'Qwen3.9-Flash'); // best eligible under low-tier weights
|
|
78
|
+
assert.match(change.expectedCostImpact, /\$0 \(free\) -> proposed ~\$/);
|
|
79
|
+
assert.match(change.expectedQualityImpact, /coding 62->70/);
|
|
80
|
+
assert.equal(change.rollback.method, 'manual-edit');
|
|
81
|
+
assert.deepEqual(change.rollback.previousBinding, { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' });
|
|
82
|
+
assert.ok(change.rollback.steps.some(s => s.includes('models.local.json')));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a free-campaign end proposes a switch when a clearly better candidate exists', () => {
|
|
86
|
+
// Same event, but the cheap backend is bound to a weak model: the margin is exceeded.
|
|
87
|
+
const reg = baseRegistry();
|
|
88
|
+
const cat = structuredClone(catalog);
|
|
89
|
+
cat.models.find(m => m.model === 'Qwen3.8-Flash').scores = { coding: 30, reasoning: 25, speed: 60 };
|
|
90
|
+
const report = evaluateMaintenance({ routing, registry: reg, agents, catalog: cat, events });
|
|
91
|
+
const cheap = decide(report, 'qoder');
|
|
92
|
+
assert.equal(cheap.decision, 'propose', cheap.reason);
|
|
93
|
+
assert.equal(cheap.proposed.model, 'Qwen3.9-Flash');
|
|
94
|
+
assert.ok(report.changes.length >= 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a free-campaign end with a comparable successor keeps the current model', () => {
|
|
98
|
+
const cat = structuredClone(catalog);
|
|
99
|
+
cat.models.find(m => m.model === 'Qwen3.8-Flash').postCampaignCost = { usdPerMInput: 0.3, usdPerMOutput: 1.2 };
|
|
100
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog: cat, events });
|
|
101
|
+
// Qwen3.9-Flash (0.25/1.0, coding 70) vs post-campaign Qwen3.8 (0.3/1.2, coding 62):
|
|
102
|
+
// advantage is below the margin -> keep.
|
|
103
|
+
const cheap = decide(report, 'qoder');
|
|
104
|
+
assert.equal(cheap.decision, 'keep', cheap.reason);
|
|
105
|
+
assert.match(cheap.reason, /below margin/);
|
|
106
|
+
assert.equal(report.changes.length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('margin is configurable and forced migration ignores it when the model is removed', () => {
|
|
110
|
+
const reg = baseRegistry();
|
|
111
|
+
const removed = { version: 1, events: [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }] };
|
|
112
|
+
const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events: removed.events, margin: 90 });
|
|
113
|
+
const cheap = decide(report, 'qoder');
|
|
114
|
+
assert.equal(cheap.decision, 'propose'); // forced: removed model must move even with margin 90
|
|
115
|
+
assert.match(cheap.reason, /migration required/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('availability listing: a bound model absent from its provider listing is forced to migrate', () => {
|
|
119
|
+
const availability = { models: new Set(['openai-codex/gpt-5.6-sol', 'openai-codex/gpt-6-astra', 'openai-codex/gpt-5.5', 'openai-codex/gpt-5.6-luna']), providers: new Set(['openai-codex', 'qoder']), source: 'test' };
|
|
120
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [], availability });
|
|
121
|
+
const cheap = decide(report, 'qoder'); // qoder listed but Qwen3.8-Flash absent -> gone
|
|
122
|
+
assert.equal(cheap.decision, 'propose');
|
|
123
|
+
assert.ok(cheap.trigger.some(t => t.includes('not')));
|
|
124
|
+
// provider not in the listing at all -> unknown, never "missing"
|
|
125
|
+
const availability2 = { models: new Set(['openai-codex/gpt-5.6-sol']), providers: new Set(['openai-codex']), source: 'test' };
|
|
126
|
+
const report2 = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [], availability: availability2 });
|
|
127
|
+
assert.equal(decide(report2, 'qoder').decision, 'keep');
|
|
128
|
+
assert.equal(decide(report2, 'local').decision, 'not-primary'); // fallback-only backend: reported, not evaluated
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('vision-requiring capability only proposes vision-capable candidates', () => {
|
|
132
|
+
const reg = baseRegistry();
|
|
133
|
+
const removed = [{ type: 'removed', provider: 'openai-codex', model: 'gpt-6-astra', asOf: '2026-03-01' }];
|
|
134
|
+
const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events: removed });
|
|
135
|
+
const astra = decide(report, 'astra');
|
|
136
|
+
assert.equal(astra.decision, 'insufficient-data'); // no other vision model in catalog
|
|
137
|
+
assert.match(astra.reason, /no eligible candidate/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('unbound/placeholder backends are skipped; fallback-only backends are reported not evaluated', () => {
|
|
141
|
+
const reg = mergeRegistries({ version: 1, backends: { qoder: { provider: 'TODO-provider', model: 'TODO-model' } } }, {
|
|
142
|
+
version: 1, backends: { codex: { provider: 'openai-codex', model: 'gpt-5.5' } },
|
|
143
|
+
});
|
|
144
|
+
const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events });
|
|
145
|
+
assert.equal(decide(report, 'qoder').decision, 'skip');
|
|
146
|
+
// codex is primary of no capability but fallback of strong-code/browser/orchestration
|
|
147
|
+
assert.equal(decide(report, 'codex').decision, 'not-primary');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('model-added triggers comparison but keeps when the new model is not better', () => {
|
|
151
|
+
const added = [{ type: 'model-added', provider: 'qoder', model: 'Qwen3.9-Flash', asOf: '2026-03-05' }];
|
|
152
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: added });
|
|
153
|
+
const cheap = decide(report, 'qoder');
|
|
154
|
+
// Qwen3.9-Flash is better than free Qwen3.8-Flash but not by the margin -> keep.
|
|
155
|
+
assert.equal(cheap.decision, 'keep', cheap.reason);
|
|
156
|
+
assert.ok(cheap.trigger.some(t => t.includes('Qwen3.9-Flash')));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('no events and healthy catalog -> every bound primary keeps, zero changes', () => {
|
|
160
|
+
const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [] });
|
|
161
|
+
assert.equal(report.changes.length, 0);
|
|
162
|
+
assert.deepEqual(report.decisions.filter(d => d.decision === 'keep').map(d => d.backend).sort(), ['astra', 'devin', 'qoder', 'sol']);
|
|
163
|
+
assert.equal(decide(report, 'local').decision, 'not-primary');
|
|
164
|
+
assert.equal(decide(report, 'codex').decision, 'not-primary');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('parseModelList extracts provider/model pairs and tolerates noise', () => {
|
|
168
|
+
const { models, providers } = parseModelList('openai-codex/gpt-5.6-sol\nqoder / Qwen3.8-Flash\nnot a model line\n');
|
|
169
|
+
assert.ok(models.has('openai-codex/gpt-5.6-sol'));
|
|
170
|
+
assert.ok(models.has('qoder/Qwen3.8-Flash'));
|
|
171
|
+
assert.ok(providers.has('qoder'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('parseModelList reads the real `pi --list-models` table (space-separated columns, header skipped)', () => {
|
|
175
|
+
// Regression: the table format is `provider model context max-out thinking images`,
|
|
176
|
+
// not `provider/model`. The old regex silently matched nothing -> probe reported null.
|
|
177
|
+
const table = 'provider model context max-out thinking images\n'
|
|
178
|
+
+ 'qoder Qwen3.8-Flash 1M 131.1K yes yes\n'
|
|
179
|
+
+ 'devin swe-2-high 256K 128K yes yes\n'
|
|
180
|
+
+ 'freetoken Qwen3.6-35B-A3B-NVFP4 262.1K 4.1K no yes\n'
|
|
181
|
+
+ 'openai-codex gpt-5.6-luna 272K 128K yes yes\n';
|
|
182
|
+
const { models, providers } = parseModelList(table);
|
|
183
|
+
assert.ok(models.has('qoder/Qwen3.8-Flash'));
|
|
184
|
+
assert.ok(models.has('devin/swe-2-high'));
|
|
185
|
+
assert.ok(models.has('freetoken/Qwen3.6-35B-A3B-NVFP4'));
|
|
186
|
+
assert.ok(models.has('openai-codex/gpt-5.6-luna'));
|
|
187
|
+
assert.ok(!providers.has('provider')); // header row excluded
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('loadAvailabilityFile produces the same shape', () => {
|
|
191
|
+
const tmp = join(fx, 'availability.txt');
|
|
192
|
+
const a = loadAvailabilityFile(tmp);
|
|
193
|
+
assert.ok(a.providers.has('qoder'));
|
|
194
|
+
assert.ok(a.models.has('qoder/Qwen3.9-Flash'));
|
|
195
|
+
});
|