apsimo 1.3.0__py3-none-any.whl

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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
@@ -0,0 +1,762 @@
1
+ """LLMRouter — route LLM requests to the appropriate model tier.
2
+
3
+ Wraps LiteLLM's completion API. All Colony code that calls an LLM
4
+ MUST go through LLMRouter rather than calling LiteLLM directly.
5
+ This centralises cost tracking, fallback logic, and self-learning.
6
+
7
+ Usage::
8
+
9
+ router = LLMRouter()
10
+
11
+ # Simple routing — scorer picks the cheapest capable tier
12
+ response = await router.complete(messages)
13
+
14
+ # Force a specific tier
15
+ response = await router.complete(messages, force_tier=ModelTier.LARGE)
16
+
17
+ # Provide task context to improve tier selection
18
+ response = await router.complete(
19
+ messages,
20
+ context={"tools": tool_defs, "user_tier": "developer"},
21
+ )
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import os
29
+ import time
30
+ import uuid
31
+ import json
32
+ import ipaddress
33
+ import socket
34
+ import threading
35
+ from collections import deque
36
+ from copy import deepcopy
37
+ from pathlib import Path
38
+ from dataclasses import dataclass, field
39
+ from typing import Any
40
+
41
+ # Colony loads its own explicit configuration. LiteLLM otherwise asks
42
+ # python-dotenv to search parent directories during import, which can pull an
43
+ # unrelated operator .env into embedded processes and test collection.
44
+ os.environ.setdefault("PYTHON_DOTENV_DISABLED", "1")
45
+ import litellm # type: ignore[import]
46
+
47
+ from apsimo.router.complexity_scorer import ComplexityScorer
48
+ from apsimo.router.fallback import FallbackHandler
49
+ from apsimo.router.self_learning import RouterSelfLearner
50
+ from apsimo.router.tiers import DEFAULT_TIERS, ModelTier, TierConfig
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ class _IncompleteFunctionResponse(ValueError):
56
+ """A completed inference did not produce the requested usable output."""
57
+
58
+
59
+ @dataclass
60
+ class LLMResponse:
61
+ request_id: str
62
+ tier_used: ModelTier
63
+ model_id: str
64
+ content: str
65
+ usage: dict[str, int] # prompt_tokens, completion_tokens, total_tokens
66
+ latency_ms: int
67
+ cost_usd: float
68
+ raw: Any = field(default=None, repr=False)
69
+ function_role: str = ""
70
+ config_revision: str = ""
71
+ model_revision: str = "unknown"
72
+ binding: str = ""
73
+ prior_attempts: list[dict[str, str]] = field(default_factory=list)
74
+
75
+
76
+ class LLMRouter:
77
+ """Route LLM requests to the appropriate model tier."""
78
+
79
+ def __init__(
80
+ self,
81
+ tiers: dict[ModelTier, TierConfig] | None = None,
82
+ scorer: ComplexityScorer | None = None,
83
+ self_learner: RouterSelfLearner | None = None,
84
+ fallback_handler: FallbackHandler | None = None,
85
+ event_bus: Any | None = None,
86
+ ) -> None:
87
+ self._tiers = DEFAULT_TIERS if tiers is None else tiers
88
+ self._snapshot = None
89
+ self._config_path = None
90
+ self._config_stamp = None
91
+ self._config_lock = threading.RLock()
92
+ self._reload_error = None
93
+ self._recent_calls = deque(maxlen=20)
94
+ from .endpoints import EndpointRuntime
95
+ self._endpoints = EndpointRuntime()
96
+ self._scorer = scorer or ComplexityScorer()
97
+ self._fallback = fallback_handler or FallbackHandler()
98
+ self._bus = event_bus
99
+ # Only the retained legacy tier selector consumes this learner.
100
+ # Configuring named functions must not open an unrelated legacy DB.
101
+ self._learner = self_learner
102
+ self._learner_initialized = self_learner is not None
103
+
104
+ @property
105
+ def supports_function_routing(self):
106
+ return self._snapshot is not None
107
+
108
+ def configure(self, host_config, *, config_path=None):
109
+ """Update this object so every retained consumer sees the same router."""
110
+ from .tiers import build_tiers_from_host
111
+ from .functions import build_snapshot
112
+ if not isinstance(host_config, dict):
113
+ raise ValueError('Host model config must be an object')
114
+ # Only explicitly named deployed models enter function routing. Provider
115
+ # presets and discovery size guesses are not capability declarations.
116
+ raw_tiers = build_tiers_from_host(host_config, configure_environment=False, discover=False)
117
+ tiers = {tier: cfg for tier, cfg in raw_tiers.items() if tier.value in host_config.get('models', {})}
118
+ snapshot = build_snapshot(host_config, tiers)
119
+ with self._config_lock:
120
+ self._snapshot = snapshot
121
+ self._tiers = snapshot.tiers
122
+ if config_path is not None:
123
+ self._config_path = Path(config_path)
124
+ self._config_stamp = None
125
+ self._reload_error = None
126
+ return snapshot.status()
127
+
128
+ def watch_config(self, path):
129
+ self._config_path = Path(path)
130
+ self._config_stamp = None
131
+
132
+ def adopt_configuration(self, prepared, *, config_path):
133
+ with self._config_lock:
134
+ self._snapshot = prepared._snapshot
135
+ self._tiers = self._snapshot.tiers
136
+ self.watch_config(config_path)
137
+ self._reload_error = None
138
+
139
+ def _reload(self):
140
+ if self._config_path is None:
141
+ return
142
+ with self._config_lock:
143
+ try:
144
+ stat = self._config_path.stat()
145
+ stamp = (stat.st_ino, stat.st_mtime_ns, stat.st_size)
146
+ if stamp == self._config_stamp:
147
+ return
148
+ if stat.st_size > 262144:
149
+ raise ValueError('Routing config is too large')
150
+ config = json.loads(self._config_path.read_text())
151
+ self.configure(config)
152
+ self._config_stamp = stamp
153
+ except Exception as exc:
154
+ # Keep the last validated config; never replace it with cloud
155
+ # defaults after a partial write or invalid candidate binding.
156
+ self._reload_error = type(exc).__name__
157
+
158
+ def routing_status(self):
159
+ self._reload()
160
+ snapshot = self._snapshot
161
+ status = snapshot.status() if snapshot else {'config_revision': None, 'roles': {}}
162
+ if snapshot:
163
+ status.update(self._endpoints.status(snapshot))
164
+ return {**status, 'reload_error': self._reload_error, 'recent_calls': list(self._recent_calls)}
165
+
166
+ async def discover_models(self):
167
+ """Observe configured pool endpoints without changing their declarations."""
168
+ self._reload()
169
+ snapshot = self._snapshot
170
+ if snapshot is None:
171
+ return None
172
+ await self._endpoints.refresh(snapshot, self._probe_models)
173
+ status = {**snapshot.status(), **self._endpoints.status(snapshot),
174
+ 'reload_error': self._reload_error, 'recent_calls': list(self._recent_calls)}
175
+ models = {}
176
+ for item in status['model_inventory']:
177
+ if item['available'] and not item['stale']:
178
+ for model in item['models']:
179
+ models.setdefault(model['id'], model)
180
+ return {'models': list(models.values()), 'routing': status,
181
+ 'provider': snapshot.provider, 'base_url': snapshot.base_url or None}
182
+
183
+ async def _local_addresses(self, snapshot, binding):
184
+ from .functions import endpoint_host
185
+ host = endpoint_host(binding, snapshot)
186
+ if host is None:
187
+ return []
188
+ try:
189
+ ipaddress.ip_address(host)
190
+ return [host]
191
+ except ValueError:
192
+ addresses = await asyncio.wait_for(asyncio.get_running_loop().getaddrinfo(
193
+ host, None, type=socket.SOCK_STREAM), 2)
194
+ if not addresses or any(not any(ipaddress.ip_address(item[4][0]) in net
195
+ for net in snapshot.networks) for item in addresses):
196
+ return []
197
+ return list(dict.fromkeys(item[4][0] for item in addresses))[:8]
198
+
199
+ async def _at_endpoint(self, snapshot, binding, operation):
200
+ # One configured hostname can resolve to an unavailable address family.
201
+ # Retry only connection failures, within the caller's existing deadline.
202
+ addresses = await self._local_addresses(snapshot, binding)
203
+ if not addresses:
204
+ raise ConnectionError('Endpoint has no eligible resolved address')
205
+ for index, address in enumerate(addresses):
206
+ try:
207
+ return await operation(address)
208
+ except Exception as error:
209
+ if index == len(addresses)-1 or not _connection_failure(error):
210
+ raise
211
+
212
+ async def _probe_models(self, snapshot, binding):
213
+ from .endpoints import models_url
214
+ headers = {'Authorization': 'Bearer '+binding.config.api_key} if binding.config.api_key else {}
215
+ async def read(pinned):
216
+ async with _local_http_client(binding.config, pinned) as client:
217
+ async with client.stream('GET', models_url(binding.config.base_url), headers=headers) as response:
218
+ response.raise_for_status()
219
+ data = bytearray()
220
+ async for chunk in response.aiter_bytes():
221
+ data.extend(chunk)
222
+ if len(data) > 262144:
223
+ raise ValueError('Model listing is too large')
224
+ return data
225
+ data = await self._at_endpoint(snapshot, binding, read)
226
+ rows = json.loads(data).get('data')
227
+ if not isinstance(rows, list) or len(rows) > 256:
228
+ raise ValueError('Expected a bounded model listing')
229
+ models = []
230
+ for row in rows:
231
+ if not isinstance(row, dict) or not isinstance(row.get('id'), str) or not 1 <= len(row['id']) <= 256:
232
+ continue
233
+ context = row.get('max_model_len')
234
+ models.append({'id': row['id'],
235
+ 'owned_by': row.get('owned_by')[:128] if isinstance(row.get('owned_by'), str) else None,
236
+ 'advertised_context_tokens': context if type(context) is int and 0 < context <= 10**9 else None})
237
+ return models
238
+
239
+ # ------------------------------------------------------------------
240
+ # Public API
241
+ # ------------------------------------------------------------------
242
+
243
+ async def complete(
244
+ self,
245
+ messages: list[dict],
246
+ *,
247
+ force_tier: ModelTier | None = None,
248
+ context: dict | None = None,
249
+ tools: list[dict] | None = None,
250
+ stream: bool = False,
251
+ ) -> LLMResponse:
252
+ """Call the LLM at the cheapest capable tier.
253
+
254
+ Parameters
255
+ ----------
256
+ messages:
257
+ OpenAI-format message list.
258
+ force_tier:
259
+ Skip scoring and use this tier.
260
+ context:
261
+ Hints for the complexity scorer (keys: ``user_tier``, ``messages``,
262
+ ``tools``, ``task``).
263
+ tools:
264
+ Tool definitions to pass to LiteLLM.
265
+ stream:
266
+ Reserved legacy argument. The function path rejects True before
267
+ dispatch; Colony consumers currently request complete responses.
268
+ """
269
+ request_id = str(uuid.uuid4())
270
+ ctx = context or {}
271
+ if tools:
272
+ ctx = {**ctx, "tools": tools}
273
+
274
+ self._reload()
275
+ snapshot = self._snapshot
276
+ if snapshot is not None:
277
+ return await self._call_function(snapshot, messages, ctx, tools, force_tier, stream, request_id)
278
+ if not self._tiers:
279
+ raise ValueError('No validated model configuration is available')
280
+
281
+ # Determine prompt text for scoring (last user message)
282
+ prompt = _last_user_text(messages)
283
+
284
+ if force_tier is not None:
285
+ tier = force_tier
286
+ else:
287
+ tier = self._select_tier(prompt, ctx)
288
+
289
+ return await self._call_with_fallback(
290
+ request_id=request_id,
291
+ messages=messages,
292
+ tier=tier,
293
+ tools=tools,
294
+ stream=stream,
295
+ prompt=prompt,
296
+ max_output_tokens=(
297
+ int(ctx["max_output_tokens"])
298
+ if ctx.get("max_output_tokens") is not None else None
299
+ ),
300
+ allow_fallback=ctx.get("allow_fallback", True) is not False,
301
+ )
302
+
303
+ def route(self, prompt: str, context: dict | None = None) -> tuple[ModelTier, str]:
304
+ """Select a model tier without making an LLM call.
305
+
306
+ Used by the gateway for pre-call model selection: returns the chosen
307
+ tier and the LiteLLM model string for that tier.
308
+
309
+ Parameters
310
+ ----------
311
+ prompt:
312
+ The user's message text to score.
313
+ context:
314
+ Optional scoring hints (``user_tier``, ``tools``, ``messages``).
315
+
316
+ Returns
317
+ -------
318
+ (tier, model_id)
319
+ """
320
+ self._reload()
321
+ if self._snapshot is not None:
322
+ cfg = self.function_config(context=context)
323
+ return cfg.tier, cfg.model_id
324
+ tier = self._select_tier(prompt, context or {})
325
+ config = self._tiers.get(tier) or self._tiers.get(ModelTier.MEDIUM)
326
+ model_id = config.model_id if config else ""
327
+ return tier, model_id
328
+
329
+ def tier_config(self, tier: ModelTier) -> TierConfig | None:
330
+ """Return the TierConfig for *tier* (None if unconfigured).
331
+
332
+ Used by callers that need tier metadata without making a call —
333
+ e.g. the context gate reads ``useful_context_tokens`` to decide
334
+ whether an input needs chunking/retrieval before dispatch.
335
+ """
336
+ self._reload()
337
+ return self._tiers.get(tier)
338
+
339
+ def function_config(self, *, context=None):
340
+ """Capability hint for the existing context gate; dispatch rechecks DNS."""
341
+ from .functions import select_role, candidates
342
+ self._reload()
343
+ ctx = context or {}
344
+ snapshot = self._snapshot
345
+ role = select_role(snapshot, ctx)
346
+ if snapshot is None or role not in snapshot.roles:
347
+ return None
348
+ eligible = candidates(snapshot, role, ctx, has_images=False, has_tools=bool(ctx.get('tools')))
349
+ if not eligible:
350
+ raise ValueError('No eligible local model for function ' + role)
351
+ return eligible[0].config
352
+
353
+ def function_deadline_seconds(self, *, context=None):
354
+ """Configured total role budget for bounded background callers."""
355
+ from .functions import select_role
356
+ self._reload()
357
+ ctx = context or {}
358
+ snapshot = self._snapshot
359
+ role = select_role(snapshot, ctx)
360
+ if snapshot is None or role not in snapshot.roles:
361
+ return None
362
+ return snapshot.roles[role].deadline_seconds
363
+
364
+ async def _call_function(self, snapshot, messages, context, tools, force_tier, stream, request_id):
365
+ from .functions import select_role, candidates
366
+ role_name = select_role(snapshot, context)
367
+ if force_tier == ModelTier.VISION:
368
+ role_name = 'vision'
369
+ if role_name not in snapshot.roles:
370
+ raise ValueError('Unknown function role')
371
+ if stream:
372
+ raise ValueError('Function routing currently returns complete responses')
373
+ # This is a caller's output contract, applied only to explicitly
374
+ # capable bindings. Consumer validation remains necessary on every
375
+ # binding, including prompt-only fallbacks and legacy transports.
376
+ response_format = None
377
+ if context.get('response_schema') is not None:
378
+ schema = deepcopy(context['response_schema'])
379
+ if (not isinstance(schema, dict) or set(schema) != {'name', 'schema'}
380
+ or not isinstance(schema['name'], str) or not 1 <= len(schema['name']) <= 64
381
+ or not isinstance(schema['schema'], dict)):
382
+ raise ValueError('response_schema requires a name and JSON schema object')
383
+ response_format = {'type': 'json_schema', 'json_schema': {**schema, 'strict': True}}
384
+ has_images = any(isinstance(m.get('content'), list) and any(
385
+ isinstance(b, dict) and b.get('type') in {'image_url', 'input_image'} for b in m['content']) for m in messages)
386
+ from apsimo.contextgate import estimate_tokens
387
+ text = '\n'.join(m['content'] if isinstance(m.get('content'), str) else '\n'.join(
388
+ b.get('text', '') for b in (m.get('content') or []) if isinstance(b, dict) and isinstance(b.get('text'), str)) for m in messages)
389
+ schema_tokens = estimate_tokens(json.dumps(response_format)) if response_format else 0
390
+ selection_context = {**context, 'estimated_input_tokens': estimate_tokens(text) + 8 * len(messages),
391
+ 'response_schema_tokens': schema_tokens}
392
+ available = candidates(snapshot, role_name, selection_context, has_images=has_images, has_tools=bool(tools))
393
+ if force_tier is not None:
394
+ # Explicit legacy tier selection remains exact, but must still
395
+ # satisfy this call's capabilities and local network restrictions.
396
+ forced = getattr(force_tier, 'value', force_tier)
397
+ available = [b for b in available if b.name == forced]
398
+ if context.get('allow_fallback') is False:
399
+ available = available[:1]
400
+ role = snapshot.roles[role_name]
401
+ deadline = time.monotonic() + role.deadline_seconds
402
+ failures = []
403
+ prior_attempts = []
404
+ seen = set()
405
+ for binding in available:
406
+ cfg = binding.config
407
+ # This finite transport path pins a local OpenAI-compatible client.
408
+ # Provider prefixes may not silently select a cloud SDK instead.
409
+ if not cfg.model_id.startswith('openai/'):
410
+ continue
411
+ endpoint_key = (cfg.base_url, cfg.model_id, binding.weight_revision)
412
+ if endpoint_key in seen:
413
+ continue
414
+ seen.add(endpoint_key)
415
+ remaining = deadline - time.monotonic()
416
+ if remaining <= 0: break
417
+ attempt_timeout = asyncio.timeout(min(role.timeout_seconds, remaining))
418
+ if not self._endpoints.acquire(snapshot, binding, request_id):
419
+ failures.append('EndpointCoolingDown')
420
+ prior_attempts.append({'binding': binding.name, 'model': cfg.model_id,
421
+ 'status': 'skipped', 'reason': 'EndpointCoolingDown'})
422
+ continue
423
+ try:
424
+ async def complete_on_address(pinned_ip):
425
+ output = {'response_format': response_format} if response_format and binding.supports_json_schema else {}
426
+ return await self._litellm_call(
427
+ request_id=request_id, config=cfg, messages=messages, tools=tools,
428
+ stream=False, max_output_tokens=context.get('max_output_tokens', context.get('max_tokens')),
429
+ local_endpoint=True, pinned_ip=pinned_ip, **output)
430
+ async with attempt_timeout:
431
+ response = await self._at_endpoint(snapshot, binding, complete_on_address)
432
+ response.function_role = role_name
433
+ response.config_revision = snapshot.revision
434
+ response.model_revision = binding.weight_revision
435
+ response.binding = binding.name
436
+ response.prior_attempts = list(prior_attempts)
437
+ # Even an unusable completion consumed inference. Validate it
438
+ # inside the finite fallback loop, before recording success.
439
+ self._emit_cost_event(response)
440
+ _require_function_output(response, tools)
441
+ self._endpoints.success(snapshot, binding, response)
442
+ self._recent_calls.append({'request_id': request_id, 'function_role': role_name,
443
+ 'model_id': cfg.model_id, 'binding': binding.name, 'config_revision': snapshot.revision,
444
+ 'weight_revision': binding.weight_revision, 'latency_ms': response.latency_ms})
445
+ return response
446
+ except Exception as exc:
447
+ reason = ('RequestBudgetExceeded' if attempt_timeout.expired() else
448
+ str(exc) if isinstance(exc, _IncompleteFunctionResponse) else type(exc).__name__)
449
+ failures.append(reason)
450
+ prior_attempts.append({'binding': binding.name, 'model': cfg.model_id,
451
+ 'status': 'failed', 'reason': reason})
452
+ if not _retryable(exc):
453
+ break
454
+ # A caller's output/context or time budget says nothing about
455
+ # availability for another role. Transport/HTTP failures still
456
+ # cool down the endpoint when our own timer has not expired.
457
+ # A stalled endpoint remains eligible if only our timer expired.
458
+ if (not attempt_timeout.expired() and not isinstance(exc, _IncompleteFunctionResponse)
459
+ and 'contextwindow' not in type(exc).__name__.lower()):
460
+ self._endpoints.failure(snapshot, binding, exc)
461
+ finally:
462
+ self._endpoints.release(snapshot, binding, request_id)
463
+ raise RuntimeError('No eligible local model completed function ' + role_name +
464
+ '; attempts=' + ','.join(failures))
465
+
466
+ def record_outcome(
467
+ self,
468
+ request_id: str,
469
+ tier_used: ModelTier,
470
+ quality_rating: float,
471
+ tokens_used: int,
472
+ latency_ms: int,
473
+ prompt: str = "",
474
+ ) -> None:
475
+ """Record legacy tier outcomes; named functions do not use tier learning."""
476
+ if self._snapshot is not None:
477
+ return
478
+ learner = self._legacy_learner()
479
+ if learner is None:
480
+ return
481
+ config = self._tiers.get(tier_used)
482
+ cost = 0.0
483
+ if config:
484
+ cost = tokens_used * config.cost_per_1k_output / 1000
485
+
486
+ score = self._scorer.score(prompt)
487
+ learner.record(score, tier_used, quality_rating, cost)
488
+
489
+ # ------------------------------------------------------------------
490
+ # Internal helpers
491
+ # ------------------------------------------------------------------
492
+
493
+ def _legacy_learner(self):
494
+ with self._config_lock:
495
+ if not self._learner_initialized:
496
+ self._learner_initialized = True
497
+ try:
498
+ self._learner = RouterSelfLearner()
499
+ except Exception as exc:
500
+ logger.warning("Legacy RouterSelfLearner unavailable: %s", exc)
501
+ return self._learner
502
+
503
+ def _select_tier(self, prompt: str, context: dict) -> ModelTier:
504
+ learner = self._legacy_learner()
505
+ if learner is not None:
506
+ small_cutoff, medium_cutoff = learner.get_thresholds()
507
+ else:
508
+ small_cutoff, medium_cutoff = 0.3, 0.65
509
+
510
+ score = self._scorer.score(prompt, context)
511
+ if score < small_cutoff:
512
+ return ModelTier.SMALL
513
+ elif score < medium_cutoff:
514
+ return ModelTier.MEDIUM
515
+ else:
516
+ return ModelTier.LARGE
517
+
518
+ async def _call_with_fallback(
519
+ self,
520
+ *,
521
+ request_id: str,
522
+ messages: list[dict],
523
+ tier: ModelTier,
524
+ tools: list[dict] | None,
525
+ stream: bool,
526
+ prompt: str,
527
+ max_output_tokens: int | None = None,
528
+ allow_fallback: bool = True,
529
+ ) -> LLMResponse:
530
+ current_tier = tier
531
+ last_exc: Exception | None = None
532
+
533
+ while True:
534
+ config = self._tiers.get(current_tier)
535
+ if config is None:
536
+ raise ValueError(f"No TierConfig for tier {current_tier}")
537
+
538
+ try:
539
+ response = await self._litellm_call(
540
+ request_id=request_id,
541
+ config=config,
542
+ messages=messages,
543
+ tools=tools,
544
+ stream=stream,
545
+ max_output_tokens=max_output_tokens,
546
+ )
547
+ self._emit_cost_event(response)
548
+ return response
549
+
550
+ except Exception as exc:
551
+ last_exc = exc
552
+ if allow_fallback and self._fallback.should_escalate(exc, current_tier):
553
+ next_t = self._fallback.next_tier(current_tier)
554
+ if next_t is None:
555
+ break
556
+ logger.warning(
557
+ "LLMRouter: escalating %s → %s for request %s",
558
+ current_tier.value,
559
+ next_t.value,
560
+ request_id,
561
+ )
562
+ current_tier = next_t
563
+ else:
564
+ break
565
+
566
+ raise RuntimeError(
567
+ f"LLMRouter: all tiers exhausted for request {request_id}"
568
+ ) from last_exc
569
+
570
+ async def _litellm_call(
571
+ self,
572
+ *,
573
+ request_id: str,
574
+ config: TierConfig,
575
+ messages: list[dict],
576
+ tools: list[dict] | None,
577
+ stream: bool,
578
+ max_output_tokens: int | None = None,
579
+ local_endpoint: bool = False,
580
+ pinned_ip: str | None = None,
581
+ response_format: dict | None = None,
582
+ ) -> LLMResponse:
583
+ kwargs: dict[str, Any] = {
584
+ "model": config.model_id,
585
+ "messages": messages,
586
+ "max_tokens": (
587
+ min(config.max_tokens, max(1, max_output_tokens))
588
+ if max_output_tokens is not None else config.max_tokens
589
+ ),
590
+ }
591
+ if tools:
592
+ kwargs["tools"] = tools
593
+ if response_format is not None:
594
+ kwargs["response_format"] = response_format
595
+ if stream:
596
+ kwargs["stream"] = True
597
+ # Per-tier endpoint overrides — different tiers may live on
598
+ # different servers (see TierConfig). When unset, LiteLLM falls
599
+ # back to the provider-wide env config (OPENAI_API_BASE etc.).
600
+ if config.base_url:
601
+ kwargs["api_base"] = config.base_url
602
+ if config.api_key:
603
+ kwargs["api_key"] = config.api_key
604
+ if config.extra_body:
605
+ kwargs["extra_body"] = dict(config.extra_body)
606
+
607
+ t0 = time.monotonic()
608
+ # LiteLLM's async completion
609
+ if local_endpoint:
610
+ # Prevent environment proxy/credential inheritance and HTTP
611
+ # redirects from turning a local fallback into a remote request.
612
+ from openai import AsyncOpenAI
613
+ async with _local_http_client(config, pinned_ip) as http_client:
614
+ async with AsyncOpenAI(base_url=config.base_url, api_key=config.api_key or 'local-no-key',
615
+ max_retries=0, http_client=http_client) as client:
616
+ raw = await litellm.acompletion(**kwargs, client=client, num_retries=0)
617
+ else:
618
+ raw = await litellm.acompletion(**kwargs)
619
+ latency_ms = int((time.monotonic() - t0) * 1000)
620
+
621
+ choice = raw.choices[0]
622
+ content = choice.message.content or ""
623
+
624
+ # Reasoning models (e.g. GLM-5.1, DeepSeek-R1) may put thinking in
625
+ # reasoning_content and leave content empty. Fall back to reasoning
626
+ # content when the final answer is blank but reasoning exists.
627
+ if not content and hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content:
628
+ content = choice.message.reasoning_content
629
+ usage = {
630
+ "prompt_tokens": raw.usage.prompt_tokens if raw.usage else 0,
631
+ "completion_tokens": raw.usage.completion_tokens if raw.usage else 0,
632
+ "total_tokens": raw.usage.total_tokens if raw.usage else 0,
633
+ }
634
+
635
+ cost_usd = _estimate_cost(config, usage)
636
+
637
+ return LLMResponse(
638
+ request_id=request_id,
639
+ tier_used=config.tier,
640
+ model_id=config.model_id,
641
+ content=content,
642
+ usage=usage,
643
+ latency_ms=latency_ms,
644
+ cost_usd=cost_usd,
645
+ raw=raw,
646
+ )
647
+
648
+ def _emit_cost_event(self, response: LLMResponse) -> None:
649
+ if self._bus is None:
650
+ return
651
+ try:
652
+ self._bus.emit(
653
+ "llm_router.cost",
654
+ {
655
+ "request_id": response.request_id,
656
+ "tier": response.tier_used.value,
657
+ "model": response.model_id,
658
+ "function_role": response.function_role,
659
+ "config_revision": response.config_revision,
660
+ "model_revision": response.model_revision,
661
+ "binding": response.binding,
662
+ "cost_usd": response.cost_usd,
663
+ "latency_ms": response.latency_ms,
664
+ "tokens": response.usage,
665
+ },
666
+ )
667
+ except Exception as exc: # noqa: BLE001
668
+ logger.debug("LLMRouter: failed to emit cost event: %s", exc)
669
+
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Helpers
673
+ # ---------------------------------------------------------------------------
674
+
675
+
676
+ def _last_user_text(messages: list[dict]) -> str:
677
+ """Extract the text of the last user message for scoring."""
678
+ for msg in reversed(messages):
679
+ if msg.get("role") == "user":
680
+ content = msg.get("content", "")
681
+ if isinstance(content, str):
682
+ return content
683
+ if isinstance(content, list):
684
+ parts = [
685
+ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
686
+ ]
687
+ return " ".join(parts)
688
+ # Fall back to all message content
689
+ return " ".join(
690
+ str(m.get("content", "")) for m in messages if isinstance(m.get("content"), str)
691
+ )
692
+
693
+
694
+ def _estimate_cost(config: TierConfig, usage: dict[str, int]) -> float:
695
+ input_cost = usage.get("prompt_tokens", 0) * config.cost_per_1k_input / 1000
696
+ output_cost = usage.get("completion_tokens", 0) * config.cost_per_1k_output / 1000
697
+ return round(input_cost + output_cost, 8)
698
+
699
+
700
+ def _local_http_client(config, pinned_ip):
701
+ """The same explicit local transport for completion and model observation."""
702
+ import httpx
703
+ from urllib.parse import urlsplit
704
+ origin = urlsplit(config.base_url)
705
+ async def pin_request(request):
706
+ if request.url.host != origin.hostname:
707
+ raise ValueError('Unexpected model request host')
708
+ if pinned_ip:
709
+ request.headers['Host'] = origin.netloc
710
+ request.extensions['sni_hostname'] = origin.hostname
711
+ request.url = request.url.copy_with(host=pinned_ip)
712
+ return httpx.AsyncClient(trust_env=False, follow_redirects=False,
713
+ event_hooks={'request': [pin_request]})
714
+
715
+
716
+ def _require_function_output(response, tools):
717
+ """Keep final text and explicitly requested tool turns, never scratch work.
718
+
719
+ Legacy tier routing keeps its compatibility behavior. Function callers get
720
+ a complete answer or a requested tool turn, within their existing budget.
721
+ Tool argument validation still belongs to the caller's executor.
722
+ """
723
+ choices = getattr(getattr(response, 'raw', None), 'choices', None)
724
+ if tools and choices:
725
+ choice = choices[0]
726
+ message = getattr(choice, 'message', None)
727
+ if (getattr(choice, 'finish_reason', None) in {None, 'stop', 'tool_calls'}
728
+ and getattr(message, 'tool_calls', None)):
729
+ response.content = getattr(message, 'content', None) or ''
730
+ return
731
+ from apsimo.util.model_output import final_text
732
+ try:
733
+ response.content = final_text(response)
734
+ except ValueError as exc:
735
+ raise _IncompleteFunctionResponse(str(exc)) from exc
736
+
737
+
738
+ def _retryable(exc):
739
+ """Finite same-role failover, never arbitrary validation/auth retries."""
740
+ if isinstance(exc, (TimeoutError, ConnectionError, OSError, _IncompleteFunctionResponse)):
741
+ return True
742
+ status = getattr(exc, 'status_code', None)
743
+ if status in {404, 408, 429, 500, 502, 503, 504}:
744
+ return True
745
+ name = type(exc).__name__.lower()
746
+ return any(word in name for word in ('timeout', 'connection', 'ratelimit', 'contextwindow'))
747
+
748
+
749
+ def _connection_failure(error):
750
+ """Recognize transport connect failures even through SDK exception wrappers."""
751
+ from httpx import ConnectError, ConnectTimeout
752
+ pending, seen = [error], set()
753
+ while pending and len(seen) < 16:
754
+ current = pending.pop()
755
+ if id(current) in seen:
756
+ continue
757
+ seen.add(id(current))
758
+ if isinstance(current, (ConnectionError, ConnectError, ConnectTimeout)):
759
+ return True
760
+ pending.extend(item for item in (current.__cause__, current.__context__)
761
+ if isinstance(item, BaseException))
762
+ return False