pi-smart-router 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +88 -7
  2. package/.pi/extensions/smart-router/commands.ts +4 -1
  3. package/.pi/extensions/smart-router/index.ts +4 -0
  4. package/README.md +75 -10
  5. package/config/benchmark-profiles.json +33 -67
  6. package/config/operator-config.json.example +8 -0
  7. package/dist/config/defaults.d.ts +1 -1
  8. package/dist/config/defaults.d.ts.map +1 -1
  9. package/dist/config/defaults.js +3 -2
  10. package/dist/config/defaults.js.map +1 -1
  11. package/dist/domain/pinning/loop-escalation.d.ts +23 -1
  12. package/dist/domain/pinning/loop-escalation.d.ts.map +1 -1
  13. package/dist/domain/pinning/loop-escalation.js +107 -20
  14. package/dist/domain/pinning/loop-escalation.js.map +1 -1
  15. package/dist/domain/pipeline/router-pipeline.d.ts +7 -1
  16. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  17. package/dist/domain/pipeline/router-pipeline.js +63 -7
  18. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  19. package/dist/domain/triage/triage-engine.d.ts.map +1 -1
  20. package/dist/domain/triage/triage-engine.js +15 -0
  21. package/dist/domain/triage/triage-engine.js.map +1 -1
  22. package/dist/domain/triage/turn-envelope.d.ts.map +1 -1
  23. package/dist/domain/triage/turn-envelope.js +4 -0
  24. package/dist/domain/triage/turn-envelope.js.map +1 -1
  25. package/dist/domain/types/schemas.d.ts +15 -0
  26. package/dist/domain/types/schemas.d.ts.map +1 -1
  27. package/dist/domain/types/schemas.js +20 -0
  28. package/dist/domain/types/schemas.js.map +1 -1
  29. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +4 -0
  30. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  31. package/dist/infrastructure/telemetry/routing-telemetry.js +13 -0
  32. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  33. package/package.json +3 -2
  34. package/src/config/defaults.ts +9 -1
  35. package/src/domain/pinning/loop-escalation.ts +136 -20
  36. package/src/domain/pipeline/router-pipeline.ts +88 -11
  37. package/src/domain/triage/triage-engine.ts +15 -0
  38. package/src/domain/triage/turn-envelope.ts +4 -0
  39. package/src/domain/types/schemas.ts +24 -0
  40. package/src/infrastructure/telemetry/routing-telemetry.ts +15 -0
@@ -2,7 +2,11 @@ import {
2
2
  DEFAULT_HISTORY_LIMIT,
3
3
  MAX_HISTORY_LIMIT,
4
4
  } from '../../../src/infrastructure/telemetry/telemetry-limits.js';
5
- import type { RoutingDecision, RoutingTelemetry } from '../../../src/domain/types/index.js';
5
+ import type {
6
+ ModelProfile,
7
+ RoutingDecision,
8
+ RoutingTelemetry,
9
+ } from '../../../src/domain/types/index.js';
6
10
  import { SMART_ROUTER_USAGE } from './commands.js';
7
11
  import {
8
12
  DEFAULT_TELEMETRY_CONTRIB_EXPORT_LIMIT,
@@ -15,6 +19,78 @@ import {
15
19
  import { formatPricingStalenessLine } from './pricing-lifecycle.js';
16
20
  import type { FleetMode, SmartRouterCommand, SmartRouterRuntime } from './types.js';
17
21
 
22
+ /** Opaque / virtual auto ids that hide the concrete delegated fleet model (SP-178). */
23
+ function isBareOrSmartRouterAuto(modelId: string): boolean {
24
+ return modelId === 'auto' || modelId === 'smart-router/auto';
25
+ }
26
+
27
+ /**
28
+ * Resolve the operator-facing model id for history/status (SP-178 / #99).
29
+ * Prefer a concrete delegated/primary id over virtual `auto`.
30
+ */
31
+ export function resolveHistoryModelId(
32
+ entry: Pick<
33
+ RoutingTelemetry,
34
+ | 'selected_model_id'
35
+ | 'planning_delegate_primary_model_id'
36
+ | 'planning_delegate_model_id'
37
+ >,
38
+ fleet?: readonly ModelProfile[],
39
+ ): string {
40
+ let modelId = entry.selected_model_id;
41
+
42
+ if (isBareOrSmartRouterAuto(modelId)) {
43
+ const primary = entry.planning_delegate_primary_model_id;
44
+ if (primary && !isBareOrSmartRouterAuto(primary)) {
45
+ modelId = primary;
46
+ } else if (
47
+ entry.planning_delegate_model_id &&
48
+ !isBareOrSmartRouterAuto(entry.planning_delegate_model_id)
49
+ ) {
50
+ modelId = entry.planning_delegate_model_id;
51
+ }
52
+ }
53
+
54
+ return qualifyModelIdForDisplay(modelId, fleet);
55
+ }
56
+
57
+ /**
58
+ * Qualify bare `auto` with provider when fleet is available so history never
59
+ * looks like the smart-router virtual model.
60
+ */
61
+ export function qualifyModelIdForDisplay(
62
+ modelId: string,
63
+ fleet?: readonly ModelProfile[],
64
+ ): string {
65
+ if (modelId !== 'auto') {
66
+ return modelId;
67
+ }
68
+
69
+ const profile = fleet?.find((m) => m.id === 'auto');
70
+ if (profile) {
71
+ return `${profile.provider}/${profile.id}`;
72
+ }
73
+
74
+ // Cursor opaque auto is the common bare-`auto` fleet id; never leave it unqualified.
75
+ return 'cursor/auto';
76
+ }
77
+
78
+ export function resolveStatusModelId(
79
+ decision: RoutingDecision,
80
+ fleet?: readonly ModelProfile[],
81
+ ): string {
82
+ const primary = decision.features?.planning_delegate?.primary_model_id ?? null;
83
+ const delegate = decision.features?.planning_delegate?.delegate_model_id ?? null;
84
+ return resolveHistoryModelId(
85
+ {
86
+ selected_model_id: decision.selected_model_id,
87
+ planning_delegate_primary_model_id: primary,
88
+ planning_delegate_model_id: delegate,
89
+ },
90
+ fleet,
91
+ );
92
+ }
93
+
18
94
  export function parseHistoryLimit(raw: string | undefined): number {
19
95
  if (raw === undefined) {
20
96
  return DEFAULT_HISTORY_LIMIT;
@@ -142,8 +218,9 @@ export function formatStatusMessage(
142
218
  return lines.join('\n');
143
219
  }
144
220
 
221
+ const displayModelId = resolveStatusModelId(decision, runtime.streamDeps.fleet);
145
222
  lines.push(
146
- `Model: ${decision.selected_model_id}`,
223
+ `Model: ${displayModelId}`,
147
224
  `Stage: ${decision.stage}`,
148
225
  `Reason: ${decision.reason_code}`,
149
226
  `Latency: ${decision.routing_latency_ms}ms`,
@@ -151,16 +228,20 @@ export function formatStatusMessage(
151
228
  return lines.join('\n');
152
229
  }
153
230
 
154
- export function formatHistoryMessage(entries: readonly RoutingTelemetry[]): string {
231
+ export function formatHistoryMessage(
232
+ entries: readonly RoutingTelemetry[],
233
+ options?: { fleet?: readonly ModelProfile[] },
234
+ ): string {
155
235
  if (entries.length === 0) {
156
236
  return 'No routing history yet.';
157
237
  }
158
238
 
239
+ const fleet = options?.fleet;
159
240
  return entries
160
- .map(
161
- (entry) =>
162
- `${entry.timestamp} | ${entry.selected_model_id} | ${entry.stage} | ${entry.turn_type} | ${entry.routing_latency_ms}ms`,
163
- )
241
+ .map((entry) => {
242
+ const modelId = resolveHistoryModelId(entry, fleet);
243
+ return `${entry.timestamp} | ${modelId} | ${entry.stage} | ${entry.turn_type} | ${entry.routing_latency_ms}ms`;
244
+ })
164
245
  .join('\n');
165
246
  }
166
247
 
@@ -160,7 +160,10 @@ export function registerSmartRouterCommand(
160
160
  throwIfCommandAborted(signal);
161
161
  const rows = await runtime.store.listTelemetry({ limit: parsed.limit });
162
162
  throwIfCommandAborted(signal);
163
- ctx.ui.notify(formatHistoryMessage(rows), 'info');
163
+ ctx.ui.notify(
164
+ formatHistoryMessage(rows, { fleet: runtime.streamDeps.fleet }),
165
+ 'info',
166
+ );
164
167
  return;
165
168
  }
166
169
 
@@ -38,6 +38,8 @@ import {
38
38
  formatHistoryMessage,
39
39
  formatStatusMessage,
40
40
  parseSmartRouterArgs,
41
+ resolveHistoryModelId,
42
+ qualifyModelIdForDisplay,
41
43
  } from './command-formatters.js';
42
44
  import { formatPricingStalenessLine, refreshPricingCatalog } from './pricing-lifecycle.js';
43
45
  import {
@@ -87,8 +89,10 @@ export {
87
89
  getSmartRouterArgumentCompletions,
88
90
  mapContextMessages,
89
91
  parseSmartRouterArgs,
92
+ qualifyModelIdForDisplay,
90
93
  refreshPricingCatalog,
91
94
  resolveDelegationOptions,
95
+ resolveHistoryModelId,
92
96
  logRoutingDecision,
93
97
  toDatasetExportRecord,
94
98
  capturePreRouteOutcomes,
package/README.md CHANGED
@@ -197,7 +197,7 @@ Cursor models bill against your **Cursor Pro subscription quota**, not per-token
197
197
  |---------|---------|
198
198
  | `/smart-router` | Same as `status` (default when no subcommand is given) |
199
199
  | `/smart-router status` | Show fleet mode, fleet size, pricing freshness/staleness, and the last routing decision (stage, tier, selected model, latency) |
200
- | `/smart-router history` | Show recent routing telemetry from SQLite (default limit; optional numeric limit, e.g. `/smart-router history 20`) |
200
+ | `/smart-router history` | Show recent routing telemetry from SQLite (default limit; optional numeric limit, e.g. `/smart-router history 20`). Displays the concrete delegated model id (never bare virtual `auto`) |
201
201
  | `/smart-router mode scoped` | Route only among pi's **enabled model patterns** (default) |
202
202
  | `/smart-router mode all` | Route among **all authenticated models** in the registry |
203
203
  | `/smart-router pricing refresh` | Manually fetch LiteLLM pricing from `LITELLM_PRICING_URL`, persist to SQLite, and rebuild the fleet with updated rates |
@@ -339,7 +339,7 @@ Cluster IDs are stable reason-code prefixes (`cluster_low_stakes_general`, `clus
339
339
  | Variable | Default | Purpose |
340
340
  |----------|---------|---------|
341
341
  | `ROUTER_STATE_DB_PATH` | `./.pi-smart-router/state.db` | Override SQLite state store location (telemetry, pricing catalog, session data) |
342
- | `SMART_ROUTER_LOG_ROUTING` | (unset) | Set to `1` to log each routing decision to stderr as JSON (debugging dogfood sessions) |
342
+ | `SMART_ROUTER_LOG_ROUTING` | (unset) | Set to `1` to log each routing decision to stderr as JSON (debugging dogfood sessions). Canonical payload builder (`buildRoutingDecisionLogPayload`) includes top-level `stage`, `reason_code`, `low_intensity_score`, `tier_hint`, `local_eligible_reason`, and `cluster_id` (plus nested `cluster_summary` / `features`). The pi extension’s live stderr logger is still a slim subset — see [LOG_ROUTING field checklist](#log_routing-field-checklist) |
343
343
  | `SMART_ROUTER_DATASET` | (unset) | Set to `1` to opt in to privacy-safe routing dataset capture (metadata and feature fields only; 30-day / 10k-row retention). Prompt text, messages, and tool arguments are never stored. Required for outcome labels and P(success) training export. See [#8](https://github.com/beettlle/pi-smart-router/issues/8). |
344
344
  | `SMART_ROUTER_DATASET_FINGERPRINT` | (unset) | Set to `1` (requires `SMART_ROUTER_DATASET=1`) to store an install-local HMAC-SHA256 fingerprint of each normalized prompt for duplicate detection within this install. The install pepper lives in `.pi-smart-router/.dataset-key` (gitignored) and is never exported. **Warning:** short or common prompts are vulnerable to offline rainbow-table guessing; use only when you accept that tradeoff. See [#10](https://github.com/beettlle/pi-smart-router/issues/10). |
345
345
  | `MODELS_YAML_PATH` | `./config/models.yaml` | Fleet catalog path (library API only) |
@@ -354,6 +354,23 @@ Cluster IDs are stable reason-code prefixes (`cluster_low_stakes_general`, `clus
354
354
  | `ROUTER_SAFE_DEFAULT_TIER` | `economical-cloud` | Fallback tier on any routing failure |
355
355
  | `LITELLM_PRICING_URL` | — | LiteLLM pricing JSON source |
356
356
 
357
+ ### LOG_ROUTING field checklist
358
+
359
+ When `SMART_ROUTER_LOG_ROUTING=1`, prefer the canonical payload from `buildRoutingDecisionLogPayload` (library / tests). Checklist for [#99](https://github.com/beettlle/pi-smart-router/issues/99):
360
+
361
+ | Field | In payload builder? | Notes |
362
+ |-------|---------------------|-------|
363
+ | `stage` | Yes (top-level) | Pipeline stage that decided |
364
+ | `reason_code` | Yes (top-level) | Machine-readable reason |
365
+ | `low_intensity_score` | Yes (top-level + `cluster_summary`) | Null when low-intensity stage did not run |
366
+ | `tier_hint` | Yes (top-level + `cluster_summary`) | Null when no tier hint |
367
+ | `local_eligible_reason` | Yes (top-level + `features`) | Null when local_zero did not evaluate eligibility |
368
+ | `cluster_id` | Yes (top-level + `cluster_summary`) | Null when no cluster match |
369
+
370
+ **Gap:** the pi extension’s live stderr path (`logRoutingDecision` in `.pi/extensions/smart-router`) still emits a slim JSON object (`selected_model_id`, `stage`, `reason_code`, `features`, `delegate`) and does **not** yet call `buildRoutingDecisionLogPayload`. SQLite `/smart-router history` and the payload builder carry the full checklist; wire the extension logger in a follow-up if dogfood needs identical stderr shape.
371
+
372
+ **History model id:** `/smart-router history` resolves bare/`smart-router` virtual `auto` to the concrete planning-delegate primary (or qualifies Cursor opaque `auto` as `cursor/auto`) so operators see the delegated fleet model, not the virtual router id.
373
+
357
374
  ### SAAR session pin and cache breakeven (v0.2.0 Continuity)
358
375
 
359
376
  v0.2.0 adds **Session-Aware Agentic Routing (SAAR)** pin knobs ([#72](https://github.com/beettlle/pi-smart-router/issues/72)) and a **cache breakeven gate** ([#73](https://github.com/beettlle/pi-smart-router/issues/73)) that blocks tier switches when `marginal_savings + future_cache_value <= cache_reprime_cost` — preventing cheap-turn savings from invalidating a warm prefix cache.
@@ -567,8 +584,10 @@ Additional operator defaults:
567
584
 
568
585
  | Key | Default | Purpose |
569
586
  |-----|---------|---------|
570
- | `loop_escalation.threshold` | 3 | Consecutive identical failures before escalating to frontier |
587
+ | `loop_escalation.threshold` | 3 | Consecutive identical failures before escalating to frontier. Also used as the default **zero-tier tool-call churn** threshold (SP-178 / [#99](https://github.com/beettlle/pi-smart-router/issues/99)): while pinned to `zero-tier`, unsupported/unknown tool results escalate immediately, and N `tool_result` turns escalate via the same `loop_escalation` pin path (FR-014) — not a cache-breakeven bypass |
571
588
  | `pin_only_fallback` | `false` | Emergency pin-on-first-turn mode — see [Pin-only emergency fallback](#pin-only-emergency-fallback) |
589
+ | `local_zero.enabled` | `true` | When `false`, skip `local_zero` dispatch (fall through to later stages). Default keeps the cheap local path for true trivial traffic |
590
+ | `local_zero.max_tool_use_requirement` | `0.25` | Ceiling (0–1) on cheap predicted tool_use for `local_zero`. Effective limit is `min(local model tool_use, this value)`. Skips agentic git/bash/edit/explore/delete/repo cues with telemetry reason `tool_use_capability_shortfall` (SP-177 / [#98](https://github.com/beettlle/pi-smart-router/issues/98)) |
572
591
  | `local.min_memory_gb_full` | 16 | Minimum RAM for full local inference |
573
592
  | `local.battery_threshold_pct` | 20 | Minimum battery to allow local inference |
574
593
  | `pricing.staleness_days` | 14 | Max age before re-fetching pricing data |
@@ -809,7 +828,7 @@ npm run routing:eval-replay
809
828
 
810
829
  ### Benchmark profile refresh
811
830
 
812
- Capability scores in `config/benchmark-profiles.json` are grounded from public leaderboard snapshots under `tests/fixtures/benchmark-leaderboards/`. Each artifact records provenance (`source_urls`, `scrape_date`, `catalog_freeze_date`) in its header.
831
+ Capability scores in `config/benchmark-profiles.json` are grounded from public leaderboard snapshots under `tests/fixtures/benchmark-leaderboards/` (and optional **recorded** live snapshots under `tests/fixtures/benchmark-leaderboards/recorded/`). Each artifact records provenance (`source_urls`, `scrape_date`, `catalog_freeze_date`) in its header.
813
832
 
814
833
  **Fleet ID aliases (SP-174):** live pi/Cursor scoped-fleet model IDs often differ from leaderboard `model_id` strings. The artifact’s optional `aliases` map sends those fleet IDs to an existing grounded row (never invents scores). `mapPiModelToProfile` sets `capability_source` to `benchmark` when a direct row or alias hits, otherwise `pattern_default`. Operators can also call `getCapabilitySource(modelId)` / `resolveBenchmarkModelId(modelId)`.
815
834
 
@@ -821,16 +840,61 @@ Capability scores in `config/benchmark-profiles.json` are grounded from public l
821
840
  4. Re-run ingest anytime — the CLI **preserves** existing `aliases` from the output file. Seed defaults live in `DEFAULT_FLEET_BENCHMARK_ALIASES` when no prior artifact exists.
822
841
  5. Confirm with `npm run routing:verify-benchmark-profiles` and a mapper unit test that `capability_source === 'benchmark'` for the fleet id.
823
842
 
843
+ **Operator refresh command (SP-179 / SP-180):**
844
+
845
+ | Mode | Command | Network? | When to use |
846
+ |------|---------|----------|-------------|
847
+ | **Fixtures (default)** | `npm run routing:ingest-benchmarks` | No | Local edits, CI, PR smoke |
848
+ | **Recorded replay** | `npm run routing:ingest-benchmarks -- --recorded` | No | Replay last successful live snapshots offline |
849
+ | **Live + record** | `npm run routing:ingest-benchmarks -- --live` | Yes | Operator refresh; writes `tests/fixtures/benchmark-leaderboards/recorded/` then regenerates profiles |
850
+
851
+ Optional flags: `--catalog-freeze-date YYYY-MM-DD`, `--scrape-date YYYY-MM-DD`, `--record-dir DIR`, `--live-url BENCHMARK=URL`, `--output PATH`. See `npm run routing:ingest-benchmarks -- --help`.
852
+
853
+ **Live sources (per benchmark):** each `--live` run resolves independently — live adapter → recorded → checked-in fixtures. One failing source never invents scores or blocks siblings. Logs: `ingest-benchmark-profiles: <id> source=live|recorded|fixture (…)`.
854
+
855
+ | Benchmark | Default live fetch | Score field | Fallback |
856
+ |-----------|-------------------|-------------|----------|
857
+ | `swebench_verified` | Native: [SWE-bench `leaderboards.json`](https://raw.githubusercontent.com/SWE-bench/swe-bench.github.io/master/data/leaderboards.json) (Verified board) | `resolved` → `score` (0–100) | recorded → fixtures |
858
+ | `livecodebench` | Native: [LCB `performances_generation.json`](https://raw.githubusercontent.com/LiveCodeBench/livecodebench.github.io/main/src/mocks/performances_generation.json) | aggregate `pass@1` | recorded → fixtures |
859
+ | `bfcl` | Native: [Gorilla `data_overall.csv`](https://raw.githubusercontent.com/ShishirPatil/gorilla/gh-pages/data_overall.csv) | `Overall Acc` | recorded → fixtures |
860
+ | `terminal_bench` | **No free stable JSON** (tbench.ai is HTML; HF leaderboard is submissions-only; paid Parse API is **not** the default). Pass `--live-url terminal_bench=URL` at a fixture-shaped mirror | `score` (0–100) | recorded → fixtures |
861
+
862
+ **Terminal-Bench operator mirror schema** (SP-185 / #104):
863
+
864
+ ```json
865
+ {
866
+ "benchmark": "terminal_bench",
867
+ "source_url": "https://www.tbench.ai/leaderboard",
868
+ "scrape_date": "YYYY-MM-DD",
869
+ "entries": [{ "model_id": "claude-opus-4-5", "score": 72.5 }]
870
+ }
871
+ ```
872
+
873
+ Example: `npm run routing:ingest-benchmarks -- --live --live-url terminal_bench=https://example.com/tb-mirror.json`. HTML bodies fail fast; without `--live-url`, TB uses recorded/fixtures. `release:refresh-benchmarks` uses the same `--live` path (fixture fallback on total failure).
874
+
875
+ **Cadence (release-tied, not calendar):**
876
+
877
+ | Trigger | When | Behavior |
878
+ |---------|------|----------|
879
+ | **Pre-tag release gate** | `npm run release:check` → `release:refresh-benchmarks` | Attempt **live** ingest; on failure fall back to fixtures; **fail if** `config/benchmark-profiles.json` or recorded snapshots are dirty — commit on `main`, re-run, then `npm version` / tag |
880
+ | **Manual dispatch** | Actions → *Benchmark Profile Refresh* → `workflow_dispatch` (`use_live` default `true`) | Same live-or-fixture path; opens a bot PR when scores change; set `use_live=false` for fixtures-only |
881
+ | **PR smoke** | PRs touching fixtures / ingest / artifact / workflow | **Fixtures only** — `npm run routing:verify-benchmark-profiles` (offline, no network) |
882
+
883
+ There is **no monthly cron**. Refresh runs when you ship so each release packages the latest grounded scores. Tag-triggered Release publish uses `--ignore-scripts` and does not re-fetch (profiles are already frozen in the tag). Offline skip: `SMART_ROUTER_SKIP_LIVE_BENCHMARK_REFRESH=1 npm run release:check`.
884
+
824
885
  **Operator policy:**
825
886
 
826
- 1. **PR smoke** — `.github/workflows/benchmark-profile-refresh.yml` runs on PRs that touch fixtures, ingest, or the checked-in artifact. It executes `npm run routing:verify-benchmark-profiles` so fixture edits cannot drift from `config/benchmark-profiles.json`.
827
- 2. **Monthly refresh** — the same workflow runs on the 1st of each month (06:00 UTC) and via `workflow_dispatch`. It re-ingests fixtures, updates `catalog_freeze_date` to the run date, and opens a PR when model scores change.
828
- 3. **Manual updates** — after editing fixture snapshots, run `npm run routing:ingest-benchmarks` (optionally `--catalog-freeze-date YYYY-MM-DD`) and commit the regenerated `config/benchmark-profiles.json` with the PR.
887
+ 1. **PR smoke** — fixture-only verify so PRs never require live network.
888
+ 2. **Every release** — live with fixture fallback via `release:check`; commit any profile/recorded diffs on `main` before tagging.
889
+ 3. **Ad-hoc** — Actions dispatch or local `--live` when refreshing between releases.
890
+ 4. **Manual local updates** — prefer fixtures or `--recorded` for offline work; use `--live` when refreshing from public leaderboard JSON endpoints.
829
891
 
830
- Regenerate locally:
892
+ Verify after any regenerate:
831
893
 
832
894
  ```bash
833
895
  npm run routing:ingest-benchmarks
896
+ # or: npm run routing:ingest-benchmarks -- --live
897
+ # or: npm run routing:ingest-benchmarks -- --recorded
834
898
  npm run routing:verify-benchmark-profiles
835
899
  ```
836
900
 
@@ -844,7 +908,7 @@ Tag-triggered publish via GitHub Actions (requires `NPMSECRET` repository secret
844
908
  2. `routing:verify-benchmark-profiles` — checked-in capability profiles match fixture ingest
845
909
  3. `assert-release-gates --fixtures tests/eval/fixtures --baseline-version 0.6.0` — eval harness aggregate metrics vs `config/release-gates.json` and semver baseline regression vs `tests/eval/baselines/v0.6.0.json`
846
910
 
847
- `release:check` runs the full pre-release path: `verify:ci`, consumer pack verify, then Tier 0 functional smoke.
911
+ `release:check` runs the full pre-release path: **live benchmark profile refresh** (fixture fallback; dirty-tree fail), then `verify:ci`, consumer pack verify, then Tier 0 functional smoke.
848
912
 
849
913
  **Baseline re-capture (post-tag):** after shipping a new semver (e.g. v0.7.0), freeze harness metrics for the next regression reference:
850
914
 
@@ -857,7 +921,8 @@ npm run routing:capture-baseline -- --version 0.7.0
857
921
 
858
922
  Commit the new baseline JSON and update `baseline_regression.reference_version` in `config/release-gates.json` plus the `--baseline-version` flag in `release:functional-smoke`. Re-run `npm run release:check` before tagging the next release.
859
923
 
860
- 1. `npm run release:check` (CI parity + consumer pack + Tier 0 functional smoke)
924
+ 1. `npm run release:check` (live benchmark refresh → CI parity + consumer pack + Tier 0 functional smoke)
925
+ - If refresh rewrites profiles/recorded snapshots, **commit them on `main`** and re-run until clean
861
926
  2. `npm version 0.1.1` (creates commit + `v0.1.1` tag)
862
927
  3. `git push && git push --tags`
863
928
  4. Actions → **Release** runs pack smoke, consumer pack verify, Tier 0 functional smoke, `npm publish`, and creates a GitHub Release
@@ -7,8 +7,8 @@
7
7
  "livecodebench": "https://livecodebench.github.io/leaderboard.html",
8
8
  "bfcl": "https://gorilla.cs.berkeley.edu/leaderboard.html"
9
9
  },
10
- "scrape_date": "2026-07-09",
11
- "catalog_freeze_date": "2026-07-09"
10
+ "scrape_date": "2026-07-11",
11
+ "catalog_freeze_date": "2026-07-11"
12
12
  },
13
13
  "aliases": {
14
14
  "claude-3-5-sonnet": "claude-sonnet-4-6",
@@ -34,50 +34,24 @@
34
34
  },
35
35
  "models": [
36
36
  {
37
- "model_id": "claude-3.5-haiku",
37
+ "model_id": "claude-opus-4-5",
38
38
  "capabilities": {
39
- "reasoning": 0.5685,
40
- "code_gen": 0.587,
41
- "tool_use": 0.6095
39
+ "reasoning": 0.7585,
40
+ "code_gen": 0.708,
41
+ "tool_use": 0.7498
42
42
  },
43
43
  "benchmark_sources": {
44
44
  "bfcl": {
45
- "raw_score": 70.6,
46
- "normalized": 0.706
45
+ "raw_score": 77.47,
46
+ "normalized": 0.7746999999999999
47
47
  },
48
48
  "livecodebench": {
49
- "raw_score": 55,
50
- "normalized": 0.55
51
- },
52
- "swebench_verified": {
53
49
  "raw_score": 62.4,
54
50
  "normalized": 0.624
55
51
  },
56
- "terminal_bench": {
57
- "raw_score": 51.3,
58
- "normalized": 0.513
59
- }
60
- }
61
- },
62
- {
63
- "model_id": "claude-opus-4-5",
64
- "capabilities": {
65
- "reasoning": 0.767,
66
- "code_gen": 0.7915,
67
- "tool_use": 0.8035
68
- },
69
- "benchmark_sources": {
70
- "bfcl": {
71
- "raw_score": 88.2,
72
- "normalized": 0.882
73
- },
74
- "livecodebench": {
75
- "raw_score": 77.4,
76
- "normalized": 0.774
77
- },
78
52
  "swebench_verified": {
79
- "raw_score": 80.9,
80
- "normalized": 0.809
53
+ "raw_score": 79.2,
54
+ "normalized": 0.792
81
55
  },
82
56
  "terminal_bench": {
83
57
  "raw_score": 72.5,
@@ -88,22 +62,22 @@
88
62
  {
89
63
  "model_id": "claude-sonnet-4-6",
90
64
  "capabilities": {
91
- "reasoning": 0.738,
92
- "code_gen": 0.7635,
93
- "tool_use": 0.7625
65
+ "reasoning": 0.724,
66
+ "code_gen": 0.681,
67
+ "tool_use": 0.7062
94
68
  },
95
69
  "benchmark_sources": {
96
70
  "bfcl": {
97
- "raw_score": 84.5,
98
- "normalized": 0.845
71
+ "raw_score": 73.24,
72
+ "normalized": 0.7323999999999999
99
73
  },
100
74
  "livecodebench": {
101
- "raw_score": 73.1,
102
- "normalized": 0.731
75
+ "raw_score": 59.4,
76
+ "normalized": 0.594
103
77
  },
104
78
  "swebench_verified": {
105
- "raw_score": 79.6,
106
- "normalized": 0.7959999999999999
79
+ "raw_score": 76.8,
80
+ "normalized": 0.768
107
81
  },
108
82
  "terminal_bench": {
109
83
  "raw_score": 68,
@@ -114,22 +88,22 @@
114
88
  {
115
89
  "model_id": "gemini-2.5-flash",
116
90
  "capabilities": {
117
- "reasoning": 0.5295,
118
- "code_gen": 0.5525,
119
- "tool_use": 0.5735
91
+ "reasoning": 0.3827,
92
+ "code_gen": 0.5192,
93
+ "tool_use": 0.5202
120
94
  },
121
95
  "benchmark_sources": {
122
96
  "bfcl": {
123
- "raw_score": 66.9,
124
- "normalized": 0.669
97
+ "raw_score": 56.24,
98
+ "normalized": 0.5624
125
99
  },
126
100
  "livecodebench": {
127
- "raw_score": 52.4,
128
- "normalized": 0.524
101
+ "raw_score": 75.1,
102
+ "normalized": 0.7509999999999999
129
103
  },
130
104
  "swebench_verified": {
131
- "raw_score": 58.1,
132
- "normalized": 0.581
105
+ "raw_score": 28.73,
106
+ "normalized": 0.2873
133
107
  },
134
108
  "terminal_bench": {
135
109
  "raw_score": 47.8,
@@ -140,22 +114,14 @@
140
114
  {
141
115
  "model_id": "gpt-5.3-codex",
142
116
  "capabilities": {
143
- "reasoning": 0.796,
144
- "code_gen": 0.833,
145
- "tool_use": 0.8215
117
+ "reasoning": 0.743,
118
+ "code_gen": 0.744,
119
+ "tool_use": 0.742
146
120
  },
147
121
  "benchmark_sources": {
148
- "bfcl": {
149
- "raw_score": 90.1,
150
- "normalized": 0.9009999999999999
151
- },
152
- "livecodebench": {
153
- "raw_score": 81.6,
154
- "normalized": 0.816
155
- },
156
122
  "swebench_verified": {
157
- "raw_score": 85,
158
- "normalized": 0.85
123
+ "raw_score": 74.4,
124
+ "normalized": 0.7440000000000001
159
125
  },
160
126
  "terminal_bench": {
161
127
  "raw_score": 74.2,
@@ -60,5 +60,13 @@
60
60
  "exclude_execution_history": true
61
61
  }
62
62
  },
63
+ "local_zero": {
64
+ "enabled": true,
65
+ "max_tool_use_requirement": 0.25,
66
+ "_documentation": {
67
+ "enabled": "When false, skip local_zero entirely (fall through to triage cloud / HyDRA). Default true — does not disable local for trivial traffic.",
68
+ "max_tool_use_requirement": "Ceiling (0–1) on cheap predicted tool_use for local_zero dispatch. Effective limit is min(local model tool_use capability, this value). Default 0.25 keeps format/lint local while skipping agentic git/bash/edit/explore/delete/repo cues (SP-177, #98)."
69
+ }
70
+ },
63
71
  "pin_only_fallback": false
64
72
  }
@@ -3,7 +3,7 @@
3
3
  * Values sourced from specs/001-build-smart-router/data-model.md § Configuration (Operator).
4
4
  */
5
5
  import { type OperatorConfig } from '../domain/types/schemas.js';
6
- export { DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv } from '../domain/types/schemas.js';
6
+ export { DEFAULT_LOCAL_ZERO_CONFIG, DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv, } from '../domain/types/schemas.js';
7
7
  /** Merge operator env overrides onto defaults (SAAR and planning delegate sections). */
8
8
  export declare function resolveOperatorConfigFromEnv(base?: OperatorConfig): OperatorConfig;
9
9
  export declare const DEFAULT_OPERATOR_CONFIG: Readonly<OperatorConfig>;
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EAAE,gCAAgC,EAAE,mBAAmB,EAAE,oCAAoC,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEnK,wFAAwF;AACxF,wBAAgB,4BAA4B,CAC1C,IAAI,GAAE,cAAwC,GAC7C,cAAc,CAMhB;AAED,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CA+BnD,CAAC"}
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAML,KAAK,cAAc,EACpB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EACL,yBAAyB,EACzB,gCAAgC,EAChC,mBAAmB,EACnB,oCAAoC,EACpC,wBAAwB,GACzB,MAAM,4BAA4B,CAAC;AAEpC,wFAAwF;AACxF,wBAAgB,4BAA4B,CAC1C,IAAI,GAAE,cAAwC,GAC7C,cAAc,CAMhB;AAED,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CAgCnD,CAAC"}
@@ -2,9 +2,9 @@
2
2
  * Operator configuration defaults (FR-021).
3
3
  * Values sourced from specs/001-build-smart-router/data-model.md § Configuration (Operator).
4
4
  */
5
- import { DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv, } from '../domain/types/schemas.js';
5
+ import { DEFAULT_LOCAL_ZERO_CONFIG, DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv, } from '../domain/types/schemas.js';
6
6
  import { DEFAULT_LOW_INTENSITY_WEIGHTS } from '../domain/routing/tier-features.js';
7
- export { DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv } from '../domain/types/schemas.js';
7
+ export { DEFAULT_LOCAL_ZERO_CONFIG, DEFAULT_PLANNING_DELEGATE_CONFIG, DEFAULT_SAAR_CONFIG, resolvePlanningDelegateConfigFromEnv, resolveSaarConfigFromEnv, } from '../domain/types/schemas.js';
8
8
  /** Merge operator env overrides onto defaults (SAAR and planning delegate sections). */
9
9
  export function resolveOperatorConfigFromEnv(base = DEFAULT_OPERATOR_CONFIG) {
10
10
  return {
@@ -43,6 +43,7 @@ export const DEFAULT_OPERATOR_CONFIG = {
43
43
  },
44
44
  saar: DEFAULT_SAAR_CONFIG,
45
45
  planning_delegate: DEFAULT_PLANNING_DELEGATE_CONFIG,
46
+ local_zero: DEFAULT_LOCAL_ZERO_CONFIG,
46
47
  pin_only_fallback: false,
47
48
  };
48
49
  //# sourceMappingURL=defaults.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,gCAAgC,EAChC,mBAAmB,EACnB,oCAAoC,EACpC,wBAAwB,GAEzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,6BAA6B,EAAE,MAAM,oCAAoC,CAAC;AAEnF,OAAO,EAAE,gCAAgC,EAAE,mBAAmB,EAAE,oCAAoC,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEnK,wFAAwF;AACxF,MAAM,UAAU,4BAA4B,CAC1C,OAAuB,uBAAuB;IAE9C,OAAO;QACL,GAAG,IAAI;QACP,IAAI,EAAE,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC;QACzC,iBAAiB,EAAE,oCAAoC,CAAC,IAAI,CAAC,iBAAiB,CAAC;KAChF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,uBAAuB,GAA6B;IAC/D,SAAS,EAAE;QACT,WAAW,EAAE,GAAG;QAChB,cAAc,EAAE,GAAG;QACnB,gBAAgB,EAAE,IAAI;KACvB;IACD,eAAe,EAAE;QACf,SAAS,EAAE,CAAC;KACb;IACD,OAAO,EAAE;QACP,cAAc,EAAE,EAAE;KACnB;IACD,KAAK,EAAE;QACL,kBAAkB,EAAE,EAAE;QACtB,4BAA4B,EAAE,CAAC;QAC/B,qBAAqB,EAAE,EAAE;KAC1B;IACD,KAAK,EAAE;QACL,mBAAmB,EAAE,0BAA0B;QAC/C,OAAO,EAAE,QAAQ;QACjB,WAAW,EAAE,oBAAoB;KAClC;IACD,aAAa,EAAE;QACb,OAAO,EAAE,6BAA6B;QACtC,cAAc,EAAE,IAAI;QACpB,aAAa,EAAE,IAAI;QACnB,eAAe,EAAE,GAAG;KACrB;IACD,IAAI,EAAE,mBAAmB;IACzB,iBAAiB,EAAE,gCAAgC;IACnD,iBAAiB,EAAE,KAAK;CAChB,CAAC"}
1
+ {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,yBAAyB,EACzB,gCAAgC,EAChC,mBAAmB,EACnB,oCAAoC,EACpC,wBAAwB,GAEzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,6BAA6B,EAAE,MAAM,oCAAoC,CAAC;AAEnF,OAAO,EACL,yBAAyB,EACzB,gCAAgC,EAChC,mBAAmB,EACnB,oCAAoC,EACpC,wBAAwB,GACzB,MAAM,4BAA4B,CAAC;AAEpC,wFAAwF;AACxF,MAAM,UAAU,4BAA4B,CAC1C,OAAuB,uBAAuB;IAE9C,OAAO;QACL,GAAG,IAAI;QACP,IAAI,EAAE,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC;QACzC,iBAAiB,EAAE,oCAAoC,CAAC,IAAI,CAAC,iBAAiB,CAAC;KAChF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,uBAAuB,GAA6B;IAC/D,SAAS,EAAE;QACT,WAAW,EAAE,GAAG;QAChB,cAAc,EAAE,GAAG;QACnB,gBAAgB,EAAE,IAAI;KACvB;IACD,eAAe,EAAE;QACf,SAAS,EAAE,CAAC;KACb;IACD,OAAO,EAAE;QACP,cAAc,EAAE,EAAE;KACnB;IACD,KAAK,EAAE;QACL,kBAAkB,EAAE,EAAE;QACtB,4BAA4B,EAAE,CAAC;QAC/B,qBAAqB,EAAE,EAAE;KAC1B;IACD,KAAK,EAAE;QACL,mBAAmB,EAAE,0BAA0B;QAC/C,OAAO,EAAE,QAAQ;QACjB,WAAW,EAAE,oBAAoB;KAClC;IACD,aAAa,EAAE;QACb,OAAO,EAAE,6BAA6B;QACtC,cAAc,EAAE,IAAI;QACpB,aAAa,EAAE,IAAI;QACnB,eAAe,EAAE,GAAG;KACrB;IACD,IAAI,EAAE,mBAAmB;IACzB,iBAAiB,EAAE,gCAAgC;IACnD,UAAU,EAAE,yBAAyB;IACrC,iBAAiB,EAAE,KAAK;CAChB,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Loop escalation — FR-014, FR-008 rule #3.
2
+ * Loop escalation — FR-014, FR-008 rule #3 / #4.
3
3
  *
4
4
  * Detects bounded repeated identical tool failures and signals
5
5
  * session pin escalation to a frontier-capable tier.
@@ -7,12 +7,27 @@
7
7
  * Escalation fires once per session; no tier oscillation (FR-008).
8
8
  * Threshold defaults to 3 identical tool failures (operator configurable).
9
9
  *
10
+ * Zero-tier observational pin-break (SP-178 / #99):
11
+ * SAAR pins preserve prefix-cache value, but a zero-tier pin that faces
12
+ * unsupported/unknown tools or sustained tool-loop churn is a capability
13
+ * mismatch — the same class of observational signal as identical tool
14
+ * failures (FR-014). Escalation reuses the `loop_escalation` pin reason
15
+ * (allowed FR-008 break) rather than inventing a cache-economics bypass.
16
+ * Voluntary cross-provider switches still go through breakeven; this path
17
+ * only fires on observational evidence that the pinned zero-tier model
18
+ * cannot complete the tool loop.
19
+ *
10
20
  * Design: pure evaluation function — caller (pipeline stage) is
11
21
  * responsible for applying pin state updates via SessionPinner.
12
22
  */
13
23
  import type { ModelProfile, RoutingRequest, SessionPin } from '../types/index.js';
14
24
  export interface LoopEscalationConfig {
15
25
  readonly threshold: number;
26
+ /**
27
+ * Tool-result turns while pinned to zero-tier before observational escalate.
28
+ * Defaults to `threshold` when omitted (same operator knob, no schema change).
29
+ */
30
+ readonly zero_tier_tool_call_threshold?: number;
16
31
  }
17
32
  export interface LoopEscalationResult {
18
33
  readonly shouldEscalate: boolean;
@@ -20,6 +35,8 @@ export interface LoopEscalationResult {
20
35
  readonly escalationTarget: ModelProfile | null;
21
36
  readonly reason: string;
22
37
  }
38
+ /** Stable signature for zero-tier tool-call churn counting (SP-178). */
39
+ export declare const ZERO_TIER_TOOL_CHURN_SIGNATURE: "zt:tool_churn";
23
40
  /**
24
41
  * Extract a tool-failure signature from the request's messages.
25
42
  * Returns null when the request does not carry a tool failure.
@@ -28,6 +45,11 @@ export interface LoopEscalationResult {
28
45
  * carry observational failure signals (FR-014: no post-generation judging).
29
46
  */
30
47
  export declare function extractToolFailureSignature(request: RoutingRequest): string | null;
48
+ /**
49
+ * True when the latest tool result reports an unsupported/unknown tool
50
+ * (capability mismatch — escalate immediately on zero-tier pins).
51
+ */
52
+ export declare function isUnsupportedOrUnknownToolResult(request: RoutingRequest): boolean;
31
53
  /**
32
54
  * Evaluate whether the session should escalate to a higher tier.
33
55
  *
@@ -1 +1 @@
1
- {"version":3,"file":"loop-escalation.d.ts","sourceRoot":"","sources":["../../../src/domain/pinning/loop-escalation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAIlF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,gBAAgB,EAAE,YAAY,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAgBD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAclF;AAmBD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,UAAU,GAAG,IAAI,EACtB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,MAAM,EAAE,oBAAoB,GAC3B,oBAAoB,CAkEtB"}
1
+ {"version":3,"file":"loop-escalation.d.ts","sourceRoot":"","sources":["../../../src/domain/pinning/loop-escalation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAIlF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,6BAA6B,CAAC,EAAE,MAAM,CAAC;CACjD;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,gBAAgB,EAAE,YAAY,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wEAAwE;AACxE,eAAO,MAAM,8BAA8B,EAAG,eAAwB,CAAC;AA4BvE;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAOlF;AAED;;;GAGG;AACH,wBAAgB,gCAAgC,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAKjF;AA+GD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,UAAU,GAAG,IAAI,EACtB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,MAAM,EAAE,oBAAoB,GAC3B,oBAAoB,CAwDtB"}