@oneciel-ai/ciel-runtime 0.1.1 → 0.2.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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,419 @@
1
+ """Shared upstream request retry transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import json
8
+ import time
9
+ from typing import Any, Callable
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class UpstreamRetryPolicy:
16
+ configured_gateway_retries: Callable[..., Any]
17
+ retry_after_exceeds_request_timeout: Callable[..., Any]
18
+ retryable_upstream_exception: Callable[..., Any]
19
+ upstream_rate_limit_retry_message: Callable[..., Any]
20
+ upstream_retry_http_codes: frozenset[int] | set[int] | tuple[int, ...]
21
+ upstream_retry_message: Callable[..., Any]
22
+ upstream_retry_wait_seconds: Callable[..., Any]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class UpstreamRetryKeys:
27
+ key_from_request_headers: Callable[..., Any]
28
+ provider_api_key_count: Callable[..., Any]
29
+ provider_has_live_api_key: Callable[..., Any]
30
+ provider_headers: Callable[..., Any]
31
+ register_api_key_cooldown: Callable[..., Any]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class UpstreamRetryRateLimit:
36
+ learn_headers: Callable[..., Any]
37
+ log: Callable[..., Any]
38
+ register_backoff: Callable[..., Any]
39
+ write_activity: Callable[..., Any]
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class UpstreamRetryHttp:
44
+ estimate_tokens: Callable[..., Any]
45
+ provider_urlopen: Callable[..., Any]
46
+ set_stream_read_timeout: Callable[..., Any]
47
+ stream_idle_timeout_seconds: Callable[..., Any]
48
+ upstream_http_error_message: Callable[..., Any]
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class UpstreamRetryServices:
53
+ policy: UpstreamRetryPolicy
54
+ keys: UpstreamRetryKeys
55
+ rate_limit: UpstreamRetryRateLimit
56
+ http: UpstreamRetryHttp
57
+
58
+
59
+ def post_json_with_rate_retry(
60
+ url: str,
61
+ req_body: Any,
62
+ headers: dict[str, str],
63
+ timeout: float,
64
+ provider: str,
65
+ pcfg: dict[str, Any],
66
+ model: str,
67
+ retry_notice: Callable[[str], None] | None = None,
68
+ *,
69
+ retry_rate_limits: bool = True,
70
+ services: UpstreamRetryServices,
71
+ ) -> Any:
72
+ policy = services.policy
73
+ keys = services.keys
74
+ rate_limit = services.rate_limit
75
+ http = services.http
76
+ UPSTREAM_RETRY_HTTP_CODES = policy.upstream_retry_http_codes
77
+ configured_gateway_retries = policy.configured_gateway_retries
78
+ retry_after_exceeds_request_timeout = policy.retry_after_exceeds_request_timeout
79
+ retryable_upstream_exception = policy.retryable_upstream_exception
80
+ upstream_rate_limit_retry_message = policy.upstream_rate_limit_retry_message
81
+ upstream_retry_message = policy.upstream_retry_message
82
+ upstream_retry_wait_seconds = policy.upstream_retry_wait_seconds
83
+ key_from_request_headers = keys.key_from_request_headers
84
+ provider_api_key_count = keys.provider_api_key_count
85
+ provider_has_live_api_key = keys.provider_has_live_api_key
86
+ provider_headers = keys.provider_headers
87
+ register_api_key_cooldown = keys.register_api_key_cooldown
88
+ learn_router_rate_limit_headers = rate_limit.learn_headers
89
+ register_router_rate_limit_backoff = rate_limit.register_backoff
90
+ router_log = rate_limit.log
91
+ write_router_activity = rate_limit.write_activity
92
+ estimate_tokens = http.estimate_tokens
93
+ provider_urlopen = http.provider_urlopen
94
+ upstream_http_error_message = http.upstream_http_error_message
95
+ gateway_retries = configured_gateway_retries(pcfg)
96
+ max_attempts = max(1, gateway_retries + 1)
97
+ rate_limit_max_attempts = max(max_attempts, provider_api_key_count(provider, pcfg))
98
+ token_estimate = estimate_tokens(req_body)
99
+ byte_estimate = len(json.dumps(req_body, ensure_ascii=False).encode("utf-8"))
100
+ for attempt in range(rate_limit_max_attempts):
101
+ try:
102
+ write_router_activity(
103
+ "request",
104
+ provider,
105
+ model,
106
+ attempt=attempt + 1,
107
+ total=max_attempts,
108
+ tokens=token_estimate,
109
+ bytes=byte_estimate,
110
+ timeout=timeout,
111
+ )
112
+ router_log("INFO", f"upstream_request provider={provider} model={model} attempt={attempt + 1}/{max_attempts} tokens={token_estimate} bytes={byte_estimate} timeout={timeout}")
113
+ data_bytes = json.dumps(req_body).encode("utf-8")
114
+ req = urllib.request.Request(url, data=data_bytes, headers=headers, method="POST")
115
+ with provider_urlopen(req, timeout=timeout, provider=provider, pcfg=pcfg) as resp:
116
+ learn_router_rate_limit_headers(provider, pcfg, model, resp.headers)
117
+ data = json.loads(resp.read().decode("utf-8"))
118
+ write_router_activity("success", provider, model, attempt=attempt + 1, tokens=token_estimate, bytes=byte_estimate)
119
+ return data
120
+ except urllib.error.HTTPError as exc:
121
+ raw = exc.read().decode("utf-8", errors="ignore")
122
+ learn_router_rate_limit_headers(provider, pcfg, model, exc.headers)
123
+ if exc.code == 429:
124
+ register_api_key_cooldown(provider, pcfg, key_from_request_headers(headers), exc.headers)
125
+ if (
126
+ exc.code == 429
127
+ and retry_rate_limits
128
+ and provider_api_key_count(provider, pcfg) > 1
129
+ and provider_has_live_api_key(provider, pcfg)
130
+ and attempt + 1 < rate_limit_max_attempts
131
+ ):
132
+ retry_no = attempt + 1
133
+ headers = provider_headers(
134
+ provider, pcfg, headers, None, True
135
+ )
136
+ next_hash = hashlib.sha256(key_from_request_headers(headers).encode("utf-8")).hexdigest()[:12]
137
+ write_router_activity("retry", provider, model, attempt=retry_no, total=rate_limit_max_attempts - 1, code=exc.code, wait=0, tokens=token_estimate, bytes=byte_estimate)
138
+ router_log("WARN", f"upstream_rate_limit_key_retry provider={provider} model={model} attempt={retry_no}/{rate_limit_max_attempts - 1} next_key_hash={next_hash} tokens={token_estimate} bytes={byte_estimate}")
139
+ continue
140
+ if exc.code == 429 and retry_rate_limits and attempt + 1 < max_attempts:
141
+ skip_retry, retry_after_seconds = retry_after_exceeds_request_timeout(exc.headers, timeout)
142
+ if skip_retry:
143
+ write_router_activity("error", provider, model, code=exc.code, retry_after=retry_after_seconds, tokens=token_estimate, bytes=byte_estimate)
144
+ router_log(
145
+ "WARN",
146
+ f"upstream_rate_limit_no_retry provider={provider} model={model} retry_after={retry_after_seconds:.2f}s timeout={timeout:.2f}s tokens={token_estimate} bytes={byte_estimate}",
147
+ )
148
+ raise RuntimeError(upstream_http_error_message(exc, raw)) from exc
149
+ retry_no = attempt + 1
150
+ wait = register_router_rate_limit_backoff(provider, pcfg, model, exc.headers.get("Retry-After"))
151
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, wait=wait, tokens=token_estimate, bytes=byte_estimate)
152
+ router_log("WARN", f"upstream_rate_limit_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} wait={wait:.2f}s tokens={token_estimate} bytes={byte_estimate}")
153
+ if retry_notice:
154
+ retry_notice(upstream_rate_limit_retry_message(retry_no, gateway_retries))
155
+ time.sleep(wait)
156
+ # The just-failed key is now resting; re-pick so the retry uses a live key.
157
+ headers = provider_headers(
158
+ provider, pcfg, headers, None, True
159
+ )
160
+ continue
161
+ if exc.code in UPSTREAM_RETRY_HTTP_CODES and attempt + 1 < max_attempts:
162
+ retry_no = attempt + 1
163
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, tokens=token_estimate, bytes=byte_estimate)
164
+ router_log("WARN", f"upstream_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} code={exc.code} tokens={token_estimate} bytes={byte_estimate}")
165
+ if retry_notice:
166
+ retry_notice(upstream_retry_message(retry_no, gateway_retries))
167
+ time.sleep(upstream_retry_wait_seconds(retry_no))
168
+ continue
169
+ write_router_activity("error", provider, model, code=exc.code, tokens=token_estimate, bytes=byte_estimate)
170
+ raise RuntimeError(upstream_http_error_message(exc, raw)) from exc
171
+ except (TimeoutError, urllib.error.URLError) as exc:
172
+ if retryable_upstream_exception(exc) and attempt + 1 < max_attempts:
173
+ retry_no = attempt + 1
174
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, error=type(exc).__name__, tokens=token_estimate, bytes=byte_estimate)
175
+ router_log("WARN", f"upstream_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} error={type(exc).__name__} tokens={token_estimate} bytes={byte_estimate}")
176
+ if retry_notice:
177
+ retry_notice(upstream_retry_message(retry_no, gateway_retries))
178
+ time.sleep(upstream_retry_wait_seconds(retry_no))
179
+ continue
180
+ write_router_activity("error", provider, model, error=type(exc).__name__, tokens=token_estimate, bytes=byte_estimate)
181
+ raise RuntimeError(f"{type(exc).__name__}: {exc}") from exc
182
+ raise RuntimeError("upstream request failed")
183
+
184
+
185
+ def open_provider_request_with_key_retry(
186
+ url: str,
187
+ req_body: Any,
188
+ headers: dict[str, str],
189
+ timeout: float,
190
+ provider: str,
191
+ pcfg: dict[str, Any],
192
+ model: str,
193
+ *,
194
+ stream: bool = False,
195
+ retry_rate_limits: bool = True,
196
+ services: UpstreamRetryServices,
197
+ ) -> Any:
198
+ policy = services.policy
199
+ keys = services.keys
200
+ rate_limit = services.rate_limit
201
+ http = services.http
202
+ UPSTREAM_RETRY_HTTP_CODES = policy.upstream_retry_http_codes
203
+ configured_gateway_retries = policy.configured_gateway_retries
204
+ retry_after_exceeds_request_timeout = policy.retry_after_exceeds_request_timeout
205
+ retryable_upstream_exception = policy.retryable_upstream_exception
206
+ upstream_retry_wait_seconds = policy.upstream_retry_wait_seconds
207
+ key_from_request_headers = keys.key_from_request_headers
208
+ provider_api_key_count = keys.provider_api_key_count
209
+ provider_has_live_api_key = keys.provider_has_live_api_key
210
+ provider_headers = keys.provider_headers
211
+ register_api_key_cooldown = keys.register_api_key_cooldown
212
+ learn_router_rate_limit_headers = rate_limit.learn_headers
213
+ register_router_rate_limit_backoff = rate_limit.register_backoff
214
+ router_log = rate_limit.log
215
+ write_router_activity = rate_limit.write_activity
216
+ estimate_tokens = http.estimate_tokens
217
+ provider_urlopen = http.provider_urlopen
218
+ gateway_retries = configured_gateway_retries(pcfg)
219
+ max_attempts = max(1, gateway_retries + 1)
220
+ rate_limit_max_attempts = max(max_attempts, provider_api_key_count(provider, pcfg))
221
+ token_estimate = estimate_tokens(req_body)
222
+ byte_estimate = len(json.dumps(req_body, ensure_ascii=False).encode("utf-8"))
223
+ data_bytes = json.dumps(req_body).encode("utf-8")
224
+ for attempt in range(rate_limit_max_attempts):
225
+ try:
226
+ write_router_activity(
227
+ "request",
228
+ provider,
229
+ model,
230
+ attempt=attempt + 1,
231
+ total=max_attempts,
232
+ tokens=token_estimate,
233
+ bytes=byte_estimate,
234
+ timeout=timeout,
235
+ stream=stream,
236
+ )
237
+ router_log("INFO", f"upstream_direct_request provider={provider} model={model} attempt={attempt + 1}/{max_attempts} tokens={token_estimate} bytes={byte_estimate} timeout={timeout}")
238
+ req = urllib.request.Request(url, data=data_bytes, headers=headers, method="POST")
239
+ resp = provider_urlopen(req, timeout=timeout, provider=provider, pcfg=pcfg)
240
+ learn_router_rate_limit_headers(provider, pcfg, model, resp.headers)
241
+ return resp
242
+ except urllib.error.HTTPError as exc:
243
+ learn_router_rate_limit_headers(provider, pcfg, model, exc.headers)
244
+ if exc.code == 429:
245
+ register_api_key_cooldown(provider, pcfg, key_from_request_headers(headers), exc.headers)
246
+ if (
247
+ exc.code == 429
248
+ and retry_rate_limits
249
+ and provider_api_key_count(provider, pcfg) > 1
250
+ and provider_has_live_api_key(provider, pcfg)
251
+ and attempt + 1 < rate_limit_max_attempts
252
+ ):
253
+ retry_no = attempt + 1
254
+ headers = provider_headers(
255
+ provider, pcfg, headers, None, True
256
+ )
257
+ next_hash = hashlib.sha256(key_from_request_headers(headers).encode("utf-8")).hexdigest()[:12]
258
+ write_router_activity("retry", provider, model, attempt=retry_no, total=rate_limit_max_attempts - 1, code=exc.code, wait=0, tokens=token_estimate, bytes=byte_estimate, stream=stream)
259
+ router_log("WARN", f"upstream_direct_rate_limit_key_retry provider={provider} model={model} attempt={retry_no}/{rate_limit_max_attempts - 1} next_key_hash={next_hash} tokens={token_estimate} bytes={byte_estimate}")
260
+ continue
261
+ if exc.code == 429 and retry_rate_limits and attempt + 1 < max_attempts:
262
+ skip_retry, retry_after_seconds = retry_after_exceeds_request_timeout(exc.headers, timeout)
263
+ if skip_retry:
264
+ write_router_activity("error", provider, model, code=exc.code, retry_after=retry_after_seconds, tokens=token_estimate, bytes=byte_estimate, stream=stream)
265
+ router_log(
266
+ "WARN",
267
+ f"upstream_direct_rate_limit_no_retry provider={provider} model={model} retry_after={retry_after_seconds:.2f}s timeout={timeout:.2f}s tokens={token_estimate} bytes={byte_estimate}",
268
+ )
269
+ raise
270
+ retry_no = attempt + 1
271
+ wait = register_router_rate_limit_backoff(provider, pcfg, model, exc.headers.get("Retry-After"))
272
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, wait=wait, tokens=token_estimate, bytes=byte_estimate, stream=stream)
273
+ router_log("WARN", f"upstream_direct_rate_limit_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} wait={wait:.2f}s tokens={token_estimate} bytes={byte_estimate}")
274
+ time.sleep(wait)
275
+ continue
276
+ if exc.code in UPSTREAM_RETRY_HTTP_CODES and attempt + 1 < max_attempts:
277
+ retry_no = attempt + 1
278
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, tokens=token_estimate, bytes=byte_estimate, stream=stream)
279
+ router_log("WARN", f"upstream_direct_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} code={exc.code} tokens={token_estimate} bytes={byte_estimate}")
280
+ time.sleep(upstream_retry_wait_seconds(retry_no))
281
+ continue
282
+ raise
283
+ except (TimeoutError, urllib.error.URLError) as exc:
284
+ if retryable_upstream_exception(exc) and attempt + 1 < max_attempts:
285
+ retry_no = attempt + 1
286
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, error=type(exc).__name__, tokens=token_estimate, bytes=byte_estimate, stream=stream)
287
+ router_log("WARN", f"upstream_direct_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} error={type(exc).__name__} tokens={token_estimate} bytes={byte_estimate}")
288
+ time.sleep(upstream_retry_wait_seconds(retry_no))
289
+ continue
290
+ raise RuntimeError(f"{type(exc).__name__}: {exc}") from exc
291
+ raise RuntimeError("upstream direct request failed")
292
+
293
+
294
+ def open_openai_stream_with_rate_retry(
295
+ url: str,
296
+ req_body: Any,
297
+ headers: dict[str, str],
298
+ timeout: float,
299
+ provider: str,
300
+ pcfg: dict[str, Any],
301
+ model: str,
302
+ retry_notice: Callable[[str], None] | None = None,
303
+ *,
304
+ retry_rate_limits: bool = True,
305
+ services: UpstreamRetryServices,
306
+ ) -> Any:
307
+ policy = services.policy
308
+ keys = services.keys
309
+ rate_limit = services.rate_limit
310
+ http = services.http
311
+ UPSTREAM_RETRY_HTTP_CODES = policy.upstream_retry_http_codes
312
+ configured_gateway_retries = policy.configured_gateway_retries
313
+ retry_after_exceeds_request_timeout = policy.retry_after_exceeds_request_timeout
314
+ retryable_upstream_exception = policy.retryable_upstream_exception
315
+ upstream_rate_limit_retry_message = policy.upstream_rate_limit_retry_message
316
+ upstream_retry_message = policy.upstream_retry_message
317
+ upstream_retry_wait_seconds = policy.upstream_retry_wait_seconds
318
+ key_from_request_headers = keys.key_from_request_headers
319
+ provider_api_key_count = keys.provider_api_key_count
320
+ provider_has_live_api_key = keys.provider_has_live_api_key
321
+ provider_headers = keys.provider_headers
322
+ register_api_key_cooldown = keys.register_api_key_cooldown
323
+ learn_router_rate_limit_headers = rate_limit.learn_headers
324
+ register_router_rate_limit_backoff = rate_limit.register_backoff
325
+ router_log = rate_limit.log
326
+ write_router_activity = rate_limit.write_activity
327
+ estimate_tokens = http.estimate_tokens
328
+ provider_stream_idle_timeout_seconds = http.stream_idle_timeout_seconds
329
+ provider_urlopen = http.provider_urlopen
330
+ set_upstream_stream_read_timeout = http.set_stream_read_timeout
331
+ upstream_http_error_message = http.upstream_http_error_message
332
+ gateway_retries = configured_gateway_retries(pcfg)
333
+ max_attempts = max(1, gateway_retries + 1)
334
+ rate_limit_max_attempts = max(max_attempts, provider_api_key_count(provider, pcfg))
335
+ token_estimate = estimate_tokens(req_body)
336
+ byte_estimate = len(json.dumps(req_body, ensure_ascii=False).encode("utf-8"))
337
+ data_bytes = json.dumps(req_body).encode("utf-8")
338
+ for attempt in range(rate_limit_max_attempts):
339
+ try:
340
+ write_router_activity(
341
+ "request",
342
+ provider,
343
+ model,
344
+ attempt=attempt + 1,
345
+ total=max_attempts,
346
+ tokens=token_estimate,
347
+ bytes=byte_estimate,
348
+ timeout=timeout,
349
+ stream=True,
350
+ )
351
+ router_log("INFO", f"upstream_stream_request provider={provider} model={model} attempt={attempt + 1}/{max_attempts} tokens={token_estimate} bytes={byte_estimate} timeout={timeout}")
352
+ req = urllib.request.Request(url, data=data_bytes, headers=headers, method="POST")
353
+ resp = provider_urlopen(req, timeout=timeout, provider=provider, pcfg=pcfg)
354
+ set_upstream_stream_read_timeout(resp, provider_stream_idle_timeout_seconds(pcfg))
355
+ learn_router_rate_limit_headers(provider, pcfg, model, resp.headers)
356
+ return resp
357
+ except urllib.error.HTTPError as exc:
358
+ raw = exc.read().decode("utf-8", errors="ignore")
359
+ learn_router_rate_limit_headers(provider, pcfg, model, exc.headers)
360
+ if exc.code == 429:
361
+ register_api_key_cooldown(provider, pcfg, key_from_request_headers(headers), exc.headers)
362
+ if (
363
+ exc.code == 429
364
+ and retry_rate_limits
365
+ and provider_api_key_count(provider, pcfg) > 1
366
+ and provider_has_live_api_key(provider, pcfg)
367
+ and attempt + 1 < rate_limit_max_attempts
368
+ ):
369
+ retry_no = attempt + 1
370
+ headers = provider_headers(
371
+ provider, pcfg, headers, None, True
372
+ )
373
+ next_hash = hashlib.sha256(key_from_request_headers(headers).encode("utf-8")).hexdigest()[:12]
374
+ write_router_activity("retry", provider, model, attempt=retry_no, total=rate_limit_max_attempts - 1, code=exc.code, wait=0, tokens=token_estimate, bytes=byte_estimate, stream=True)
375
+ router_log("WARN", f"upstream_stream_rate_limit_key_retry provider={provider} model={model} attempt={retry_no}/{rate_limit_max_attempts - 1} next_key_hash={next_hash} tokens={token_estimate} bytes={byte_estimate}")
376
+ continue
377
+ if exc.code == 429 and retry_rate_limits and attempt + 1 < max_attempts:
378
+ skip_retry, retry_after_seconds = retry_after_exceeds_request_timeout(exc.headers, timeout)
379
+ if skip_retry:
380
+ write_router_activity("error", provider, model, code=exc.code, retry_after=retry_after_seconds, tokens=token_estimate, bytes=byte_estimate, stream=True)
381
+ router_log(
382
+ "WARN",
383
+ f"upstream_stream_rate_limit_no_retry provider={provider} model={model} retry_after={retry_after_seconds:.2f}s timeout={timeout:.2f}s tokens={token_estimate} bytes={byte_estimate}",
384
+ )
385
+ raise RuntimeError(upstream_http_error_message(exc, raw)) from exc
386
+ retry_no = attempt + 1
387
+ wait = register_router_rate_limit_backoff(provider, pcfg, model, exc.headers.get("Retry-After"))
388
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, wait=wait, tokens=token_estimate, bytes=byte_estimate, stream=True)
389
+ router_log("WARN", f"upstream_stream_rate_limit_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} wait={wait:.2f}s tokens={token_estimate} bytes={byte_estimate}")
390
+ if retry_notice:
391
+ retry_notice(upstream_rate_limit_retry_message(retry_no, gateway_retries))
392
+ time.sleep(wait)
393
+ # The just-failed key is now resting; re-pick so the retry uses a live key.
394
+ headers = provider_headers(
395
+ provider, pcfg, headers, None, True
396
+ )
397
+ continue
398
+ if exc.code in UPSTREAM_RETRY_HTTP_CODES and attempt + 1 < max_attempts:
399
+ retry_no = attempt + 1
400
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, code=exc.code, tokens=token_estimate, bytes=byte_estimate, stream=True)
401
+ router_log("WARN", f"upstream_stream_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} code={exc.code} tokens={token_estimate} bytes={byte_estimate}")
402
+ if retry_notice:
403
+ retry_notice(upstream_retry_message(retry_no, gateway_retries))
404
+ time.sleep(upstream_retry_wait_seconds(retry_no))
405
+ continue
406
+ write_router_activity("error", provider, model, code=exc.code, tokens=token_estimate, bytes=byte_estimate, stream=True)
407
+ raise RuntimeError(upstream_http_error_message(exc, raw)) from exc
408
+ except (TimeoutError, urllib.error.URLError) as exc:
409
+ if retryable_upstream_exception(exc) and attempt + 1 < max_attempts:
410
+ retry_no = attempt + 1
411
+ write_router_activity("retry", provider, model, attempt=retry_no, total=gateway_retries, error=type(exc).__name__, tokens=token_estimate, bytes=byte_estimate, stream=True)
412
+ router_log("WARN", f"upstream_stream_retry provider={provider} model={model} attempt={retry_no}/{gateway_retries} error={type(exc).__name__} tokens={token_estimate} bytes={byte_estimate}")
413
+ if retry_notice:
414
+ retry_notice(upstream_retry_message(retry_no, gateway_retries))
415
+ time.sleep(upstream_retry_wait_seconds(retry_no))
416
+ continue
417
+ write_router_activity("error", provider, model, error=type(exc).__name__, tokens=token_estimate, bytes=byte_estimate, stream=True)
418
+ raise RuntimeError(f"{type(exc).__name__}: {exc}") from exc
419
+ raise RuntimeError("upstream stream request failed")
@@ -0,0 +1,106 @@
1
+ """Socket adapter for cancellable upstream response streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import select
6
+ import socket
7
+ import time
8
+ from collections.abc import Callable, Iterable
9
+ from typing import Any
10
+
11
+
12
+ class UpstreamClientDisconnected(Exception):
13
+ """The downstream client closed while an upstream stream was active."""
14
+
15
+
16
+ def stream_idle_timeout(
17
+ config: dict[str, Any],
18
+ *,
19
+ positive_int: Callable[[Any], int],
20
+ request_timeout: Callable[[dict[str, Any]], float],
21
+ ) -> float:
22
+ milliseconds = positive_int(config.get("stream_idle_timeout_ms"))
23
+ if milliseconds:
24
+ return max(5.0, milliseconds / 1000.0)
25
+ return max(30.0, min(request_timeout(config), 300.0))
26
+
27
+
28
+ def set_stream_read_timeout(response: Any, timeout: float) -> None:
29
+ try:
30
+ if hasattr(response, "fp") and getattr(response, "fp") is not None:
31
+ raw = getattr(response.fp, "raw", None)
32
+ sock = getattr(raw, "_sock", None)
33
+ if sock is not None and hasattr(sock, "settimeout"):
34
+ sock.settimeout(timeout)
35
+ return
36
+ sock = getattr(response, "sock", None)
37
+ if sock is not None and hasattr(sock, "settimeout"):
38
+ sock.settimeout(timeout)
39
+ except (AttributeError, OSError, TypeError, ValueError):
40
+ pass
41
+
42
+
43
+ def client_connection_closed(handler: Any) -> bool:
44
+ connection = getattr(handler, "connection", None)
45
+ if connection is None:
46
+ return False
47
+ try:
48
+ readable, _, _ = select.select([connection], [], [], 0)
49
+ except (OSError, ValueError):
50
+ return True
51
+ except (AttributeError, TypeError):
52
+ return False
53
+ if not readable:
54
+ return False
55
+ try:
56
+ data = connection.recv(1, getattr(socket, "MSG_PEEK", 0))
57
+ return data == b""
58
+ except BlockingIOError:
59
+ return False
60
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
61
+ return True
62
+
63
+
64
+ def iter_lines_until_disconnect(
65
+ handler: Any,
66
+ response: Any,
67
+ idle_timeout: float,
68
+ *,
69
+ set_timeout: Callable[[Any, float], None],
70
+ disconnected: Callable[[Any], bool],
71
+ ) -> Iterable[bytes]:
72
+ try:
73
+ idle = max(1.0, float(idle_timeout))
74
+ except (TypeError, ValueError, OverflowError):
75
+ idle = 30.0
76
+ set_timeout(response, idle)
77
+ while True:
78
+ if disconnected(handler):
79
+ raise UpstreamClientDisconnected("downstream client disconnected")
80
+ try:
81
+ raw = response.readline()
82
+ except (TimeoutError, OSError) as exc:
83
+ if disconnected(handler):
84
+ raise UpstreamClientDisconnected(
85
+ "downstream client disconnected during upstream read"
86
+ ) from exc
87
+ raise
88
+ if raw in (b"", ""):
89
+ return
90
+ yield raw
91
+
92
+
93
+ def sleep_until_disconnect(
94
+ handler: Any,
95
+ seconds: float,
96
+ *,
97
+ disconnected: Callable[[Any], bool],
98
+ ) -> bool:
99
+ deadline = time.monotonic() + max(0.0, seconds)
100
+ while True:
101
+ if disconnected(handler):
102
+ return False
103
+ remaining = deadline - time.monotonic()
104
+ if remaining <= 0:
105
+ return True
106
+ time.sleep(min(0.25, remaining))
@@ -0,0 +1,96 @@
1
+ """Provider-neutral token usage events and durable sink ports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Callable, Protocol
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class UsageEvent:
15
+ provider: str
16
+ model: str
17
+ input_tokens: int = 0
18
+ output_tokens: int = 0
19
+ request_id: str = ""
20
+ protocol: str = ""
21
+ status: str = "completed"
22
+ timestamp: float = 0.0
23
+
24
+ def normalized(self, clock: Callable[[], float] = time.time) -> UsageEvent:
25
+ return UsageEvent(
26
+ provider=str(self.provider or "").strip(),
27
+ model=str(self.model or "").strip(),
28
+ input_tokens=max(0, int(self.input_tokens or 0)),
29
+ output_tokens=max(0, int(self.output_tokens or 0)),
30
+ request_id=str(self.request_id or "").strip(),
31
+ protocol=str(self.protocol or "").strip(),
32
+ status=str(self.status or "completed").strip() or "completed",
33
+ timestamp=float(self.timestamp or clock()),
34
+ )
35
+
36
+
37
+ class UsageEventSink(Protocol):
38
+ def record(self, event: UsageEvent) -> None: ...
39
+
40
+
41
+ class NullUsageEventSink:
42
+ def record(self, event: UsageEvent) -> None:
43
+ del event
44
+
45
+
46
+ class JsonlUsageEventSink:
47
+ """Append-only durable sink with bounded single-generation rotation."""
48
+
49
+ def __init__(
50
+ self,
51
+ path: Path,
52
+ *,
53
+ max_bytes: int = 10 * 1024 * 1024,
54
+ enabled: Callable[[], bool] = lambda: True,
55
+ clock: Callable[[], float] = time.time,
56
+ ) -> None:
57
+ self._path = path
58
+ self._max_bytes = max(1024, int(max_bytes))
59
+ self._enabled = enabled
60
+ self._clock = clock
61
+ self._lock = threading.Lock()
62
+
63
+ def record(self, event: UsageEvent) -> None:
64
+ if not self._enabled():
65
+ return
66
+ normalized = event.normalized(self._clock)
67
+ payload = json.dumps(asdict(normalized), ensure_ascii=False, separators=(",", ":"))
68
+ with self._lock:
69
+ self._path.parent.mkdir(parents=True, exist_ok=True)
70
+ if self._path.exists() and self._path.stat().st_size >= self._max_bytes:
71
+ self._path.replace(self._path.with_suffix(".jsonl.1"))
72
+ with self._path.open("a", encoding="utf-8") as stream:
73
+ stream.write(payload + "\n")
74
+
75
+
76
+ class CompositeUsageEventSink:
77
+ def __init__(self, *sinks: UsageEventSink) -> None:
78
+ self._sinks = sinks
79
+
80
+ def record(self, event: UsageEvent) -> None:
81
+ for sink in self._sinks:
82
+ sink.record(event)
83
+
84
+
85
+ def summarize_usage(events: list[UsageEvent]) -> dict[tuple[str, str], dict[str, int]]:
86
+ """Aggregate usage without coupling the domain to a dashboard or database."""
87
+
88
+ summary: dict[tuple[str, str], dict[str, int]] = {}
89
+ for raw in events:
90
+ event = raw.normalized()
91
+ key = (event.provider, event.model)
92
+ totals = summary.setdefault(key, {"requests": 0, "input_tokens": 0, "output_tokens": 0})
93
+ totals["requests"] += 1
94
+ totals["input_tokens"] += event.input_tokens
95
+ totals["output_tokens"] += event.output_tokens
96
+ return summary