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.
Files changed (310) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +167 -0
  2. package/.pi/extensions/smart-router/commands.ts +242 -0
  3. package/.pi/extensions/smart-router/dataset-export.ts +142 -0
  4. package/.pi/extensions/smart-router/delegate-stream.ts +105 -0
  5. package/.pi/extensions/smart-router/delegation-runtime.ts +355 -0
  6. package/.pi/extensions/smart-router/extension-setup.ts +115 -0
  7. package/.pi/extensions/smart-router/fleet-bootstrap.ts +244 -0
  8. package/.pi/extensions/smart-router/index.ts +115 -0
  9. package/.pi/extensions/smart-router/package.json +3 -0
  10. package/.pi/extensions/smart-router/pi-model-scope.ts +55 -0
  11. package/.pi/extensions/smart-router/pricing-lifecycle.ts +43 -0
  12. package/.pi/extensions/smart-router/route-and-delegate.ts +562 -0
  13. package/.pi/extensions/smart-router/routing-context.ts +171 -0
  14. package/.pi/extensions/smart-router/routing-outcomes.ts +44 -0
  15. package/.pi/extensions/smart-router/session-lifecycle.ts +140 -0
  16. package/.pi/extensions/smart-router/stream-delegation.ts +75 -0
  17. package/.pi/extensions/smart-router/types.ts +90 -0
  18. package/.pi/extensions/smart-router/utils.ts +58 -0
  19. package/README.md +593 -0
  20. package/bin/pi-smart-router.mjs +79 -0
  21. package/config/.gitkeep +0 -0
  22. package/config/hydra-projection-weights.json.example +1169 -0
  23. package/config/models.yaml.example +58 -0
  24. package/config/p-success-weights.json.example +19 -0
  25. package/config/routing-calibration.json.example +1605 -0
  26. package/config/routing-centroids.json.example +1570 -0
  27. package/config/routing-clusters.yaml.example +41 -0
  28. package/data/contrib/example.json +25 -0
  29. package/dist/api/explain/router-explain.d.ts +47 -0
  30. package/dist/api/explain/router-explain.d.ts.map +1 -0
  31. package/dist/api/explain/router-explain.js +101 -0
  32. package/dist/api/explain/router-explain.js.map +1 -0
  33. package/dist/api/middleware/pi-router-middleware.d.ts +86 -0
  34. package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -0
  35. package/dist/api/middleware/pi-router-middleware.js +83 -0
  36. package/dist/api/middleware/pi-router-middleware.js.map +1 -0
  37. package/dist/cli/smart-router-cli.d.ts +85 -0
  38. package/dist/cli/smart-router-cli.d.ts.map +1 -0
  39. package/dist/cli/smart-router-cli.js +293 -0
  40. package/dist/cli/smart-router-cli.js.map +1 -0
  41. package/dist/config/defaults.d.ts +7 -0
  42. package/dist/config/defaults.d.ts.map +1 -0
  43. package/dist/config/defaults.js +33 -0
  44. package/dist/config/defaults.js.map +1 -0
  45. package/dist/config/models-loader.d.ts +22 -0
  46. package/dist/config/models-loader.d.ts.map +1 -0
  47. package/dist/config/models-loader.js +51 -0
  48. package/dist/config/models-loader.js.map +1 -0
  49. package/dist/config/pi-model-mapper.d.ts +40 -0
  50. package/dist/config/pi-model-mapper.d.ts.map +1 -0
  51. package/dist/config/pi-model-mapper.js +216 -0
  52. package/dist/config/pi-model-mapper.js.map +1 -0
  53. package/dist/config/routing-clusters-loader.d.ts +35 -0
  54. package/dist/config/routing-clusters-loader.d.ts.map +1 -0
  55. package/dist/config/routing-clusters-loader.js +100 -0
  56. package/dist/config/routing-clusters-loader.js.map +1 -0
  57. package/dist/domain/delegation/delegation-context.d.ts +23 -0
  58. package/dist/domain/delegation/delegation-context.d.ts.map +1 -0
  59. package/dist/domain/delegation/delegation-context.js +73 -0
  60. package/dist/domain/delegation/delegation-context.js.map +1 -0
  61. package/dist/domain/delegation/execution-ledger.d.ts +19 -0
  62. package/dist/domain/delegation/execution-ledger.d.ts.map +1 -0
  63. package/dist/domain/delegation/execution-ledger.js +23 -0
  64. package/dist/domain/delegation/execution-ledger.js.map +1 -0
  65. package/dist/domain/delegation/output-headroom.d.ts +39 -0
  66. package/dist/domain/delegation/output-headroom.d.ts.map +1 -0
  67. package/dist/domain/delegation/output-headroom.js +78 -0
  68. package/dist/domain/delegation/output-headroom.js.map +1 -0
  69. package/dist/domain/matching/cluster-matcher.d.ts +69 -0
  70. package/dist/domain/matching/cluster-matcher.d.ts.map +1 -0
  71. package/dist/domain/matching/cluster-matcher.js +294 -0
  72. package/dist/domain/matching/cluster-matcher.js.map +1 -0
  73. package/dist/domain/matching/embedding-provider.d.ts +21 -0
  74. package/dist/domain/matching/embedding-provider.d.ts.map +1 -0
  75. package/dist/domain/matching/embedding-provider.js +42 -0
  76. package/dist/domain/matching/embedding-provider.js.map +1 -0
  77. package/dist/domain/matching/hydra-input.d.ts +18 -0
  78. package/dist/domain/matching/hydra-input.d.ts.map +1 -0
  79. package/dist/domain/matching/hydra-input.js +40 -0
  80. package/dist/domain/matching/hydra-input.js.map +1 -0
  81. package/dist/domain/matching/hydra-matcher.d.ts +103 -0
  82. package/dist/domain/matching/hydra-matcher.d.ts.map +1 -0
  83. package/dist/domain/matching/hydra-matcher.js +275 -0
  84. package/dist/domain/matching/hydra-matcher.js.map +1 -0
  85. package/dist/domain/pinning/cache-economics.d.ts +53 -0
  86. package/dist/domain/pinning/cache-economics.d.ts.map +1 -0
  87. package/dist/domain/pinning/cache-economics.js +72 -0
  88. package/dist/domain/pinning/cache-economics.js.map +1 -0
  89. package/dist/domain/pinning/loop-escalation.d.ts +41 -0
  90. package/dist/domain/pinning/loop-escalation.d.ts.map +1 -0
  91. package/dist/domain/pinning/loop-escalation.js +137 -0
  92. package/dist/domain/pinning/loop-escalation.js.map +1 -0
  93. package/dist/domain/pinning/session-pinner.d.ts +81 -0
  94. package/dist/domain/pinning/session-pinner.d.ts.map +1 -0
  95. package/dist/domain/pinning/session-pinner.js +237 -0
  96. package/dist/domain/pinning/session-pinner.js.map +1 -0
  97. package/dist/domain/pinning/sub-route-policy.d.ts +41 -0
  98. package/dist/domain/pinning/sub-route-policy.d.ts.map +1 -0
  99. package/dist/domain/pinning/sub-route-policy.js +72 -0
  100. package/dist/domain/pinning/sub-route-policy.js.map +1 -0
  101. package/dist/domain/pipeline/router-pipeline.d.ts +180 -0
  102. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -0
  103. package/dist/domain/pipeline/router-pipeline.js +807 -0
  104. package/dist/domain/pipeline/router-pipeline.js.map +1 -0
  105. package/dist/domain/pipeline/safe-default.d.ts +25 -0
  106. package/dist/domain/pipeline/safe-default.d.ts.map +1 -0
  107. package/dist/domain/pipeline/safe-default.js +41 -0
  108. package/dist/domain/pipeline/safe-default.js.map +1 -0
  109. package/dist/domain/refactor-guardrails.d.ts +48 -0
  110. package/dist/domain/refactor-guardrails.d.ts.map +1 -0
  111. package/dist/domain/refactor-guardrails.js +129 -0
  112. package/dist/domain/refactor-guardrails.js.map +1 -0
  113. package/dist/domain/routing/context-fit.d.ts +53 -0
  114. package/dist/domain/routing/context-fit.d.ts.map +1 -0
  115. package/dist/domain/routing/context-fit.js +142 -0
  116. package/dist/domain/routing/context-fit.js.map +1 -0
  117. package/dist/domain/routing/expected-cost.d.ts +63 -0
  118. package/dist/domain/routing/expected-cost.d.ts.map +1 -0
  119. package/dist/domain/routing/expected-cost.js +177 -0
  120. package/dist/domain/routing/expected-cost.js.map +1 -0
  121. package/dist/domain/routing/p-success-classifier.d.ts +130 -0
  122. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -0
  123. package/dist/domain/routing/p-success-classifier.js +350 -0
  124. package/dist/domain/routing/p-success-classifier.js.map +1 -0
  125. package/dist/domain/routing/tier-features.d.ts +70 -0
  126. package/dist/domain/routing/tier-features.d.ts.map +1 -0
  127. package/dist/domain/routing/tier-features.js +174 -0
  128. package/dist/domain/routing/tier-features.js.map +1 -0
  129. package/dist/domain/routing/tool-history-guard.d.ts +32 -0
  130. package/dist/domain/routing/tool-history-guard.d.ts.map +1 -0
  131. package/dist/domain/routing/tool-history-guard.js +110 -0
  132. package/dist/domain/routing/tool-history-guard.js.map +1 -0
  133. package/dist/domain/scoring/multi-objective.d.ts +48 -0
  134. package/dist/domain/scoring/multi-objective.d.ts.map +1 -0
  135. package/dist/domain/scoring/multi-objective.js +116 -0
  136. package/dist/domain/scoring/multi-objective.js.map +1 -0
  137. package/dist/domain/triage/triage-engine.d.ts +43 -0
  138. package/dist/domain/triage/triage-engine.d.ts.map +1 -0
  139. package/dist/domain/triage/triage-engine.js +317 -0
  140. package/dist/domain/triage/triage-engine.js.map +1 -0
  141. package/dist/domain/triage/turn-envelope.d.ts +17 -0
  142. package/dist/domain/triage/turn-envelope.d.ts.map +1 -0
  143. package/dist/domain/triage/turn-envelope.js +81 -0
  144. package/dist/domain/triage/turn-envelope.js.map +1 -0
  145. package/dist/domain/types/entities.d.ts +303 -0
  146. package/dist/domain/types/entities.d.ts.map +1 -0
  147. package/dist/domain/types/entities.js +6 -0
  148. package/dist/domain/types/entities.js.map +1 -0
  149. package/dist/domain/types/index.d.ts +4 -0
  150. package/dist/domain/types/index.d.ts.map +1 -0
  151. package/dist/domain/types/index.js +2 -0
  152. package/dist/domain/types/index.js.map +1 -0
  153. package/dist/domain/types/schemas.d.ts +346 -0
  154. package/dist/domain/types/schemas.d.ts.map +1 -0
  155. package/dist/domain/types/schemas.js +241 -0
  156. package/dist/domain/types/schemas.js.map +1 -0
  157. package/dist/domain/types/store-port.d.ts +44 -0
  158. package/dist/domain/types/store-port.d.ts.map +1 -0
  159. package/dist/domain/types/store-port.js +6 -0
  160. package/dist/domain/types/store-port.js.map +1 -0
  161. package/dist/index.d.ts +39 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +36 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/infra/gemini-provider.d.ts +28 -0
  166. package/dist/infra/gemini-provider.d.ts.map +1 -0
  167. package/dist/infra/gemini-provider.js +112 -0
  168. package/dist/infra/gemini-provider.js.map +1 -0
  169. package/dist/infra/telemetry.d.ts +51 -0
  170. package/dist/infra/telemetry.d.ts.map +1 -0
  171. package/dist/infra/telemetry.js +100 -0
  172. package/dist/infra/telemetry.js.map +1 -0
  173. package/dist/infrastructure/delegation/provider-error.d.ts +44 -0
  174. package/dist/infrastructure/delegation/provider-error.d.ts.map +1 -0
  175. package/dist/infrastructure/delegation/provider-error.js +179 -0
  176. package/dist/infrastructure/delegation/provider-error.js.map +1 -0
  177. package/dist/infrastructure/gateway/circuit-breaker.d.ts +68 -0
  178. package/dist/infrastructure/gateway/circuit-breaker.d.ts.map +1 -0
  179. package/dist/infrastructure/gateway/circuit-breaker.js +150 -0
  180. package/dist/infrastructure/gateway/circuit-breaker.js.map +1 -0
  181. package/dist/infrastructure/gateway/gateway-dispatch.d.ts +138 -0
  182. package/dist/infrastructure/gateway/gateway-dispatch.d.ts.map +1 -0
  183. package/dist/infrastructure/gateway/gateway-dispatch.js +308 -0
  184. package/dist/infrastructure/gateway/gateway-dispatch.js.map +1 -0
  185. package/dist/infrastructure/hardware/hardware-probe.d.ts +32 -0
  186. package/dist/infrastructure/hardware/hardware-probe.d.ts.map +1 -0
  187. package/dist/infrastructure/hardware/hardware-probe.js +199 -0
  188. package/dist/infrastructure/hardware/hardware-probe.js.map +1 -0
  189. package/dist/infrastructure/local/local-zero-tier.d.ts +47 -0
  190. package/dist/infrastructure/local/local-zero-tier.d.ts.map +1 -0
  191. package/dist/infrastructure/local/local-zero-tier.js +92 -0
  192. package/dist/infrastructure/local/local-zero-tier.js.map +1 -0
  193. package/dist/infrastructure/persistence/memory-store.d.ts +31 -0
  194. package/dist/infrastructure/persistence/memory-store.d.ts.map +1 -0
  195. package/dist/infrastructure/persistence/memory-store.js +87 -0
  196. package/dist/infrastructure/persistence/memory-store.js.map +1 -0
  197. package/dist/infrastructure/persistence/sqlite-store.d.ts +81 -0
  198. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -0
  199. package/dist/infrastructure/persistence/sqlite-store.js +658 -0
  200. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -0
  201. package/dist/infrastructure/pricing/litellm-fetch.d.ts +46 -0
  202. package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -0
  203. package/dist/infrastructure/pricing/litellm-fetch.js +145 -0
  204. package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -0
  205. package/dist/infrastructure/pricing/price-broker.d.ts +56 -0
  206. package/dist/infrastructure/pricing/price-broker.d.ts.map +1 -0
  207. package/dist/infrastructure/pricing/price-broker.js +133 -0
  208. package/dist/infrastructure/pricing/price-broker.js.map +1 -0
  209. package/dist/infrastructure/pricing/pricing-monitor.d.ts +27 -0
  210. package/dist/infrastructure/pricing/pricing-monitor.d.ts.map +1 -0
  211. package/dist/infrastructure/pricing/pricing-monitor.js +46 -0
  212. package/dist/infrastructure/pricing/pricing-monitor.js.map +1 -0
  213. package/dist/infrastructure/telemetry/dataset-limits.d.ts +13 -0
  214. package/dist/infrastructure/telemetry/dataset-limits.d.ts.map +1 -0
  215. package/dist/infrastructure/telemetry/dataset-limits.js +28 -0
  216. package/dist/infrastructure/telemetry/dataset-limits.js.map +1 -0
  217. package/dist/infrastructure/telemetry/dataset-recorder.d.ts +48 -0
  218. package/dist/infrastructure/telemetry/dataset-recorder.d.ts.map +1 -0
  219. package/dist/infrastructure/telemetry/dataset-recorder.js +154 -0
  220. package/dist/infrastructure/telemetry/dataset-recorder.js.map +1 -0
  221. package/dist/infrastructure/telemetry/outcome-limits.d.ts +12 -0
  222. package/dist/infrastructure/telemetry/outcome-limits.d.ts.map +1 -0
  223. package/dist/infrastructure/telemetry/outcome-limits.js +28 -0
  224. package/dist/infrastructure/telemetry/outcome-limits.js.map +1 -0
  225. package/dist/infrastructure/telemetry/outcome-recorder.d.ts +29 -0
  226. package/dist/infrastructure/telemetry/outcome-recorder.d.ts.map +1 -0
  227. package/dist/infrastructure/telemetry/outcome-recorder.js +49 -0
  228. package/dist/infrastructure/telemetry/outcome-recorder.js.map +1 -0
  229. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +115 -0
  230. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -0
  231. package/dist/infrastructure/telemetry/routing-telemetry.js +529 -0
  232. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -0
  233. package/dist/infrastructure/telemetry/telemetry-limits.d.ts +16 -0
  234. package/dist/infrastructure/telemetry/telemetry-limits.d.ts.map +1 -0
  235. package/dist/infrastructure/telemetry/telemetry-limits.js +35 -0
  236. package/dist/infrastructure/telemetry/telemetry-limits.js.map +1 -0
  237. package/package.json +110 -0
  238. package/skills/router-backlog-orchestrator/SKILL.md +183 -0
  239. package/skills/router-backlog-orchestrator/references/github-router-issue-template.md +78 -0
  240. package/skills/router-backlog-orchestrator/references/packet-from-issue.md +99 -0
  241. package/skills/router-backlog-orchestrator/references/prioritization-rubric.md +65 -0
  242. package/skills/router-backlog-orchestrator/scripts/collect-backlog.sh +44 -0
  243. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +151 -0
  244. package/src/api/explain/.gitkeep +0 -0
  245. package/src/api/explain/router-explain.ts +146 -0
  246. package/src/api/middleware/.gitkeep +0 -0
  247. package/src/api/middleware/pi-router-middleware.ts +169 -0
  248. package/src/cli/smart-router-cli.ts +436 -0
  249. package/src/config/.gitkeep +0 -0
  250. package/src/config/defaults.ts +35 -0
  251. package/src/config/models-loader.ts +68 -0
  252. package/src/config/pi-model-mapper.ts +285 -0
  253. package/src/config/routing-clusters-loader.ts +148 -0
  254. package/src/domain/delegation/delegation-context.ts +111 -0
  255. package/src/domain/delegation/execution-ledger.ts +34 -0
  256. package/src/domain/delegation/output-headroom.ts +131 -0
  257. package/src/domain/matching/.gitkeep +0 -0
  258. package/src/domain/matching/cluster-matcher.ts +474 -0
  259. package/src/domain/matching/embedding-provider.ts +79 -0
  260. package/src/domain/matching/hydra-input.ts +53 -0
  261. package/src/domain/matching/hydra-matcher.ts +454 -0
  262. package/src/domain/pinning/.gitkeep +0 -0
  263. package/src/domain/pinning/cache-economics.ts +128 -0
  264. package/src/domain/pinning/loop-escalation.ts +181 -0
  265. package/src/domain/pinning/session-pinner.ts +388 -0
  266. package/src/domain/pinning/sub-route-policy.ts +119 -0
  267. package/src/domain/pipeline/.gitkeep +0 -0
  268. package/src/domain/pipeline/router-pipeline.ts +1139 -0
  269. package/src/domain/pipeline/safe-default.ts +70 -0
  270. package/src/domain/refactor-guardrails.ts +202 -0
  271. package/src/domain/routing/context-fit.ts +221 -0
  272. package/src/domain/routing/expected-cost.ts +320 -0
  273. package/src/domain/routing/p-success-classifier.ts +511 -0
  274. package/src/domain/routing/tier-features.ts +290 -0
  275. package/src/domain/routing/tool-history-guard.ts +159 -0
  276. package/src/domain/scoring/.gitkeep +0 -0
  277. package/src/domain/scoring/multi-objective.ts +175 -0
  278. package/src/domain/triage/.gitkeep +0 -0
  279. package/src/domain/triage/triage-engine.ts +373 -0
  280. package/src/domain/triage/turn-envelope.ts +104 -0
  281. package/src/domain/types/.gitkeep +0 -0
  282. package/src/domain/types/entities.ts +384 -0
  283. package/src/domain/types/index.ts +41 -0
  284. package/src/domain/types/schemas.ts +289 -0
  285. package/src/domain/types/store-port.ts +59 -0
  286. package/src/index.ts +103 -0
  287. package/src/infra/gemini-provider.ts +142 -0
  288. package/src/infra/telemetry.ts +169 -0
  289. package/src/infrastructure/delegation/provider-error.ts +251 -0
  290. package/src/infrastructure/gateway/.gitkeep +0 -0
  291. package/src/infrastructure/gateway/circuit-breaker.ts +196 -0
  292. package/src/infrastructure/gateway/gateway-dispatch.ts +444 -0
  293. package/src/infrastructure/hardware/.gitkeep +0 -0
  294. package/src/infrastructure/hardware/hardware-probe.ts +276 -0
  295. package/src/infrastructure/local/.gitkeep +0 -0
  296. package/src/infrastructure/local/local-zero-tier.ts +151 -0
  297. package/src/infrastructure/persistence/.gitkeep +0 -0
  298. package/src/infrastructure/persistence/memory-store.ts +122 -0
  299. package/src/infrastructure/persistence/sqlite-store.ts +900 -0
  300. package/src/infrastructure/pricing/.gitkeep +0 -0
  301. package/src/infrastructure/pricing/litellm-fetch.ts +215 -0
  302. package/src/infrastructure/pricing/price-broker.ts +183 -0
  303. package/src/infrastructure/pricing/pricing-monitor.ts +65 -0
  304. package/src/infrastructure/telemetry/.gitkeep +0 -0
  305. package/src/infrastructure/telemetry/dataset-limits.ts +39 -0
  306. package/src/infrastructure/telemetry/dataset-recorder.ts +215 -0
  307. package/src/infrastructure/telemetry/outcome-limits.ts +42 -0
  308. package/src/infrastructure/telemetry/outcome-recorder.ts +118 -0
  309. package/src/infrastructure/telemetry/routing-telemetry.ts +824 -0
  310. package/src/infrastructure/telemetry/telemetry-limits.ts +50 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Pi model registry → ModelProfile mapper.
3
+ *
4
+ * Maps pi `Model` objects (provider + id) to router fleet entries using
5
+ * pattern-based lookup for known families. Unknown models receive conservative
6
+ * economical-cloud defaults.
7
+ */
8
+
9
+ import type {
10
+ ModelCapabilities,
11
+ ModelLimits,
12
+ ModelPerformance,
13
+ ModelPricing,
14
+ ModelProfile,
15
+ Tier,
16
+ } from '../domain/types/entities.js';
17
+
18
+ /** Pi registry `Model.cost` shape — per-token USD rates. */
19
+ export interface PiRegistryCost {
20
+ readonly input: number;
21
+ readonly output: number;
22
+ readonly cacheRead: number;
23
+ readonly cacheWrite: number;
24
+ }
25
+
26
+ export interface PiModelInput {
27
+ readonly provider: string;
28
+ readonly id: string;
29
+ readonly name?: string;
30
+ readonly cost?: PiRegistryCost;
31
+ }
32
+
33
+ interface ModelFamilyDefaults {
34
+ readonly tier: Tier;
35
+ readonly capabilities: ModelCapabilities;
36
+ readonly performance?: ModelPerformance;
37
+ readonly pricing: ModelPricing;
38
+ readonly endpoint?: string;
39
+ }
40
+
41
+ interface ModelPatternRule {
42
+ readonly pattern: RegExp;
43
+ readonly defaults: ModelFamilyDefaults;
44
+ }
45
+
46
+ const LOCAL_PROVIDERS = new Set(['lmstudio', 'ollama']);
47
+
48
+ /** Conservative context limits when YAML and LiteLLM registry have no entry (SP-092). */
49
+ export const DEFAULT_MODEL_LIMITS: Readonly<Record<Tier, ModelLimits>> = {
50
+ 'zero-tier': { max_input_tokens: 32_768, max_output_tokens: 4_096 },
51
+ 'economical-cloud': { max_input_tokens: 128_000, max_output_tokens: 8_192 },
52
+ 'frontier-cloud': { max_input_tokens: 200_000, max_output_tokens: 16_384 },
53
+ };
54
+
55
+ export function getDefaultLimitsForTier(tier: Tier): ModelLimits {
56
+ return { ...DEFAULT_MODEL_LIMITS[tier] };
57
+ }
58
+
59
+ const LOCAL_DEFAULTS: ModelFamilyDefaults = {
60
+ tier: 'zero-tier',
61
+ capabilities: { reasoning: 0.3, code_gen: 0.6, tool_use: 0.1 },
62
+ performance: {
63
+ latency_p50_ms: 120,
64
+ verbosity_factor: 0.9,
65
+ cache_friendly: true,
66
+ },
67
+ pricing: {
68
+ registry_key: 'local/free',
69
+ fallback_cost_per_1m: 0.0,
70
+ },
71
+ endpoint: 'http://localhost:1234/v1',
72
+ };
73
+
74
+ const FRONTIER_DEFAULTS: ModelFamilyDefaults = {
75
+ tier: 'frontier-cloud',
76
+ capabilities: { reasoning: 0.95, code_gen: 0.95, tool_use: 0.95 },
77
+ performance: {
78
+ latency_p50_ms: 450,
79
+ verbosity_factor: 1.2,
80
+ cache_friendly: true,
81
+ },
82
+ pricing: {
83
+ fallback_cost_per_1m: 3.0,
84
+ },
85
+ };
86
+
87
+ const ECONOMICAL_DEFAULTS: ModelFamilyDefaults = {
88
+ tier: 'economical-cloud',
89
+ capabilities: { reasoning: 0.7, code_gen: 0.75, tool_use: 0.7 },
90
+ performance: {
91
+ latency_p50_ms: 280,
92
+ verbosity_factor: 0.95,
93
+ cache_friendly: true,
94
+ },
95
+ pricing: {
96
+ fallback_cost_per_1m: 0.8,
97
+ },
98
+ };
99
+
100
+ const UNKNOWN_DEFAULTS: ModelFamilyDefaults = {
101
+ tier: 'economical-cloud',
102
+ capabilities: { reasoning: 0.6, code_gen: 0.65, tool_use: 0.6 },
103
+ performance: {
104
+ latency_p50_ms: 350,
105
+ verbosity_factor: 1.0,
106
+ cache_friendly: true,
107
+ },
108
+ pricing: {
109
+ fallback_cost_per_1m: 1.0,
110
+ },
111
+ };
112
+
113
+ /**
114
+ * Default virtual subscription-quota cost for Cursor frontier models (SP-096 / #70).
115
+ * `fallback_cost_per_1m` stays 0 (no per-token API billing); `quota_cost_per_1m`
116
+ * is used only for frugality scoring and telemetry so economical API models are
117
+ * not dominated solely because subscription registry cost is zero.
118
+ */
119
+ export const DEFAULT_CURSOR_QUOTA_COST_PER_1M = 3.0;
120
+
121
+ /**
122
+ * Opaque pi/Cursor fleet placeholder id `default` — SP-098 / #70.
123
+ * Without an explicit rule this id fell through to UNKNOWN_DEFAULTS (economical-cloud)
124
+ * and won turn_envelope lowest-cost selection for tool_result turns.
125
+ */
126
+ const OPAQUE_FLEET_DEFAULT_ID = 'default';
127
+
128
+ /**
129
+ * Cursor opaque-auto models (`cursor/auto`, etc.) — SP-086 / #40.
130
+ * Frontier tier: Cursor picks the underlying model; HyDRA needs high capability
131
+ * scores so these are not dominated by mapped economical Gemini/OpenAI models.
132
+ * Registry `fallback_cost_per_1m` is zero (subscription billing); virtual quota
133
+ * cost (SP-096) drives frugality scoring and telemetry.
134
+ */
135
+ const CURSOR_AUTO_DEFAULTS: ModelFamilyDefaults = {
136
+ tier: 'frontier-cloud',
137
+ capabilities: { reasoning: 0.9, code_gen: 0.9, tool_use: 0.95 },
138
+ performance: {
139
+ latency_p50_ms: 350,
140
+ verbosity_factor: 1.0,
141
+ cache_friendly: false,
142
+ },
143
+ pricing: {
144
+ fallback_cost_per_1m: 0.0,
145
+ quota_cost_per_1m: DEFAULT_CURSOR_QUOTA_COST_PER_1M,
146
+ },
147
+ };
148
+
149
+ /**
150
+ * Cursor Composer coding models (`composer-latest`, `composer-*`) — SP-086 / #40.
151
+ * Frontier tier with strong code_gen; zero API fallback with virtual quota cost (SP-096).
152
+ */
153
+ const COMPOSER_DEFAULTS: ModelFamilyDefaults = {
154
+ tier: 'frontier-cloud',
155
+ capabilities: { reasoning: 0.85, code_gen: 0.95, tool_use: 0.9 },
156
+ performance: {
157
+ latency_p50_ms: 400,
158
+ verbosity_factor: 1.05,
159
+ cache_friendly: false,
160
+ },
161
+ pricing: {
162
+ fallback_cost_per_1m: 0.0,
163
+ quota_cost_per_1m: DEFAULT_CURSOR_QUOTA_COST_PER_1M,
164
+ },
165
+ };
166
+
167
+ /** Ordered rules — first match wins. */
168
+ const MODEL_PATTERN_RULES: readonly ModelPatternRule[] = [
169
+ { pattern: /claude[-_.]?opus|claude[-_.]?sonnet|opus|sonnet/i, defaults: FRONTIER_DEFAULTS },
170
+ { pattern: /claude[-_.]?haiku|haiku/i, defaults: ECONOMICAL_DEFAULTS },
171
+ { pattern: /gpt[-_.]?5\.5|gpt-5-5/i, defaults: FRONTIER_DEFAULTS },
172
+ { pattern: /gpt[-_.]?5\.1|gpt[-_.]?5[-_.]?mini|gpt[-_.]?mini/i, defaults: ECONOMICAL_DEFAULTS },
173
+ { pattern: /gemini[-_.]?2\.5[-_.]?pro|gemini-2-5-pro/i, defaults: FRONTIER_DEFAULTS },
174
+ { pattern: /gemini[-_.]?3.*pro/i, defaults: FRONTIER_DEFAULTS },
175
+ { pattern: /gemini.*pro/i, defaults: FRONTIER_DEFAULTS },
176
+ { pattern: /gemini.*flash|gemini-flash/i, defaults: ECONOMICAL_DEFAULTS },
177
+ { pattern: /^composer[-_]/i, defaults: COMPOSER_DEFAULTS },
178
+ { pattern: /^cursor\//i, defaults: CURSOR_AUTO_DEFAULTS },
179
+ ];
180
+
181
+ function normalizeProvider(provider: string): string {
182
+ return provider.trim().toLowerCase();
183
+ }
184
+
185
+ function matchPatternRules(id: string): ModelFamilyDefaults | undefined {
186
+ for (const rule of MODEL_PATTERN_RULES) {
187
+ if (rule.pattern.test(id)) {
188
+ return rule.defaults;
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ /**
195
+ * Registry cost → USD per 1M tokens: average of input and output rates scaled to 1M.
196
+ * Matches `computeCostPer1MTokens` in litellm-fetch (SP-045/SP-046).
197
+ * Returns undefined when both input and output rates are zero (use pattern default).
198
+ */
199
+ function deriveFallbackCostPer1M(cost: PiRegistryCost): number | undefined {
200
+ const { input, output } = cost;
201
+ if (input === 0 && output === 0) {
202
+ return undefined;
203
+ }
204
+
205
+ const inputPer1M = input * 1_000_000;
206
+ const outputPer1M = output * 1_000_000;
207
+ return (inputPer1M + outputPer1M) / 2;
208
+ }
209
+
210
+ function resolveFallbackCost(
211
+ input: PiModelInput,
212
+ defaults: ModelFamilyDefaults,
213
+ ): number {
214
+ if (input.cost === undefined) {
215
+ return defaults.pricing.fallback_cost_per_1m;
216
+ }
217
+
218
+ const fromRegistry = deriveFallbackCostPer1M(input.cost);
219
+ return fromRegistry ?? defaults.pricing.fallback_cost_per_1m;
220
+ }
221
+
222
+ function buildProfile(input: PiModelInput, defaults: ModelFamilyDefaults): ModelProfile {
223
+ const provider = input.provider.trim();
224
+ const registryKey = `${normalizeProvider(provider)}/${input.id}`;
225
+
226
+ const profile: ModelProfile = {
227
+ id: input.id,
228
+ tier: defaults.tier,
229
+ provider,
230
+ capabilities: { ...defaults.capabilities },
231
+ pricing: {
232
+ registry_key: defaults.pricing.registry_key ?? registryKey,
233
+ fallback_cost_per_1m: resolveFallbackCost(input, defaults),
234
+ ...(defaults.pricing.quota_cost_per_1m !== undefined
235
+ ? { quota_cost_per_1m: defaults.pricing.quota_cost_per_1m }
236
+ : {}),
237
+ },
238
+ };
239
+
240
+ const withPerformance =
241
+ defaults.performance !== undefined
242
+ ? { ...profile, performance: { ...defaults.performance } }
243
+ : profile;
244
+
245
+ if (defaults.endpoint !== undefined) {
246
+ return { ...withPerformance, endpoint: defaults.endpoint };
247
+ }
248
+
249
+ return withPerformance;
250
+ }
251
+
252
+ /**
253
+ * Map a pi model registry entry to a router ModelProfile.
254
+ */
255
+ export function mapPiModelToProfile(input: PiModelInput): ModelProfile {
256
+ const provider = normalizeProvider(input.provider);
257
+
258
+ if (LOCAL_PROVIDERS.has(provider)) {
259
+ // Local models stay free — registry cost must not override zero-tier pricing.
260
+ const localInput: PiModelInput = {
261
+ provider: input.provider,
262
+ id: input.id,
263
+ ...(input.name !== undefined ? { name: input.name } : {}),
264
+ };
265
+ return buildProfile(localInput, LOCAL_DEFAULTS);
266
+ }
267
+
268
+ if (input.id === OPAQUE_FLEET_DEFAULT_ID) {
269
+ return buildProfile(input, CURSOR_AUTO_DEFAULTS);
270
+ }
271
+
272
+ const matched = matchPatternRules(input.id);
273
+ if (matched) {
274
+ return buildProfile(input, matched);
275
+ }
276
+
277
+ return buildProfile(input, UNKNOWN_DEFAULTS);
278
+ }
279
+
280
+ /**
281
+ * Map an array of pi registry models to a router fleet catalog.
282
+ */
283
+ export function mapFleetFromRegistry(models: readonly PiModelInput[]): ModelProfile[] {
284
+ return models.map(mapPiModelToProfile);
285
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Routing cluster catalog loader — reads routing-clusters.yaml, validates via Zod,
3
+ * and precomputes centroid embeddings from reference prompts at load time.
4
+ *
5
+ * Maps to GitHub issue #55 / SP-099.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+
11
+ import { parse as parseYaml } from 'yaml';
12
+
13
+ import { RoutingClustersFileSchema } from '../domain/types/schemas.js';
14
+ import type {
15
+ LoadedRoutingCluster,
16
+ RoutingCluster,
17
+ RoutingClusterCatalog,
18
+ } from '../domain/types/index.js';
19
+
20
+ /** Prefix for cluster match reason codes (`cluster_low_stakes_general`, etc.). */
21
+ export const CLUSTER_REASON_CODE_PREFIX = 'cluster_';
22
+
23
+ export interface TextEmbedder {
24
+ embed(text: string): Promise<Float32Array>;
25
+ }
26
+
27
+ export interface LoadRoutingClustersOptions {
28
+ readonly filePath?: string;
29
+ readonly embedder: TextEmbedder;
30
+ }
31
+
32
+ export class RoutingClustersLoaderError extends Error {
33
+ override readonly name = 'RoutingClustersLoaderError';
34
+
35
+ constructor(message: string, options?: ErrorOptions) {
36
+ super(message, options);
37
+ }
38
+ }
39
+
40
+ /** Stable reason-code prefix for a cluster id. */
41
+ export function clusterReasonCode(clusterId: string): string {
42
+ return `${CLUSTER_REASON_CODE_PREFIX}${clusterId}`;
43
+ }
44
+
45
+ function formatZodIssues(error: { issues: readonly { path: readonly PropertyKey[]; message: string }[] }): string {
46
+ return error.issues
47
+ .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
48
+ .join('\n');
49
+ }
50
+
51
+ /**
52
+ * Parse and validate routing cluster YAML without computing centroids.
53
+ *
54
+ * @throws {RoutingClustersLoaderError} when validation fails.
55
+ */
56
+ export function parseRoutingClustersYaml(raw: string): readonly RoutingCluster[] {
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = parseYaml(raw);
60
+ } catch (err: unknown) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ throw new RoutingClustersLoaderError(`Failed to parse YAML: ${message}`, { cause: err });
63
+ }
64
+
65
+ const result = RoutingClustersFileSchema.safeParse(parsed);
66
+ if (!result.success) {
67
+ throw new RoutingClustersLoaderError(
68
+ `Invalid routing cluster catalog:\n${formatZodIssues(result.error)}`,
69
+ { cause: result.error },
70
+ );
71
+ }
72
+
73
+ return result.data.clusters;
74
+ }
75
+
76
+ function computeCentroid(embeddings: readonly Float32Array[]): Float32Array {
77
+ if (embeddings.length === 0) {
78
+ throw new RoutingClustersLoaderError('Cannot compute centroid from zero embeddings');
79
+ }
80
+
81
+ const dim = embeddings[0]!.length;
82
+ const centroid = new Float32Array(dim);
83
+
84
+ for (const embedding of embeddings) {
85
+ if (embedding.length !== dim) {
86
+ throw new RoutingClustersLoaderError(
87
+ `Embedding shape mismatch: expected ${dim}, got ${embedding.length}`,
88
+ );
89
+ }
90
+ for (let i = 0; i < dim; i++) {
91
+ centroid[i] = (centroid[i] ?? 0) + (embedding[i] ?? 0);
92
+ }
93
+ }
94
+
95
+ for (let i = 0; i < dim; i++) {
96
+ centroid[i] = (centroid[i] ?? 0) / embeddings.length;
97
+ }
98
+
99
+ return centroid;
100
+ }
101
+
102
+ async function loadClusterCentroids(
103
+ clusters: readonly RoutingCluster[],
104
+ embedder: TextEmbedder,
105
+ ): Promise<readonly LoadedRoutingCluster[]> {
106
+ const loaded: LoadedRoutingCluster[] = [];
107
+
108
+ for (const cluster of clusters) {
109
+ const embeddings: Float32Array[] = [];
110
+ for (const prompt of cluster.reference_prompts) {
111
+ embeddings.push(await embedder.embed(prompt));
112
+ }
113
+
114
+ loaded.push({
115
+ ...cluster,
116
+ centroid: computeCentroid(embeddings),
117
+ });
118
+ }
119
+
120
+ return loaded;
121
+ }
122
+
123
+ /**
124
+ * Load, validate, and precompute centroid embeddings for the routing cluster catalog.
125
+ *
126
+ * @throws {RoutingClustersLoaderError} when the file cannot be read or validation fails.
127
+ */
128
+ export async function loadRoutingClusters(
129
+ options: LoadRoutingClustersOptions,
130
+ ): Promise<RoutingClusterCatalog> {
131
+ const filePath = options.filePath ?? resolve('config', 'routing-clusters.yaml');
132
+
133
+ let raw: string;
134
+ try {
135
+ raw = readFileSync(filePath, 'utf8');
136
+ } catch (err: unknown) {
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ throw new RoutingClustersLoaderError(
139
+ `Failed to read routing clusters file: ${message}`,
140
+ { cause: err },
141
+ );
142
+ }
143
+
144
+ const clusters = parseRoutingClustersYaml(raw);
145
+ const loaded = await loadClusterCentroids(clusters, options.embedder);
146
+
147
+ return { clusters: loaded };
148
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Delegation context normalization — provider-agnostic replay identity fix.
3
+ *
4
+ * pi-ai transformMessages compares assistant message provider/api/model to the
5
+ * target model. Virtual smart-router tags break isSameModel and strip replay
6
+ * state (thoughtSignature, thinkingSignature, etc.).
7
+ */
8
+
9
+ import type {
10
+ Api,
11
+ AssistantMessage,
12
+ Context,
13
+ Message,
14
+ Model,
15
+ } from '@earendil-works/pi-ai/compat';
16
+
17
+ import type { ExecutionModel } from './execution-ledger.js';
18
+
19
+ export const VIRTUAL_ROUTER_PROVIDER = 'smart-router' as const;
20
+ export const VIRTUAL_ROUTER_MODEL_ID = 'auto' as const;
21
+
22
+ export function isVirtualRouterIdentity(provider: string, modelId: string): boolean {
23
+ return provider === VIRTUAL_ROUTER_PROVIDER && modelId === VIRTUAL_ROUTER_MODEL_ID;
24
+ }
25
+
26
+ export function hasReplaySensitiveState(messages: readonly Message[]): boolean {
27
+ for (const message of messages) {
28
+ if (message.role !== 'assistant') {
29
+ continue;
30
+ }
31
+
32
+ for (const block of message.content) {
33
+ if (block.type === 'thinking') {
34
+ if (block.redacted) {
35
+ return true;
36
+ }
37
+ if (block.thinkingSignature && block.thinkingSignature.length > 0) {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ if (block.type === 'text' && block.textSignature && block.textSignature.length > 0) {
43
+ return true;
44
+ }
45
+
46
+ if (
47
+ block.type === 'toolCall' &&
48
+ block.thoughtSignature &&
49
+ block.thoughtSignature.length > 0
50
+ ) {
51
+ return true;
52
+ }
53
+ }
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ function rewriteAssistantIdentity(
60
+ message: AssistantMessage,
61
+ executionModel: ExecutionModel,
62
+ ): AssistantMessage {
63
+ return {
64
+ ...message,
65
+ provider: executionModel.provider as AssistantMessage['provider'],
66
+ api: executionModel.api,
67
+ model: executionModel.id,
68
+ };
69
+ }
70
+
71
+ export interface NormalizeDelegationContextOptions {
72
+ readonly virtualProvider?: typeof VIRTUAL_ROUTER_PROVIDER;
73
+ readonly sessionExecution?: ExecutionModel | null;
74
+ }
75
+
76
+ /**
77
+ * Rewrite assistant messages tagged with the virtual router to the executing
78
+ * model identity pi-ai expects for same-model replay.
79
+ */
80
+ export function normalizeDelegationContext<TApi extends Api>(
81
+ context: Context,
82
+ targetModel: Model<TApi>,
83
+ options?: NormalizeDelegationContextOptions,
84
+ ): Context {
85
+ const virtualProvider = options?.virtualProvider ?? VIRTUAL_ROUTER_PROVIDER;
86
+ const fallbackExecution: ExecutionModel = {
87
+ provider: targetModel.provider,
88
+ api: targetModel.api,
89
+ id: targetModel.id,
90
+ };
91
+ const sessionExecution = options?.sessionExecution ?? null;
92
+ const executionForVirtual = sessionExecution ?? fallbackExecution;
93
+
94
+ const messages = context.messages.map((message) => {
95
+ if (message.role !== 'assistant') {
96
+ return message;
97
+ }
98
+
99
+ const assistant = message;
100
+ if (
101
+ assistant.provider === virtualProvider &&
102
+ assistant.model === VIRTUAL_ROUTER_MODEL_ID
103
+ ) {
104
+ return rewriteAssistantIdentity(assistant, executionForVirtual);
105
+ }
106
+
107
+ return message;
108
+ });
109
+
110
+ return { ...context, messages };
111
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Tracks the last successfully executing model per session.
3
+ *
4
+ * Used to rewrite virtual smart-router assistant identity on replay so pi-ai
5
+ * transformMessages sees the model that actually produced prior turns.
6
+ */
7
+
8
+ import type { Api } from '@earendil-works/pi-ai/compat';
9
+
10
+ export interface ExecutionModel {
11
+ readonly provider: string;
12
+ readonly api: Api;
13
+ readonly id: string;
14
+ }
15
+
16
+ export class ExecutionLedger {
17
+ private readonly bySession = new Map<string, ExecutionModel>();
18
+
19
+ recordSuccess(sessionId: string, model: ExecutionModel): void {
20
+ this.bySession.set(sessionId, {
21
+ provider: model.provider,
22
+ api: model.api,
23
+ id: model.id,
24
+ });
25
+ }
26
+
27
+ getLastExecution(sessionId: string): ExecutionModel | null {
28
+ return this.bySession.get(sessionId) ?? null;
29
+ }
30
+
31
+ clear(sessionId: string): void {
32
+ this.bySession.delete(sessionId);
33
+ }
34
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Delegation output headroom — SP-108.
3
+ *
4
+ * Reserves output budget from the model context window before provider dispatch.
5
+ * Prevents stopReason "length" with zero output when input consumes the window.
6
+ */
7
+
8
+ import type { Api, Model } from '@earendil-works/pi-ai/compat';
9
+
10
+ import type { ModelProfile } from '../types/index.js';
11
+
12
+ export const MIN_OUTPUT_TOKEN_FLOOR = 256;
13
+ export const DEFAULT_OUTPUT_HEADROOM_BUFFER = 64;
14
+
15
+ export const MIN_OUTPUT_TOKEN_FLOOR_ENV = 'MIN_OUTPUT_TOKEN_FLOOR';
16
+ export const OUTPUT_HEADROOM_BUFFER_ENV = 'OUTPUT_HEADROOM_BUFFER';
17
+
18
+ export interface OutputHeadroomConfig {
19
+ readonly minOutputFloor?: number;
20
+ readonly buffer?: number;
21
+ }
22
+
23
+ export interface OutputHeadroomFit {
24
+ readonly kind: 'fit';
25
+ readonly maxTokens: number;
26
+ readonly contextWindow: number;
27
+ }
28
+
29
+ export interface OutputHeadroomNoFit {
30
+ readonly kind: 'no_fit';
31
+ readonly contextWindow: number;
32
+ readonly availableOutputTokens: number;
33
+ }
34
+
35
+ export type OutputHeadroomResult = OutputHeadroomFit | OutputHeadroomNoFit;
36
+
37
+ function readPositiveIntEnv(name: string, fallback: number): number {
38
+ const raw = process.env[name];
39
+ if (raw === undefined || raw.trim() === '') {
40
+ return fallback;
41
+ }
42
+
43
+ const parsed = Number(raw);
44
+ if (!Number.isFinite(parsed) || parsed <= 0) {
45
+ return fallback;
46
+ }
47
+
48
+ return Math.floor(parsed);
49
+ }
50
+
51
+ export function resolveMinOutputFloor(config?: OutputHeadroomConfig): number {
52
+ if (config?.minOutputFloor !== undefined) {
53
+ return config.minOutputFloor;
54
+ }
55
+ return readPositiveIntEnv(MIN_OUTPUT_TOKEN_FLOOR_ENV, MIN_OUTPUT_TOKEN_FLOOR);
56
+ }
57
+
58
+ export function resolveOutputHeadroomBuffer(config?: OutputHeadroomConfig): number {
59
+ if (config?.buffer !== undefined) {
60
+ return config.buffer;
61
+ }
62
+ return readPositiveIntEnv(OUTPUT_HEADROOM_BUFFER_ENV, DEFAULT_OUTPUT_HEADROOM_BUFFER);
63
+ }
64
+
65
+ export function resolveContextWindow(
66
+ profile: ModelProfile,
67
+ registryModel?: Model<Api>,
68
+ ): number {
69
+ return (
70
+ profile.limits?.max_input_tokens ??
71
+ registryModel?.contextWindow ??
72
+ Number.MAX_SAFE_INTEGER
73
+ );
74
+ }
75
+
76
+ export function resolveMaxOutputCap(
77
+ profile: ModelProfile,
78
+ registryModel?: Model<Api>,
79
+ ): number {
80
+ return (
81
+ profile.limits?.max_output_tokens ??
82
+ registryModel?.maxTokens ??
83
+ Number.MAX_SAFE_INTEGER
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Compute delegatable maxTokens and whether output headroom meets the floor.
89
+ *
90
+ * Validates `estimatedInputTokens + minOutputFloor <= contextWindow` and returns
91
+ * `maxTokens = min(maxOutputCap, contextWindow - estimatedInput - buffer)`.
92
+ */
93
+ export function computeOutputHeadroom(
94
+ profile: ModelProfile,
95
+ estimatedInputTokens: number,
96
+ config?: OutputHeadroomConfig,
97
+ registryModel?: Model<Api>,
98
+ ): OutputHeadroomResult {
99
+ const contextWindow = resolveContextWindow(profile, registryModel);
100
+ const minFloor = resolveMinOutputFloor(config);
101
+ const buffer = resolveOutputHeadroomBuffer(config);
102
+ const maxOutputCap = resolveMaxOutputCap(profile, registryModel);
103
+
104
+ if (contextWindow === Number.MAX_SAFE_INTEGER) {
105
+ const maxTokens = Math.min(maxOutputCap, Number.MAX_SAFE_INTEGER);
106
+ return { kind: 'fit', maxTokens, contextWindow };
107
+ }
108
+
109
+ const availableOutputTokens = contextWindow - estimatedInputTokens;
110
+
111
+ if (estimatedInputTokens + minFloor > contextWindow) {
112
+ return {
113
+ kind: 'no_fit',
114
+ contextWindow,
115
+ availableOutputTokens: Math.max(0, availableOutputTokens),
116
+ };
117
+ }
118
+
119
+ const rawMaxTokens = contextWindow - estimatedInputTokens - buffer;
120
+ const maxTokens = Math.min(maxOutputCap, rawMaxTokens);
121
+
122
+ if (maxTokens < minFloor) {
123
+ return {
124
+ kind: 'no_fit',
125
+ contextWindow,
126
+ availableOutputTokens: rawMaxTokens,
127
+ };
128
+ }
129
+
130
+ return { kind: 'fit', maxTokens, contextWindow };
131
+ }
File without changes