steerable-agent-runtime 0.7.4__tar.gz → 0.8.1__tar.gz

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 (166) hide show
  1. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/PKG-INFO +2 -2
  2. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/pyproject.toml +2 -2
  3. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/__init__.py +10 -1
  4. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/default.harness.json +5 -1
  5. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/default.harness.yaml +9 -1
  6. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/harness.py +18 -3
  7. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/anthropic_native.py +22 -6
  8. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/errors.py +50 -0
  9. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/google_genai.py +16 -6
  10. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/openai_compat.py +18 -6
  11. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/openai_responses.py +16 -6
  12. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/system_proxy.py +133 -1
  13. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/native_bridge.py +2 -0
  14. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/otel.py +127 -0
  15. steerable_agent_runtime-0.8.1/src/steerable_agent_runtime/retry.py +163 -0
  16. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime.egg-info/PKG-INFO +2 -2
  17. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime.egg-info/SOURCES.txt +2 -0
  18. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime.egg-info/requires.txt +1 -1
  19. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_otel.py +134 -0
  20. steerable_agent_runtime-0.8.1/tests/test_resume_failed_turn.py +291 -0
  21. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_retry_hooks.py +130 -0
  22. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_system_proxy.py +146 -0
  23. steerable_agent_runtime-0.8.1/tests/test_transport_error_detail.py +281 -0
  24. steerable_agent_runtime-0.7.4/src/steerable_agent_runtime/retry.py +0 -82
  25. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/README.md +0 -0
  26. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/setup.cfg +0 -0
  27. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/antihallucination.py +0 -0
  28. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/approval.py +0 -0
  29. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/approval_policy.py +0 -0
  30. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/ask_user.py +0 -0
  31. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/branch.py +0 -0
  32. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/cache_control.py +0 -0
  33. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/calibration.py +0 -0
  34. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/compaction.py +0 -0
  35. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/compaction_policy.py +0 -0
  36. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/config.py +0 -0
  37. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/delegation_gate.py +0 -0
  38. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/errors.py +0 -0
  39. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
  40. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/handoff.py +0 -0
  41. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/harness_spec.py +0 -0
  42. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/history.py +0 -0
  43. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/hooks.py +0 -0
  44. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/__init__.py +0 -0
  45. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/compat.py +0 -0
  46. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/parts.py +0 -0
  47. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/llm/presets.py +0 -0
  48. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/loop.py +0 -0
  49. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/maintenance.py +0 -0
  50. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/mcp.py +0 -0
  51. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/mcp_server.py +0 -0
  52. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/model_catalog.py +0 -0
  53. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/model_info.py +0 -0
  54. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/model_resolve.py +0 -0
  55. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/observation_aging.py +0 -0
  56. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/orchestration.py +0 -0
  57. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/plugins.py +0 -0
  58. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/pool.py +0 -0
  59. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/pricing.py +0 -0
  60. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/pseudo.py +0 -0
  61. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/recording.py +0 -0
  62. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/reminders.py +0 -0
  63. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/replay.py +0 -0
  64. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/resume.py +0 -0
  65. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/sandboxed.py +0 -0
  66. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/skills.py +0 -0
  67. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/spill.py +0 -0
  68. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/storage/__init__.py +0 -0
  69. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
  70. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
  71. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
  72. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
  73. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/subagent.py +0 -0
  74. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/todo.py +0 -0
  75. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tokens.py +0 -0
  76. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tool_presentation.py +0 -0
  77. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tool_schema.py +0 -0
  78. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tool_search.py +0 -0
  79. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tools.py +0 -0
  80. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/tracing.py +0 -0
  81. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  82. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  83. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  84. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime/world_state.py +0 -0
  85. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  86. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  87. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_antihallucination.py +0 -0
  88. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_approval.py +0 -0
  89. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_approval_policy.py +0 -0
  90. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_ask_user.py +0 -0
  91. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_branch.py +0 -0
  92. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_cache_control.py +0 -0
  93. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_cache_instrumentation.py +0 -0
  94. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_calibration.py +0 -0
  95. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_compaction.py +0 -0
  96. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_compaction_policy.py +0 -0
  97. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_compaction_strategy.py +0 -0
  98. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_config.py +0 -0
  99. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_content_parts.py +0 -0
  100. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_delegation_gate.py +0 -0
  101. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_error_taxonomy.py +0 -0
  102. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_fragment_bounds.py +0 -0
  103. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_gateway_catalog.py +0 -0
  104. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_golden.py +0 -0
  105. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_handoff.py +0 -0
  106. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_harness.py +0 -0
  107. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_harness_spec.py +0 -0
  108. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_history.py +0 -0
  109. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_history_persistence.py +0 -0
  110. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_hooks.py +0 -0
  111. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_in_memory_storage.py +0 -0
  112. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_llm_gemini_wire.py +0 -0
  113. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_llm_responses_wire.py +0 -0
  114. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_llm_stream_e2e.py +0 -0
  115. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_llm_wire_helpers.py +0 -0
  116. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_long_session.py +0 -0
  117. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_loop.py +0 -0
  118. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_loop_cancellation.py +0 -0
  119. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_loop_replay.py +0 -0
  120. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_loop_sandbox_event.py +0 -0
  121. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_loop_terminal.py +0 -0
  122. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_maintenance.py +0 -0
  123. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_mcp.py +0 -0
  124. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_mcp_server.py +0 -0
  125. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_model_catalog.py +0 -0
  126. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_model_equivalence.py +0 -0
  127. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_model_info.py +0 -0
  128. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_model_resolve.py +0 -0
  129. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_native_default.py +0 -0
  130. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_observation_aging.py +0 -0
  131. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_orchestration.py +0 -0
  132. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_parallel_tools.py +0 -0
  133. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_plugins.py +0 -0
  134. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_provider_compat.py +0 -0
  135. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_provider_presets.py +0 -0
  136. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_pseudo.py +0 -0
  137. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_pyo3_api_surface.py +0 -0
  138. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_recording.py +0 -0
  139. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_reminders.py +0 -0
  140. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_replay_crosslang.py +0 -0
  141. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_resume.py +0 -0
  142. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_safety_gate.py +0 -0
  143. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_sandboxed.py +0 -0
  144. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_skills.py +0 -0
  145. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_soft_timeout.py +0 -0
  146. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_spill.py +0 -0
  147. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_sqlite_storage.py +0 -0
  148. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_steer.py +0 -0
  149. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_storage_contract.py +0 -0
  150. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_stream_strip.py +0 -0
  151. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_subagent.py +0 -0
  152. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_todo.py +0 -0
  153. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_todo_gate.py +0 -0
  154. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tokens.py +0 -0
  155. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_exposure.py +0 -0
  156. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_hygiene.py +0 -0
  157. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_presentation.py +0 -0
  158. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_router.py +0 -0
  159. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_schema.py +0 -0
  160. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_tool_timeout.py +0 -0
  161. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_trace_recorder.py +0 -0
  162. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_transport_jsonrpc.py +0 -0
  163. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_transport_sse.py +0 -0
  164. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_usage_attribution.py +0 -0
  165. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_world_state.py +0 -0
  166. {steerable_agent_runtime-0.7.4 → steerable_agent_runtime-0.8.1}/tests/test_write_lease.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.7.4
3
+ Version: 0.8.1
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -10,7 +10,7 @@ Requires-Dist: mcp<2.0.0,>=1.13.0
10
10
  Requires-Dist: pydantic>=2.10.0
11
11
  Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
12
12
  Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
13
- Requires-Dist: steerable-agent-runtime-native==1.0.5
13
+ Requires-Dist: steerable-agent-runtime-native==1.0.7
14
14
  Provides-Extra: sqlalchemy
15
15
  Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
16
16
  Provides-Extra: fastapi
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.7.4"
3
+ version = "0.8.1"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  license = { text = "Apache-2.0" }
6
6
  readme = "README.md"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "pydantic>=2.10.0",
22
22
  "steerable-agent-protocol>=0.1.0,<1.0.0",
23
23
  "steerable-agent-harness>=0.1.0,<1.0.0",
24
- "steerable-agent-runtime-native==1.0.5",
24
+ "steerable-agent-runtime-native==1.0.7",
25
25
  ]
26
26
 
27
27
  [project.optional-dependencies]
@@ -193,7 +193,14 @@ from .orchestration import (
193
193
  OrchestrationExecutor,
194
194
  orchestration_tool_descriptors,
195
195
  )
196
- from .otel import PrivacyMode, export_otlp_http, export_trace, to_otlp_json
196
+ from .otel import (
197
+ PrivacyMode,
198
+ export_metrics,
199
+ export_otlp_http,
200
+ export_trace,
201
+ to_otlp_json,
202
+ to_otlp_metrics_json,
203
+ )
197
204
  from .pricing import (
198
205
  MODEL_PRICES,
199
206
  ModelPrice,
@@ -462,6 +469,8 @@ __all__ = [
462
469
  "estimate_tokens",
463
470
  "export_otlp_http",
464
471
  "export_trace",
472
+ "export_metrics",
473
+ "to_otlp_metrics_json",
465
474
  "extract_inline_tool_calls",
466
475
  "factor_for_model",
467
476
  "family_tree",
@@ -14,7 +14,11 @@
14
14
  {
15
15
  "impl": "simple",
16
16
  "params": {
17
- "max_attempts": 3
17
+ "max_attempts": 3,
18
+ "transport_max_attempts": 5,
19
+ "transport_base_delay_ms": 1000,
20
+ "transport_max_delay_ms": 8000,
21
+ "transport_deadline_ms": 60000
18
22
  }
19
23
  }
20
24
  ],
@@ -13,8 +13,16 @@ retry:
13
13
  - impl: informed_backtrack
14
14
  # max_attempts 3 pins the production default (RetryHooks()); the strategy
15
15
  # default of 4 would silently diverge the spec from the sidecar chat path.
16
+ # transport_* is the connection-failure policy: seconds-scale backoff under
17
+ # a wall-clock deadline, so a proxy or link outage is retried long enough to
18
+ # ride out a blip and fails within the deadline instead of after minutes.
16
19
  - impl: simple
17
- params: {max_attempts: 3}
20
+ params:
21
+ max_attempts: 3
22
+ transport_max_attempts: 5
23
+ transport_base_delay_ms: 1000
24
+ transport_max_delay_ms: 8000
25
+ transport_deadline_ms: 60000
18
26
  # Quoted: bare `null` is YAML's null literal, not the implementation name.
19
27
  validator: "null"
20
28
  tools: full
@@ -27,7 +27,7 @@ from .compaction import CompactionHooks
27
27
  from .history import ContextFragment
28
28
  from .hooks import LoopHooks, NoopHooks
29
29
  from .llm import LLMProvider
30
- from .retry import RetryHooks
30
+ from .retry import RetryHooks, TransportRetryPolicy
31
31
  from .storage import InMemoryStorage, StorageAdapter
32
32
  from .tool_search import DEFAULT_MAX_RESULTS
33
33
  from .tools import ToolRouter
@@ -329,11 +329,20 @@ class NoRetry:
329
329
 
330
330
  @dataclass(frozen=True, slots=True)
331
331
  class SimpleRetry:
332
- """The existing bounded exponential backoff (RetryHooks)."""
332
+ """Bounded exponential backoff (RetryHooks).
333
+
334
+ The ``transport_*`` fields configure the connection-failure policy, which
335
+ is deliberately more patient than the shared one and stops at a deadline:
336
+ see :class:`TransportRetryPolicy`.
337
+ """
333
338
 
334
339
  max_attempts: int = 4
335
340
  base_delay_ms: int = 200
336
341
  max_delay_ms: int = 5000
342
+ transport_max_attempts: int = 5
343
+ transport_base_delay_ms: int = 1000
344
+ transport_max_delay_ms: int = 8000
345
+ transport_deadline_ms: int = 60_000
337
346
  name: str = "simple"
338
347
  assumes: str = (
339
348
  "failures are dominated by transient transport/rate-limit/server "
@@ -350,7 +359,13 @@ class SimpleRetry:
350
359
  max_attempts=self.max_attempts,
351
360
  base_delay_ms=self.base_delay_ms,
352
361
  max_delay_ms=self.max_delay_ms,
353
- )
362
+ ),
363
+ transport_policy=TransportRetryPolicy(
364
+ max_attempts=self.transport_max_attempts,
365
+ base_delay_ms=self.transport_base_delay_ms,
366
+ max_delay_ms=self.transport_max_delay_ms,
367
+ deadline_ms=self.transport_deadline_ms,
368
+ ),
354
369
  )
355
370
  )
356
371
 
@@ -20,16 +20,19 @@ from typing import Any
20
20
  from steerable_agent_protocol.generated import ToolCall
21
21
 
22
22
  from . import LLMMessage, LLMStreamChunk, LLMUsage
23
- from .errors import LLMError, classify_http_status
23
+ from .errors import LLMError, classify_http_status, describe_transport_error
24
24
  from .parts import ImagePart, TextPart
25
25
 
26
26
 
27
- def _classify_anthropic_error(exc: Exception, *, provider: str) -> Exception:
27
+ def _classify_anthropic_error(
28
+ exc: Exception, *, provider: str, proxy: str | None = None
29
+ ) -> Exception:
28
30
  """Normalize anthropic SDK exceptions into the ``LLMError`` taxonomy.
29
31
 
30
32
  The SDK raises ``APIStatusError`` (has ``status_code``) for HTTP failures
31
33
  and ``APIConnectionError`` for transport problems; anything already
32
- classified (or unrecognized) passes through with ``unknown``.
34
+ classified (or unrecognized) passes through with ``unknown``. `proxy` is
35
+ the hop the SDK client used, reported on transport failures.
33
36
  """
34
37
  if isinstance(exc, LLMError):
35
38
  return exc
@@ -45,7 +48,10 @@ def _classify_anthropic_error(exc: Exception, *, provider: str) -> Exception:
45
48
  # APIConnectionError / APITimeoutError have no status_code.
46
49
  if exc.__class__.__name__ in ("APIConnectionError", "APITimeoutError"):
47
50
  return LLMError(
48
- f"{provider}: transport error: {exc}", kind="transport", provider=provider
51
+ f"{provider}: transport error: {describe_transport_error(exc)}",
52
+ kind="transport",
53
+ provider=provider,
54
+ proxy=proxy,
49
55
  )
50
56
  return LLMError(f"{provider}: {exc}", kind="unknown", provider=provider)
51
57
 
@@ -79,7 +85,9 @@ class AnthropicProvider:
79
85
  try:
80
86
  message = await client.messages.create(**body)
81
87
  except Exception as exc: # noqa: BLE001 - normalized into the taxonomy
82
- raise _classify_anthropic_error(exc, provider=self.name) from exc
88
+ raise _classify_anthropic_error(
89
+ exc, provider=self.name, proxy=self._egress_proxy()
90
+ ) from exc
83
91
  text_chunks: list[str] = []
84
92
  tool_calls: list[ToolCall] = []
85
93
  for block in message.content or []:
@@ -134,12 +142,20 @@ class AnthropicProvider:
134
142
  finish_reason="stop",
135
143
  )
136
144
  except Exception as exc: # noqa: BLE001 - normalized into the taxonomy
137
- raise _classify_anthropic_error(exc, provider=self.name) from exc
145
+ raise _classify_anthropic_error(
146
+ exc, provider=self.name, proxy=self._egress_proxy()
147
+ ) from exc
138
148
 
139
149
  # ------------------------------------------------------------------
140
150
  # Helpers
141
151
  # ------------------------------------------------------------------
142
152
 
153
+ def _egress_proxy(self) -> str | None:
154
+ """Proxy the SDK client reaches `base_url` through, for failure reports."""
155
+ from .system_proxy import egress_path
156
+
157
+ return egress_path(self.base_url or "https://api.anthropic.com").proxy
158
+
143
159
  def _client(self) -> Any:
144
160
  from anthropic import AsyncAnthropic # local import keeps optional dep optional
145
161
 
@@ -54,6 +54,7 @@ class LLMError(Exception):
54
54
  provider: str | None = None,
55
55
  raw: Any | None = None,
56
56
  retry_after_ms: int | None = None,
57
+ proxy: str | None = None,
57
58
  ) -> None:
58
59
  super().__init__(message)
59
60
  self.kind = kind
@@ -61,12 +62,61 @@ class LLMError(Exception):
61
62
  self.provider = provider
62
63
  self.raw = raw
63
64
  self.retry_after_ms = retry_after_ms
65
+ #: Proxy the request went through, credentials stripped; None means
66
+ #: the request went direct. A failure report shows this so the reader
67
+ #: knows which hop failed without having to reproduce the settings.
68
+ self.proxy = proxy
64
69
 
65
70
  @property
66
71
  def retryable(self) -> bool:
67
72
  return self.kind in RETRYABLE_KINDS
68
73
 
69
74
 
75
+ #: Cause-chain walk depth for :func:`describe_transport_error`. httpx →
76
+ #: httpcore → anyio → ssl/socket is four; a little headroom costs nothing.
77
+ _MAX_CAUSE_DEPTH = 6
78
+
79
+
80
+ def describe_transport_error(exc: BaseException) -> str:
81
+ """Flatten a transport exception into one diagnosable string.
82
+
83
+ httpx reports a proxy that accepted ``CONNECT`` and then dropped the
84
+ tunnel as ``ConnectError('')`` — the empty message is httpcore's, and the
85
+ ``ssl.SSLEOFError`` or ``socket.gaierror`` that explains it is three
86
+ ``__cause__`` hops down. Logging ``str(exc)`` alone therefore produced
87
+ ``transport error: `` with nothing after it. This walks the chain and
88
+ keeps every hop that names something, outermost first.
89
+
90
+ The walk follows ``__cause__``; it falls back to ``__context__`` only
91
+ when the raiser did not set ``__cause__`` and did not ask for the
92
+ context to be suppressed (``raise ... from None``), so an explicit
93
+ suppression does not leak the exception it hid.
94
+
95
+ Consecutive hops of the same class with no message each collapse into
96
+ one: httpcore re-wraps its own ``ConnectError`` on the proxy path, and
97
+ ``ConnectError <- ConnectError`` names nothing the first hop did not.
98
+
99
+ A chain with no message at all still returns the class names, so the
100
+ line never ends in silence.
101
+ """
102
+ parts: list[tuple[str, str]] = []
103
+ seen: set[int] = set()
104
+ current: BaseException | None = exc
105
+ while current is not None and len(seen) < _MAX_CAUSE_DEPTH:
106
+ if id(current) in seen:
107
+ break
108
+ seen.add(id(current))
109
+ name = type(current).__name__
110
+ text = str(current).strip()
111
+ if text or not parts or parts[-1] != (name, ""):
112
+ parts.append((name, text))
113
+ cause = current.__cause__
114
+ if cause is None and not current.__suppress_context__:
115
+ cause = current.__context__
116
+ current = cause if cause is not None and cause is not current else None
117
+ return " <- ".join(f"{name}: {text}" if text else name for name, text in parts)
118
+
119
+
70
120
  #: Body substrings that mark a 400/413 as a context-window overflow rather
71
121
  #: than a generic invalid request. Covers OpenAI, Ollama, vLLM, DeepSeek,
72
122
  #: and Anthropic phrasings (matched case-insensitively).
@@ -23,10 +23,15 @@ from typing import Any, Literal
23
23
  from steerable_agent_protocol.generated import ToolCall
24
24
 
25
25
  from . import LLMMessage, LLMStreamChunk, LLMUsage
26
- from .errors import LLMError, classify_http_status, parse_retry_after_ms
26
+ from .errors import (
27
+ LLMError,
28
+ classify_http_status,
29
+ describe_transport_error,
30
+ parse_retry_after_ms,
31
+ )
27
32
  from .parts import ImagePart, TextPart
28
33
  from .presets import ProviderPreset, preset_for
29
- from .system_proxy import client_env_kwargs
34
+ from .system_proxy import client_env_kwargs, egress_path
30
35
 
31
36
  logger = logging.getLogger(__name__)
32
37
 
@@ -79,6 +84,9 @@ class GoogleGenAIProvider:
79
84
  base_url: str = "https://generativelanguage.googleapis.com"
80
85
  api_key: str | None = None
81
86
  default_temperature: float | None = None
87
+ #: Send this endpoint's requests direct even when the host declares a
88
+ #: proxy (see `OpenAICompatProvider.force_direct`).
89
+ force_direct: bool = False
82
90
  preset: ProviderPreset | Literal["auto", "off"] = "auto"
83
91
 
84
92
  def __post_init__(self) -> None:
@@ -110,7 +118,7 @@ class GoogleGenAIProvider:
110
118
  try:
111
119
  async with httpx.AsyncClient(
112
120
  timeout=httpx.Timeout(60.0),
113
- **client_env_kwargs(self.base_url),
121
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
114
122
  ) as client:
115
123
  response = await client.post(
116
124
  self._url(stream=False),
@@ -124,9 +132,10 @@ class GoogleGenAIProvider:
124
132
  payload = response.json()
125
133
  except httpx.TransportError as exc:
126
134
  raise LLMError(
127
- f"{self.name}: transport error: {exc}",
135
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
128
136
  kind="transport",
129
137
  provider=self.name,
138
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
130
139
  ) from exc
131
140
 
132
141
  return _decode_response(payload)
@@ -153,7 +162,7 @@ class GoogleGenAIProvider:
153
162
  async with (
154
163
  httpx.AsyncClient(
155
164
  timeout=_stream_timeout(),
156
- **client_env_kwargs(self.base_url),
165
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
157
166
  ) as client,
158
167
  client.stream(
159
168
  "POST",
@@ -185,9 +194,10 @@ class GoogleGenAIProvider:
185
194
  yield parsed
186
195
  except httpx.TransportError as exc:
187
196
  raise LLMError(
188
- f"{self.name}: transport error: {exc}",
197
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
189
198
  kind="transport",
190
199
  provider=self.name,
200
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
191
201
  ) from exc
192
202
 
193
203
  # ------------------------------------------------------------------
@@ -30,10 +30,15 @@ from ..model_info import (
30
30
  )
31
31
  from . import LLMMessage, LLMStreamChunk, LLMUsage
32
32
  from .compat import OpenAICompatFlags
33
- from .errors import LLMError, classify_http_status, parse_retry_after_ms
33
+ from .errors import (
34
+ LLMError,
35
+ classify_http_status,
36
+ describe_transport_error,
37
+ parse_retry_after_ms,
38
+ )
34
39
  from .parts import ImagePart, TextPart
35
40
  from .presets import ProviderPreset, preset_for
36
- from .system_proxy import client_env_kwargs
41
+ from .system_proxy import client_env_kwargs, egress_path
37
42
 
38
43
  logger = logging.getLogger(__name__)
39
44
 
@@ -145,6 +150,11 @@ class OpenAICompatProvider:
145
150
  #: a ``ProviderPreset`` instance pins that preset regardless of the
146
151
  #: registry (host settings UIs send an explicit choice this way).
147
152
  preset: ProviderPreset | Literal["auto", "off"] = "auto"
153
+ #: Send this endpoint's requests direct even when the host declares a
154
+ #: proxy. Set for a model service the user redirected after a proxy turned
155
+ #: a working connection into a timeout; the proxy stays configured for
156
+ #: everything else.
157
+ force_direct: bool = False
148
158
  #: Per-request reasoning effort from the host's model picker; wins over
149
159
  #: ``STEERABLE_REASONING_EFFORT`` and the preset default. Explicitly
150
160
  #: requested effort is validated strict (``clamp_reasoning_effort``) —
@@ -197,7 +207,7 @@ class OpenAICompatProvider:
197
207
  try:
198
208
  async with httpx.AsyncClient(
199
209
  timeout=httpx.Timeout(60.0),
200
- **client_env_kwargs(self.base_url),
210
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
201
211
  ) as client:
202
212
  response = await client.post(
203
213
  f"{self.base_url.rstrip('/')}/chat/completions",
@@ -211,9 +221,10 @@ class OpenAICompatProvider:
211
221
  payload = response.json()
212
222
  except httpx.TransportError as exc:
213
223
  raise LLMError(
214
- f"{self.name}: transport error: {exc}",
224
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
215
225
  kind="transport",
216
226
  provider=self.name,
227
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
217
228
  ) from exc
218
229
 
219
230
  choice = payload["choices"][0]
@@ -250,7 +261,7 @@ class OpenAICompatProvider:
250
261
  async with (
251
262
  httpx.AsyncClient(
252
263
  timeout=_stream_timeout(),
253
- **client_env_kwargs(self.base_url),
264
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
254
265
  ) as client,
255
266
  client.stream(
256
267
  "POST",
@@ -306,9 +317,10 @@ class OpenAICompatProvider:
306
317
  yield LLMStreamChunk(tool_call_delta=call)
307
318
  except httpx.TransportError as exc:
308
319
  raise LLMError(
309
- f"{self.name}: transport error: {exc}",
320
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
310
321
  kind="transport",
311
322
  provider=self.name,
323
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
312
324
  ) from exc
313
325
 
314
326
  # ------------------------------------------------------------------
@@ -25,10 +25,15 @@ from steerable_agent_protocol.generated import ToolCall
25
25
 
26
26
  from ..model_info import ReasoningEffortUnsupported, clamp_reasoning_effort
27
27
  from . import LLMMessage, LLMStreamChunk, LLMUsage
28
- from .errors import LLMError, classify_http_status, parse_retry_after_ms
28
+ from .errors import (
29
+ LLMError,
30
+ classify_http_status,
31
+ describe_transport_error,
32
+ parse_retry_after_ms,
33
+ )
29
34
  from .parts import ImagePart, TextPart
30
35
  from .presets import ProviderPreset, preset_for
31
- from .system_proxy import client_env_kwargs
36
+ from .system_proxy import client_env_kwargs, egress_path
32
37
 
33
38
  logger = logging.getLogger(__name__)
34
39
 
@@ -75,6 +80,9 @@ class OpenAIResponsesProvider:
75
80
  api_key: str | None = None
76
81
  default_temperature: float | None = None
77
82
  preset: ProviderPreset | Literal["auto", "off"] = "auto"
83
+ #: Send this endpoint's requests direct even when the host declares a
84
+ #: proxy (see `OpenAICompatProvider.force_direct`).
85
+ force_direct: bool = False
78
86
  #: Per-request reasoning effort from the host's model picker; wins over
79
87
  #: ``STEERABLE_REASONING_EFFORT`` and the preset default. Explicitly
80
88
  #: requested effort is validated strict (``clamp_reasoning_effort``) —
@@ -112,7 +120,7 @@ class OpenAIResponsesProvider:
112
120
  try:
113
121
  async with httpx.AsyncClient(
114
122
  timeout=httpx.Timeout(60.0),
115
- **client_env_kwargs(self.base_url),
123
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
116
124
  ) as client:
117
125
  response = await client.post(
118
126
  f"{self.base_url.rstrip('/')}/responses",
@@ -126,9 +134,10 @@ class OpenAIResponsesProvider:
126
134
  payload = response.json()
127
135
  except httpx.TransportError as exc:
128
136
  raise LLMError(
129
- f"{self.name}: transport error: {exc}",
137
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
130
138
  kind="transport",
131
139
  provider=self.name,
140
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
132
141
  ) from exc
133
142
 
134
143
  if payload.get("status") == "failed" or payload.get("error"):
@@ -163,7 +172,7 @@ class OpenAIResponsesProvider:
163
172
  async with (
164
173
  httpx.AsyncClient(
165
174
  timeout=_stream_timeout(),
166
- **client_env_kwargs(self.base_url),
175
+ **client_env_kwargs(self.base_url, force_direct=self.force_direct),
167
176
  ) as client,
168
177
  client.stream(
169
178
  "POST",
@@ -207,9 +216,10 @@ class OpenAIResponsesProvider:
207
216
  yield LLMStreamChunk(tool_call_delta=call)
208
217
  except httpx.TransportError as exc:
209
218
  raise LLMError(
210
- f"{self.name}: transport error: {exc}",
219
+ f"{self.name}: transport error: {describe_transport_error(exc)}",
211
220
  kind="transport",
212
221
  provider=self.name,
222
+ proxy=None if self.force_direct else egress_path(self.base_url).proxy,
213
223
  ) from exc
214
224
 
215
225
  # ------------------------------------------------------------------
@@ -46,7 +46,8 @@ import os
46
46
  import urllib.request
47
47
  from collections.abc import Callable, Mapping
48
48
  from functools import lru_cache
49
- from urllib.parse import urlsplit
49
+ from typing import NamedTuple
50
+ from urllib.parse import urlsplit, urlunsplit
50
51
 
51
52
  #: The desktop sets this alongside the proxy variables when it confines the
52
53
  #: sidecar's egress to its per-host proxy, and never on the startup-failure
@@ -117,6 +118,22 @@ def direct_mounts(
117
118
  return _pin_direct(host)
118
119
 
119
120
 
121
+ @lru_cache(maxsize=1)
122
+ def _platform_proxies() -> tuple[tuple[str, str], ...]:
123
+ """The platform's own proxy map, read once per process.
124
+
125
+ `urllib.request.getproxies` reaches System Configuration on macOS and the
126
+ registry on Windows; a failure report must not pay that round trip on
127
+ every request.
128
+ """
129
+ try:
130
+ return tuple(
131
+ (key, value) for key, value in urllib.request.getproxies().items() if value
132
+ )
133
+ except Exception:
134
+ return ()
135
+
136
+
120
137
  def _egress_is_confined() -> bool:
121
138
  return (os.environ.get(_EGRESS_CONFINED_ENV) or "").strip() == "1"
122
139
 
@@ -136,9 +153,114 @@ def _unbuildable_proxy_schemes(
136
153
  return missing
137
154
 
138
155
 
156
+ class EgressPath(NamedTuple):
157
+ """How a provider request to `base_url` leaves this process.
158
+
159
+ `proxy` is the client-side proxy address without credentials, or None for a
160
+ direct connection. It is what a failure report shows: the address alone
161
+ tells the reader which hop to inspect, while credentials would leak into
162
+ logs and copied diagnostics.
163
+ """
164
+
165
+ proxy: str | None
166
+
167
+ @property
168
+ def uses_proxy(self) -> bool:
169
+ return self.proxy is not None
170
+
171
+
172
+ def _redacted_proxy_url(value: str) -> str:
173
+ """Proxy URL without userinfo; a bare `host:port` is returned as written."""
174
+ candidate = value if "://" in value else f"http://{value}"
175
+ parts = urlsplit(candidate)
176
+ host = parts.netloc.rpartition("@")[2]
177
+ return urlunsplit((parts.scheme, host, parts.path, "", ""))
178
+
179
+
180
+ def _proxy_for_scheme(
181
+ proxies: Mapping[str, str],
182
+ *,
183
+ scheme: str,
184
+ env_bypass: Callable[[str], bool],
185
+ platform_list_bypass: Callable[[str], bool],
186
+ host: str | None,
187
+ ) -> str | None:
188
+ """The declared proxy `host` would use, or None when it is exempt."""
189
+ if host and (env_bypass(host) or platform_list_bypass(host)):
190
+ return None
191
+ for key in (scheme, "all"):
192
+ declared = proxies.get(key)
193
+ if declared:
194
+ return _redacted_proxy_url(declared)
195
+ return None
196
+
197
+
198
+ def egress_path(
199
+ base_url: str,
200
+ *,
201
+ env_proxies: Callable[[], Mapping[str, str]] = urllib.request.getproxies_environment,
202
+ platform_proxies: Callable[[], Mapping[str, str]] = lambda: dict(_platform_proxies()),
203
+ platform_bypass: Callable[[str], bool] = _platform_says_direct,
204
+ env_bypass: Callable[[str], bool] = urllib.request.proxy_bypass_environment,
205
+ platform_list_bypass: Callable[[str], bool] = _platform_list_says_direct,
206
+ confined: Callable[[], bool] = _egress_is_confined,
207
+ ) -> EgressPath:
208
+ """Which hop a request to `base_url` takes, for failure reports.
209
+
210
+ Mirrors `client_env_kwargs` rather than re-deciding: environment proxies
211
+ first, the platform's own proxy map when the environment declares none
212
+ (httpx reads `getproxies()` under `trust_env`), the confinement marker,
213
+ and the two bypass lists. It answers only what a report should show.
214
+ """
215
+ host = urlsplit(base_url).hostname
216
+ scheme = urlsplit(base_url).scheme or "https"
217
+ proxies = env_proxies()
218
+ if not proxies:
219
+ if direct_mounts(
220
+ base_url, env_proxies=lambda: proxies, platform_bypass=platform_bypass
221
+ ):
222
+ return EgressPath(proxy=None)
223
+ proxies = platform_proxies()
224
+ if confined():
225
+ declared = next(iter(proxies.values()), "")
226
+ return EgressPath(proxy=_redacted_proxy_url(declared) if declared else None)
227
+ return EgressPath(
228
+ proxy=_proxy_for_scheme(
229
+ proxies,
230
+ scheme=scheme,
231
+ env_bypass=env_bypass,
232
+ platform_list_bypass=platform_list_bypass,
233
+ host=host,
234
+ )
235
+ )
236
+
237
+
238
+ def _direct_pin_or_trust_env(
239
+ base_url: str,
240
+ proxies: Mapping[str, str],
241
+ *,
242
+ has_module: Callable[[str], bool],
243
+ ) -> dict[str, None] | None:
244
+ """A direct pin for `base_url`, or None when the pin cannot be built.
245
+
246
+ httpx builds a transport for every declared proxy while constructing the
247
+ client, so one unbuildable scheme fails the whole client no matter which
248
+ pattern the request matches. When that happens the caller must fall back to
249
+ `trust_env=False`, which is the only way to keep this host out of a proxy
250
+ map that cannot be assembled.
251
+ """
252
+ host = urlsplit(base_url).hostname
253
+ if not host:
254
+ return None
255
+ if proxies and _unbuildable_proxy_schemes(proxies, has_module=has_module):
256
+ return None
257
+ return _pin_direct(host)
258
+
259
+
139
260
  def client_env_kwargs(
140
261
  base_url: str,
141
262
  *,
263
+ force_direct: bool = False,
142
264
  env_proxies: Callable[[], Mapping[str, str]] = urllib.request.getproxies_environment,
143
265
  platform_bypass: Callable[[str], bool] = _platform_says_direct,
144
266
  env_bypass: Callable[[str], bool] = urllib.request.proxy_bypass_environment,
@@ -157,7 +279,17 @@ def client_env_kwargs(
157
279
 
158
280
  An empty dict leaves httpx's own environment handling untouched: the endpoint
159
281
  is proxied as declared, or confined egress owns every host.
282
+
283
+ `force_direct` overrides all of that for one endpoint: the user asked this
284
+ model service to connect directly, typically after a proxy turned a working
285
+ connection into a timeout. It cannot override confined egress — that
286
+ declaration owns every host, and the host's own sandbox decides reachability
287
+ — so it resolves to `trust_env=False` there, which is what keeps the
288
+ per-host egress proxy out of the request.
160
289
  """
290
+ if force_direct:
291
+ pinned = _direct_pin_or_trust_env(base_url, env_proxies(), has_module=has_module)
292
+ return {"trust_env": False} if pinned is None else {"mounts": pinned}
161
293
  proxies = env_proxies()
162
294
  if not proxies:
163
295
  mounts = direct_mounts(
@@ -308,6 +308,7 @@ async def _collect_stream(
308
308
  status_code=getattr(exc, "status_code", None),
309
309
  provider=getattr(exc, "provider", None),
310
310
  retry_after_ms=getattr(exc, "retry_after_ms", None),
311
+ proxy=getattr(exc, "proxy", None),
311
312
  )
312
313
  return
313
314
  display_tail = stripper.feed(content_carry) + stripper.flush()
@@ -470,6 +471,7 @@ async def _on_request_error(
470
471
  status_code=error_raw.get("status_code"),
471
472
  provider=error_raw.get("provider"),
472
473
  retry_after_ms=error_raw.get("retry_after_ms"),
474
+ proxy=error_raw.get("proxy"),
473
475
  )
474
476
  transcript = [_msg_from_json(item) for item in raw.get("transcript", [])]
475
477
  loop._run_context.round_index = int(raw.get("round_index") or 0)