coco-framework 0.1.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 (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,684 @@
1
+ """Helpers for OpenAI httpx client construction, transport tuning, and streaming.
2
+
3
+ Covers cached default client builders, proxy-aware variants for the
4
+ `openai_proxy` path, kernel-level TCP keepalive / `TCP_USER_TIMEOUT` socket
5
+ options, and the `_astream_with_chunk_timeout` wrapper that bounds per-chunk
6
+ wall-clock time on async SSE streams.
7
+
8
+ Client-builder boilerplate mirrors the patterns in `openai._base_client`;
9
+ socket-option tuning and the streaming timeout are original to this module.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import inspect
16
+ import logging
17
+ import os
18
+ import socket
19
+ import sys
20
+ import urllib.request
21
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
22
+ from functools import lru_cache
23
+ from typing import Any, TypeVar, cast
24
+
25
+ import openai
26
+ from pydantic import SecretStr
27
+
28
+ from coco_openai._compat import httpx
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ SocketOption = tuple[int, int, int]
33
+
34
+ # socket.TCP_KEEPIDLE etc. are absent on darwin/win32; use raw UAPI constants.
35
+ _LINUX_TCP_KEEPIDLE = 4
36
+ _LINUX_TCP_KEEPINTVL = 5
37
+ _LINUX_TCP_KEEPCNT = 6
38
+ _LINUX_TCP_USER_TIMEOUT = 18
39
+
40
+ # macOS: same semantics, different constants from <netinet/tcp.h>.
41
+ _DARWIN_TCP_KEEPALIVE = 0x10 # idle seconds before first probe
42
+ _DARWIN_TCP_KEEPINTVL = 0x101
43
+ _DARWIN_TCP_KEEPCNT = 0x102
44
+
45
+ # Mirrors the openai SDK's pool defaults. Hardcoded to avoid depending on
46
+ # an internal module path (openai._constants) that can move across SDK versions.
47
+ _DEFAULT_CONNECTION_LIMITS = httpx.Limits(
48
+ max_connections=1000,
49
+ max_keepalive_connections=100,
50
+ keepalive_expiry=5.0,
51
+ )
52
+
53
+
54
+ def _int_env(name: str, default: int, *, allow_negative: bool = False) -> int:
55
+ """Read an int env var with graceful fallback + discoverable warning.
56
+
57
+ Unparseable or (by default) negative values fall back to `default` and
58
+ emit a single `WARNING` naming the offending variable. A misconfigured
59
+ environment still loads, but operators see the fallback in their logs
60
+ rather than silently getting a surprising default.
61
+ """
62
+ raw = os.environ.get(name)
63
+ if raw is None:
64
+ return default
65
+ try:
66
+ value = int(raw)
67
+ except (TypeError, ValueError):
68
+ logger.warning(
69
+ "Invalid value for %s=%r (not an int); falling back to %d.",
70
+ name,
71
+ raw,
72
+ default,
73
+ )
74
+ return default
75
+ if not allow_negative and value < 0:
76
+ logger.warning(
77
+ "Invalid value for %s=%r (negative); falling back to %d.",
78
+ name,
79
+ raw,
80
+ default,
81
+ )
82
+ return default
83
+ return value
84
+
85
+
86
+ def _float_env(name: str, default: float, *, allow_negative: bool = False) -> float:
87
+ """Read a float env var with graceful fallback + discoverable warning.
88
+
89
+ See `_int_env`. Negative values are rejected by default so a typo in
90
+ `COCO_OPENAI_STREAM_CHUNK_TIMEOUT_S=-10` can't silently disable the
91
+ wrapper it was meant to configure.
92
+ """
93
+ raw = os.environ.get(name)
94
+ if raw is None:
95
+ return default
96
+ try:
97
+ value = float(raw)
98
+ except (TypeError, ValueError):
99
+ logger.warning(
100
+ "Invalid value for %s=%r (not a float); falling back to %s.",
101
+ name,
102
+ raw,
103
+ default,
104
+ )
105
+ return default
106
+ if not allow_negative and value < 0:
107
+ logger.warning(
108
+ "Invalid value for %s=%r (negative); falling back to %s.",
109
+ name,
110
+ raw,
111
+ default,
112
+ )
113
+ return default
114
+ return value
115
+
116
+
117
+ def _filter_supported(opts: list[SocketOption]) -> list[SocketOption]:
118
+ """Drop socket options the running platform rejects.
119
+
120
+ Probes each option against a throwaway socket via `setsockopt` and keeps
121
+ only those the kernel accepts. This keeps the library-computed defaults
122
+ non-fatal across platforms that don't implement every Linux option —
123
+ `TCP_USER_TIMEOUT` in particular is Linux-only and silently missing on
124
+ macOS, some minimal kernels, and older gVisor builds. Dropped options
125
+ are logged at `DEBUG` so an operator can confirm whether a kernel-level
126
+ knob took effect on their platform.
127
+
128
+ If the probe socket cannot be created (sandboxed runtimes, `pytest-socket`
129
+ under `--disable-socket`, tight seccomp policies), the input list is
130
+ returned unfiltered. This preserves the pass-through behavior used for
131
+ explicit user overrides: unsupported options will surface as a clear
132
+ `OSError` at the first real `connect()` rather than being silently
133
+ dropped during `ChatOpenAI` construction.
134
+ """
135
+ try:
136
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
137
+ except Exception:
138
+ # Broad catch is deliberate: `pytest_socket` under `--disable-socket`
139
+ # raises `SocketBlockedError` (a `RuntimeError`, not `OSError`), and
140
+ # seccomp/sandboxed runtimes have been observed to raise other
141
+ # `OSError` subclasses and `PermissionError`. The intent is "any
142
+ # inability to create a probe socket -> pass through unfiltered,"
143
+ # and narrowing the type would silently regress sandboxed CI.
144
+ return list(opts)
145
+ try:
146
+ supported: list[SocketOption] = []
147
+ dropped: list[SocketOption] = []
148
+ for level, optname, optval in opts:
149
+ try:
150
+ probe.setsockopt(level, optname, optval)
151
+ except OSError:
152
+ dropped.append((level, optname, optval))
153
+ continue
154
+ supported.append((level, optname, optval))
155
+ if dropped:
156
+ logger.debug(
157
+ "Dropped %d unsupported socket option(s) on %s: %s",
158
+ len(dropped),
159
+ sys.platform,
160
+ dropped,
161
+ )
162
+ return supported
163
+ finally:
164
+ probe.close()
165
+
166
+
167
+ def _default_socket_options() -> tuple[SocketOption, ...]:
168
+ """Return default TCP socket options, or `()` if disabled via env.
169
+
170
+ Always returns a tuple (never None) so callers and `@lru_cache` keys
171
+ remain uniform: `()` is the single shape for "no options".
172
+
173
+ Target behavior on Linux/gVisor with the full option set: silent peers
174
+ are surfaced within ~90-120s via `SO_KEEPALIVE` + `TCP_USER_TIMEOUT`
175
+ (keepalive path gives a ~90s floor at the defaults; `TCP_USER_TIMEOUT`
176
+ caps at 120s). On platforms that reject some options,
177
+ `_filter_supported` drops them and the bound degrades to whatever the
178
+ remaining options provide.
179
+ """
180
+ if os.environ.get("COCO_OPENAI_TCP_KEEPALIVE", "1") == "0":
181
+ return ()
182
+
183
+ keepidle = _int_env("COCO_OPENAI_TCP_KEEPIDLE", 60)
184
+ keepintvl = _int_env("COCO_OPENAI_TCP_KEEPINTVL", 10)
185
+ keepcnt = _int_env("COCO_OPENAI_TCP_KEEPCNT", 3)
186
+ user_timeout_ms = _int_env("COCO_OPENAI_TCP_USER_TIMEOUT_MS", 120000)
187
+
188
+ opts: list[SocketOption] = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
189
+ if sys.platform == "linux":
190
+ opts += [
191
+ (socket.IPPROTO_TCP, _LINUX_TCP_KEEPIDLE, keepidle),
192
+ (socket.IPPROTO_TCP, _LINUX_TCP_KEEPINTVL, keepintvl),
193
+ (socket.IPPROTO_TCP, _LINUX_TCP_KEEPCNT, keepcnt),
194
+ (socket.IPPROTO_TCP, _LINUX_TCP_USER_TIMEOUT, user_timeout_ms),
195
+ ]
196
+ elif sys.platform == "darwin":
197
+ opts += [
198
+ (socket.IPPROTO_TCP, _DARWIN_TCP_KEEPALIVE, keepidle),
199
+ (socket.IPPROTO_TCP, _DARWIN_TCP_KEEPINTVL, keepintvl),
200
+ (socket.IPPROTO_TCP, _DARWIN_TCP_KEEPCNT, keepcnt),
201
+ ]
202
+ # Windows (win32): SO_KEEPALIVE only; per-option tuning requires WSAIoctl.
203
+ return tuple(_filter_supported(opts))
204
+
205
+
206
+ _PROXY_ENV_VARS = (
207
+ "HTTP_PROXY",
208
+ "HTTPS_PROXY",
209
+ "ALL_PROXY",
210
+ "http_proxy",
211
+ "https_proxy",
212
+ "all_proxy",
213
+ )
214
+ _proxy_env_warning_emitted = False
215
+ _proxy_env_bypass_info_emitted = False
216
+
217
+
218
+ def _proxy_env_detected() -> bool:
219
+ """True when httpx would pick up a proxy from env or system config.
220
+
221
+ Mirrors the surface httpx reads (`urllib.request.getproxies()` plus the
222
+ uppercase env var names) so a positive result means env-proxy
223
+ auto-detection is live on pre-PR code paths.
224
+ """
225
+ if any(os.environ.get(name) for name in _PROXY_ENV_VARS):
226
+ return True
227
+ try:
228
+ return bool(urllib.request.getproxies())
229
+ except Exception:
230
+ return False
231
+
232
+
233
+ def _should_bypass_socket_options_for_proxy_env(
234
+ *,
235
+ http_socket_options: Sequence[SocketOption] | None,
236
+ http_client: Any,
237
+ http_async_client: Any,
238
+ openai_proxy: str | None,
239
+ ) -> bool:
240
+ """True when default shape + env proxy detected → skip transport injection.
241
+
242
+ Preserves pre-PR behavior for apps relying on httpx's env-proxy
243
+ auto-detection. Only triggers when the user has made no explicit choice
244
+ that would signal they want the custom transport:
245
+
246
+ - `http_socket_options` left at `None` (default, not `()` or a sequence)
247
+ - `COCO_OPENAI_TCP_KEEPALIVE` is not `0` (kill-switch is its own path)
248
+ - No `http_client` or `http_async_client` supplied
249
+ - No `openai_proxy` supplied
250
+ - A proxy env var / system proxy is visible to httpx
251
+
252
+ If any of those are set, the user has opted in to the transport path
253
+ (directly or via `openai_proxy`) and normal behavior — including the
254
+ shadowed-proxy WARNING — applies. When the kill-switch is set,
255
+ `_default_socket_options` already returns `()`, so the bypass INFO
256
+ would be noise; route through the normal path instead.
257
+ """
258
+ if http_socket_options is not None:
259
+ return False
260
+ if os.environ.get("COCO_OPENAI_TCP_KEEPALIVE", "1") == "0":
261
+ return False
262
+ if http_client is not None or http_async_client is not None:
263
+ return False
264
+ if openai_proxy:
265
+ return False
266
+ return _proxy_env_detected()
267
+
268
+
269
+ def _log_proxy_env_bypass_once() -> None:
270
+ """Emit a one-time INFO when the proxy-env bypass triggers.
271
+
272
+ Visibility for operators running with a custom log pipeline: the bypass
273
+ is the *safe* outcome (env-proxy auto-detection preserved), but it means
274
+ socket-level keepalive / `TCP_USER_TIMEOUT` aren't applied on this
275
+ instance. INFO-level, since it's not a problem — just a diagnostic.
276
+ """
277
+ global _proxy_env_bypass_info_emitted
278
+ if _proxy_env_bypass_info_emitted:
279
+ return
280
+ _proxy_env_bypass_info_emitted = True
281
+ active = [name for name in _PROXY_ENV_VARS if os.environ.get(name)]
282
+ source = ", ".join(active) if active else "system proxy configuration"
283
+ logger.info(
284
+ "trustcoco-openai detected %s and no explicit `http_socket_options` / "
285
+ "`http_client` / `http_async_client` / `openai_proxy`; skipping the "
286
+ "custom `httpx` transport so httpx's env-proxy auto-detection applies. "
287
+ "Pass `http_socket_options=[...]` to opt back into kernel-level TCP "
288
+ "keepalive tuning on top of the env proxy.",
289
+ source,
290
+ )
291
+
292
+
293
+ def _warn_if_proxy_env_shadowed(
294
+ socket_options: tuple[SocketOption, ...],
295
+ *,
296
+ openai_proxy: str | None,
297
+ ) -> None:
298
+ """Warn once if a custom transport will shadow httpx's proxy auto-detection.
299
+
300
+ When `socket_options` is non-empty we pass a custom `httpx` transport,
301
+ which disables httpx's native proxy auto-detection — both the uppercase
302
+ `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` env vars and their lowercase
303
+ equivalents, plus macOS/Windows system proxy config. If the user
304
+ supplies `openai_proxy` explicitly we route through it and the env-var
305
+ handling is moot. Otherwise, a user whose app was transparently relying
306
+ on any of those sources will silently stop using them on upgrade —
307
+ emit a single WARNING so the behavior change is discoverable.
308
+
309
+ Detection uses `urllib.request.getproxies()` — the same surface httpx
310
+ reads — so lowercase env vars and macOS/Windows system proxy settings
311
+ are caught alongside the uppercase names.
312
+ """
313
+ global _proxy_env_warning_emitted
314
+ if _proxy_env_warning_emitted or not socket_options or openai_proxy:
315
+ return
316
+ active = [name for name in _PROXY_ENV_VARS if os.environ.get(name)]
317
+ try:
318
+ detected = bool(urllib.request.getproxies())
319
+ except Exception:
320
+ detected = False
321
+ if not active and not detected:
322
+ return
323
+ _proxy_env_warning_emitted = True
324
+ if active:
325
+ source = ", ".join(active) + " set in environment"
326
+ else:
327
+ source = "system proxy configuration detected"
328
+ logger.warning(
329
+ "trustcoco-openai injected a custom httpx transport to apply "
330
+ "`http_socket_options`, which disables httpx's proxy "
331
+ "auto-detection (%s). Set "
332
+ "`COCO_OPENAI_TCP_KEEPALIVE=0` or pass `http_socket_options=()` "
333
+ "to restore default proxy behavior, or supply `openai_proxy` / your "
334
+ "own `http_client` / `http_async_client` to take full control.",
335
+ source,
336
+ )
337
+
338
+
339
+ def _resolve_socket_options(
340
+ value: Sequence[SocketOption] | None,
341
+ ) -> tuple[SocketOption, ...]:
342
+ """Normalize the user-facing field to the tuple form builders expect.
343
+
344
+ - `None` => env-driven defaults (may itself be `()` if the user set
345
+ `COCO_OPENAI_TCP_KEEPALIVE=0`). This path runs through
346
+ `_filter_supported()` inside `_default_socket_options()` because
347
+ the library-computed option set is aspirational and silent degradation
348
+ is the right posture.
349
+ - Any other sequence (including empty) => retupled for cache hashability.
350
+ An empty tuple is the explicit "disabled" signal. A non-empty sequence
351
+ is passed verbatim — **not** filtered. The user chose these options
352
+ explicitly, so an unsupported constant should surface as a clear
353
+ `OSError` at connect time, not be silently dropped.
354
+
355
+ Always returns a tuple — never `None` — so downstream signatures take
356
+ `tuple[SocketOption, ...]` with `()` as the single "no options" shape.
357
+ """
358
+ if value is None:
359
+ return _default_socket_options()
360
+ return tuple(value)
361
+
362
+
363
+ class _SyncHttpxClientWrapper(openai.DefaultHttpxClient):
364
+ """Borrowed from openai._base_client."""
365
+
366
+ def __del__(self) -> None:
367
+ try:
368
+ if self.is_closed:
369
+ return
370
+ self.close()
371
+ except Exception: # noqa: S110
372
+ pass
373
+
374
+
375
+ class _AsyncHttpxClientWrapper(openai.DefaultAsyncHttpxClient):
376
+ """Borrowed from openai._base_client."""
377
+
378
+ def __del__(self) -> None:
379
+ try:
380
+ if self.is_closed:
381
+ return
382
+ # TODO(someday): support non asyncio runtimes here
383
+ asyncio.get_running_loop().create_task(self.aclose())
384
+ except Exception: # noqa: S110
385
+ pass
386
+
387
+
388
+ def _build_sync_httpx_client(
389
+ base_url: str | None,
390
+ timeout: Any,
391
+ socket_options: tuple[SocketOption, ...] = (),
392
+ ) -> _SyncHttpxClientWrapper:
393
+ kwargs: dict[str, Any] = {
394
+ "base_url": base_url
395
+ or os.environ.get("OPENAI_BASE_URL")
396
+ or "https://api.openai.com/v1",
397
+ "timeout": timeout,
398
+ }
399
+ if socket_options:
400
+ # httpx ignores limits= when transport= is provided; set it explicitly
401
+ # on the transport to avoid silently shrinking the connection pool.
402
+ kwargs["transport"] = httpx.HTTPTransport(
403
+ socket_options=list(socket_options),
404
+ limits=_DEFAULT_CONNECTION_LIMITS,
405
+ )
406
+ return _SyncHttpxClientWrapper(**kwargs)
407
+
408
+
409
+ def _build_async_httpx_client(
410
+ base_url: str | None,
411
+ timeout: Any,
412
+ socket_options: tuple[SocketOption, ...] = (),
413
+ ) -> _AsyncHttpxClientWrapper:
414
+ kwargs: dict[str, Any] = {
415
+ "base_url": base_url
416
+ or os.environ.get("OPENAI_BASE_URL")
417
+ or "https://api.openai.com/v1",
418
+ "timeout": timeout,
419
+ }
420
+ if socket_options:
421
+ # See _build_sync_httpx_client for the limits= rationale.
422
+ kwargs["transport"] = httpx.AsyncHTTPTransport(
423
+ socket_options=list(socket_options),
424
+ limits=_DEFAULT_CONNECTION_LIMITS,
425
+ )
426
+ return _AsyncHttpxClientWrapper(**kwargs)
427
+
428
+
429
+ def _build_proxied_sync_httpx_client(
430
+ proxy: str,
431
+ verify: Any,
432
+ socket_options: tuple[SocketOption, ...] = (),
433
+ ) -> httpx.Client:
434
+ """httpx.Client for the openai_proxy code path.
435
+
436
+ When socket options are disabled (`()`), returns a plain
437
+ `httpx.Client(proxy=..., verify=...)` with no transport injected.
438
+ """
439
+ if not socket_options:
440
+ return httpx.Client(proxy=proxy, verify=verify)
441
+ # Mount under `all://` (not `transport=`) so `Client._mounts` mirrors the
442
+ # shape produced by httpx's own `proxy=` path — a single-entry dict keyed
443
+ # by `URLPattern("all://")`. Callers (and the existing proxy integration
444
+ # test) reach into `_mounts` to introspect the proxy URL; a bare
445
+ # `transport=` leaves `_mounts` empty.
446
+ #
447
+ # `httpx.HTTPTransport(proxy=...)` is stricter about string coercion than
448
+ # `httpx.Client(proxy=...)`; wrap in the public `httpx.Proxy` type for
449
+ # version-stable behavior.
450
+ transport = httpx.HTTPTransport(
451
+ proxy=httpx.Proxy(proxy),
452
+ verify=verify,
453
+ socket_options=list(socket_options),
454
+ limits=_DEFAULT_CONNECTION_LIMITS,
455
+ )
456
+ return httpx.Client(mounts={"all://": transport})
457
+
458
+
459
+ def _build_proxied_async_httpx_client(
460
+ proxy: str,
461
+ verify: Any,
462
+ socket_options: tuple[SocketOption, ...] = (),
463
+ ) -> httpx.AsyncClient:
464
+ """httpx.AsyncClient for the openai_proxy code path.
465
+
466
+ See `_build_proxied_sync_httpx_client` for the opt-out fallback,
467
+ the `mounts={"all://": ...}` shape, and the `httpx.Proxy` wrapping
468
+ rationale.
469
+ """
470
+ if not socket_options:
471
+ return httpx.AsyncClient(proxy=proxy, verify=verify)
472
+ transport = httpx.AsyncHTTPTransport(
473
+ proxy=httpx.Proxy(proxy),
474
+ verify=verify,
475
+ socket_options=list(socket_options),
476
+ limits=_DEFAULT_CONNECTION_LIMITS,
477
+ )
478
+ return httpx.AsyncClient(mounts={"all://": transport})
479
+
480
+
481
+ @lru_cache
482
+ def _cached_sync_httpx_client(
483
+ base_url: str | None,
484
+ timeout: Any,
485
+ socket_options: tuple[SocketOption, ...] = (),
486
+ ) -> _SyncHttpxClientWrapper:
487
+ return _build_sync_httpx_client(base_url, timeout, socket_options)
488
+
489
+
490
+ @lru_cache
491
+ def _cached_async_httpx_client(
492
+ base_url: str | None,
493
+ timeout: Any,
494
+ socket_options: tuple[SocketOption, ...] = (),
495
+ ) -> _AsyncHttpxClientWrapper:
496
+ return _build_async_httpx_client(base_url, timeout, socket_options)
497
+
498
+
499
+ def _get_default_httpx_client(
500
+ base_url: str | None,
501
+ timeout: Any,
502
+ socket_options: tuple[SocketOption, ...] = (),
503
+ ) -> _SyncHttpxClientWrapper:
504
+ """Get default httpx client.
505
+
506
+ Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
507
+ """
508
+ try:
509
+ hash(timeout)
510
+ except TypeError:
511
+ return _build_sync_httpx_client(base_url, timeout, socket_options)
512
+ else:
513
+ return _cached_sync_httpx_client(base_url, timeout, socket_options)
514
+
515
+
516
+ def _get_default_async_httpx_client(
517
+ base_url: str | None,
518
+ timeout: Any,
519
+ socket_options: tuple[SocketOption, ...] = (),
520
+ ) -> _AsyncHttpxClientWrapper:
521
+ """Get default httpx client.
522
+
523
+ Uses cached client unless timeout is `httpx.Timeout`, which is not hashable.
524
+ """
525
+ try:
526
+ hash(timeout)
527
+ except TypeError:
528
+ return _build_async_httpx_client(base_url, timeout, socket_options)
529
+ else:
530
+ return _cached_async_httpx_client(base_url, timeout, socket_options)
531
+
532
+
533
+ def _resolve_sync_and_async_api_keys(
534
+ api_key: SecretStr | Callable[[], str] | Callable[[], Awaitable[str]],
535
+ ) -> tuple[str | None | Callable[[], str], str | Callable[[], Awaitable[str]]]:
536
+ """Resolve sync and async API key values.
537
+
538
+ Because OpenAI and AsyncOpenAI clients support either sync or async callables for
539
+ the API key, we need to resolve separate values here.
540
+ """
541
+ if isinstance(api_key, SecretStr):
542
+ sync_api_key_value: str | None | Callable[[], str] = api_key.get_secret_value()
543
+ async_api_key_value: str | Callable[[], Awaitable[str]] = (
544
+ api_key.get_secret_value()
545
+ )
546
+ elif callable(api_key):
547
+ if inspect.iscoroutinefunction(api_key):
548
+ async_api_key_value = api_key
549
+ sync_api_key_value = None
550
+ else:
551
+ sync_api_key_value = cast(Callable, api_key)
552
+
553
+ async def async_api_key_wrapper() -> str:
554
+ return await asyncio.get_running_loop().run_in_executor(
555
+ None, cast(Callable, api_key)
556
+ )
557
+
558
+ async_api_key_value = async_api_key_wrapper
559
+
560
+ return sync_api_key_value, async_api_key_value
561
+
562
+
563
+ T = TypeVar("T")
564
+
565
+ # On Python ≤3.10, asyncio.TimeoutError and builtins.TimeoutError are distinct
566
+ # hierarchies, so subclassing only asyncio.TimeoutError would not be caught by
567
+ # `except TimeoutError:`. On Python ≥3.11 they are the same object, so listing
568
+ # both bases would raise TypeError: duplicate base class. We resolve this at
569
+ # class-definition time.
570
+ _StreamChunkTimeoutBases: tuple[type, ...] = (
571
+ (asyncio.TimeoutError,)
572
+ if issubclass(asyncio.TimeoutError, TimeoutError)
573
+ else (asyncio.TimeoutError, TimeoutError)
574
+ )
575
+
576
+
577
+ class StreamChunkTimeoutError(*_StreamChunkTimeoutBases): # type: ignore[misc]
578
+ """Raised when no streaming chunk arrives within `stream_chunk_timeout`.
579
+
580
+ `issubclass(StreamChunkTimeoutError, asyncio.TimeoutError)` and
581
+ `issubclass(StreamChunkTimeoutError, TimeoutError)` both hold on all
582
+ supported Python versions, so existing `except asyncio.TimeoutError:`
583
+ and `except TimeoutError:` handlers keep catching the exception. On
584
+ Python 3.11+ the two exceptions are the same object, so only
585
+ `asyncio.TimeoutError` appears in `__bases__`.
586
+
587
+ Structured attributes (`timeout_s`, `model_name`, `chunks_received`)
588
+ mirror the WARNING log's `extra=` payload so diagnostic code doesn't
589
+ need to regex the message.
590
+ """
591
+
592
+ def __init__(
593
+ self,
594
+ timeout_s: float,
595
+ *,
596
+ model_name: str | None = None,
597
+ chunks_received: int = 0,
598
+ ) -> None:
599
+ self.timeout_s = timeout_s
600
+ self.model_name = model_name
601
+ self.chunks_received = chunks_received
602
+ context = []
603
+ if model_name:
604
+ context.append(f"model={model_name}")
605
+ context.append(f"chunks_received={chunks_received}")
606
+ suffix = f" ({', '.join(context)})"
607
+ super().__init__(
608
+ f"No streaming chunk received for {timeout_s:.1f}s{suffix}. The "
609
+ f"connection may be alive at the TCP layer but is not producing "
610
+ f"content. Tune or disable via the `stream_chunk_timeout` "
611
+ f"constructor kwarg (set to None or 0 to disable) or the "
612
+ f"`COCO_OPENAI_STREAM_CHUNK_TIMEOUT_S` env var. See also "
613
+ f"`http_socket_options` for the kernel-level TCP timeout that "
614
+ f"catches dead TCP peers."
615
+ )
616
+
617
+
618
+ async def _astream_with_chunk_timeout(
619
+ source: AsyncIterator[T],
620
+ timeout: float | None,
621
+ *,
622
+ model_name: str | None = None,
623
+ ) -> AsyncIterator[T]:
624
+ """Yield from `source` but bound the per-chunk wait time.
625
+
626
+ If `timeout` is None or <=0, yields directly with no wall-clock bound.
627
+ Otherwise, each `__anext__` is wrapped in
628
+ `asyncio.wait_for(..., timeout)`. A timeout raises
629
+ `StreamChunkTimeoutError` (a `TimeoutError` subclass) whose message
630
+ names the knob, the env-var override, the model, and how many chunks
631
+ were received before the stall. A single-line structured log also
632
+ fires at WARNING so the signal is visible in aggregate logging systems
633
+ even when the exception is caught upstream.
634
+
635
+ When the timeout is active, the source iterator is explicitly
636
+ `aclose()`-d on early exit (timeout, consumer break, any exception) so
637
+ the underlying httpx streaming connection is released promptly. The
638
+ pass-through branch (timeout disabled) relies on httpx's GC-driven
639
+ cleanup instead — matching the behavior of unwrapped streams.
640
+ """
641
+ if not timeout or timeout <= 0:
642
+ async for item in source:
643
+ yield item
644
+ return
645
+
646
+ chunks_received = 0
647
+ it = source.__aiter__()
648
+ try:
649
+ while True:
650
+ try:
651
+ chunk = await asyncio.wait_for(it.__anext__(), timeout=timeout)
652
+ except StopAsyncIteration:
653
+ return
654
+ except asyncio.TimeoutError as e:
655
+ logger.warning(
656
+ "coco_openai.stream_chunk_timeout fired",
657
+ extra={
658
+ "source": "stream_chunk_timeout",
659
+ "timeout_s": timeout,
660
+ "model_name": model_name,
661
+ "chunks_received": chunks_received,
662
+ },
663
+ )
664
+ raise StreamChunkTimeoutError(
665
+ timeout,
666
+ model_name=model_name,
667
+ chunks_received=chunks_received,
668
+ ) from e
669
+ chunks_received += 1
670
+ yield chunk
671
+ finally:
672
+ aclose = getattr(it, "aclose", None)
673
+ if aclose is not None:
674
+ try:
675
+ await aclose()
676
+ except Exception as cleanup_exc:
677
+ # Best-effort cleanup; don't mask the original exception,
678
+ # but leave a DEBUG trace so pool/transport bugs stay
679
+ # discoverable at the right log level.
680
+ logger.debug(
681
+ "aclose() during _astream_with_chunk_timeout cleanup "
682
+ "raised; ignoring",
683
+ exc_info=cleanup_exc,
684
+ )