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,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop escalation — FR-014, FR-008 rule #3.
|
|
3
|
+
*
|
|
4
|
+
* Detects bounded repeated identical tool failures and signals
|
|
5
|
+
* session pin escalation to a frontier-capable tier.
|
|
6
|
+
*
|
|
7
|
+
* Escalation fires once per session; no tier oscillation (FR-008).
|
|
8
|
+
* Threshold defaults to 3 identical tool failures (operator configurable).
|
|
9
|
+
*
|
|
10
|
+
* Design: pure evaluation function — caller (pipeline stage) is
|
|
11
|
+
* responsible for applying pin state updates via SessionPinner.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ModelProfile, RoutingRequest, SessionPin } from '../types/index.js';
|
|
15
|
+
|
|
16
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface LoopEscalationConfig {
|
|
19
|
+
readonly threshold: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoopEscalationResult {
|
|
23
|
+
readonly shouldEscalate: boolean;
|
|
24
|
+
readonly updatedPin: SessionPin | null;
|
|
25
|
+
readonly escalationTarget: ModelProfile | null;
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Failure signature extraction ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const FAILURE_PATTERNS = [
|
|
32
|
+
'error',
|
|
33
|
+
'fail',
|
|
34
|
+
'exception',
|
|
35
|
+
'timed out',
|
|
36
|
+
'timeout',
|
|
37
|
+
'econnrefused',
|
|
38
|
+
'enotfound',
|
|
39
|
+
'econnreset',
|
|
40
|
+
'epipe',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract a tool-failure signature from the request's messages.
|
|
45
|
+
* Returns null when the request does not carry a tool failure.
|
|
46
|
+
*
|
|
47
|
+
* Only inspects tool_result turns — other turn types cannot
|
|
48
|
+
* carry observational failure signals (FR-014: no post-generation judging).
|
|
49
|
+
*/
|
|
50
|
+
export function extractToolFailureSignature(request: RoutingRequest): string | null {
|
|
51
|
+
const msgs = request.messages;
|
|
52
|
+
if (!msgs || msgs.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
55
|
+
const msg = msgs[i]!;
|
|
56
|
+
if (msg.role === 'tool') {
|
|
57
|
+
if (looksLikeFailure(msg.content)) {
|
|
58
|
+
return computeSignature(msg.content);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function looksLikeFailure(content: string): boolean {
|
|
67
|
+
const lower = content.toLowerCase();
|
|
68
|
+
return FAILURE_PATTERNS.some((p) => lower.includes(p));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Deterministic djb2 hash of normalised error content. */
|
|
72
|
+
function computeSignature(content: string): string {
|
|
73
|
+
const normalized = content.trim().slice(0, 256).toLowerCase();
|
|
74
|
+
let h = 5381;
|
|
75
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
76
|
+
h = ((h << 5) + h + normalized.charCodeAt(i)) | 0;
|
|
77
|
+
}
|
|
78
|
+
return `tf:${(h >>> 0).toString(16)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Evaluation ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Evaluate whether the session should escalate to a higher tier.
|
|
85
|
+
*
|
|
86
|
+
* Pure function — does not mutate pin state. Returns `updatedPin`
|
|
87
|
+
* when the caller should persist new failure-tracking state.
|
|
88
|
+
*
|
|
89
|
+
* Caller applies updates via `SessionPinner.loadPin()`, and on
|
|
90
|
+
* escalation via `breakPin()` + `recordPin()`.
|
|
91
|
+
*/
|
|
92
|
+
export function evaluateLoopEscalation(
|
|
93
|
+
pin: SessionPin | null,
|
|
94
|
+
request: RoutingRequest,
|
|
95
|
+
fleet: readonly ModelProfile[],
|
|
96
|
+
config: LoopEscalationConfig,
|
|
97
|
+
): LoopEscalationResult {
|
|
98
|
+
if (!pin) {
|
|
99
|
+
return noEscalation('no_pin');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (pin.pin_reason === 'loop_escalation') {
|
|
103
|
+
return noEscalation('already_escalated');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (request.turn_type !== 'tool_result') {
|
|
107
|
+
return noEscalation('not_tool_result');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const signature = extractToolFailureSignature(request);
|
|
111
|
+
|
|
112
|
+
if (!signature) {
|
|
113
|
+
if (pin.consecutive_tool_failures > 0) {
|
|
114
|
+
return {
|
|
115
|
+
shouldEscalate: false,
|
|
116
|
+
updatedPin: {
|
|
117
|
+
...pin,
|
|
118
|
+
consecutive_tool_failures: 0,
|
|
119
|
+
last_tool_failure_signature: null,
|
|
120
|
+
updated_at: new Date().toISOString(),
|
|
121
|
+
},
|
|
122
|
+
escalationTarget: null,
|
|
123
|
+
reason: 'success_reset',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return noEscalation('no_failure');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isIdentical = pin.last_tool_failure_signature === signature;
|
|
130
|
+
const newCount = isIdentical ? pin.consecutive_tool_failures + 1 : 1;
|
|
131
|
+
|
|
132
|
+
const updated: SessionPin = {
|
|
133
|
+
...pin,
|
|
134
|
+
consecutive_tool_failures: newCount,
|
|
135
|
+
last_tool_failure_signature: signature,
|
|
136
|
+
updated_at: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (newCount >= config.threshold) {
|
|
140
|
+
const target = selectEscalationTarget(fleet, pin.pinned_model_id);
|
|
141
|
+
if (target) {
|
|
142
|
+
return {
|
|
143
|
+
shouldEscalate: true,
|
|
144
|
+
updatedPin: updated,
|
|
145
|
+
escalationTarget: target,
|
|
146
|
+
reason: 'threshold_exceeded',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
shouldEscalate: false,
|
|
151
|
+
updatedPin: updated,
|
|
152
|
+
escalationTarget: null,
|
|
153
|
+
reason: 'no_frontier_available',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
shouldEscalate: false,
|
|
159
|
+
updatedPin: updated,
|
|
160
|
+
escalationTarget: null,
|
|
161
|
+
reason: 'below_threshold',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function noEscalation(reason: string): LoopEscalationResult {
|
|
168
|
+
return { shouldEscalate: false, updatedPin: null, escalationTarget: null, reason };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Select the best healthy frontier-cloud model that differs from the current pin. */
|
|
172
|
+
function selectEscalationTarget(
|
|
173
|
+
fleet: readonly ModelProfile[],
|
|
174
|
+
currentModelId: string,
|
|
175
|
+
): ModelProfile | null {
|
|
176
|
+
return (
|
|
177
|
+
fleet.find(
|
|
178
|
+
(m) => m.tier === 'frontier-cloud' && m.id !== currentModelId && m.healthy !== false,
|
|
179
|
+
) ?? null
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session pinner — FR-006, FR-007, FR-008, FR-024.
|
|
3
|
+
*
|
|
4
|
+
* Pins a session to one model after the initial routing decision.
|
|
5
|
+
* Pin holds across subsequent turns until a qualified break event.
|
|
6
|
+
*
|
|
7
|
+
* Break rules (FR-008, exhaustive):
|
|
8
|
+
* 1. History compaction
|
|
9
|
+
* 2. Context overflow (estimated tokens exceed pinned model window)
|
|
10
|
+
* 3. Explicit operator/user override
|
|
11
|
+
* 4. Qualified loop escalation (threshold managed externally)
|
|
12
|
+
* 5. Cache-warmup economics when a cross-provider switch is proposed
|
|
13
|
+
*
|
|
14
|
+
* Sub-routing (FR-024): tool-result turns below the payload threshold
|
|
15
|
+
* may use an economical model on the same provider without breaking the pin.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ModelProfile,
|
|
20
|
+
PinReason,
|
|
21
|
+
RoutingRequest,
|
|
22
|
+
SessionPin,
|
|
23
|
+
} from '../types/index.js';
|
|
24
|
+
import type { StorePort } from '../types/store-port.js';
|
|
25
|
+
import {
|
|
26
|
+
evaluateCacheEconomics,
|
|
27
|
+
type CacheEconomicsConfig,
|
|
28
|
+
} from './cache-economics.js';
|
|
29
|
+
|
|
30
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type PinAction = 'use_pin' | 'sub_route' | 'break' | 'no_pin';
|
|
33
|
+
|
|
34
|
+
export interface PinLookupResult {
|
|
35
|
+
readonly action: PinAction;
|
|
36
|
+
readonly pinnedModel?: ModelProfile;
|
|
37
|
+
readonly subRouteModel?: ModelProfile;
|
|
38
|
+
readonly breakReason?: PinReason;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SessionPinnerConfig {
|
|
42
|
+
/** FR-024: max payload size (bytes or token estimate) for sub-routing. Default 2048. */
|
|
43
|
+
readonly toolResultSizeThreshold?: number;
|
|
44
|
+
/** Optional persistence — pins survive process restart when set. */
|
|
45
|
+
readonly store?: StorePort;
|
|
46
|
+
/** FR-008 rule #4: cache-warmup economics thresholds. */
|
|
47
|
+
readonly cacheEconomicsConfig?: CacheEconomicsConfig;
|
|
48
|
+
/**
|
|
49
|
+
* Break pin when estimated input tokens exceed the pinned model's
|
|
50
|
+
* max_input_tokens multiplied by this margin. Default 0.90.
|
|
51
|
+
*/
|
|
52
|
+
readonly contextOverflowSafetyMargin?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const DEFAULT_TOOL_RESULT_SIZE_THRESHOLD = 2048;
|
|
58
|
+
const DEFAULT_CONTEXT_OVERFLOW_SAFETY_MARGIN = 0.9;
|
|
59
|
+
|
|
60
|
+
// ─── SessionPinner ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export class SessionPinner {
|
|
63
|
+
private readonly pins = new Map<string, SessionPin>();
|
|
64
|
+
private readonly toolResultSizeThreshold: number;
|
|
65
|
+
private readonly store: StorePort | undefined;
|
|
66
|
+
private readonly cacheEconomicsConfig: CacheEconomicsConfig | undefined;
|
|
67
|
+
private readonly contextOverflowSafetyMargin: number;
|
|
68
|
+
|
|
69
|
+
constructor(config?: SessionPinnerConfig) {
|
|
70
|
+
this.toolResultSizeThreshold =
|
|
71
|
+
config?.toolResultSizeThreshold ?? DEFAULT_TOOL_RESULT_SIZE_THRESHOLD;
|
|
72
|
+
this.store = config?.store;
|
|
73
|
+
this.cacheEconomicsConfig = config?.cacheEconomicsConfig;
|
|
74
|
+
this.contextOverflowSafetyMargin =
|
|
75
|
+
config?.contextOverflowSafetyMargin ?? DEFAULT_CONTEXT_OVERFLOW_SAFETY_MARGIN;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hydrate in-memory pin state for a session from persistent storage.
|
|
80
|
+
* Call on session start after a pi restart so lookupPin stays synchronous.
|
|
81
|
+
*/
|
|
82
|
+
async restoreSessionPin(sessionId: string): Promise<void> {
|
|
83
|
+
if (!this.store) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pin = await this.store.getSessionPin(sessionId);
|
|
88
|
+
if (pin) {
|
|
89
|
+
this.pins.set(sessionId, pin);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Synchronous pin lookup — must complete in <1ms.
|
|
95
|
+
* All data is in-memory (Map); no I/O.
|
|
96
|
+
*/
|
|
97
|
+
lookupPin(
|
|
98
|
+
request: RoutingRequest,
|
|
99
|
+
fleet: readonly ModelProfile[],
|
|
100
|
+
): PinLookupResult {
|
|
101
|
+
const pin = this.pins.get(request.session_id);
|
|
102
|
+
|
|
103
|
+
if (!pin) {
|
|
104
|
+
return { action: 'no_pin' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Break rule evaluation (FR-008) ──────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const breakResult = this.evaluateBreakRules(request, pin, fleet);
|
|
110
|
+
if (breakResult) {
|
|
111
|
+
return breakResult;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Sub-routing (FR-024) ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const subRouteResult = this.evaluateSubRouting(request, pin, fleet);
|
|
117
|
+
if (subRouteResult) {
|
|
118
|
+
return subRouteResult;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Default: use pinned model (FR-006, FR-007) ──────────────────────────
|
|
122
|
+
|
|
123
|
+
const pinnedModel = fleet.find(
|
|
124
|
+
(m) => m.id === pin.pinned_model_id && m.healthy !== false,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!pinnedModel) {
|
|
128
|
+
this.breakPin(request.session_id);
|
|
129
|
+
return { action: 'no_pin' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { action: 'use_pin', pinnedModel };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create or update a session pin after a routing decision.
|
|
137
|
+
*/
|
|
138
|
+
recordPin(
|
|
139
|
+
sessionId: string,
|
|
140
|
+
modelId: string,
|
|
141
|
+
reason: PinReason,
|
|
142
|
+
): SessionPin {
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
const existing = this.pins.get(sessionId);
|
|
145
|
+
|
|
146
|
+
const pin: SessionPin = {
|
|
147
|
+
session_id: sessionId,
|
|
148
|
+
pinned_model_id: modelId,
|
|
149
|
+
pin_reason: reason,
|
|
150
|
+
has_ever_switched: existing
|
|
151
|
+
? existing.pinned_model_id !== modelId || existing.has_ever_switched
|
|
152
|
+
: false,
|
|
153
|
+
consecutive_upstream_errors: 0,
|
|
154
|
+
consecutive_tool_failures: existing?.consecutive_tool_failures ?? 0,
|
|
155
|
+
last_tool_failure_signature: existing?.last_tool_failure_signature ?? null,
|
|
156
|
+
created_at: existing?.created_at ?? now,
|
|
157
|
+
updated_at: now,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this.pins.set(sessionId, pin);
|
|
161
|
+
this.persistPin(pin);
|
|
162
|
+
return pin;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete a session pin — used by loop escalation or external callers.
|
|
167
|
+
*/
|
|
168
|
+
breakPin(sessionId: string): void {
|
|
169
|
+
this.pins.delete(sessionId);
|
|
170
|
+
this.deletePersistedPin(sessionId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hydrate a pin from persistent storage (e.g. SQLite restore).
|
|
175
|
+
*/
|
|
176
|
+
loadPin(pin: SessionPin): void {
|
|
177
|
+
this.pins.set(pin.session_id, pin);
|
|
178
|
+
this.persistPin(pin);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read-only access to the current pin (telemetry, inspection).
|
|
183
|
+
*/
|
|
184
|
+
getPin(sessionId: string): SessionPin | null {
|
|
185
|
+
return this.pins.get(sessionId) ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Break rules (FR-008) ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
private evaluateBreakRules(
|
|
191
|
+
request: RoutingRequest,
|
|
192
|
+
pin: SessionPin,
|
|
193
|
+
fleet: readonly ModelProfile[],
|
|
194
|
+
): PinLookupResult | null {
|
|
195
|
+
// 1. History compaction → break pin, allow full re-route
|
|
196
|
+
if (request.compaction_flag) {
|
|
197
|
+
this.breakPin(request.session_id);
|
|
198
|
+
return { action: 'break', breakReason: 'compaction' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 2. Context overflow → break pin when input exceeds pinned model window
|
|
202
|
+
const overflowResult = this.evaluateContextOverflowBreak(
|
|
203
|
+
request,
|
|
204
|
+
pin,
|
|
205
|
+
fleet,
|
|
206
|
+
);
|
|
207
|
+
if (overflowResult) {
|
|
208
|
+
return overflowResult;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 3. Explicit operator / user override → pin to forced model
|
|
212
|
+
if (request.force_model_id) {
|
|
213
|
+
return this.handleForceOverride(request, pin, fleet);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 4. Loop escalation — threshold tracking is on the pin record;
|
|
217
|
+
// the loop_escalation pipeline stage calls breakPin() externally
|
|
218
|
+
// when the threshold fires. No evaluation here beyond what the
|
|
219
|
+
// pin record already reflects (consecutive_tool_failures).
|
|
220
|
+
|
|
221
|
+
// 5. Cache-warmup economics (FR-008 rule #4)
|
|
222
|
+
const cacheEconomicsResult = this.evaluateCacheEconomicsBreak(
|
|
223
|
+
request,
|
|
224
|
+
pin,
|
|
225
|
+
fleet,
|
|
226
|
+
);
|
|
227
|
+
if (cacheEconomicsResult) {
|
|
228
|
+
return cacheEconomicsResult;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private evaluateContextOverflowBreak(
|
|
235
|
+
request: RoutingRequest,
|
|
236
|
+
pin: SessionPin,
|
|
237
|
+
fleet: readonly ModelProfile[],
|
|
238
|
+
): PinLookupResult | null {
|
|
239
|
+
const pinnedModel = fleet.find(
|
|
240
|
+
(m) => m.id === pin.pinned_model_id && m.healthy !== false,
|
|
241
|
+
);
|
|
242
|
+
const maxInputTokens = pinnedModel?.limits?.max_input_tokens;
|
|
243
|
+
if (!maxInputTokens) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tokenEstimate =
|
|
248
|
+
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
249
|
+
const effectiveLimit = maxInputTokens * this.contextOverflowSafetyMargin;
|
|
250
|
+
|
|
251
|
+
if (tokenEstimate > effectiveLimit) {
|
|
252
|
+
this.breakPin(request.session_id);
|
|
253
|
+
return { action: 'break', breakReason: 'context_overflow' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private evaluateCacheEconomicsBreak(
|
|
260
|
+
request: RoutingRequest,
|
|
261
|
+
pin: SessionPin,
|
|
262
|
+
fleet: readonly ModelProfile[],
|
|
263
|
+
): PinLookupResult | null {
|
|
264
|
+
if (!request.candidate_model_id) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (request.candidate_model_id === pin.pinned_model_id) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pinnedModel = fleet.find(
|
|
273
|
+
(m) => m.id === pin.pinned_model_id && m.healthy !== false,
|
|
274
|
+
);
|
|
275
|
+
const candidate = fleet.find(
|
|
276
|
+
(m) => m.id === request.candidate_model_id && m.healthy !== false,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (!pinnedModel || !candidate) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (pinnedModel.provider === candidate.provider) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const tokenEstimate =
|
|
288
|
+
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
289
|
+
|
|
290
|
+
const econ = evaluateCacheEconomics(
|
|
291
|
+
pin,
|
|
292
|
+
pinnedModel,
|
|
293
|
+
candidate,
|
|
294
|
+
tokenEstimate,
|
|
295
|
+
this.cacheEconomicsConfig,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
if (econ.shouldSwitch) {
|
|
299
|
+
this.breakPin(request.session_id);
|
|
300
|
+
return { action: 'break', breakReason: 'cache_economics' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { action: 'use_pin', pinnedModel };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private handleForceOverride(
|
|
307
|
+
request: RoutingRequest,
|
|
308
|
+
_pin: SessionPin,
|
|
309
|
+
fleet: readonly ModelProfile[],
|
|
310
|
+
): PinLookupResult {
|
|
311
|
+
const forced = fleet.find(
|
|
312
|
+
(m) => m.id === request.force_model_id && m.healthy !== false,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (forced) {
|
|
316
|
+
this.recordPin(request.session_id, forced.id, 'user_forced');
|
|
317
|
+
return { action: 'use_pin', pinnedModel: forced };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Forced model unavailable — break pin, allow re-route
|
|
321
|
+
this.breakPin(request.session_id);
|
|
322
|
+
return { action: 'break', breakReason: 'user_forced' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Persistence (StorePort) ────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
private persistPin(pin: SessionPin): void {
|
|
328
|
+
if (!this.store) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
void this.store.putSessionPin(pin).catch((error: unknown) => {
|
|
333
|
+
console.warn('Failed to persist session pin', { sessionId: pin.session_id, error });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private deletePersistedPin(sessionId: string): void {
|
|
338
|
+
if (!this.store) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
void this.store.deleteSessionPin(sessionId).catch((error: unknown) => {
|
|
343
|
+
console.warn('Failed to delete persisted session pin', { sessionId, error });
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── Sub-routing (FR-024) ───────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
private evaluateSubRouting(
|
|
350
|
+
request: RoutingRequest,
|
|
351
|
+
pin: SessionPin,
|
|
352
|
+
fleet: readonly ModelProfile[],
|
|
353
|
+
): PinLookupResult | null {
|
|
354
|
+
if (request.turn_type !== 'tool_result') {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const payloadSize =
|
|
359
|
+
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
360
|
+
|
|
361
|
+
if (payloadSize > this.toolResultSizeThreshold) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const pinnedModel = fleet.find((m) => m.id === pin.pinned_model_id);
|
|
366
|
+
if (!pinnedModel) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const econModel = fleet.find(
|
|
371
|
+
(m) =>
|
|
372
|
+
m.tier === 'economical-cloud' &&
|
|
373
|
+
m.provider === pinnedModel.provider &&
|
|
374
|
+
m.id !== pin.pinned_model_id &&
|
|
375
|
+
m.healthy !== false,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (!econModel) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
action: 'sub_route',
|
|
384
|
+
subRouteModel: econModel,
|
|
385
|
+
pinnedModel,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-route policy — FR-024.
|
|
3
|
+
*
|
|
4
|
+
* Pure policy function that evaluates whether a tool-result turn is
|
|
5
|
+
* eligible for same-provider economical sub-routing without breaking
|
|
6
|
+
* the session pin.
|
|
7
|
+
*
|
|
8
|
+
* Rules (FR-024, exhaustive):
|
|
9
|
+
* 1. Turn must be a tool_result.
|
|
10
|
+
* 2. Payload must be below the configurable size threshold (default 2 KB).
|
|
11
|
+
* 3. An economical-cloud model on the same provider as the pin must exist
|
|
12
|
+
* and be healthy.
|
|
13
|
+
* 4. The economical model must not be the pinned model itself.
|
|
14
|
+
*
|
|
15
|
+
* When all conditions are met the caller may dispatch to the economical
|
|
16
|
+
* model. The session pin record is unchanged — the pin holds.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ModelProfile, RoutingRequest, SessionPin } from '../types/index.js';
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type SubRouteReason =
|
|
24
|
+
| 'eligible'
|
|
25
|
+
| 'not_tool_result'
|
|
26
|
+
| 'payload_exceeds_threshold'
|
|
27
|
+
| 'pinned_model_not_in_fleet'
|
|
28
|
+
| 'no_same_provider_economical';
|
|
29
|
+
|
|
30
|
+
export interface SubRouteEvaluation {
|
|
31
|
+
readonly eligible: boolean;
|
|
32
|
+
readonly subRouteModel?: ModelProfile;
|
|
33
|
+
readonly pinnedModel?: ModelProfile;
|
|
34
|
+
readonly reason: SubRouteReason;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SubRoutePolicyConfig {
|
|
38
|
+
/** Max payload size (bytes or token estimate) for sub-routing. Default 2048. */
|
|
39
|
+
readonly sizeThreshold?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const DEFAULT_SIZE_THRESHOLD = 2048;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Select the lowest-cost healthy model from candidates (SP-085).
|
|
48
|
+
*/
|
|
49
|
+
export function selectLowestCostModel(
|
|
50
|
+
candidates: readonly ModelProfile[],
|
|
51
|
+
): ModelProfile | undefined {
|
|
52
|
+
let best: ModelProfile | undefined;
|
|
53
|
+
let bestCost = Infinity;
|
|
54
|
+
|
|
55
|
+
for (const model of candidates) {
|
|
56
|
+
if (model.healthy === false) continue;
|
|
57
|
+
const cost = model.pricing.fallback_cost_per_1m;
|
|
58
|
+
if (cost < bestCost) {
|
|
59
|
+
bestCost = cost;
|
|
60
|
+
best = model;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Policy ───────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Evaluate sub-route eligibility for a single request against a session pin.
|
|
71
|
+
*
|
|
72
|
+
* Designed for the hot path — no allocation beyond the return object,
|
|
73
|
+
* no I/O, deterministic.
|
|
74
|
+
*/
|
|
75
|
+
export function evaluateSubRoutePolicy(
|
|
76
|
+
request: RoutingRequest,
|
|
77
|
+
pin: SessionPin,
|
|
78
|
+
fleet: readonly ModelProfile[],
|
|
79
|
+
config?: SubRoutePolicyConfig,
|
|
80
|
+
): SubRouteEvaluation {
|
|
81
|
+
const threshold = config?.sizeThreshold ?? DEFAULT_SIZE_THRESHOLD;
|
|
82
|
+
|
|
83
|
+
if (request.turn_type !== 'tool_result') {
|
|
84
|
+
return { eligible: false, reason: 'not_tool_result' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const payloadSize =
|
|
88
|
+
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
89
|
+
|
|
90
|
+
if (payloadSize > threshold) {
|
|
91
|
+
return { eligible: false, reason: 'payload_exceeds_threshold' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pinnedModel = fleet.find((m) => m.id === pin.pinned_model_id);
|
|
95
|
+
if (!pinnedModel) {
|
|
96
|
+
return { eligible: false, reason: 'pinned_model_not_in_fleet' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const candidates = fleet.filter(
|
|
100
|
+
(m) =>
|
|
101
|
+
m.tier === 'economical-cloud' &&
|
|
102
|
+
m.provider === pinnedModel.provider &&
|
|
103
|
+
m.id !== pin.pinned_model_id &&
|
|
104
|
+
m.healthy !== false,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const econModel = selectLowestCostModel(candidates);
|
|
108
|
+
|
|
109
|
+
if (!econModel) {
|
|
110
|
+
return { eligible: false, reason: 'no_same_provider_economical' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
eligible: true,
|
|
115
|
+
subRouteModel: econModel,
|
|
116
|
+
pinnedModel,
|
|
117
|
+
reason: 'eligible',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
File without changes
|