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,169 @@
1
+ /**
2
+ * Privacy-safe community telemetry export (SP-082).
3
+ *
4
+ * Maps routing telemetry rows to anonymized community export shapes and
5
+ * HyDRA calibration batches. Never exports prompt text, messages, request_id,
6
+ * or raw session_id — session identifiers are one-way hashed only.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+
11
+ import type {
12
+ RoutingFeatureSidecar,
13
+ RoutingTelemetry,
14
+ } from '../domain/types/index.js';
15
+
16
+ export const COMMUNITY_TELEMETRY_ENABLED_ENV = 'SMART_ROUTER_COMMUNITY_TELEMETRY';
17
+
18
+ export const COMMUNITY_TELEMETRY_ENABLED_NOTIFY_MESSAGE =
19
+ 'Smart Router community telemetry export is enabled. Only anonymized routing metadata is exported — no prompt text, messages, or raw session identifiers.';
20
+
21
+ export const TELEMETRY_EXPORT_FORBIDDEN_KEYS = [
22
+ 'session_id',
23
+ 'request_id',
24
+ 'prompt_text',
25
+ 'messages',
26
+ 'prompt',
27
+ 'pepper',
28
+ 'install_pepper',
29
+ 'dataset_pepper',
30
+ 'dataset_key',
31
+ 'prompt_fingerprint',
32
+ ] as const;
33
+
34
+ export const HYDRA_CALIBRATION_STAGE = 'hydra_match' as const;
35
+
36
+ export function isCommunityTelemetryExportEnabled(): boolean {
37
+ return process.env[COMMUNITY_TELEMETRY_ENABLED_ENV] === '1';
38
+ }
39
+
40
+ /** One-way hash for session correlation without exporting raw session_id. */
41
+ export function hashSessionIdForTelemetryExport(sessionId: string): string {
42
+ return createHash('sha256').update(sessionId).digest('hex');
43
+ }
44
+
45
+ export interface CommunityTelemetryRecord {
46
+ readonly timestamp: string;
47
+ readonly session_id_hash: string;
48
+ readonly turn_type: string;
49
+ readonly stage: string;
50
+ readonly reason_code: string;
51
+ readonly selected_model_id: string;
52
+ readonly estimated_cost_usd: number;
53
+ readonly routing_latency_ms: number;
54
+ readonly pin_reason: string | null;
55
+ }
56
+
57
+ /** HyDRA calibration row — routing signals only, no prompt content. */
58
+ export interface HydraCalibrationRecord {
59
+ readonly timestamp: string;
60
+ readonly session_id_hash: string;
61
+ readonly turn_type: string;
62
+ readonly reason_code: string;
63
+ readonly selected_model_id: string;
64
+ readonly routing_latency_ms: number;
65
+ readonly requirement_reasoning: number | null;
66
+ readonly requirement_code_gen: number | null;
67
+ readonly requirement_tool_use: number | null;
68
+ readonly top_candidate_model_id: string | null;
69
+ readonly top_candidate_score: number | null;
70
+ }
71
+
72
+ export function isHydraMatchTelemetry(record: RoutingTelemetry): boolean {
73
+ return record.stage === HYDRA_CALIBRATION_STAGE;
74
+ }
75
+
76
+ export function selectHydraMatchTelemetry(
77
+ records: readonly RoutingTelemetry[],
78
+ ): readonly RoutingTelemetry[] {
79
+ return records.filter(isHydraMatchTelemetry);
80
+ }
81
+
82
+ /** Map a telemetry row to a privacy-safe community export object. */
83
+ export function toCommunityTelemetryRecord(
84
+ record: RoutingTelemetry,
85
+ ): CommunityTelemetryRecord {
86
+ return {
87
+ timestamp: record.timestamp,
88
+ session_id_hash: hashSessionIdForTelemetryExport(record.session_id),
89
+ turn_type: record.turn_type,
90
+ stage: record.stage,
91
+ reason_code: record.reason_code,
92
+ selected_model_id: record.selected_model_id,
93
+ estimated_cost_usd: record.estimated_cost_usd,
94
+ routing_latency_ms: record.routing_latency_ms,
95
+ pin_reason: record.pin_reason,
96
+ };
97
+ }
98
+
99
+ function topViableCandidate(features: RoutingFeatureSidecar | undefined): {
100
+ readonly model_id: string | null;
101
+ readonly score: number | null;
102
+ } {
103
+ const candidates = features?.candidates;
104
+ if (!candidates || candidates.length === 0) {
105
+ return { model_id: null, score: null };
106
+ }
107
+
108
+ const viable = candidates.filter((candidate) => candidate.rejected_reason === null);
109
+ const pool = viable.length > 0 ? viable : [...candidates];
110
+ const best = pool.reduce((leading, candidate) =>
111
+ candidate.score > leading.score ? candidate : leading,
112
+ );
113
+
114
+ return { model_id: best.model_id, score: best.score };
115
+ }
116
+
117
+ /** Map a hydra_match telemetry row to a collaborative calibration export row. */
118
+ export function toHydraCalibrationRecord(
119
+ record: RoutingTelemetry,
120
+ features?: RoutingFeatureSidecar,
121
+ ): HydraCalibrationRecord {
122
+ const requirements = features?.requirements ?? null;
123
+ const top = topViableCandidate(features);
124
+
125
+ return {
126
+ timestamp: record.timestamp,
127
+ session_id_hash: hashSessionIdForTelemetryExport(record.session_id),
128
+ turn_type: record.turn_type,
129
+ reason_code: record.reason_code,
130
+ selected_model_id: record.selected_model_id,
131
+ routing_latency_ms: record.routing_latency_ms,
132
+ requirement_reasoning: requirements?.reasoning ?? null,
133
+ requirement_code_gen: requirements?.code_gen ?? null,
134
+ requirement_tool_use: requirements?.tool_use ?? null,
135
+ top_candidate_model_id: top.model_id,
136
+ top_candidate_score: top.score,
137
+ };
138
+ }
139
+
140
+ export function formatCommunityTelemetryJsonl(
141
+ records: readonly RoutingTelemetry[],
142
+ ): string {
143
+ return records
144
+ .map((record) => JSON.stringify(toCommunityTelemetryRecord(record)))
145
+ .join('\n');
146
+ }
147
+
148
+ export function formatHydraCalibrationJsonl(
149
+ records: readonly RoutingTelemetry[],
150
+ featuresByRequestId?: ReadonlyMap<string, RoutingFeatureSidecar>,
151
+ ): string {
152
+ return selectHydraMatchTelemetry(records)
153
+ .map((record) => {
154
+ const features = featuresByRequestId?.get(record.request_id);
155
+ return JSON.stringify(toHydraCalibrationRecord(record, features));
156
+ })
157
+ .join('\n');
158
+ }
159
+
160
+ /** Defense-in-depth scrub for loose export objects. */
161
+ export function scrubTelemetryExportObject(
162
+ value: Record<string, unknown>,
163
+ ): Record<string, unknown> {
164
+ const scrubbed = { ...value };
165
+ for (const key of TELEMETRY_EXPORT_FORBIDDEN_KEYS) {
166
+ delete scrubbed[key];
167
+ }
168
+ return scrubbed;
169
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Parse provider error payloads from pi-ai assistant errorMessage strings.
3
+ */
4
+
5
+ import type { AssistantMessage } from '@earendil-works/pi-ai/compat';
6
+ import { isContextOverflow } from '@earendil-works/pi-ai';
7
+
8
+ import {
9
+ formatGeminiThoughtSignatureErrorMessage,
10
+ isGeminiThoughtSignatureAssistantError,
11
+ isGeminiThoughtSignatureError,
12
+ } from '../../infra/gemini-provider.js';
13
+ import { isInfraError } from '../gateway/circuit-breaker.js';
14
+
15
+ export {
16
+ formatGeminiThoughtSignatureErrorMessage,
17
+ isGeminiThoughtSignatureAssistantError,
18
+ isGeminiThoughtSignatureError,
19
+ };
20
+
21
+ export interface ParsedProviderError {
22
+ readonly statusCode?: number;
23
+ readonly code?: string;
24
+ readonly message?: string;
25
+ }
26
+
27
+ function parseNumericCode(value: unknown): number | undefined {
28
+ if (typeof value === 'number' && Number.isFinite(value)) {
29
+ return value;
30
+ }
31
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
32
+ return Number.parseInt(value, 10);
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Extract HTTP status / error code from JSON provider error blobs.
39
+ */
40
+ export function parseProviderError(errorMessage: string): ParsedProviderError | undefined {
41
+ const trimmed = errorMessage.trim();
42
+ if (!trimmed.startsWith('{')) {
43
+ const statusMatch = trimmed.match(/\b(429|5\d{2})\b/);
44
+ if (statusMatch) {
45
+ return { statusCode: Number.parseInt(statusMatch[1]!, 10) };
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ try {
51
+ const parsed = JSON.parse(trimmed) as {
52
+ error?: { code?: unknown; status?: unknown; message?: unknown };
53
+ code?: unknown;
54
+ status?: unknown;
55
+ message?: unknown;
56
+ };
57
+
58
+ const nested = parsed.error;
59
+ const statusCode =
60
+ parseNumericCode(nested?.code) ??
61
+ parseNumericCode(nested?.status) ??
62
+ parseNumericCode(parsed.code) ??
63
+ parseNumericCode(parsed.status);
64
+
65
+ const code =
66
+ typeof nested?.status === 'string'
67
+ ? nested.status
68
+ : typeof parsed.status === 'string'
69
+ ? parsed.status
70
+ : undefined;
71
+
72
+ let message =
73
+ typeof nested?.message === 'string'
74
+ ? nested.message
75
+ : typeof parsed.message === 'string'
76
+ ? parsed.message
77
+ : undefined;
78
+
79
+ if (message && message.trim().startsWith('{')) {
80
+ try {
81
+ const inner = JSON.parse(message) as { error?: { message?: unknown }; message?: unknown };
82
+ if (typeof inner.error?.message === 'string') {
83
+ message = inner.error.message;
84
+ } else if (typeof inner.message === 'string') {
85
+ message = inner.message;
86
+ }
87
+ } catch {
88
+ // Nested provider error payloads may be malformed JSON — keep outer message.
89
+ }
90
+ }
91
+
92
+ if (statusCode === undefined && code === undefined && message === undefined) {
93
+ return undefined;
94
+ }
95
+
96
+ return {
97
+ ...(statusCode !== undefined ? { statusCode } : {}),
98
+ ...(code ? { code } : {}),
99
+ ...(message ? { message } : {}),
100
+ };
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ export function parseAssistantMessageError(
107
+ message: AssistantMessage,
108
+ ): ParsedProviderError | undefined {
109
+ if (message.stopReason !== 'error' || !message.errorMessage) {
110
+ return undefined;
111
+ }
112
+ return parseProviderError(message.errorMessage);
113
+ }
114
+
115
+ export function isInfraAssistantError(message: AssistantMessage): boolean {
116
+ const parsed = parseAssistantMessageError(message);
117
+ if (!parsed) {
118
+ return false;
119
+ }
120
+ return isInfraError(parsed);
121
+ }
122
+
123
+ const FORMATTED_ERROR_MAX_LENGTH = 200;
124
+
125
+ /** Output tokens at or below this count are treated as no usable generation. */
126
+ export const NEGLIGIBLE_OUTPUT_TOKEN_MAX = 4;
127
+
128
+ /** Input ratio of context window that signals context pressure on length stops. */
129
+ export const LENGTH_STOP_CONTEXT_PRESSURE_RATIO = 0.99;
130
+
131
+ export type LengthStopKind = 'context_pressure' | 'output_truncation';
132
+
133
+ export interface LengthStopHints {
134
+ readonly contextWindow?: number;
135
+ }
136
+
137
+ export function isNegligibleOutputTokens(outputTokens: number): boolean {
138
+ return outputTokens <= NEGLIGIBLE_OUTPUT_TOKEN_MAX;
139
+ }
140
+
141
+ function inputTokensUsed(message: AssistantMessage): number {
142
+ return message.usage.input + message.usage.cacheRead;
143
+ }
144
+
145
+ /**
146
+ * Classify a `stopReason: length` assistant message as context pressure vs output truncation.
147
+ */
148
+ export function classifyLengthStop(
149
+ message: AssistantMessage,
150
+ hints?: LengthStopHints,
151
+ ): LengthStopKind | undefined {
152
+ if (message.stopReason !== 'length') {
153
+ return undefined;
154
+ }
155
+
156
+ if (!isNegligibleOutputTokens(message.usage.output)) {
157
+ return 'output_truncation';
158
+ }
159
+
160
+ const contextWindow = hints?.contextWindow;
161
+ if (contextWindow !== undefined) {
162
+ if (isContextOverflow(message, contextWindow)) {
163
+ return 'context_pressure';
164
+ }
165
+
166
+ const inputTokens = inputTokensUsed(message);
167
+ if (inputTokens >= contextWindow * LENGTH_STOP_CONTEXT_PRESSURE_RATIO) {
168
+ return 'context_pressure';
169
+ }
170
+ }
171
+
172
+ return 'output_truncation';
173
+ }
174
+
175
+ /**
176
+ * User-facing text for length stops — distinct wording for context pressure vs truncation.
177
+ */
178
+ export function formatLengthStopMessage(
179
+ message: AssistantMessage,
180
+ hints?: LengthStopHints,
181
+ ): string {
182
+ const kind = classifyLengthStop(message, hints);
183
+ if (kind === 'context_pressure') {
184
+ const inputTokens = inputTokensUsed(message);
185
+ const limitSuffix =
186
+ hints?.contextWindow !== undefined
187
+ ? ` (context limit: ${hints.contextWindow.toLocaleString()} tokens)`
188
+ : '';
189
+ return (
190
+ `Context window exceeded: ${inputTokens.toLocaleString()} input tokens used${limitSuffix}, ` +
191
+ 'leaving no room for output. Try compacting history or switching to a larger-context model.'
192
+ );
193
+ }
194
+
195
+ return (
196
+ 'Model stopped because it reached the maximum output token limit. ' +
197
+ 'The response may be incomplete.'
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Rewrite context-pressure length stops as errors so pi surfaces the overflow message.
203
+ */
204
+ export function sanitizeLengthStopMessage(
205
+ message: AssistantMessage,
206
+ hints?: LengthStopHints,
207
+ ): AssistantMessage {
208
+ if (classifyLengthStop(message, hints) !== 'context_pressure') {
209
+ return message;
210
+ }
211
+
212
+ return {
213
+ ...message,
214
+ stopReason: 'error',
215
+ errorMessage: formatLengthStopMessage(message, hints),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Format a provider error blob for user-facing display (terminal/session).
221
+ * Never returns raw nested JSON.
222
+ */
223
+ export function formatProviderErrorMessage(errorMessage: string): string {
224
+ const parsed = parseProviderError(errorMessage);
225
+ if (parsed) {
226
+ const prefixParts: string[] = [];
227
+ if (parsed.statusCode !== undefined) {
228
+ prefixParts.push(String(parsed.statusCode));
229
+ }
230
+ if (parsed.code) {
231
+ prefixParts.push(parsed.code);
232
+ }
233
+ const prefix = prefixParts.length > 0 ? `${prefixParts.join(' ')}: ` : '';
234
+ if (parsed.message) {
235
+ return `${prefix}${parsed.message}`;
236
+ }
237
+ if (prefixParts.length > 0) {
238
+ return `Provider error (${prefixParts.join(' ')})`;
239
+ }
240
+ }
241
+
242
+ const trimmed = errorMessage.trim();
243
+ if (trimmed.startsWith('{')) {
244
+ return 'Provider error (unparseable response)';
245
+ }
246
+ if (trimmed.length > FORMATTED_ERROR_MAX_LENGTH) {
247
+ return `${trimmed.slice(0, FORMATTED_ERROR_MAX_LENGTH)}…`;
248
+ }
249
+ return trimmed;
250
+ }
251
+
File without changes
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Circuit breaker for gateway dispatch — T055, FR-018, FR-022.
3
+ *
4
+ * Tracks consecutive infrastructure failures per model endpoint and trips
5
+ * the breaker when the threshold is exceeded. Only infra errors (timeouts,
6
+ * network failures, 5xx) count — policy/safety rejections (4xx content
7
+ * filtering, 403 auth) do NOT trigger the breaker (FR-018).
8
+ *
9
+ * States: CLOSED → OPEN → HALF_OPEN → CLOSED (on success) or OPEN (on fail).
10
+ */
11
+
12
+ export type CircuitState = 'closed' | 'open' | 'half_open';
13
+
14
+ export interface CircuitBreakerConfig {
15
+ /** Consecutive infra failures before tripping the breaker. */
16
+ readonly failureThreshold: number;
17
+ /** Milliseconds to remain in OPEN state before allowing a probe. */
18
+ readonly resetTimeoutMs: number;
19
+ /** Max consecutive successes in HALF_OPEN required to close. */
20
+ readonly halfOpenSuccesses: number;
21
+ }
22
+
23
+ export const DEFAULT_CIRCUIT_CONFIG: CircuitBreakerConfig = {
24
+ failureThreshold: 3,
25
+ resetTimeoutMs: 30_000,
26
+ halfOpenSuccesses: 2,
27
+ };
28
+
29
+ export interface CircuitSnapshot {
30
+ readonly modelId: string;
31
+ readonly state: CircuitState;
32
+ readonly consecutiveFailures: number;
33
+ readonly consecutiveSuccesses: number;
34
+ readonly lastFailureAt: number | null;
35
+ readonly lastStateChangeAt: number;
36
+ }
37
+
38
+ interface CircuitEntry {
39
+ state: CircuitState;
40
+ consecutiveFailures: number;
41
+ consecutiveSuccesses: number;
42
+ lastFailureAt: number | null;
43
+ lastStateChangeAt: number;
44
+ }
45
+
46
+ /**
47
+ * Returns true when the error is an infrastructure failure that should
48
+ * trigger the circuit breaker. Policy/safety rejections are excluded
49
+ * per FR-018.
50
+ */
51
+ export function isInfraError(error: { statusCode?: number; code?: string; message?: string }): boolean {
52
+ if (error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET' ||
53
+ error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND' ||
54
+ error.code === 'UND_ERR_CONNECT_TIMEOUT' || error.code === 'UND_ERR_SOCKET') {
55
+ return true;
56
+ }
57
+
58
+ if (error.statusCode !== undefined) {
59
+ if (error.statusCode >= 500 || error.statusCode === 429) {
60
+ return true;
61
+ }
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ export class CircuitBreaker {
68
+ private readonly circuits = new Map<string, CircuitEntry>();
69
+ private readonly config: CircuitBreakerConfig;
70
+
71
+ constructor(config: Partial<CircuitBreakerConfig> = {}) {
72
+ this.config = { ...DEFAULT_CIRCUIT_CONFIG, ...config };
73
+ }
74
+
75
+ /**
76
+ * Check whether a model endpoint is available for requests.
77
+ * OPEN circuits reject immediately. OPEN circuits whose reset timeout
78
+ * has elapsed transition to HALF_OPEN and allow a probe.
79
+ */
80
+ canDispatch(modelId: string): boolean {
81
+ const entry = this.circuits.get(modelId);
82
+ if (!entry) return true;
83
+
84
+ if (entry.state === 'closed' || entry.state === 'half_open') {
85
+ return true;
86
+ }
87
+
88
+ const elapsed = Date.now() - entry.lastStateChangeAt;
89
+ if (elapsed >= this.config.resetTimeoutMs) {
90
+ entry.state = 'half_open';
91
+ entry.consecutiveSuccesses = 0;
92
+ entry.lastStateChangeAt = Date.now();
93
+ return true;
94
+ }
95
+
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Record a successful response from a model endpoint.
101
+ * In HALF_OPEN, accumulates successes until the threshold is met,
102
+ * then transitions to CLOSED. In CLOSED, resets the failure counter.
103
+ */
104
+ recordSuccess(modelId: string): void {
105
+ const entry = this.circuits.get(modelId);
106
+ if (!entry) return;
107
+
108
+ entry.consecutiveFailures = 0;
109
+
110
+ if (entry.state === 'half_open') {
111
+ entry.consecutiveSuccesses += 1;
112
+ if (entry.consecutiveSuccesses >= this.config.halfOpenSuccesses) {
113
+ entry.state = 'closed';
114
+ entry.lastStateChangeAt = Date.now();
115
+ }
116
+ } else if (entry.state === 'closed') {
117
+ entry.consecutiveSuccesses = 0;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Record an infrastructure failure from a model endpoint.
123
+ * Only infra errors should be reported here (caller checks isInfraError).
124
+ * HALF_OPEN → OPEN immediately. CLOSED → OPEN when threshold exceeded.
125
+ */
126
+ recordFailure(modelId: string): void {
127
+ const now = Date.now();
128
+ let entry = this.circuits.get(modelId);
129
+
130
+ if (!entry) {
131
+ entry = {
132
+ state: 'closed',
133
+ consecutiveFailures: 0,
134
+ consecutiveSuccesses: 0,
135
+ lastFailureAt: null,
136
+ lastStateChangeAt: now,
137
+ };
138
+ this.circuits.set(modelId, entry);
139
+ }
140
+
141
+ entry.consecutiveFailures += 1;
142
+ entry.lastFailureAt = now;
143
+ entry.consecutiveSuccesses = 0;
144
+
145
+ if (entry.state === 'half_open') {
146
+ entry.state = 'open';
147
+ entry.lastStateChangeAt = now;
148
+ return;
149
+ }
150
+
151
+ if (entry.consecutiveFailures >= this.config.failureThreshold) {
152
+ entry.state = 'open';
153
+ entry.lastStateChangeAt = now;
154
+ }
155
+ }
156
+
157
+ /** Get a read-only snapshot of a model's circuit state. */
158
+ getSnapshot(modelId: string): CircuitSnapshot {
159
+ const entry = this.circuits.get(modelId);
160
+ if (!entry) {
161
+ return {
162
+ modelId,
163
+ state: 'closed',
164
+ consecutiveFailures: 0,
165
+ consecutiveSuccesses: 0,
166
+ lastFailureAt: null,
167
+ lastStateChangeAt: 0,
168
+ };
169
+ }
170
+
171
+ return {
172
+ modelId,
173
+ state: entry.state,
174
+ consecutiveFailures: entry.consecutiveFailures,
175
+ consecutiveSuccesses: entry.consecutiveSuccesses,
176
+ lastFailureAt: entry.lastFailureAt,
177
+ lastStateChangeAt: entry.lastStateChangeAt,
178
+ };
179
+ }
180
+
181
+ /** Reset a specific model's circuit to CLOSED. */
182
+ reset(modelId: string): void {
183
+ this.circuits.delete(modelId);
184
+ }
185
+
186
+ /** List all model IDs with OPEN circuits (unhealthy endpoints). */
187
+ getOpenCircuits(): readonly string[] {
188
+ const open: string[] = [];
189
+ for (const [modelId, entry] of this.circuits) {
190
+ if (entry.state === 'open') {
191
+ open.push(modelId);
192
+ }
193
+ }
194
+ return open;
195
+ }
196
+ }