coderouter-cli 2.13.0__tar.gz → 2.15.0__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 (318) hide show
  1. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/CHANGELOG.md +254 -0
  2. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/PKG-INFO +2 -2
  3. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/anthropic_native.py +110 -3
  4. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/base.py +65 -0
  5. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/openai_compat.py +65 -2
  6. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cli.py +140 -2
  7. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/loader.py +24 -1
  8. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/schemas.py +209 -0
  9. coderouter_cli-2.15.0/coderouter/credentials.py +332 -0
  10. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/doctor_apply.py +54 -9
  11. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/anthropic_routes.py +82 -12
  12. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/dashboard_routes.py +24 -4
  13. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/metrics_routes.py +13 -2
  14. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/openai_routes.py +31 -2
  15. coderouter_cli-2.15.0/coderouter/ingress/ui_auth.py +94 -0
  16. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/logging.py +150 -0
  17. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/collector.py +40 -0
  18. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/prometheus.py +46 -0
  19. coderouter_cli-2.15.0/coderouter/rollback.py +217 -0
  20. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/__init__.py +8 -0
  21. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/fallback.py +304 -4
  22. coderouter_cli-2.15.0/coderouter/routing/fallback_trace.py +501 -0
  23. coderouter_cli-2.15.0/coderouter/secret_redaction.py +606 -0
  24. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/audit_log.py +10 -0
  25. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/request_log.py +6 -0
  26. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/vscode_init.py +166 -23
  27. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/README.en.md +30 -8
  28. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/README.md +35 -13
  29. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher.en.md +2 -0
  30. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher.md +2 -0
  31. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/architecture.en.md +10 -0
  32. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/architecture.md +10 -0
  33. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/partial-stitch.en.md +6 -1
  34. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/partial-stitch.md +5 -1
  35. coderouter_cli-2.15.0/docs/concepts/stream-truncation.en.md +252 -0
  36. coderouter_cli-2.15.0/docs/concepts/stream-truncation.md +247 -0
  37. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/external-agents-adapter.md +9 -1
  38. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/orchestration-companion.md +3 -0
  39. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.6-auto-router-verification.md +9 -0
  40. coderouter_cli-2.15.0/docs/guides/security.en.md +438 -0
  41. coderouter_cli-2.15.0/docs/guides/security.md +249 -0
  42. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/troubleshooting.en.md +34 -0
  43. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/troubleshooting.md +34 -0
  44. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/usage-guide.en.md +35 -0
  45. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/usage-guide.md +35 -0
  46. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/vscode.md +54 -9
  47. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/README.md +4 -3
  48. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/README.md +18 -2
  49. coderouter_cli-2.15.0/examples/providers.cli-session.yaml +127 -0
  50. coderouter_cli-2.15.0/examples/providers.llamacpp-vllm.yaml +544 -0
  51. coderouter_cli-2.15.0/examples/providers.opusplan.yaml +168 -0
  52. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/pyproject.toml +1 -1
  53. coderouter_cli-2.15.0/tests/test_credentials.py +348 -0
  54. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor_apply.py +65 -0
  55. coderouter_cli-2.15.0/tests/test_fallback_reason.py +919 -0
  56. coderouter_cli-2.15.0/tests/test_fallback_tool_integrity.py +400 -0
  57. coderouter_cli-2.15.0/tests/test_rollback.py +195 -0
  58. coderouter_cli-2.15.0/tests/test_secret_redaction.py +340 -0
  59. coderouter_cli-2.15.0/tests/test_stream_truncation.py +1444 -0
  60. coderouter_cli-2.15.0/tests/test_ui_auth.py +142 -0
  61. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_vscode_init.py +145 -3
  62. coderouter_cli-2.13.0/docs/guides/security.en.md +0 -204
  63. coderouter_cli-2.13.0/docs/guides/security.md +0 -126
  64. coderouter_cli-2.13.0/examples/providers.llamacpp-vllm.yaml +0 -332
  65. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/.gitignore +0 -0
  66. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/LICENSE +0 -0
  67. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/README.en.md +0 -0
  68. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/README.md +0 -0
  69. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/__init__.py +0 -0
  70. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/__main__.py +0 -0
  71. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/__init__.py +0 -0
  72. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/registry.py +0 -0
  73. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cli_stats.py +0 -0
  74. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/__init__.py +0 -0
  75. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/capability_registry.py +0 -0
  76. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/env_file.py +0 -0
  77. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cost.py +0 -0
  78. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/data/__init__.py +0 -0
  79. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/data/model-capabilities.yaml +0 -0
  80. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/doctor.py +0 -0
  81. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/env_security.py +0 -0
  82. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/errors.py +0 -0
  83. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/gguf_introspect.py +0 -0
  84. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/__init__.py +0 -0
  85. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/_fingerprint.py +0 -0
  86. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/backend_health.py +0 -0
  87. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/context_budget.py +0 -0
  88. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/continuous_probe.py +0 -0
  89. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/drift_actions.py +0 -0
  90. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/drift_detection.py +0 -0
  91. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/memory_budget.py +0 -0
  92. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/memory_pressure.py +0 -0
  93. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/self_healing.py +0 -0
  94. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/tool_loop.py +0 -0
  95. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/hardware.py +0 -0
  96. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/__init__.py +0 -0
  97. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/app.py +0 -0
  98. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/launcher_routes.py +0 -0
  99. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/language_tax.py +0 -0
  100. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_devices.py +0 -0
  101. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_speculative.py +0 -0
  102. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_swap.py +0 -0
  103. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/__init__.py +0 -0
  104. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/output_filters.py +0 -0
  105. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/__init__.py +0 -0
  106. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/base.py +0 -0
  107. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/loader.py +0 -0
  108. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/registry.py +0 -0
  109. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/py.typed +0 -0
  110. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/adaptive.py +0 -0
  111. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/auto_router.py +0 -0
  112. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/budget.py +0 -0
  113. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/capability.py +0 -0
  114. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/__init__.py +0 -0
  115. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/replay.py +0 -0
  116. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/store.py +0 -0
  117. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/suggest_rules.py +0 -0
  118. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/token_estimation.py +0 -0
  119. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/token_estimation_accurate.py +0 -0
  120. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/__init__.py +0 -0
  121. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/anthropic.py +0 -0
  122. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/convert.py +0 -0
  123. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/tool_repair.py +0 -0
  124. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/assets/dashboard-demo.png +0 -0
  125. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/claude-code-llamacpp-vllm.en.md +0 -0
  126. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/claude-code-llamacpp-vllm.md +0 -0
  127. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/external-agents.en.md +0 -0
  128. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/external-agents.md +0 -0
  129. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/gguf_dl.en.md +0 -0
  130. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/gguf_dl.md +0 -0
  131. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/hf-ollama-models.en.md +0 -0
  132. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/hf-ollama-models.md +0 -0
  133. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/install-backends.en.md +0 -0
  134. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/install-backends.md +0 -0
  135. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher-quickstart.en.md +0 -0
  136. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher-quickstart.md +0 -0
  137. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/llamacpp-direct.en.md +0 -0
  138. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/llamacpp-direct.md +0 -0
  139. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/lmstudio-direct.en.md +0 -0
  140. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/lmstudio-direct.md +0 -0
  141. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/verify-ollama-0.23.1.md +0 -0
  142. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/context-budget.en.md +0 -0
  143. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/context-budget.md +0 -0
  144. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/continuous-probing.en.md +0 -0
  145. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/continuous-probing.md +0 -0
  146. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/drift-detection.en.md +0 -0
  147. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/drift-detection.md +0 -0
  148. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/agent-cli-plugin-extraction.md +0 -0
  149. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/launcher-model-swap.md +0 -0
  150. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/launcher-multi-build.md +0 -0
  151. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.5-dashboard-mockup.html +0 -0
  152. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.6-auto-router.md +0 -0
  153. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/free-tier-guide.en.md +0 -0
  154. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/free-tier-guide.md +0 -0
  155. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/language-tax.en.md +0 -0
  156. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/language-tax.md +0 -0
  157. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/remote-access.en.md +0 -0
  158. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/remote-access.md +0 -0
  159. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/subagent-routing.en.md +0 -0
  160. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/subagent-routing.md +0 -0
  161. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/low-memory-integration.en.md +0 -0
  162. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/low-memory-integration.md +0 -0
  163. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/CHANGES.md +0 -0
  164. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/latest.json +0 -0
  165. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.4.md +0 -0
  166. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.5-verify.md +0 -0
  167. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.5.md +0 -0
  168. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.6.md +0 -0
  169. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.7.md +0 -0
  170. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v1.0-verify.md +0 -0
  171. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v1.0.md +0 -0
  172. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/quickstart.en.md +0 -0
  173. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/quickstart.md +0 -0
  174. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/when-do-i-need-coderouter.en.md +0 -0
  175. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/when-do-i-need-coderouter.md +0 -0
  176. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/.env.example +0 -0
  177. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers-agent-cli.yaml +0 -0
  178. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers-multiagent.yaml +0 -0
  179. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.context-budget-test.yaml +0 -0
  180. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.nvidia-nim.yaml +0 -0
  181. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-auto-custom.yaml +0 -0
  182. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-auto.yaml +0 -0
  183. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-free-chain.yaml +0 -0
  184. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.raspberrypi.yaml +0 -0
  185. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.swap.yaml +0 -0
  186. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.yaml +0 -0
  187. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/demo_traffic.sh +0 -0
  188. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/openrouter_roster_diff.py +0 -0
  189. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/smoke_v2_2.sh +0 -0
  190. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify-providers.yaml +0 -0
  191. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_ollama_0_23.py +0 -0
  192. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_v0_5.sh +0 -0
  193. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_v1_0.sh +0 -0
  194. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/__init__.py +0 -0
  195. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/conftest.py +0 -0
  196. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_adapter_anthropic.py +0 -0
  197. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_agent_cli.py +0 -0
  198. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_audit_log.py +0 -0
  199. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_auto_router.py +0 -0
  200. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_auto_router_cjk.py +0 -0
  201. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_backend_health.py +0 -0
  202. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_budget.py +0 -0
  203. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability.py +0 -0
  204. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_degraded_payload.py +0 -0
  205. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_registry.py +0 -0
  206. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_registry_cache_control.py +0 -0
  207. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_tool_choice.py +0 -0
  208. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_claude_code_suitability.py +0 -0
  209. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli.py +0 -0
  210. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli_bind_warning.py +0 -0
  211. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli_stats.py +0 -0
  212. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config.py +0 -0
  213. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config_cwd_warning.py +0 -0
  214. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config_shim_actions.py +0 -0
  215. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_context_budget.py +0 -0
  216. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_continuous_probe.py +0 -0
  217. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_dashboard_endpoint.py +0 -0
  218. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor.py +0 -0
  219. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor_cache_probe.py +0 -0
  220. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_actions.py +0 -0
  221. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_detection.py +0 -0
  222. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_detection_integration.py +0 -0
  223. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_env_file.py +0 -0
  224. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_env_security.py +0 -0
  225. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_errors.py +0 -0
  226. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_examples_yaml.py +0 -0
  227. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_examples_yaml_roundtrip.py +0 -0
  228. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback.py +0 -0
  229. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_anthropic.py +0 -0
  230. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_control.py +0 -0
  231. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_observed.py +0 -0
  232. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_strip.py +0 -0
  233. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_empty_response.py +0 -0
  234. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_misconfig_warn.py +0 -0
  235. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_paid_gate.py +0 -0
  236. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_thinking.py +0 -0
  237. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_tool_choice_emulate.py +0 -0
  238. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h1_prometheus_drift.py +0 -0
  239. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h2_h6_translation.py +0 -0
  240. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h3_client_reuse.py +0 -0
  241. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h4_h5_guards.py +0 -0
  242. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h7_probe_restore.py +0 -0
  243. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h8_launcher_auth.py +0 -0
  244. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mA_routing.py +0 -0
  245. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mB_adapters.py +0 -0
  246. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mC_convert.py +0 -0
  247. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mD_logging_metrics.py +0 -0
  248. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mE_config_validation.py +0 -0
  249. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mF_ingress.py +0 -0
  250. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_v213_launcher_hardening.py +0 -0
  251. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_gguf_introspect.py +0 -0
  252. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_guards_tool_loop.py +0 -0
  253. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_hardware.py +0 -0
  254. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_import_hygiene.py +0 -0
  255. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_anthropic.py +0 -0
  256. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_count_tokens.py +0 -0
  257. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_profile.py +0 -0
  258. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_language_tax.py +0 -0
  259. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_language_tax_integration.py +0 -0
  260. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_backend_variants.py +0 -0
  261. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_config_bench.py +0 -0
  262. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices.py +0 -0
  263. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices_detect.py +0 -0
  264. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices_routes.py +0 -0
  265. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_devices.py +0 -0
  266. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_events.py +0 -0
  267. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_readiness.py +0 -0
  268. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_sweep_close.py +0 -0
  269. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_mtp.py +0 -0
  270. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_mtp_fallback.py +0 -0
  271. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_provider_sync.py +0 -0
  272. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_readiness_restart.py +0 -0
  273. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_swap.py +0 -0
  274. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_swap_review.py +0 -0
  275. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_config.py +0 -0
  276. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_gui.py +0 -0
  277. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_routes.py +0 -0
  278. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_sweep.py +0 -0
  279. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_memory_budget.py +0 -0
  280. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_memory_pressure.py +0 -0
  281. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_cache.py +0 -0
  282. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_collector.py +0 -0
  283. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_cost.py +0 -0
  284. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_endpoint.py +0 -0
  285. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_jsonl.py +0 -0
  286. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_prometheus.py +0 -0
  287. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_prometheus_cache.py +0 -0
  288. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_models_passthrough.py +0 -0
  289. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_openai_compat.py +0 -0
  290. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_openrouter_roster_diff.py +0 -0
  291. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_output_filters.py +0 -0
  292. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_output_filters_adapters.py +0 -0
  293. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_packaging.py +0 -0
  294. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_partial_stitch.py +0 -0
  295. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugin_adapter.py +0 -0
  296. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_integration.py +0 -0
  297. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_loader.py +0 -0
  298. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_registry.py +0 -0
  299. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_reasoning_strip.py +0 -0
  300. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_repair_byte_fallback.py +0 -0
  301. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_request_log.py +0 -0
  302. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_role_normalization.py +0 -0
  303. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_routing_adaptive.py +0 -0
  304. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_self_healing.py +0 -0
  305. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_setup_sh.py +0 -0
  306. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_state_store.py +0 -0
  307. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_stop_reason_forward_compat.py +0 -0
  308. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_token_estimation.py +0 -0
  309. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_token_estimation_accurate.py +0 -0
  310. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tokens_saved_metric.py +0 -0
  311. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair.py +0 -0
  312. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_differential.py +0 -0
  313. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_lenient.py +0 -0
  314. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_r4.py +0 -0
  315. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_scanners.py +0 -0
  316. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_toolrepair_bench.py +0 -0
  317. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_translation_anthropic.py +0 -0
  318. {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_translation_reverse.py +0 -0
@@ -9,6 +9,260 @@ are kept verbatim where the Japanese text itself is the subject).
9
9
 
10
10
  ---
11
11
 
12
+ ## [v2.15.0] — 2026-08-21 (fallback explainability + stream truncation detection)
13
+
14
+ ### Added
15
+
16
+ - **Fallback explainability — `X-CodeRouter-Fallback-*` response headers.**
17
+ When a request changes providers, the response now says who it left, who
18
+ took over, and why: `X-CodeRouter-Fallback-From` / `-To` / `-Reason` /
19
+ `-Chain` (multi-hop chains render as `local>ollama>openrouter` with one
20
+ comma-separated reason per departure). Reasons come from a single
21
+ canonical vocabulary in `coderouter/routing/fallback_trace.py` covering
22
+ both chain-resolve filters (`paid-gate`, `budget-exceeded`,
23
+ `memory-pressure`, `backend-unhealthy`, `self-healing-excluded`,
24
+ `unknown-provider`) and runtime attempt failures (`timeout`,
25
+ `rate-limit`, `auth`, `upstream-5xx`, `upstream-4xx`, `connection`,
26
+ `empty-response`, `empty-stream`). The same trail is emitted as
27
+ structured `fallback-occurred` log lines (auditable) and, on the
28
+ Anthropic streaming path, as a trailing `coderouter_fallback` SSE
29
+ metadata event for reasons discovered after the HTTP headers ship.
30
+ Purely additive — a request served by its first provider produces no new
31
+ headers, no SSE trailer and no log lines.
32
+ - **Stream truncation detection — `stream_truncation_action`
33
+ (`off` | `warn` | `error`, default `off`).** An upstream can end its HTTP
34
+ body *cleanly* while the LLM protocol carried inside it is still
35
+ mid-message: no `message_stop` on the Anthropic wire, no `data: [DONE]`
36
+ and no `finish_reason` on the OpenAI wire. llama.cpp slot preemption, an
37
+ `--n-predict` cut-off, a proxy closing an EOF-delimited body, or a local
38
+ server that OOM'd all look exactly like that. Transport breakage
39
+ (timeouts, `httpx.RemoteProtocolError`) was already caught; this is the
40
+ layer the transport cannot see, and until now CodeRouter reported it as a
41
+ *successful* completion — the translation layer's terminator-synthesis
42
+ guards fabricated `stop_reason: end_turn` so the client would not hang,
43
+ which also erased every trace of the cut. The synthesis stays (removing
44
+ it hangs Claude Code); what is new is that the engine gets told. Both
45
+ adapters now track whether a terminator arrived, and under `error` raise
46
+ a retryable `StreamTruncatedError` that joins the *existing* branches: no
47
+ bytes forwarded yet → fall back to the next provider with reason
48
+ `stream-truncated`; bytes already out → `MidStreamError`, which
49
+ `partial_stitch_action: surface` renders as a graceful close carrying a
50
+ `coderouter_partial` event labelled `stream_truncated`. If *every*
51
+ provider in the chain truncates before any byte reached the client, the
52
+ request ends in `NoProvidersAvailableError` (an `overloaded_error` SSE
53
+ frame) rather than flushing a half-written message — see **Fixed** below.
54
+ Because the failure is an ordinary `AdapterError`, the L2/L4/L5/L6
55
+ self-healing guards learn it — a backend that keeps going quiet gets
56
+ demoted by adaptive routing instead of being *preferred* for its fast
57
+ first byte. `warn` measures without changing a single byte of output;
58
+ `off` is byte-for-byte v2.14.0 **on the wire** — see the caveat on
59
+ guard-internal state below. Observability: a `stream-truncation-detected` log
60
+ line (with `wire`, `events_forwarded`, `saw_stream_start`,
61
+ `tool_call_in_flight`), `stream_truncated_total` in `/metrics.json` and
62
+ Prometheus (labelled by provider and by action), and the
63
+ `stream-truncated` reason on the `X-CodeRouter-Fallback-*` headers.
64
+ Terminator matching is deliberately lenient — a `message_delta` carrying
65
+ a `stop_reason`, or a `finish_reason` on any choice, both count — so
66
+ providers that legitimately omit the sentinel are not flagged. On the
67
+ Anthropic streaming path, pre-content fallback additionally needs
68
+ `empty_response_action: fallback`, which is the knob that withholds the
69
+ opening events from the client. Known gap: the terminator synthesis this
70
+ entry refers to lives in `translation/convert.py`, so the one route that
71
+ does not pass through it — `/v1/messages` served by a `kind: anthropic`
72
+ backend, i.e. native passthrough — has never had it. Under `off` a
73
+ truncated native stream still reaches the client unterminated, exactly as
74
+ in v2.14.0; that route is the strongest argument for moving to `warn`.
75
+ Docs: `docs/concepts/stream-truncation.md` / `.en.md`.
76
+ - **Tool-call integrity regression tests across a fallback hop**
77
+ (`tests/test_fallback_tool_integrity.py`): the conversation handed to the
78
+ second provider keeps every `tool_use` / `tool_result` id paired, and the
79
+ response the client receives is still a structurally valid `tool_use`
80
+ block on both the buffered and the streaming path.
81
+ - **Stream truncation tests** (`tests/test_stream_truncation.py`): both
82
+ adapters driven over real SSE bodies (off / warn / error, the
83
+ terminator-leniency false-positive guards, the tool-call-in-flight flag),
84
+ engine-level fallback and `MidStreamError` integration, the ingress
85
+ `coderouter_partial` labelling, and an explicit `off` regression proving
86
+ the default path — including the H6 terminator synthesis — is unchanged.
87
+
88
+ ### Fixed
89
+
90
+ - **Anthropic streaming: a provider that failed before forwarding any byte
91
+ was invisible to the self-healing guards.** Under
92
+ `empty_response_action: fallback` the engine withholds the opening events
93
+ until real content appears, and an `AdapterError` raised in that window
94
+ is handled by swapping providers instead of raising. That branch recorded
95
+ an adaptive failure but never called the L2 memory-pressure / L4 drift /
96
+ L5 backend-health hooks — while `_observe_provider_success` had *already*
97
+ fired the moment the first event landed. The net effect was a backend
98
+ that opens a stream and then dies keeping a clean bill of health, and
99
+ adaptive routing preferring it for its fast first byte. The identical
100
+ failure under `empty_response_action: off` takes the mid-stream path and
101
+ *was* observed, so provider health effectively depended on an unrelated
102
+ knob. Both hooks now run on that branch (and the error joins the `errors`
103
+ list carried by `NoProvidersAvailableError`), matching the mid-stream
104
+ sibling. A stream that terminates cleanly with no content is still not a
105
+ failure — it is a 200 blank, and reaches neither hook.
106
+ **Operational note:** this is a behavior change for anyone already
107
+ running `empty_response_action: fallback` — pre-content failures now
108
+ reach L2/L4/L5, so if `memory_pressure_action: skip` and/or
109
+ `backend_health_action: demote` are also set, skips and demotions can
110
+ become *more frequent* for a backend that was silently dying before real
111
+ content shipped, even though nothing about those knobs' own
112
+ configuration changed. This is independent of `stream_truncation_action`
113
+ — it fires under the default `off` exactly as it does under `warn` /
114
+ `error`, since the branch it lives in is the *pre-existing*
115
+ `empty_response_action: fallback` path, not the new truncation
116
+ detection. See `docs/concepts/stream-truncation.md` / `.en.md` for
117
+ details.
118
+ - **Anthropic streaming: a chain where every provider truncated left the
119
+ client hanging.** On chain exhaustion the engine flushes the last
120
+ buffered preamble so a genuinely empty chain returns a well-formed 200
121
+ blank (`message_start` … `message_stop`). A *truncated* stream's buffer
122
+ has no `message_stop` by definition, so flushing it emitted an SSE
123
+ sequence that never ended — no exception, no error frame, and a client
124
+ waiting forever — and charged `empty_responses_total` for a stream that
125
+ was not empty. The flush is now gated on the buffer actually terminating;
126
+ a truncation-produced buffer falls through to `NoProvidersAvailableError`,
127
+ which the ingress turns into a terminal `overloaded_error` SSE frame. Only
128
+ reachable with `stream_truncation_action: error`, so the default path is
129
+ unchanged, and the empty-chain 200 blank is untouched (including the
130
+ mixed case where a truncated hop is followed by a provider that answered
131
+ with a well-formed empty message — that buffer still gets flushed).
132
+ - **`partial-stitch-surfaced` log shape under the default `off`.** The
133
+ `reason` key added for truncation labelling was attached
134
+ unconditionally, so every `partial_stitch_action: surface` log line
135
+ gained a field even where truncation detection is disabled and the value
136
+ could only ever be `mid_stream_failure`. It is now emitted only when it
137
+ says something new, restoring the v2.14.0 key set. (`/metrics.json` was
138
+ never affected — `MetricsCollector` whitelists the fields it copies.)
139
+
140
+ ---
141
+
142
+ ## [v2.14.0] — 2026-08-09 (credential hygiene + undo)
143
+
144
+ Five changes that came out of reading `duolahypercho/codex-router` and
145
+ comparing it with what CodeRouter actually does. Four of them close holes
146
+ this project had; one imports a capability it was missing. Everything is
147
+ opt-in except the `.envrc` behaviour change called out under **Changed
148
+ (BREAKING)**.
149
+
150
+ ### Added
151
+
152
+ - **`credential.source: cli_session` — borrow the token a vendor CLI
153
+ already wrote.** A subscription-authenticated provider (Kimi Code CLI,
154
+ Grok CLI, …) can now be an ordinary `openai_compat` / `anthropic`
155
+ entry: CodeRouter reads the OAuth token out of the JSON file the
156
+ vendor's CLI maintains and makes the HTTP call itself. Until now the
157
+ only answer to those was `kind: agent_cli`, which spawns the CLI once
158
+ per request and therefore has no streaming, no tool-call repair, no
159
+ context-budget guard, and does not sit in a fallback chain — the
160
+ subscription ended up an island. It is now a chain link:
161
+ `providers: [kimi-sub, free-cloud, local-llama]` just works.
162
+ Refresh delegates to the vendor's own CLI (`refresh.command`, an argv
163
+ list run with `shell=False`) rather than reimplementing OAuth, because
164
+ endpoints, client ids and rotation policies all change and a bespoke
165
+ implementation rots. Refresh is single-flighted in-process and
166
+ serialised across processes with an advisory `flock`. `credential` and
167
+ `api_key_env` are mutually exclusive at load time, `credential.path`
168
+ must live under `$HOME`, and a missing or malformed session file
169
+ resolves to `None` (unauthenticated request → upstream 401 → the chain
170
+ moves on) instead of raising. See `examples/providers.cli-session.yaml`.
171
+ - **`coderouter rollback`** — restores `providers.yaml`,
172
+ `~/.coderouter/model-capabilities.yaml` and (with `--workspace`)
173
+ `.vscode/settings.json` / `.envrc` from their `.bak` siblings.
174
+ `doctor --apply` and `vscode-init` have always written a backup and
175
+ never offered a way back. Restore is a **swap**: the current contents
176
+ become the new `.bak`, so a second run returns you to where you
177
+ started. Exit 0 restored / 2 nothing to restore / 1 a restore failed.
178
+ - **`coderouter doctor --check-secrets`** — proves the log-redaction
179
+ filter scrubs by pushing a canary through it, reports which declared
180
+ `api_key_env` vars are actually set, flags credentials pasted into a
181
+ `base_url`, and scans the already-written `requests.jsonl` /
182
+ `audit.jsonl` (plus rotated `.1`) for live key values. Same 0/2/1 exit
183
+ contract as `--check-env`. The log scan is the part that produces
184
+ evidence rather than reassurance.
185
+ - **`CODEROUTER_METRICS_TOKEN`** — opt-in token auth for `/dashboard`,
186
+ `/metrics.json` and `/metrics`, mirroring `CODEROUTER_LAUNCHER_TOKEN`.
187
+
188
+ ### Changed (BREAKING)
189
+
190
+ - **`vscode-init --with-envrc --force` no longer replaces `.envrc`.** The
191
+ block CodeRouter owns is now fenced between
192
+ `# BEGIN coderouter-managed` / `# END coderouter-managed`, and only
193
+ that block is rewritten. Consequences:
194
+ - A re-run against a fenced file needs **no `--force`** — rewriting our
195
+ own fence is not destructive, so a port change is an ordinary update
196
+ instead of a conflict to override.
197
+ - `--force` now means "adopt a file I did not write": the block is
198
+ appended and every existing line survives. Previously it deleted
199
+ them, which is how the H-11 `.envrc` incident happened — including
200
+ the `source_env_if_exists .envrc.local` line the docs tell users to
201
+ add.
202
+ - A managed variable exported *outside* the fence is refused, because
203
+ direnv applies exports in order and a silent duplicate leaves nobody
204
+ able to say which value is live. Commented-out lines are not
205
+ conflicts.
206
+ - A file byte-identical to a pre-v2.14.0 generation is adopted into the
207
+ fence silently, so upgrading does not demand `--force` for our own
208
+ previous output.
209
+ `settings.json` handling is unchanged (it already refused to overwrite
210
+ a user-owned value without `--force`).
211
+ - **A failed `doctor --apply` is now a no-op instead of a partial one.**
212
+ The write loop had no error handling: an apply spanning `providers.yaml`
213
+ and `model-capabilities.yaml` left the first file rewritten when the
214
+ second failed. It now restores what it already wrote (and deletes what
215
+ it created) before raising.
216
+
217
+ ### Fixed (security)
218
+
219
+ - **Credentials are scrubbed from every log sink.** CodeRouter had no
220
+ notion of "this string is a secret": keys were read by
221
+ `resolve_api_key`, dropped into `x-api-key` / `Authorization`, and
222
+ nothing masked them because nothing knew which strings needed masking.
223
+ No call site logged a header dict, so this was not a live leak — the
224
+ problem was that the safety of every future log line depended on
225
+ whoever wrote it remembering what was in their hand. There is now a
226
+ process-global registry keyed on the exact credential value, armed by
227
+ `resolve_api_key` (the choke point every key passes through, plugins
228
+ included) and pre-armed by `load_config`, plus a
229
+ `SecretRedactingFilter` on the stderr handler, `RequestLogHandler` and
230
+ `AuditLogHandler`. It scrubs the message, printf args, `exc_text` and
231
+ every `extra={...}` field, recursing into nested dicts and lists.
232
+ Exact-match registry first (zero false positives); a small set of
233
+ anchored backstop patterns (`sk-*`, `gh*_`, `AIza*`, `Bearer`,
234
+ `?api_key=`, URL userinfo) covers credentials that never passed through
235
+ the resolver. Values under 8 characters are refused — redacting those
236
+ would corrupt ordinary log text.
237
+ - **`/dashboard`, `/metrics.json` and `/metrics` were unauthenticated
238
+ with no way to close them.** They change nothing, so they were never
239
+ part of the H-8 work, but `/metrics.json` returns every provider's
240
+ name, kind, paid flag and `base_url` plus the profile graph — the full
241
+ topology of which models an operator runs and which vendors they pay.
242
+ With `CODEROUTER_METRICS_TOKEN` unset they stay open exactly as before
243
+ (no running scrape breaks on upgrade) and log a one-time
244
+ `metrics-auth-disabled` warning. The check runs **before** the payload
245
+ is assembled, so a 401 leaks nothing, and the page receives only a
246
+ boolean — never the token. `base_url` now also goes through the
247
+ scrubber on its way into the JSON, since it is the one config field an
248
+ operator can paste a credential into and this endpoint hands it to a
249
+ browser.
250
+
251
+ ### Tests
252
+
253
+ - 2572 passed / 1 skipped (was 2464 on `main`), ruff clean, and 51 mypy
254
+ errors against 53 on `main` (net −2; strict mypy is not CI-enforced).
255
+ - New: `tests/test_secret_redaction.py` (36),
256
+ `tests/test_credentials.py` (25), `tests/test_rollback.py` (17),
257
+ `tests/test_ui_auth.py` (17), plus 9 in `tests/test_vscode_init.py` and
258
+ 1 in `tests/test_doctor_apply.py`.
259
+ - `test_existing_envrc_force_overwrites` asserted the old destructive
260
+ `--force` contract and is rewritten as
261
+ `test_existing_envrc_force_adopts_without_destroying`, pinning the
262
+ opposite.
263
+
264
+ ---
265
+
12
266
  ## [v2.13.0] — 2026-08-07 (remaining-high security hardening)
13
267
 
14
268
  ### Changed (BREAKING)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: coderouter-cli
3
- Version: 2.13.0
3
+ Version: 2.15.0
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -44,9 +44,14 @@ from coderouter.adapters.base import (
44
44
  ChatResponse,
45
45
  ProviderCallOverrides,
46
46
  StreamChunk,
47
+ StreamTruncatedError,
48
+ )
49
+ from coderouter.credentials import resolve_provider_credential
50
+ from coderouter.logging import (
51
+ get_logger,
52
+ log_output_filter_applied,
53
+ log_stream_truncation_detected,
47
54
  )
48
- from coderouter.config.loader import resolve_api_key
49
- from coderouter.logging import get_logger, log_output_filter_applied
50
55
  from coderouter.output_filters import OutputFilterChain
51
56
  from coderouter.translation.anthropic import (
52
57
  AnthropicRequest,
@@ -74,6 +79,74 @@ _RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
74
79
  _DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
75
80
 
76
81
 
82
+ class _AnthropicTerminatorTracker:
83
+ """v2.15.0: watch an Anthropic SSE event stream for its terminator.
84
+
85
+ The parse loop in :meth:`AnthropicAdapter.stream_anthropic` used to be
86
+ stateless across events — ``current_event`` was reset on every blank
87
+ line and nothing remembered whether the stream had ended *properly*.
88
+ That made "upstream sent 30 deltas and hung up" indistinguishable from
89
+ "upstream sent the whole message". This tracker is the missing memory.
90
+
91
+ What counts as a terminator (deliberately generous, to keep the false
92
+ positive rate down — see ``stream_truncation_action`` in
93
+ ``config/schemas.py``):
94
+
95
+ * ``message_stop`` — the canonical Anthropic terminator.
96
+ * ``message_delta`` carrying a non-null ``stop_reason`` — the message is
97
+ semantically complete even if the provider omits ``message_stop``.
98
+ Some Anthropic-compatible local servers do exactly that, and their
99
+ content is *not* truncated, so flagging them would be a false positive.
100
+ * a top-level ``error`` event — the upstream ended the stream on purpose
101
+ and the frame is forwarded to the client verbatim (current behavior).
102
+ Reporting truncation on top would double-report one failure.
103
+
104
+ ``open_block_type`` tracks the currently-open content block so a stream
105
+ cut in the middle of a ``tool_use`` block's ``input_json_delta`` can be
106
+ reported as such: that is the case where the translation layer's
107
+ terminator synthesis closes a block whose argument JSON is provably
108
+ incomplete.
109
+ """
110
+
111
+ __slots__ = ("events", "open_block_type", "saw_start", "saw_terminator")
112
+
113
+ def __init__(self) -> None:
114
+ """Start with nothing observed."""
115
+ self.saw_terminator: bool = False
116
+ self.saw_start: bool = False
117
+ self.events: int = 0
118
+ self.open_block_type: str | None = None
119
+
120
+ def observe(self, event_type: str, data: Any) -> None:
121
+ """Fold one parsed SSE event into the tracked state.
122
+
123
+ ``data`` is whatever ``json.loads`` produced for the block, so it is
124
+ not guaranteed to be a mapping; non-dict payloads are treated as
125
+ empty rather than raising inside a streaming hot path.
126
+ """
127
+ self.events += 1
128
+ if not isinstance(data, dict):
129
+ data = {}
130
+ if event_type == "message_start":
131
+ self.saw_start = True
132
+ elif event_type == "content_block_start":
133
+ block = data.get("content_block")
134
+ self.open_block_type = block.get("type") if isinstance(block, dict) else None
135
+ elif event_type == "content_block_stop":
136
+ self.open_block_type = None
137
+ elif event_type == "message_delta":
138
+ delta = data.get("delta")
139
+ if isinstance(delta, dict) and delta.get("stop_reason"):
140
+ self.saw_terminator = True
141
+ elif event_type in {"message_stop", "error"}:
142
+ self.saw_terminator = True
143
+
144
+ @property
145
+ def tool_call_in_flight(self) -> bool:
146
+ """True when a ``tool_use`` block was still open at end-of-stream."""
147
+ return self.open_block_type == "tool_use"
148
+
149
+
77
150
  def anthropic_messages_url(base_url: str) -> str:
78
151
  """Resolve the ``/v1/messages`` endpoint URL from a ``base_url``.
79
152
 
@@ -134,7 +207,9 @@ class AnthropicAdapter(BaseAdapter):
134
207
  self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
135
208
  ),
136
209
  }
137
- api_key = resolve_api_key(self.config.api_key_env)
210
+ # v2.14.0: see openai_compat._headers — same resolver, so an
211
+ # Anthropic-protocol provider can also ride a CLI session token.
212
+ api_key = resolve_provider_credential(self.config)
138
213
  if api_key:
139
214
  headers["x-api-key"] = api_key
140
215
  # v0.4-D: forward the client's anthropic-beta header verbatim.
@@ -473,6 +548,12 @@ class AnthropicAdapter(BaseAdapter):
473
548
  filter_chains: dict[int, OutputFilterChain] = {}
474
549
  logged_flag: list[bool] = [False]
475
550
 
551
+ # v2.15.0 (stream-truncation): remember whether the upstream ever
552
+ # terminated its message. Resolved once per call — the knob is a
553
+ # profile-level setting threaded in through ProviderCallOverrides.
554
+ truncation_action = self.effective_stream_truncation_action(overrides)
555
+ terminator = _AnthropicTerminatorTracker()
556
+
476
557
  try:
477
558
  # H3: stream over the shared client (pool / keep-alive / TLS
478
559
  # reuse). Only the ``stream(...)`` context is scoped here; the
@@ -512,6 +593,7 @@ class AnthropicAdapter(BaseAdapter):
512
593
  current_event = None
513
594
  data_lines = []
514
595
  continue
596
+ terminator.observe(current_event, data_obj)
515
597
  for out_event in self._process_stream_event_for_filters(
516
598
  AnthropicStreamEvent(type=current_event, data=data_obj),
517
599
  chains=filter_chains,
@@ -540,12 +622,37 @@ class AnthropicAdapter(BaseAdapter):
540
622
  except json.JSONDecodeError:
541
623
  data_obj = None
542
624
  if data_obj is not None:
625
+ terminator.observe(current_event, data_obj)
543
626
  for out_event in self._process_stream_event_for_filters(
544
627
  AnthropicStreamEvent(type=current_event, data=data_obj),
545
628
  chains=filter_chains,
546
629
  logged_flag=logged_flag,
547
630
  ):
548
631
  yield out_event
632
+
633
+ # v2.15.0 (stream-truncation): the upstream line iterator is
634
+ # exhausted. Reaching here with no terminator observed means the
635
+ # HTTP body ended cleanly while the Anthropic message was still
636
+ # open — the exact case transport-level error handling below
637
+ # cannot see (``httpx.RemoteProtocolError`` and timeouts already
638
+ # raise). Under ``off`` this block is inert, which is what keeps
639
+ # the default byte-for-byte identical to v2.14.0.
640
+ if truncation_action != "off" and not terminator.saw_terminator:
641
+ log_stream_truncation_detected(
642
+ logger,
643
+ self.name,
644
+ action=truncation_action,
645
+ wire="anthropic",
646
+ events_forwarded=terminator.events,
647
+ saw_stream_start=terminator.saw_start,
648
+ tool_call_in_flight=terminator.tool_call_in_flight,
649
+ )
650
+ if truncation_action == "error":
651
+ raise StreamTruncatedError(
652
+ "upstream stream truncated (no message_stop terminator)",
653
+ provider=self.name,
654
+ tool_call_in_flight=terminator.tool_call_in_flight,
655
+ )
549
656
  except httpx.TimeoutException as exc:
550
657
  raise AdapterError(
551
658
  f"timeout streaming from {url}", provider=self.name, retryable=True
@@ -137,6 +137,49 @@ class AdapterError(CodeRouterError):
137
137
  return f"[{self.provider}{sc}] {super().__str__()}"
138
138
 
139
139
 
140
+ # v2.15.0 (stream-truncation): raised by an adapter when the upstream SSE
141
+ # stream ended *without* its protocol terminator (``message_stop`` on the
142
+ # Anthropic wire, ``data: [DONE]`` / a ``finish_reason`` on the OpenAI wire).
143
+ #
144
+ # HTTP-level breakage (timeout, ``httpx.RemoteProtocolError``) already became a
145
+ # plain ``AdapterError`` in both adapters. This subclass covers the layer
146
+ # mismatch the transport cannot see: the HTTP body ended cleanly, but the *LLM
147
+ # protocol* carried inside it was still mid-message.
148
+ #
149
+ # It subclasses ``AdapterError`` on purpose — every existing engine branch
150
+ # (``except AdapterError``) keeps working untouched, and the L2/L4/L5/L6 guards
151
+ # learn from the failure automatically. The subclass only adds identity, so
152
+ # ``classify_adapter_error`` can label the hop ``stream-truncated`` instead of
153
+ # the generic ``upstream-error``.
154
+ #
155
+ # Raised only when the active profile sets ``stream_truncation_action: error``.
156
+ # Under ``off`` (the default) and ``warn`` the adapter returns normally and the
157
+ # legacy terminator-synthesis path in ``coderouter.translation.convert`` (H6 /
158
+ # M9) runs byte-for-byte as before.
159
+ class StreamTruncatedError(AdapterError):
160
+ """Upstream SSE stream ended without its protocol terminator event."""
161
+
162
+ def __init__(
163
+ self,
164
+ message: str,
165
+ *,
166
+ provider: str,
167
+ tool_call_in_flight: bool = False,
168
+ ) -> None:
169
+ """Construct a truncation error.
170
+
171
+ Args:
172
+ message: Human-readable reason. Never contains upstream body text.
173
+ provider: The ``ProviderConfig.name`` whose stream was cut.
174
+ tool_call_in_flight: True when the stream was cut while a
175
+ ``tool_use`` / ``tool_calls`` block was still open, i.e. the
176
+ accumulated argument JSON is certainly incomplete. Carried for
177
+ observability only — it never changes control flow.
178
+ """
179
+ super().__init__(message, provider=provider, status_code=None, retryable=True)
180
+ self.tool_call_in_flight = tool_call_in_flight
181
+
182
+
140
183
  # v0.6-B: per-call overrides resolved from the active profile. The engine
141
184
  # builds one instance per request (since a profile is invariant across its
142
185
  # chain) and threads it through every adapter call on that chain. Adapters
@@ -150,6 +193,13 @@ class AdapterError(CodeRouterError):
150
193
  # - ``append_system_prompt=""`` is a meaningful explicit value: "for
151
194
  # this profile, clear the provider's directive". The adapter must
152
195
  # distinguish ``None`` (no override) from ``""`` (override-to-empty).
196
+ #
197
+ # v2.15.0 (stream-truncation): ``stream_truncation_action`` rides the same
198
+ # channel. It is a *profile*-level knob but has to be read inside the adapter's
199
+ # SSE parse loop, and ``ProviderCallOverrides`` is the only object the engine
200
+ # already threads into every adapter call. Its default of ``"off"`` keeps
201
+ # ``ProviderCallOverrides()`` (and every legacy call site that passes nothing)
202
+ # on the pre-v2.15.0 behavior.
153
203
  class ProviderCallOverrides(BaseModel):
154
204
  """Per-call provider overrides, resolved from the active profile."""
155
205
 
@@ -157,6 +207,7 @@ class ProviderCallOverrides(BaseModel):
157
207
 
158
208
  timeout_s: float | None = None
159
209
  append_system_prompt: str | None = None
210
+ stream_truncation_action: Literal["off", "warn", "error"] = "off"
160
211
 
161
212
 
162
213
  class BaseAdapter(ABC):
@@ -236,6 +287,20 @@ class BaseAdapter(ABC):
236
287
  return overrides.append_system_prompt or None
237
288
  return self.config.append_system_prompt
238
289
 
290
+ def effective_stream_truncation_action(
291
+ self, overrides: ProviderCallOverrides | None
292
+ ) -> str:
293
+ """v2.15.0: resolve ``stream_truncation_action`` for this call.
294
+
295
+ There is no per-provider counterpart — the knob lives on the profile
296
+ only — so this is just a null-safe read. ``None`` overrides (legacy
297
+ call sites, unit tests constructing adapters directly) resolve to
298
+ ``"off"``, which is the pre-v2.15.0 behavior.
299
+ """
300
+ if overrides is None:
301
+ return "off"
302
+ return overrides.stream_truncation_action
303
+
239
304
  @abstractmethod
240
305
  async def healthcheck(self) -> bool:
241
306
  """Lightweight check that the upstream is reachable. Return True if healthy."""
@@ -28,12 +28,14 @@ from coderouter.adapters.base import (
28
28
  ChatResponse,
29
29
  ProviderCallOverrides,
30
30
  StreamChunk,
31
+ StreamTruncatedError,
31
32
  )
32
- from coderouter.config.loader import resolve_api_key
33
+ from coderouter.credentials import resolve_provider_credential
33
34
  from coderouter.logging import (
34
35
  get_logger,
35
36
  log_capability_degraded,
36
37
  log_output_filter_applied,
38
+ log_stream_truncation_detected,
37
39
  )
38
40
  from coderouter.output_filters import OutputFilterChain
39
41
 
@@ -105,7 +107,10 @@ class OpenAICompatAdapter(BaseAdapter):
105
107
  "Content-Type": "application/json",
106
108
  "User-Agent": "CodeRouter/0.1",
107
109
  }
108
- api_key = resolve_api_key(self.config.api_key_env)
110
+ # v2.14.0: resolve_provider_credential covers api_key_env (the
111
+ # historical path, unchanged) and credential.source=cli_session,
112
+ # which borrows the token a vendor CLI already wrote to disk.
113
+ api_key = resolve_provider_credential(self.config)
109
114
  if api_key:
110
115
  headers["Authorization"] = f"Bearer {api_key}"
111
116
  return headers
@@ -344,6 +349,20 @@ class OpenAICompatAdapter(BaseAdapter):
344
349
  # Captured for the closing flush chunk (if any): reuse the last
345
350
  # seen chunk's id/model so the flush emission looks native.
346
351
  last_chunk_template: dict[str, Any] | None = None
352
+
353
+ # v2.15.0 (stream-truncation): the loop below used to treat "saw
354
+ # [DONE]" and "the line iterator ran out" as the same event. These
355
+ # three flags are the memory that tells them apart.
356
+ #
357
+ # A terminator is EITHER ``data: [DONE]`` OR a ``finish_reason`` on
358
+ # any choice. Accepting ``finish_reason`` matters: a provider that
359
+ # emits a complete message and then closes without the ``[DONE]``
360
+ # sentinel is not truncated, and flagging it would be a false
361
+ # positive. See ``stream_truncation_action`` in ``config/schemas.py``.
362
+ truncation_action = self.effective_stream_truncation_action(overrides)
363
+ saw_terminator = False
364
+ chunks_seen = 0
365
+ tool_call_in_flight = False
347
366
  try:
348
367
  # H3: stream over the shared client so the connection pool /
349
368
  # keep-alive / TLS session are reused. Only the ``stream(...)``
@@ -373,11 +392,31 @@ class OpenAICompatAdapter(BaseAdapter):
373
392
  continue
374
393
  data_str = line[len("data:") :].strip()
375
394
  if data_str == "[DONE]":
395
+ # v2.15.0: the sentinel — this stream ended on purpose.
396
+ saw_terminator = True
376
397
  break
377
398
  try:
378
399
  payload_obj = json.loads(data_str)
379
400
  except json.JSONDecodeError:
380
401
  continue # skip malformed chunks rather than abort
402
+ # v2.15.0 (stream-truncation): fold this chunk into the
403
+ # terminator state before any filtering / validation, so a
404
+ # chunk that is later dropped as malformed still counts as
405
+ # "the upstream got this far".
406
+ chunks_seen += 1
407
+ for _choice in payload_obj.get("choices") or []:
408
+ if not isinstance(_choice, dict):
409
+ continue
410
+ if _choice.get("finish_reason"):
411
+ # A finish_reason means the message is complete
412
+ # even if the [DONE] sentinel never arrives, and
413
+ # it also closes any tool call that was open.
414
+ saw_terminator = True
415
+ tool_call_in_flight = False
416
+ elif isinstance(_choice.get("delta"), dict) and _choice[
417
+ "delta"
418
+ ].get("tool_calls"):
419
+ tool_call_in_flight = True
381
420
  if strip_reasoning:
382
421
  stripped = _strip_reasoning_field(
383
422
  payload_obj.get("choices"), delta_key=True
@@ -443,6 +482,30 @@ class OpenAICompatAdapter(BaseAdapter):
443
482
  streaming=True,
444
483
  )
445
484
  output_filter_logged = True
485
+
486
+ # v2.15.0 (stream-truncation): decided AFTER the filter flush on
487
+ # purpose — the flush is the last chance to hand the caller every
488
+ # safe byte the filter chain was still holding, and under
489
+ # ``partial_stitch_action: surface`` those bytes end up in the
490
+ # partial content the client receives. Under ``off`` this block is
491
+ # inert, keeping the default byte-for-byte identical to v2.14.0.
492
+ if truncation_action != "off" and not saw_terminator:
493
+ log_stream_truncation_detected(
494
+ logger,
495
+ self.name,
496
+ action=truncation_action,
497
+ wire="openai",
498
+ events_forwarded=chunks_seen,
499
+ saw_stream_start=chunks_seen > 0,
500
+ tool_call_in_flight=tool_call_in_flight,
501
+ )
502
+ if truncation_action == "error":
503
+ raise StreamTruncatedError(
504
+ "upstream stream truncated "
505
+ "(no [DONE] sentinel and no finish_reason)",
506
+ provider=self.name,
507
+ tool_call_in_flight=tool_call_in_flight,
508
+ )
446
509
  except httpx.TimeoutException as exc:
447
510
  raise AdapterError(
448
511
  f"timeout streaming from {url}", provider=self.name, retryable=True