pi-smart-router 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/.pi/extensions/smart-router/command-formatters.ts +167 -0
- package/.pi/extensions/smart-router/commands.ts +242 -0
- package/.pi/extensions/smart-router/dataset-export.ts +142 -0
- package/.pi/extensions/smart-router/delegate-stream.ts +105 -0
- package/.pi/extensions/smart-router/delegation-runtime.ts +355 -0
- package/.pi/extensions/smart-router/extension-setup.ts +115 -0
- package/.pi/extensions/smart-router/fleet-bootstrap.ts +244 -0
- package/.pi/extensions/smart-router/index.ts +115 -0
- package/.pi/extensions/smart-router/package.json +3 -0
- package/.pi/extensions/smart-router/pi-model-scope.ts +55 -0
- package/.pi/extensions/smart-router/pricing-lifecycle.ts +43 -0
- package/.pi/extensions/smart-router/route-and-delegate.ts +562 -0
- package/.pi/extensions/smart-router/routing-context.ts +171 -0
- package/.pi/extensions/smart-router/routing-outcomes.ts +44 -0
- package/.pi/extensions/smart-router/session-lifecycle.ts +140 -0
- package/.pi/extensions/smart-router/stream-delegation.ts +75 -0
- package/.pi/extensions/smart-router/types.ts +90 -0
- package/.pi/extensions/smart-router/utils.ts +58 -0
- package/README.md +593 -0
- package/bin/pi-smart-router.mjs +79 -0
- package/config/.gitkeep +0 -0
- package/config/hydra-projection-weights.json.example +1169 -0
- package/config/models.yaml.example +58 -0
- package/config/p-success-weights.json.example +19 -0
- package/config/routing-calibration.json.example +1605 -0
- package/config/routing-centroids.json.example +1570 -0
- package/config/routing-clusters.yaml.example +41 -0
- package/data/contrib/example.json +25 -0
- package/dist/api/explain/router-explain.d.ts +47 -0
- package/dist/api/explain/router-explain.d.ts.map +1 -0
- package/dist/api/explain/router-explain.js +101 -0
- package/dist/api/explain/router-explain.js.map +1 -0
- package/dist/api/middleware/pi-router-middleware.d.ts +86 -0
- package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -0
- package/dist/api/middleware/pi-router-middleware.js +83 -0
- package/dist/api/middleware/pi-router-middleware.js.map +1 -0
- package/dist/cli/smart-router-cli.d.ts +85 -0
- package/dist/cli/smart-router-cli.d.ts.map +1 -0
- package/dist/cli/smart-router-cli.js +293 -0
- package/dist/cli/smart-router-cli.js.map +1 -0
- package/dist/config/defaults.d.ts +7 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +33 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/models-loader.d.ts +22 -0
- package/dist/config/models-loader.d.ts.map +1 -0
- package/dist/config/models-loader.js +51 -0
- package/dist/config/models-loader.js.map +1 -0
- package/dist/config/pi-model-mapper.d.ts +40 -0
- package/dist/config/pi-model-mapper.d.ts.map +1 -0
- package/dist/config/pi-model-mapper.js +216 -0
- package/dist/config/pi-model-mapper.js.map +1 -0
- package/dist/config/routing-clusters-loader.d.ts +35 -0
- package/dist/config/routing-clusters-loader.d.ts.map +1 -0
- package/dist/config/routing-clusters-loader.js +100 -0
- package/dist/config/routing-clusters-loader.js.map +1 -0
- package/dist/domain/delegation/delegation-context.d.ts +23 -0
- package/dist/domain/delegation/delegation-context.d.ts.map +1 -0
- package/dist/domain/delegation/delegation-context.js +73 -0
- package/dist/domain/delegation/delegation-context.js.map +1 -0
- package/dist/domain/delegation/execution-ledger.d.ts +19 -0
- package/dist/domain/delegation/execution-ledger.d.ts.map +1 -0
- package/dist/domain/delegation/execution-ledger.js +23 -0
- package/dist/domain/delegation/execution-ledger.js.map +1 -0
- package/dist/domain/delegation/output-headroom.d.ts +39 -0
- package/dist/domain/delegation/output-headroom.d.ts.map +1 -0
- package/dist/domain/delegation/output-headroom.js +78 -0
- package/dist/domain/delegation/output-headroom.js.map +1 -0
- package/dist/domain/matching/cluster-matcher.d.ts +69 -0
- package/dist/domain/matching/cluster-matcher.d.ts.map +1 -0
- package/dist/domain/matching/cluster-matcher.js +294 -0
- package/dist/domain/matching/cluster-matcher.js.map +1 -0
- package/dist/domain/matching/embedding-provider.d.ts +21 -0
- package/dist/domain/matching/embedding-provider.d.ts.map +1 -0
- package/dist/domain/matching/embedding-provider.js +42 -0
- package/dist/domain/matching/embedding-provider.js.map +1 -0
- package/dist/domain/matching/hydra-input.d.ts +18 -0
- package/dist/domain/matching/hydra-input.d.ts.map +1 -0
- package/dist/domain/matching/hydra-input.js +40 -0
- package/dist/domain/matching/hydra-input.js.map +1 -0
- package/dist/domain/matching/hydra-matcher.d.ts +103 -0
- package/dist/domain/matching/hydra-matcher.d.ts.map +1 -0
- package/dist/domain/matching/hydra-matcher.js +275 -0
- package/dist/domain/matching/hydra-matcher.js.map +1 -0
- package/dist/domain/pinning/cache-economics.d.ts +53 -0
- package/dist/domain/pinning/cache-economics.d.ts.map +1 -0
- package/dist/domain/pinning/cache-economics.js +72 -0
- package/dist/domain/pinning/cache-economics.js.map +1 -0
- package/dist/domain/pinning/loop-escalation.d.ts +41 -0
- package/dist/domain/pinning/loop-escalation.d.ts.map +1 -0
- package/dist/domain/pinning/loop-escalation.js +137 -0
- package/dist/domain/pinning/loop-escalation.js.map +1 -0
- package/dist/domain/pinning/session-pinner.d.ts +81 -0
- package/dist/domain/pinning/session-pinner.d.ts.map +1 -0
- package/dist/domain/pinning/session-pinner.js +237 -0
- package/dist/domain/pinning/session-pinner.js.map +1 -0
- package/dist/domain/pinning/sub-route-policy.d.ts +41 -0
- package/dist/domain/pinning/sub-route-policy.d.ts.map +1 -0
- package/dist/domain/pinning/sub-route-policy.js +72 -0
- package/dist/domain/pinning/sub-route-policy.js.map +1 -0
- package/dist/domain/pipeline/router-pipeline.d.ts +180 -0
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -0
- package/dist/domain/pipeline/router-pipeline.js +807 -0
- package/dist/domain/pipeline/router-pipeline.js.map +1 -0
- package/dist/domain/pipeline/safe-default.d.ts +25 -0
- package/dist/domain/pipeline/safe-default.d.ts.map +1 -0
- package/dist/domain/pipeline/safe-default.js +41 -0
- package/dist/domain/pipeline/safe-default.js.map +1 -0
- package/dist/domain/refactor-guardrails.d.ts +48 -0
- package/dist/domain/refactor-guardrails.d.ts.map +1 -0
- package/dist/domain/refactor-guardrails.js +129 -0
- package/dist/domain/refactor-guardrails.js.map +1 -0
- package/dist/domain/routing/context-fit.d.ts +53 -0
- package/dist/domain/routing/context-fit.d.ts.map +1 -0
- package/dist/domain/routing/context-fit.js +142 -0
- package/dist/domain/routing/context-fit.js.map +1 -0
- package/dist/domain/routing/expected-cost.d.ts +63 -0
- package/dist/domain/routing/expected-cost.d.ts.map +1 -0
- package/dist/domain/routing/expected-cost.js +177 -0
- package/dist/domain/routing/expected-cost.js.map +1 -0
- package/dist/domain/routing/p-success-classifier.d.ts +130 -0
- package/dist/domain/routing/p-success-classifier.d.ts.map +1 -0
- package/dist/domain/routing/p-success-classifier.js +350 -0
- package/dist/domain/routing/p-success-classifier.js.map +1 -0
- package/dist/domain/routing/tier-features.d.ts +70 -0
- package/dist/domain/routing/tier-features.d.ts.map +1 -0
- package/dist/domain/routing/tier-features.js +174 -0
- package/dist/domain/routing/tier-features.js.map +1 -0
- package/dist/domain/routing/tool-history-guard.d.ts +32 -0
- package/dist/domain/routing/tool-history-guard.d.ts.map +1 -0
- package/dist/domain/routing/tool-history-guard.js +110 -0
- package/dist/domain/routing/tool-history-guard.js.map +1 -0
- package/dist/domain/scoring/multi-objective.d.ts +48 -0
- package/dist/domain/scoring/multi-objective.d.ts.map +1 -0
- package/dist/domain/scoring/multi-objective.js +116 -0
- package/dist/domain/scoring/multi-objective.js.map +1 -0
- package/dist/domain/triage/triage-engine.d.ts +43 -0
- package/dist/domain/triage/triage-engine.d.ts.map +1 -0
- package/dist/domain/triage/triage-engine.js +317 -0
- package/dist/domain/triage/triage-engine.js.map +1 -0
- package/dist/domain/triage/turn-envelope.d.ts +17 -0
- package/dist/domain/triage/turn-envelope.d.ts.map +1 -0
- package/dist/domain/triage/turn-envelope.js +81 -0
- package/dist/domain/triage/turn-envelope.js.map +1 -0
- package/dist/domain/types/entities.d.ts +303 -0
- package/dist/domain/types/entities.d.ts.map +1 -0
- package/dist/domain/types/entities.js +6 -0
- package/dist/domain/types/entities.js.map +1 -0
- package/dist/domain/types/index.d.ts +4 -0
- package/dist/domain/types/index.d.ts.map +1 -0
- package/dist/domain/types/index.js +2 -0
- package/dist/domain/types/index.js.map +1 -0
- package/dist/domain/types/schemas.d.ts +346 -0
- package/dist/domain/types/schemas.d.ts.map +1 -0
- package/dist/domain/types/schemas.js +241 -0
- package/dist/domain/types/schemas.js.map +1 -0
- package/dist/domain/types/store-port.d.ts +44 -0
- package/dist/domain/types/store-port.d.ts.map +1 -0
- package/dist/domain/types/store-port.js +6 -0
- package/dist/domain/types/store-port.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/infra/gemini-provider.d.ts +28 -0
- package/dist/infra/gemini-provider.d.ts.map +1 -0
- package/dist/infra/gemini-provider.js +112 -0
- package/dist/infra/gemini-provider.js.map +1 -0
- package/dist/infra/telemetry.d.ts +51 -0
- package/dist/infra/telemetry.d.ts.map +1 -0
- package/dist/infra/telemetry.js +100 -0
- package/dist/infra/telemetry.js.map +1 -0
- package/dist/infrastructure/delegation/provider-error.d.ts +44 -0
- package/dist/infrastructure/delegation/provider-error.d.ts.map +1 -0
- package/dist/infrastructure/delegation/provider-error.js +179 -0
- package/dist/infrastructure/delegation/provider-error.js.map +1 -0
- package/dist/infrastructure/gateway/circuit-breaker.d.ts +68 -0
- package/dist/infrastructure/gateway/circuit-breaker.d.ts.map +1 -0
- package/dist/infrastructure/gateway/circuit-breaker.js +150 -0
- package/dist/infrastructure/gateway/circuit-breaker.js.map +1 -0
- package/dist/infrastructure/gateway/gateway-dispatch.d.ts +138 -0
- package/dist/infrastructure/gateway/gateway-dispatch.d.ts.map +1 -0
- package/dist/infrastructure/gateway/gateway-dispatch.js +308 -0
- package/dist/infrastructure/gateway/gateway-dispatch.js.map +1 -0
- package/dist/infrastructure/hardware/hardware-probe.d.ts +32 -0
- package/dist/infrastructure/hardware/hardware-probe.d.ts.map +1 -0
- package/dist/infrastructure/hardware/hardware-probe.js +199 -0
- package/dist/infrastructure/hardware/hardware-probe.js.map +1 -0
- package/dist/infrastructure/local/local-zero-tier.d.ts +47 -0
- package/dist/infrastructure/local/local-zero-tier.d.ts.map +1 -0
- package/dist/infrastructure/local/local-zero-tier.js +92 -0
- package/dist/infrastructure/local/local-zero-tier.js.map +1 -0
- package/dist/infrastructure/persistence/memory-store.d.ts +31 -0
- package/dist/infrastructure/persistence/memory-store.d.ts.map +1 -0
- package/dist/infrastructure/persistence/memory-store.js +87 -0
- package/dist/infrastructure/persistence/memory-store.js.map +1 -0
- package/dist/infrastructure/persistence/sqlite-store.d.ts +81 -0
- package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -0
- package/dist/infrastructure/persistence/sqlite-store.js +658 -0
- package/dist/infrastructure/persistence/sqlite-store.js.map +1 -0
- package/dist/infrastructure/pricing/litellm-fetch.d.ts +46 -0
- package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -0
- package/dist/infrastructure/pricing/litellm-fetch.js +145 -0
- package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -0
- package/dist/infrastructure/pricing/price-broker.d.ts +56 -0
- package/dist/infrastructure/pricing/price-broker.d.ts.map +1 -0
- package/dist/infrastructure/pricing/price-broker.js +133 -0
- package/dist/infrastructure/pricing/price-broker.js.map +1 -0
- package/dist/infrastructure/pricing/pricing-monitor.d.ts +27 -0
- package/dist/infrastructure/pricing/pricing-monitor.d.ts.map +1 -0
- package/dist/infrastructure/pricing/pricing-monitor.js +46 -0
- package/dist/infrastructure/pricing/pricing-monitor.js.map +1 -0
- package/dist/infrastructure/telemetry/dataset-limits.d.ts +13 -0
- package/dist/infrastructure/telemetry/dataset-limits.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/dataset-limits.js +28 -0
- package/dist/infrastructure/telemetry/dataset-limits.js.map +1 -0
- package/dist/infrastructure/telemetry/dataset-recorder.d.ts +48 -0
- package/dist/infrastructure/telemetry/dataset-recorder.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/dataset-recorder.js +154 -0
- package/dist/infrastructure/telemetry/dataset-recorder.js.map +1 -0
- package/dist/infrastructure/telemetry/outcome-limits.d.ts +12 -0
- package/dist/infrastructure/telemetry/outcome-limits.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/outcome-limits.js +28 -0
- package/dist/infrastructure/telemetry/outcome-limits.js.map +1 -0
- package/dist/infrastructure/telemetry/outcome-recorder.d.ts +29 -0
- package/dist/infrastructure/telemetry/outcome-recorder.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/outcome-recorder.js +49 -0
- package/dist/infrastructure/telemetry/outcome-recorder.js.map +1 -0
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts +115 -0
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/routing-telemetry.js +529 -0
- package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -0
- package/dist/infrastructure/telemetry/telemetry-limits.d.ts +16 -0
- package/dist/infrastructure/telemetry/telemetry-limits.d.ts.map +1 -0
- package/dist/infrastructure/telemetry/telemetry-limits.js +35 -0
- package/dist/infrastructure/telemetry/telemetry-limits.js.map +1 -0
- package/package.json +110 -0
- package/skills/router-backlog-orchestrator/SKILL.md +183 -0
- package/skills/router-backlog-orchestrator/references/github-router-issue-template.md +78 -0
- package/skills/router-backlog-orchestrator/references/packet-from-issue.md +99 -0
- package/skills/router-backlog-orchestrator/references/prioritization-rubric.md +65 -0
- package/skills/router-backlog-orchestrator/scripts/collect-backlog.sh +44 -0
- package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +151 -0
- package/src/api/explain/.gitkeep +0 -0
- package/src/api/explain/router-explain.ts +146 -0
- package/src/api/middleware/.gitkeep +0 -0
- package/src/api/middleware/pi-router-middleware.ts +169 -0
- package/src/cli/smart-router-cli.ts +436 -0
- package/src/config/.gitkeep +0 -0
- package/src/config/defaults.ts +35 -0
- package/src/config/models-loader.ts +68 -0
- package/src/config/pi-model-mapper.ts +285 -0
- package/src/config/routing-clusters-loader.ts +148 -0
- package/src/domain/delegation/delegation-context.ts +111 -0
- package/src/domain/delegation/execution-ledger.ts +34 -0
- package/src/domain/delegation/output-headroom.ts +131 -0
- package/src/domain/matching/.gitkeep +0 -0
- package/src/domain/matching/cluster-matcher.ts +474 -0
- package/src/domain/matching/embedding-provider.ts +79 -0
- package/src/domain/matching/hydra-input.ts +53 -0
- package/src/domain/matching/hydra-matcher.ts +454 -0
- package/src/domain/pinning/.gitkeep +0 -0
- package/src/domain/pinning/cache-economics.ts +128 -0
- package/src/domain/pinning/loop-escalation.ts +181 -0
- package/src/domain/pinning/session-pinner.ts +388 -0
- package/src/domain/pinning/sub-route-policy.ts +119 -0
- package/src/domain/pipeline/.gitkeep +0 -0
- package/src/domain/pipeline/router-pipeline.ts +1139 -0
- package/src/domain/pipeline/safe-default.ts +70 -0
- package/src/domain/refactor-guardrails.ts +202 -0
- package/src/domain/routing/context-fit.ts +221 -0
- package/src/domain/routing/expected-cost.ts +320 -0
- package/src/domain/routing/p-success-classifier.ts +511 -0
- package/src/domain/routing/tier-features.ts +290 -0
- package/src/domain/routing/tool-history-guard.ts +159 -0
- package/src/domain/scoring/.gitkeep +0 -0
- package/src/domain/scoring/multi-objective.ts +175 -0
- package/src/domain/triage/.gitkeep +0 -0
- package/src/domain/triage/triage-engine.ts +373 -0
- package/src/domain/triage/turn-envelope.ts +104 -0
- package/src/domain/types/.gitkeep +0 -0
- package/src/domain/types/entities.ts +384 -0
- package/src/domain/types/index.ts +41 -0
- package/src/domain/types/schemas.ts +289 -0
- package/src/domain/types/store-port.ts +59 -0
- package/src/index.ts +103 -0
- package/src/infra/gemini-provider.ts +142 -0
- package/src/infra/telemetry.ts +169 -0
- package/src/infrastructure/delegation/provider-error.ts +251 -0
- package/src/infrastructure/gateway/.gitkeep +0 -0
- package/src/infrastructure/gateway/circuit-breaker.ts +196 -0
- package/src/infrastructure/gateway/gateway-dispatch.ts +444 -0
- package/src/infrastructure/hardware/.gitkeep +0 -0
- package/src/infrastructure/hardware/hardware-probe.ts +276 -0
- package/src/infrastructure/local/.gitkeep +0 -0
- package/src/infrastructure/local/local-zero-tier.ts +151 -0
- package/src/infrastructure/persistence/.gitkeep +0 -0
- package/src/infrastructure/persistence/memory-store.ts +122 -0
- package/src/infrastructure/persistence/sqlite-store.ts +900 -0
- package/src/infrastructure/pricing/.gitkeep +0 -0
- package/src/infrastructure/pricing/litellm-fetch.ts +215 -0
- package/src/infrastructure/pricing/price-broker.ts +183 -0
- package/src/infrastructure/pricing/pricing-monitor.ts +65 -0
- package/src/infrastructure/telemetry/.gitkeep +0 -0
- package/src/infrastructure/telemetry/dataset-limits.ts +39 -0
- package/src/infrastructure/telemetry/dataset-recorder.ts +215 -0
- package/src/infrastructure/telemetry/outcome-limits.ts +42 -0
- package/src/infrastructure/telemetry/outcome-recorder.ts +118 -0
- package/src/infrastructure/telemetry/routing-telemetry.ts +824 -0
- package/src/infrastructure/telemetry/telemetry-limits.ts +50 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing telemetry emitter — T039.
|
|
3
|
+
*
|
|
4
|
+
* Maintains an append-only rolling window of routing decisions for
|
|
5
|
+
* observability and audit. Window bounds: 168 hours (7 days), max 1111 entries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CONTEXT_FIT_EXCEEDED,
|
|
10
|
+
CONTEXT_OVERFLOW_FRONTIER_FALLBACK,
|
|
11
|
+
CONTEXT_OVERFLOW_NO_FIT,
|
|
12
|
+
CONTEXT_OVERFLOW_SAME_PROVIDER_FALLBACK,
|
|
13
|
+
modelFitsContext,
|
|
14
|
+
resolveSafetyMargin,
|
|
15
|
+
type ContextFitConfig,
|
|
16
|
+
} from '../../domain/routing/context-fit.js';
|
|
17
|
+
import { CLUSTER_REASON_CODE_PREFIX } from '../../config/routing-clusters-loader.js';
|
|
18
|
+
import type { ClusterMatcher } from '../../domain/matching/cluster-matcher.js';
|
|
19
|
+
import type {
|
|
20
|
+
ClusterMatchTableEntry,
|
|
21
|
+
ContextFitObservability,
|
|
22
|
+
ContextFitRejectedEntry,
|
|
23
|
+
LowIntensityBreakdown,
|
|
24
|
+
ModelProfile,
|
|
25
|
+
PriceCatalog,
|
|
26
|
+
RejectedTierEntry,
|
|
27
|
+
RoutingDecision,
|
|
28
|
+
RoutingRequest,
|
|
29
|
+
RoutingTelemetry,
|
|
30
|
+
TierFeatureSummary,
|
|
31
|
+
TierSelectionObservability,
|
|
32
|
+
} from '../../domain/types/index.js';
|
|
33
|
+
import { resolveFrugalityCostPer1M } from '../pricing/price-broker.js';
|
|
34
|
+
import {
|
|
35
|
+
TELEMETRY_MAX_ENTRIES,
|
|
36
|
+
TELEMETRY_WINDOW_MS,
|
|
37
|
+
evictExpiredTelemetryEntries,
|
|
38
|
+
makeTelemetryRoom,
|
|
39
|
+
} from './telemetry-limits.js';
|
|
40
|
+
|
|
41
|
+
export const CONTEXT_FIT_PASS = 'context_fit_pass' as const;
|
|
42
|
+
export const CONTEXT_FIT_REJECTED_ALL = 'context_fit_rejected_all' as const;
|
|
43
|
+
export const CONTEXT_OVERFLOW_PIN_BREAK = 'context_overflow_pin_break' as const;
|
|
44
|
+
|
|
45
|
+
export const LOW_INTENSITY_STRUCTURAL = 'low_intensity_structural' as const;
|
|
46
|
+
export const HIGH_INTENSITY_STRUCTURAL = 'high_intensity_structural' as const;
|
|
47
|
+
export const P_SUCCESS_CHEAP = 'p_success_cheap' as const;
|
|
48
|
+
export const P_SUCCESS_UNCERTAIN = 'p_success_uncertain' as const;
|
|
49
|
+
|
|
50
|
+
const EXPECTED_COST_PREFIX = 'expected_cost_';
|
|
51
|
+
const EXPECTED_COST_DEFER_CODES = new Set<string>([
|
|
52
|
+
'expected_cost_price_delta_insufficient',
|
|
53
|
+
'expected_cost_no_viable_tier',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const OVERFLOW_REASON_CODES = new Set<string>([
|
|
57
|
+
CONTEXT_OVERFLOW_SAME_PROVIDER_FALLBACK,
|
|
58
|
+
CONTEXT_OVERFLOW_FRONTIER_FALLBACK,
|
|
59
|
+
CONTEXT_OVERFLOW_NO_FIT,
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Estimate per-request routing cost in USD from resolved model pricing (SP-085).
|
|
64
|
+
* Uses estimated_input_tokens when present, otherwise prompt_text length as a token proxy.
|
|
65
|
+
*/
|
|
66
|
+
export function estimateRoutingCost(
|
|
67
|
+
model: ModelProfile,
|
|
68
|
+
request: RoutingRequest,
|
|
69
|
+
catalog: PriceCatalog | null,
|
|
70
|
+
): number {
|
|
71
|
+
const tokens = request.estimated_input_tokens ?? request.prompt_text.length;
|
|
72
|
+
const costPer1M = resolveFrugalityCostPer1M(model, catalog);
|
|
73
|
+
return (tokens / 1_000_000) * costPer1M;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
DEFAULT_HISTORY_LIMIT,
|
|
78
|
+
MAX_HISTORY_LIMIT,
|
|
79
|
+
TELEMETRY_MAX_ENTRIES,
|
|
80
|
+
TELEMETRY_WINDOW_HOURS,
|
|
81
|
+
TELEMETRY_WINDOW_MS,
|
|
82
|
+
} from './telemetry-limits.js';
|
|
83
|
+
|
|
84
|
+
export interface TelemetryEmitterOptions {
|
|
85
|
+
readonly maxEntries?: number;
|
|
86
|
+
readonly windowMs?: number;
|
|
87
|
+
readonly clock?: () => string;
|
|
88
|
+
readonly onRecord?: (record: RoutingTelemetry) => void;
|
|
89
|
+
readonly fleet?: readonly ModelProfile[];
|
|
90
|
+
readonly contextFitConfig?: ContextFitConfig;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ContextFitObservabilityInput {
|
|
94
|
+
readonly request: RoutingRequest;
|
|
95
|
+
readonly decision: RoutingDecision;
|
|
96
|
+
readonly fleet?: readonly ModelProfile[] | undefined;
|
|
97
|
+
readonly contextFitConfig?: ContextFitConfig | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveEstimatedInputTokens(request: RoutingRequest): number {
|
|
101
|
+
return request.estimated_input_tokens ?? request.prompt_text.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractContextFitRejected(decision: RoutingDecision) {
|
|
105
|
+
const candidates = decision.features?.candidates ?? decision.candidates ?? [];
|
|
106
|
+
return candidates.filter(
|
|
107
|
+
(candidate) => candidate.rejected_reason === CONTEXT_FIT_EXCEEDED,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lookupMaxInputTokens(
|
|
112
|
+
fleet: readonly ModelProfile[] | undefined,
|
|
113
|
+
modelId: string,
|
|
114
|
+
): number | null {
|
|
115
|
+
const profile = fleet?.find((model) => model.id === modelId);
|
|
116
|
+
return profile?.limits?.max_input_tokens ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function serializeContextFitRejected(
|
|
120
|
+
rejected: readonly { model_id: string; rejected_reason: string | null }[],
|
|
121
|
+
fleet: readonly ModelProfile[] | undefined,
|
|
122
|
+
): string | null {
|
|
123
|
+
if (rejected.length === 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const entries: ContextFitRejectedEntry[] = rejected.map((candidate) => ({
|
|
128
|
+
model_id: candidate.model_id,
|
|
129
|
+
max_input_tokens: lookupMaxInputTokens(fleet, candidate.model_id),
|
|
130
|
+
reason: candidate.rejected_reason ?? CONTEXT_FIT_EXCEEDED,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
return JSON.stringify(entries);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function countViableModels(
|
|
137
|
+
fleet: readonly ModelProfile[],
|
|
138
|
+
estimatedInputTokens: number,
|
|
139
|
+
safetyMargin: number,
|
|
140
|
+
): number {
|
|
141
|
+
let count = 0;
|
|
142
|
+
for (const model of fleet) {
|
|
143
|
+
if (modelFitsContext(model, estimatedInputTokens, safetyMargin)) {
|
|
144
|
+
count += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return count;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveContextOverflowPinBreak(decision: RoutingDecision): boolean {
|
|
151
|
+
if (decision.pin_reason === 'context_overflow') {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return OVERFLOW_REASON_CODES.has(decision.reason_code);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveContextFitReasonCode(
|
|
159
|
+
decision: RoutingDecision,
|
|
160
|
+
rejectedCount: number,
|
|
161
|
+
viableCount: number | null,
|
|
162
|
+
gateRan: boolean,
|
|
163
|
+
): string | null {
|
|
164
|
+
if (!gateRan) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (decision.pin_reason === 'context_overflow') {
|
|
169
|
+
return CONTEXT_OVERFLOW_PIN_BREAK;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (OVERFLOW_REASON_CODES.has(decision.reason_code)) {
|
|
173
|
+
return decision.reason_code;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (decision.reason_code === CONTEXT_OVERFLOW_PIN_BREAK) {
|
|
177
|
+
return CONTEXT_OVERFLOW_PIN_BREAK;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
viableCount === 0 ||
|
|
182
|
+
decision.reason_code === CONTEXT_OVERFLOW_NO_FIT ||
|
|
183
|
+
(rejectedCount > 0 && decision.selected_model_id === 'unknown')
|
|
184
|
+
) {
|
|
185
|
+
return CONTEXT_FIT_REJECTED_ALL;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (rejectedCount > 0 || viableCount !== null) {
|
|
189
|
+
return CONTEXT_FIT_PASS;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return CONTEXT_FIT_PASS;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function gateSkipped(request: RoutingRequest): boolean {
|
|
196
|
+
return request.force_model_id !== undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function gateRan(request: RoutingRequest, decision: RoutingDecision): boolean {
|
|
200
|
+
if (gateSkipped(request)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rejected = extractContextFitRejected(decision);
|
|
205
|
+
if (rejected.length > 0) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (request.estimated_input_tokens !== undefined) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (OVERFLOW_REASON_CODES.has(decision.reason_code)) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (decision.pin_reason === 'context_overflow') {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return decision.features?.context_fit !== undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build privacy-safe context-fit observability from a routing decision (SP-110). */
|
|
225
|
+
export function buildContextFitObservability(
|
|
226
|
+
input: ContextFitObservabilityInput,
|
|
227
|
+
): ContextFitObservability | null {
|
|
228
|
+
const { request, decision, fleet, contextFitConfig } = input;
|
|
229
|
+
|
|
230
|
+
if (decision.features?.context_fit) {
|
|
231
|
+
return decision.features.context_fit;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (gateSkipped(request)) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const ran = gateRan(request, decision);
|
|
239
|
+
if (!ran) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const rejected = extractContextFitRejected(decision);
|
|
244
|
+
const estimatedInputTokens = resolveEstimatedInputTokens(request);
|
|
245
|
+
const safetyMargin = resolveSafetyMargin(contextFitConfig);
|
|
246
|
+
const viableCount =
|
|
247
|
+
fleet !== undefined ? countViableModels(fleet, estimatedInputTokens, safetyMargin) : null;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
estimated_input_tokens: estimatedInputTokens,
|
|
251
|
+
context_fit_viable_count: viableCount,
|
|
252
|
+
context_fit_rejected_json: serializeContextFitRejected(rejected, fleet),
|
|
253
|
+
context_overflow_pin_break: resolveContextOverflowPinBreak(decision),
|
|
254
|
+
selected_model_max_input_tokens: lookupMaxInputTokens(
|
|
255
|
+
fleet,
|
|
256
|
+
decision.selected_model_id,
|
|
257
|
+
),
|
|
258
|
+
context_fit_reason_code: resolveContextFitReasonCode(
|
|
259
|
+
decision,
|
|
260
|
+
rejected.length,
|
|
261
|
+
viableCount,
|
|
262
|
+
ran,
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseClusterIdFromReasonCode(reasonCode: string | null | undefined): string | null {
|
|
268
|
+
if (reasonCode === null || reasonCode === undefined || !reasonCode.startsWith(CLUSTER_REASON_CODE_PREFIX)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return reasonCode.slice(CLUSTER_REASON_CODE_PREFIX.length);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Normalize tier-selection reason codes for telemetry and explain (SP-113). */
|
|
276
|
+
export function resolveTierSelectionReasonCode(
|
|
277
|
+
features: RoutingDecision['features'],
|
|
278
|
+
): string | null {
|
|
279
|
+
if (!features) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const reasonCode = features.tier_hint_reason_code;
|
|
284
|
+
if (reasonCode === null || reasonCode === undefined) {
|
|
285
|
+
if (features.p_success_cheap !== null && features.tier_hint === null) {
|
|
286
|
+
return P_SUCCESS_UNCERTAIN;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
reasonCode.startsWith(CLUSTER_REASON_CODE_PREFIX) ||
|
|
293
|
+
reasonCode === LOW_INTENSITY_STRUCTURAL ||
|
|
294
|
+
reasonCode === HIGH_INTENSITY_STRUCTURAL
|
|
295
|
+
) {
|
|
296
|
+
return reasonCode;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (reasonCode.startsWith(EXPECTED_COST_PREFIX)) {
|
|
300
|
+
if (EXPECTED_COST_DEFER_CODES.has(reasonCode) || features.tier_hint === null) {
|
|
301
|
+
return P_SUCCESS_UNCERTAIN;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (features.tier_hint === 'economical-cloud' || features.tier_hint === 'zero-tier') {
|
|
305
|
+
return P_SUCCESS_CHEAP;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return reasonCode;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function extractRejectedTiers(
|
|
313
|
+
candidates: NonNullable<RoutingDecision['features']>['candidates'],
|
|
314
|
+
): readonly RejectedTierEntry[] {
|
|
315
|
+
if (!candidates || candidates.length === 0) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const rejected: RejectedTierEntry[] = [];
|
|
320
|
+
for (const candidate of candidates) {
|
|
321
|
+
if (!candidate.model_id.startsWith('__expected_cost_')) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const tier = candidate.model_id
|
|
326
|
+
.replace('__expected_cost_', '')
|
|
327
|
+
.replace(/__$/, '');
|
|
328
|
+
|
|
329
|
+
rejected.push({
|
|
330
|
+
tier,
|
|
331
|
+
expected_cost_usd: candidate.score,
|
|
332
|
+
adjusted_expected_cost_usd: candidate.shortfall,
|
|
333
|
+
reason: candidate.rejected_reason ?? '',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return rejected;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildTierFeatureSummary(
|
|
341
|
+
features: NonNullable<RoutingDecision['features']>,
|
|
342
|
+
): TierFeatureSummary {
|
|
343
|
+
return {
|
|
344
|
+
triage_verdict: features.triage?.verdict ?? null,
|
|
345
|
+
triage_reason_code: features.triage?.reason_code ?? null,
|
|
346
|
+
cyclomatic_score: features.triage?.cyclomatic_score ?? null,
|
|
347
|
+
requirement_reasoning: features.requirements?.reasoning ?? null,
|
|
348
|
+
requirement_code_gen: features.requirements?.code_gen ?? null,
|
|
349
|
+
requirement_tool_use: features.requirements?.tool_use ?? null,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function buildLowIntensityBreakdown(
|
|
354
|
+
features: NonNullable<RoutingDecision['features']>,
|
|
355
|
+
): LowIntensityBreakdown {
|
|
356
|
+
return {
|
|
357
|
+
score: features.low_intensity_score,
|
|
358
|
+
tier_hint: features.tier_hint,
|
|
359
|
+
tier_hint_reason_code: features.tier_hint_reason_code,
|
|
360
|
+
tier_selection_reason_code: resolveTierSelectionReasonCode(features),
|
|
361
|
+
p_success_cheap: features.p_success_cheap,
|
|
362
|
+
p_success_alpha: features.p_success_alpha,
|
|
363
|
+
rejected_tiers: extractRejectedTiers(features.candidates),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Infer why local_zero did not dispatch when another stage won (SP-113). */
|
|
368
|
+
export function buildLocalZeroSkipReasons(
|
|
369
|
+
decision: RoutingDecision,
|
|
370
|
+
features: RoutingDecision['features'],
|
|
371
|
+
): readonly string[] {
|
|
372
|
+
if (decision.stage === 'local_zero') {
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const reasons: string[] = [];
|
|
377
|
+
if (!features?.local_eligible_reason) {
|
|
378
|
+
reasons.push('not_locally_eligible');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const rejectedJson = features?.context_fit?.context_fit_rejected_json;
|
|
382
|
+
if (rejectedJson !== null && rejectedJson !== undefined && rejectedJson.includes('zero-tier')) {
|
|
383
|
+
reasons.push('context_fit_excluded_local');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (features?.local_eligible_reason) {
|
|
387
|
+
if (decision.pin_reason !== null) {
|
|
388
|
+
reasons.push('session_pin_active');
|
|
389
|
+
} else {
|
|
390
|
+
reasons.push('hardware_or_local_unavailable');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return reasons;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function resolveClusterScalars(
|
|
398
|
+
features: RoutingDecision['features'],
|
|
399
|
+
clusterMatchTable: readonly ClusterMatchTableEntry[] | null,
|
|
400
|
+
): Pick<
|
|
401
|
+
TierSelectionObservability,
|
|
402
|
+
'cluster_id' | 'cluster_similarity' | 'cluster_margin'
|
|
403
|
+
> {
|
|
404
|
+
const selected =
|
|
405
|
+
clusterMatchTable?.find((entry) => entry.selected) ??
|
|
406
|
+
clusterMatchTable?.[0] ??
|
|
407
|
+
null;
|
|
408
|
+
|
|
409
|
+
if (selected) {
|
|
410
|
+
return {
|
|
411
|
+
cluster_id: selected.cluster_id,
|
|
412
|
+
cluster_similarity: selected.similarity,
|
|
413
|
+
cluster_margin: selected.margin,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const clusterId =
|
|
418
|
+
parseClusterIdFromReasonCode(features?.tier_hint_reason_code) ??
|
|
419
|
+
parseClusterIdFromReasonCode(features?.local_eligible_reason);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
cluster_id: clusterId,
|
|
423
|
+
cluster_similarity: null,
|
|
424
|
+
cluster_margin: null,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export interface TierSelectionObservabilityInput {
|
|
429
|
+
readonly decision: RoutingDecision;
|
|
430
|
+
readonly clusterMatchTable?: readonly ClusterMatchTableEntry[] | null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Build privacy-safe tier/cluster observability from routing decision features (SP-113). */
|
|
434
|
+
export function buildTierSelectionObservability(
|
|
435
|
+
input: TierSelectionObservabilityInput,
|
|
436
|
+
): TierSelectionObservability | null {
|
|
437
|
+
const { decision, clusterMatchTable = null } = input;
|
|
438
|
+
const features = decision.features;
|
|
439
|
+
|
|
440
|
+
if (!features) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const tierGateRan =
|
|
445
|
+
features.low_intensity_score !== null ||
|
|
446
|
+
features.tier_hint !== null ||
|
|
447
|
+
features.tier_hint_reason_code !== null ||
|
|
448
|
+
features.p_success_cheap !== null;
|
|
449
|
+
|
|
450
|
+
if (!tierGateRan) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const clusterScalars = resolveClusterScalars(features, clusterMatchTable);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
...clusterScalars,
|
|
458
|
+
low_intensity_score: features.low_intensity_score,
|
|
459
|
+
tier_hint: features.tier_hint,
|
|
460
|
+
p_success_cheap: features.p_success_cheap,
|
|
461
|
+
local_eligible_reason: features.local_eligible_reason,
|
|
462
|
+
tier_selection_reason_code: resolveTierSelectionReasonCode(features),
|
|
463
|
+
cluster_match_table: clusterMatchTable,
|
|
464
|
+
tier_feature_summary: buildTierFeatureSummary(features),
|
|
465
|
+
low_intensity_breakdown: buildLowIntensityBreakdown(features),
|
|
466
|
+
local_zero_skip_reasons: buildLocalZeroSkipReasons(decision, features),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function emptyFeatureSidecar() {
|
|
471
|
+
return {
|
|
472
|
+
triage: null,
|
|
473
|
+
requirements: null,
|
|
474
|
+
candidates: null,
|
|
475
|
+
tier_hint: null,
|
|
476
|
+
tier_hint_reason_code: null,
|
|
477
|
+
low_intensity_score: null,
|
|
478
|
+
p_success_cheap: null,
|
|
479
|
+
p_success_alpha: null,
|
|
480
|
+
local_eligible_reason: null,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Attach tier-selection observability to a routing decision features sidecar (SP-113). */
|
|
485
|
+
export function enrichRoutingDecisionWithTierSelection(
|
|
486
|
+
decision: RoutingDecision,
|
|
487
|
+
clusterMatchTable?: readonly ClusterMatchTableEntry[] | null,
|
|
488
|
+
): RoutingDecision {
|
|
489
|
+
const tierSelection = buildTierSelectionObservability({
|
|
490
|
+
decision,
|
|
491
|
+
clusterMatchTable: clusterMatchTable ?? null,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
if (!tierSelection) {
|
|
495
|
+
return decision;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
...decision,
|
|
500
|
+
features: {
|
|
501
|
+
...(decision.features ?? emptyFeatureSidecar()),
|
|
502
|
+
tier_selection: tierSelection,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export interface ExplainEnrichmentOptions {
|
|
508
|
+
readonly fleet?: readonly ModelProfile[];
|
|
509
|
+
readonly contextFitConfig?: ContextFitConfig;
|
|
510
|
+
readonly clusterMatcher?: ClusterMatcher;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Attach context-fit and tier-selection observability for explain responses (SP-110, SP-113). */
|
|
514
|
+
export async function enrichRoutingDecisionForExplain(
|
|
515
|
+
request: RoutingRequest,
|
|
516
|
+
decision: RoutingDecision,
|
|
517
|
+
options?: ExplainEnrichmentOptions,
|
|
518
|
+
): Promise<RoutingDecision> {
|
|
519
|
+
const withContextFit = enrichRoutingDecisionWithContextFit(
|
|
520
|
+
request,
|
|
521
|
+
decision,
|
|
522
|
+
options?.fleet,
|
|
523
|
+
options?.contextFitConfig,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
let clusterMatchTable: readonly ClusterMatchTableEntry[] | null = null;
|
|
527
|
+
if (options?.clusterMatcher) {
|
|
528
|
+
try {
|
|
529
|
+
clusterMatchTable = await options.clusterMatcher.matchTable(request);
|
|
530
|
+
} catch {
|
|
531
|
+
clusterMatchTable = null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return enrichRoutingDecisionWithTierSelection(withContextFit, clusterMatchTable);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Attach context-fit observability to a routing decision features sidecar (SP-110). */
|
|
539
|
+
export function enrichRoutingDecisionWithContextFit(
|
|
540
|
+
request: RoutingRequest,
|
|
541
|
+
decision: RoutingDecision,
|
|
542
|
+
fleet?: readonly ModelProfile[],
|
|
543
|
+
contextFitConfig?: ContextFitConfig,
|
|
544
|
+
): RoutingDecision {
|
|
545
|
+
const contextFit = buildContextFitObservability({
|
|
546
|
+
request,
|
|
547
|
+
decision,
|
|
548
|
+
fleet,
|
|
549
|
+
...(contextFitConfig !== undefined ? { contextFitConfig } : {}),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
if (!contextFit) {
|
|
553
|
+
return decision;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
...decision,
|
|
558
|
+
features: {
|
|
559
|
+
...(decision.features ?? emptyFeatureSidecar()),
|
|
560
|
+
context_fit: contextFit,
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export interface RoutingDecisionLogDelegate {
|
|
566
|
+
readonly provider: string;
|
|
567
|
+
readonly modelId: string;
|
|
568
|
+
readonly api: string;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** JSON payload for SMART_ROUTER_LOG_ROUTING=1 stderr lines (SP-110). */
|
|
572
|
+
export function buildRoutingDecisionLogPayload(
|
|
573
|
+
request: RoutingRequest,
|
|
574
|
+
decision: RoutingDecision,
|
|
575
|
+
delegate?: RoutingDecisionLogDelegate,
|
|
576
|
+
fleet?: readonly ModelProfile[],
|
|
577
|
+
contextFitConfig?: ContextFitConfig,
|
|
578
|
+
): Record<string, unknown> {
|
|
579
|
+
const enriched = enrichRoutingDecisionWithTierSelection(
|
|
580
|
+
enrichRoutingDecisionWithContextFit(
|
|
581
|
+
request,
|
|
582
|
+
decision,
|
|
583
|
+
fleet,
|
|
584
|
+
contextFitConfig,
|
|
585
|
+
),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
const tierSelection = enriched.features?.tier_selection;
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
request_id: enriched.request_id,
|
|
592
|
+
selected_model_id: enriched.selected_model_id,
|
|
593
|
+
tier: enriched.tier,
|
|
594
|
+
stage: enriched.stage,
|
|
595
|
+
reason_code: enriched.reason_code,
|
|
596
|
+
routing_latency_ms: enriched.routing_latency_ms,
|
|
597
|
+
features: enriched.features ?? null,
|
|
598
|
+
cluster_summary: tierSelection
|
|
599
|
+
? {
|
|
600
|
+
cluster_id: tierSelection.cluster_id,
|
|
601
|
+
cluster_similarity: tierSelection.cluster_similarity,
|
|
602
|
+
cluster_margin: tierSelection.cluster_margin,
|
|
603
|
+
tier_hint: tierSelection.tier_hint,
|
|
604
|
+
tier_selection_reason_code: tierSelection.tier_selection_reason_code,
|
|
605
|
+
low_intensity_score: tierSelection.low_intensity_score,
|
|
606
|
+
p_success_cheap: tierSelection.p_success_cheap,
|
|
607
|
+
}
|
|
608
|
+
: null,
|
|
609
|
+
delegate,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function defaultContextFitTelemetry(): Pick<
|
|
614
|
+
RoutingTelemetry,
|
|
615
|
+
| 'estimated_input_tokens'
|
|
616
|
+
| 'context_fit_viable_count'
|
|
617
|
+
| 'context_fit_rejected_json'
|
|
618
|
+
| 'context_overflow_pin_break'
|
|
619
|
+
| 'selected_model_max_input_tokens'
|
|
620
|
+
| 'context_fit_reason_code'
|
|
621
|
+
> {
|
|
622
|
+
return {
|
|
623
|
+
estimated_input_tokens: null,
|
|
624
|
+
context_fit_viable_count: null,
|
|
625
|
+
context_fit_rejected_json: null,
|
|
626
|
+
context_overflow_pin_break: false,
|
|
627
|
+
selected_model_max_input_tokens: null,
|
|
628
|
+
context_fit_reason_code: null,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Default context-fit telemetry scalars for tests and legacy store reads. */
|
|
633
|
+
export const DEFAULT_CONTEXT_FIT_TELEMETRY_FIELDS = defaultContextFitTelemetry();
|
|
634
|
+
|
|
635
|
+
function defaultTierSelectionTelemetry(): Pick<
|
|
636
|
+
RoutingTelemetry,
|
|
637
|
+
| 'cluster_id'
|
|
638
|
+
| 'cluster_similarity'
|
|
639
|
+
| 'cluster_margin'
|
|
640
|
+
| 'low_intensity_score'
|
|
641
|
+
| 'tier_hint'
|
|
642
|
+
| 'p_success_cheap'
|
|
643
|
+
| 'local_eligible_reason'
|
|
644
|
+
| 'tier_selection_reason_code'
|
|
645
|
+
> {
|
|
646
|
+
return {
|
|
647
|
+
cluster_id: null,
|
|
648
|
+
cluster_similarity: null,
|
|
649
|
+
cluster_margin: null,
|
|
650
|
+
low_intensity_score: null,
|
|
651
|
+
tier_hint: null,
|
|
652
|
+
p_success_cheap: null,
|
|
653
|
+
local_eligible_reason: null,
|
|
654
|
+
tier_selection_reason_code: null,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Default tier-selection telemetry scalars for tests and legacy store reads. */
|
|
659
|
+
export const DEFAULT_TIER_SELECTION_TELEMETRY_FIELDS = defaultTierSelectionTelemetry();
|
|
660
|
+
|
|
661
|
+
function tierSelectionTelemetryFromDecision(
|
|
662
|
+
decision: RoutingDecision,
|
|
663
|
+
): ReturnType<typeof defaultTierSelectionTelemetry> {
|
|
664
|
+
const observability = buildTierSelectionObservability({ decision });
|
|
665
|
+
if (!observability) {
|
|
666
|
+
return defaultTierSelectionTelemetry();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
cluster_id: observability.cluster_id,
|
|
671
|
+
cluster_similarity: observability.cluster_similarity,
|
|
672
|
+
cluster_margin: observability.cluster_margin,
|
|
673
|
+
low_intensity_score: observability.low_intensity_score,
|
|
674
|
+
tier_hint: observability.tier_hint,
|
|
675
|
+
p_success_cheap: observability.p_success_cheap,
|
|
676
|
+
local_eligible_reason: observability.local_eligible_reason,
|
|
677
|
+
tier_selection_reason_code: observability.tier_selection_reason_code,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Default context-fit dataset scalars for tests and legacy store reads. */
|
|
682
|
+
export const DEFAULT_CONTEXT_FIT_DATASET_FIELDS = {
|
|
683
|
+
estimated_input_tokens_gate: null,
|
|
684
|
+
context_fit_viable_count: null,
|
|
685
|
+
context_fit_rejected_json: null,
|
|
686
|
+
context_overflow_pin_break: false,
|
|
687
|
+
selected_model_max_input_tokens: null,
|
|
688
|
+
context_fit_reason_code: null,
|
|
689
|
+
} as const satisfies Pick<
|
|
690
|
+
import('../../domain/types/index.js').RoutingDatasetRecord,
|
|
691
|
+
| 'estimated_input_tokens_gate'
|
|
692
|
+
| 'context_fit_viable_count'
|
|
693
|
+
| 'context_fit_rejected_json'
|
|
694
|
+
| 'context_overflow_pin_break'
|
|
695
|
+
| 'selected_model_max_input_tokens'
|
|
696
|
+
| 'context_fit_reason_code'
|
|
697
|
+
>;
|
|
698
|
+
|
|
699
|
+
/** Default tier-selection dataset scalars for tests and legacy store reads. */
|
|
700
|
+
export const DEFAULT_TIER_SELECTION_DATASET_FIELDS = {
|
|
701
|
+
cluster_id: null,
|
|
702
|
+
cluster_similarity: null,
|
|
703
|
+
cluster_margin: null,
|
|
704
|
+
low_intensity_score: null,
|
|
705
|
+
tier_hint: null,
|
|
706
|
+
p_success_cheap: null,
|
|
707
|
+
local_eligible_reason: null,
|
|
708
|
+
tier_selection_reason_code: null,
|
|
709
|
+
} as const satisfies Pick<
|
|
710
|
+
import('../../domain/types/index.js').RoutingDatasetRecord,
|
|
711
|
+
| 'cluster_id'
|
|
712
|
+
| 'cluster_similarity'
|
|
713
|
+
| 'cluster_margin'
|
|
714
|
+
| 'low_intensity_score'
|
|
715
|
+
| 'tier_hint'
|
|
716
|
+
| 'p_success_cheap'
|
|
717
|
+
| 'local_eligible_reason'
|
|
718
|
+
| 'tier_selection_reason_code'
|
|
719
|
+
>;
|
|
720
|
+
|
|
721
|
+
// ─── Emitter ─────────────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
export class RoutingTelemetryEmitter {
|
|
724
|
+
private readonly entries: RoutingTelemetry[] = [];
|
|
725
|
+
private readonly maxEntries: number;
|
|
726
|
+
private readonly windowMs: number;
|
|
727
|
+
private readonly clock: () => string;
|
|
728
|
+
private readonly onRecord: ((record: RoutingTelemetry) => void) | undefined;
|
|
729
|
+
private readonly fleet: readonly ModelProfile[] | undefined;
|
|
730
|
+
private readonly contextFitConfig: ContextFitConfig | undefined;
|
|
731
|
+
|
|
732
|
+
constructor(options?: TelemetryEmitterOptions) {
|
|
733
|
+
this.maxEntries = options?.maxEntries ?? TELEMETRY_MAX_ENTRIES;
|
|
734
|
+
this.windowMs = options?.windowMs ?? TELEMETRY_WINDOW_MS;
|
|
735
|
+
this.clock = options?.clock ?? (() => new Date().toISOString());
|
|
736
|
+
this.onRecord = options?.onRecord;
|
|
737
|
+
this.fleet = options?.fleet;
|
|
738
|
+
this.contextFitConfig = options?.contextFitConfig;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Emit a telemetry record from a completed routing decision.
|
|
743
|
+
* Enforces the rolling window (time + count) before appending.
|
|
744
|
+
*/
|
|
745
|
+
emit(request: RoutingRequest, decision: RoutingDecision): RoutingTelemetry {
|
|
746
|
+
return this.appendRecord(request, decision);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Emit telemetry when a pipeline stage throws and routing degrades to safe default.
|
|
751
|
+
*/
|
|
752
|
+
emitPipelineError(
|
|
753
|
+
request: RoutingRequest,
|
|
754
|
+
failedStage: string,
|
|
755
|
+
fallback: RoutingDecision,
|
|
756
|
+
): RoutingTelemetry {
|
|
757
|
+
const errorDecision: RoutingDecision = {
|
|
758
|
+
...fallback,
|
|
759
|
+
stage: failedStage as RoutingDecision['stage'],
|
|
760
|
+
reason_code: 'pipeline_error',
|
|
761
|
+
};
|
|
762
|
+
return this.appendRecord(request, errorDecision);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private appendRecord(
|
|
766
|
+
request: RoutingRequest,
|
|
767
|
+
decision: RoutingDecision,
|
|
768
|
+
): RoutingTelemetry {
|
|
769
|
+
makeTelemetryRoom(this.entries, this.maxEntries);
|
|
770
|
+
|
|
771
|
+
const contextFit = buildContextFitObservability({
|
|
772
|
+
request,
|
|
773
|
+
decision,
|
|
774
|
+
...(this.fleet !== undefined ? { fleet: this.fleet } : {}),
|
|
775
|
+
...(this.contextFitConfig !== undefined
|
|
776
|
+
? { contextFitConfig: this.contextFitConfig }
|
|
777
|
+
: {}),
|
|
778
|
+
});
|
|
779
|
+
const contextFitFields = contextFit ?? defaultContextFitTelemetry();
|
|
780
|
+
const tierSelectionFields = tierSelectionTelemetryFromDecision(decision);
|
|
781
|
+
|
|
782
|
+
const record: RoutingTelemetry = {
|
|
783
|
+
timestamp: this.clock(),
|
|
784
|
+
session_id: request.session_id,
|
|
785
|
+
request_id: decision.request_id,
|
|
786
|
+
turn_type: request.turn_type ?? 'unknown',
|
|
787
|
+
stage: decision.stage,
|
|
788
|
+
reason_code: decision.reason_code,
|
|
789
|
+
selected_model_id: decision.selected_model_id,
|
|
790
|
+
estimated_cost_usd: decision.estimated_cost_usd ?? 0,
|
|
791
|
+
routing_latency_ms: decision.routing_latency_ms,
|
|
792
|
+
pin_reason: decision.pin_reason,
|
|
793
|
+
estimated_input_tokens: contextFitFields.estimated_input_tokens,
|
|
794
|
+
context_fit_viable_count: contextFitFields.context_fit_viable_count,
|
|
795
|
+
context_fit_rejected_json: contextFitFields.context_fit_rejected_json,
|
|
796
|
+
context_overflow_pin_break: contextFitFields.context_overflow_pin_break,
|
|
797
|
+
selected_model_max_input_tokens: contextFitFields.selected_model_max_input_tokens,
|
|
798
|
+
context_fit_reason_code: contextFitFields.context_fit_reason_code,
|
|
799
|
+
cluster_id: tierSelectionFields.cluster_id,
|
|
800
|
+
cluster_similarity: tierSelectionFields.cluster_similarity,
|
|
801
|
+
cluster_margin: tierSelectionFields.cluster_margin,
|
|
802
|
+
low_intensity_score: tierSelectionFields.low_intensity_score,
|
|
803
|
+
tier_hint: tierSelectionFields.tier_hint,
|
|
804
|
+
p_success_cheap: tierSelectionFields.p_success_cheap,
|
|
805
|
+
local_eligible_reason: tierSelectionFields.local_eligible_reason,
|
|
806
|
+
tier_selection_reason_code: tierSelectionFields.tier_selection_reason_code,
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
this.entries.push(record);
|
|
810
|
+
this.onRecord?.(record);
|
|
811
|
+
return record;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Current number of retained entries. */
|
|
815
|
+
get size(): number {
|
|
816
|
+
return this.entries.length;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Snapshot of all retained entries (newest last). */
|
|
820
|
+
snapshot(): readonly RoutingTelemetry[] {
|
|
821
|
+
evictExpiredTelemetryEntries(this.entries, this.windowMs);
|
|
822
|
+
return [...this.entries];
|
|
823
|
+
}
|
|
824
|
+
}
|