coding-proxy 0.3.1a3__tar.gz → 0.3.1a5__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 (179) hide show
  1. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/CHANGELOG.md +1 -0
  2. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/PKG-INFO +1 -1
  3. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/issue.md +38 -0
  4. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/pyproject.toml +1 -1
  5. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/anthropic_to_openai.py +23 -13
  6. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/vendor_channels.py +46 -0
  7. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/executor.py +25 -0
  8. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/usage_parser.py +6 -0
  9. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_copilot_convert_request.py +129 -0
  10. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_parse_usage.py +28 -0
  11. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_parse_usage_gemini.py +16 -0
  12. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_router_executor.py +180 -0
  13. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_vendor_channels.py +159 -0
  14. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/uv.lock +1 -1
  15. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/.github/workflows/ci.yml +0 -0
  16. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/.github/workflows/coverage.yml +0 -0
  17. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/.github/workflows/release.yml +0 -0
  18. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/.gitignore +0 -0
  19. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/.pre-commit-config.yaml +0 -0
  20. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/AGENTS.md +0 -0
  21. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/CLAUDE.md +0 -0
  22. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/LICENSE +0 -0
  23. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/README.md +0 -0
  24. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/assets/dashboard-v0.2.4.png +0 -0
  25. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/config-reference.md +0 -0
  26. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/convert.md +0 -0
  27. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/design-patterns.md +0 -0
  28. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/routing.md +0 -0
  29. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/testing.md +0 -0
  30. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/arch/vendors.md +0 -0
  31. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/ci-cd.md +0 -0
  32. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/framework.md +0 -0
  33. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/api-reference.md +0 -0
  34. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/cli-reference.md +0 -0
  35. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/dashboard.md +0 -0
  36. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/monitoring.md +0 -0
  37. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/quickstart.md +0 -0
  38. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/guide/vendors.md +0 -0
  39. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/user-guide.md +0 -0
  40. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/docs/zh-CN/README.md +0 -0
  41. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/__init__.py +0 -0
  42. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/__init__.py +0 -0
  43. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/__main__.py +0 -0
  44. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/__init__.py +0 -0
  45. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/providers/__init__.py +0 -0
  46. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/providers/base.py +0 -0
  47. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/providers/github.py +0 -0
  48. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/providers/google.py +0 -0
  49. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/runtime.py +0 -0
  50. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/auth/store.py +0 -0
  51. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/cli/__init__.py +0 -0
  52. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/cli/auth_commands.py +0 -0
  53. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/cli/banner.py +0 -0
  54. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/compat/__init__.py +0 -0
  55. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/compat/canonical.py +0 -0
  56. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/compat/session_store.py +0 -0
  57. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/__init__.py +0 -0
  58. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/auth_schema.py +0 -0
  59. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/config.default.yaml +0 -0
  60. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/loader.py +0 -0
  61. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/resiliency.py +0 -0
  62. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/routing.py +0 -0
  63. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/schema.py +0 -0
  64. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/server.py +0 -0
  65. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/config/vendors.py +0 -0
  66. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/__init__.py +0 -0
  67. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  68. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  69. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  70. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  71. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/logging/__init__.py +0 -0
  72. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/logging/db.py +0 -0
  73. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/logging/formatters.py +0 -0
  74. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/logging/stats.py +0 -0
  75. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/__init__.py +0 -0
  76. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/auth.py +0 -0
  77. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/compat.py +0 -0
  78. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/constants.py +0 -0
  79. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/pricing.py +0 -0
  80. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/token.py +0 -0
  81. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/model/vendor.py +0 -0
  82. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/__init__.py +0 -0
  83. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/config.py +0 -0
  84. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  85. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  86. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  87. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  88. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/handler.py +0 -0
  89. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/operation.py +0 -0
  90. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/routes.py +0 -0
  91. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/native_api/usage_registry.py +0 -0
  92. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/pricing.py +0 -0
  93. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/__init__.py +0 -0
  94. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  95. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/error_classifier.py +0 -0
  96. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/model_mapper.py +0 -0
  97. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/quota_guard.py +0 -0
  98. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/rate_limit.py +0 -0
  99. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/retry.py +0 -0
  100. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/router.py +0 -0
  101. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/session_manager.py +0 -0
  102. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/tier.py +0 -0
  103. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/routing/usage_recorder.py +0 -0
  104. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/__init__.py +0 -0
  105. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/app.py +0 -0
  106. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/dashboard.py +0 -0
  107. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/factory.py +0 -0
  108. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/responses.py +0 -0
  109. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/server/routes.py +0 -0
  110. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/streaming/__init__.py +0 -0
  111. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  112. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/__init__.py +0 -0
  113. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/alibaba.py +0 -0
  114. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/anthropic.py +0 -0
  115. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/antigravity.py +0 -0
  116. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/base.py +0 -0
  117. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/copilot.py +0 -0
  118. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/copilot_models.py +0 -0
  119. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  120. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  121. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/doubao.py +0 -0
  122. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/kimi.py +0 -0
  123. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/minimax.py +0 -0
  124. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/mixins.py +0 -0
  125. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  126. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/token_manager.py +0 -0
  127. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/xiaomi.py +0 -0
  128. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/src/coding/proxy/vendors/zhipu.py +0 -0
  129. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/__init__.py +0 -0
  130. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_antigravity.py +0 -0
  131. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_app_routes.py +0 -0
  132. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_auto_login.py +0 -0
  133. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_banner.py +0 -0
  134. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_circuit_breaker.py +0 -0
  135. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_cli_usage.py +0 -0
  136. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_compat.py +0 -0
  137. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_config_init.py +0 -0
  138. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_config_loader.py +0 -0
  139. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_convert_request.py +0 -0
  140. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_convert_response.py +0 -0
  141. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_convert_sse.py +0 -0
  142. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_copilot.py +0 -0
  143. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_copilot_convert_response.py +0 -0
  144. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_copilot_models.py +0 -0
  145. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_copilot_urls.py +0 -0
  146. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_currency.py +0 -0
  147. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_error_classifier.py +0 -0
  148. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_logging_dual_write.py +0 -0
  149. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_mixins.py +0 -0
  150. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_auth.py +0 -0
  151. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_compat.py +0 -0
  152. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_constants.py +0 -0
  153. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_mapper.py +0 -0
  154. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_pricing.py +0 -0
  155. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_token.py +0 -0
  156. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_model_vendor.py +0 -0
  157. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_api_base_url_override.py +0 -0
  158. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_api_extractors.py +0 -0
  159. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_api_handler.py +0 -0
  160. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_api_operation.py +0 -0
  161. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_api_routes.py +0 -0
  162. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_native_vendors.py +0 -0
  163. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_pricing.py +0 -0
  164. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_quota_guard.py +0 -0
  165. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_rate_limit.py +0 -0
  166. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_router_chain.py +0 -0
  167. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_runtime_reauth.py +0 -0
  168. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_schema.py +0 -0
  169. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_streaming_anthropic_compat.py +0 -0
  170. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_tier.py +0 -0
  171. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_tiers_config.py +0 -0
  172. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_time_range.py +0 -0
  173. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_token_logger.py +0 -0
  174. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_token_logger_native_columns.py +0 -0
  175. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_token_manager.py +0 -0
  176. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_types.py +0 -0
  177. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_vendor_streaming.py +0 -0
  178. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_vendors.py +0 -0
  179. {coding_proxy-0.3.1a3 → coding_proxy-0.3.1a5}/tests/test_zhipu.py +0 -0
@@ -4,6 +4,7 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ - fix(vendor-channels): 为所有 targeting zhipu 的转换通道(zhipu→zhipu、copilot→zhipu、anthropic→zhipu)新增 `tool_result.id` 字段注入,修复 zhipu GLM-5 后端错误访问 `.id` 属性(`'ClaudeContentBlockToolResult' object has no attribute 'id'`)导致的 500 错误,使 zhipu 可完全承接含 tool_result 的会话;
7
8
  - fix(vendor-channels): 新增 zhipu 同 vendor 自清理通道,修复 GLM-5 自循环 400 + tool_results 偶发降级;
8
9
  - fix(vendor-channels): 修复 `_rewrite_srvtoolu_ids` 块顺序敏感性导致 inline tool_result 漏改名,进而 enforce 阶段 dict key 与 tool_use_ids 错位、anthropic 报 `tool_use ids without tool_result blocks immediately after` 的 cascade failover 问题(改为两遍扫描:先收集 id_map,再统一改写所有 tool_result.tool_use_id 引用);
9
10
  - fix(vendor-channels): `enforce_anthropic_tool_pairing` 增加全局 sanity check pass,主循环边角错位让 dangling tool_use 漏过校验时兜底合成 is_error 占位并打 `pairing_sanity_repaired` 标签,避免 anthropic 二次报错;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.3.1a3
3
+ Version: 0.3.1a5
4
4
  Summary: A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao...
5
5
  Project-URL: Source Code, https://github.com/ThreeFish-AI/coding-proxy
6
6
  Project-URL: User Guide, https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md
@@ -141,3 +141,41 @@ adaptations 列表显示 `misplaced_tool_result_relocated` 但**没有** `orphan
141
141
 
142
142
  - 任何对 messages 进行 ID 重写的转换链 (如 `_rewrite_srvtoolu_ids`、`anthropic_to_openai`、`anthropic_to_gemini`) 都应使用两遍扫描或一次性收集后再批量改写, 以保证 block 顺序无关性。
143
143
  - enforce 类校验函数若依赖 dict key 与 list 元素的**等同性**, 必须先确保两者在同一参考系下 (改名前 vs 改名后); 否则错位会以 "看起来 OK 实际有漏" 的方式静默泄漏到下游。
144
+
145
+ ---
146
+
147
+ ## zhipu 500 `'ClaudeContentBlockToolResult' object has no attribute 'id'`
148
+
149
+ **问题描述**
150
+
151
+ zhipu GLM-5 在处理含 `tool_result` 块的会话时持续返回 500 错误,每次请求都触发故障转移至 copilot,zhipu 完全无法承接含工具调用的多轮对话:
152
+
153
+ ```
154
+ WARNING zhipu stream error: status=500 body='...message":"\'ClaudeContentBlockToolResult\' object has no attribute \'id\'"}'
155
+ WARNING Tier zhipu zhipu tool_result format error (500), treating as format incompatibility without circuit breaker penalty
156
+ INFO Failover: zhipu → copilot (reason: HTTP 500)
157
+ ```
158
+
159
+ **表因**
160
+
161
+ zhipu 后端在解析 `tool_result` 内容块时错误地访问 `.id` 属性。但 Anthropic API 规范中 `tool_result` 块只有 `tool_use_id` 字段(用于关联对应的 `tool_use`),没有 `id` 字段(`id` 是 `tool_use` 块的属性)。
162
+
163
+ **根因**
164
+
165
+ 所有 targeting zhipu 的转换通道(`prepare_zhipu_self_cleanup`、`prepare_copilot_to_zhipu`、`prepare_anthropic_to_zhipu`)在完成 `enforce_anthropic_tool_pairing` 后,没有为 `tool_result` 块补上 zhipu 后端期望的 `id` 字段。搬迁或合成的 `tool_result` 块仅有 `tool_use_id`,缺少 `id`。
166
+
167
+ **处理方式**
168
+
169
+ - 在 `vendor_channels.py` 新增 `_inject_tool_result_id_for_zhipu` 辅助函数:扫描所有消息中的 `tool_result` 块,将 `tool_use_id` 值复制为 `id` 字段(仅注入尚无 `id` 的块,保持幂等)
170
+ - 在三个 targeting zhipu 的转换通道末尾统一调用此辅助函数
171
+ - 保留 executor 中已有的 500 错误检测作为纵深防御
172
+
173
+ **后续防范**
174
+
175
+ - 其他 `NativeAnthropicVendor` 子类若出现类似的「后端期望非标准字段」问题,可参考此模式在对应的转换通道中注入兼容字段。
176
+ - 当 zhipu 后端修复此 bug(不再访问 `.id`)后,此 workaround 仍安全保留(多一个 `id` 字段不影响 Anthropic API 语义)。
177
+
178
+ **同类问题影响与处理注意事项**
179
+
180
+ - `enforce_anthropic_tool_pairing` 合成的 `is_error=True` 占位块只有 `tool_use_id`,同样需要 `id` 注入——辅助函数在配对后统一处理,无需在合成逻辑中单独添加。
181
+ - `tool_result.id` 的值设为与 `tool_use_id` 相同,语义上可视为「内容块标识符」,对 zhipu 后端足够区分不同 tool_result 块。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.3.1a3"
3
+ version = "0.3.1a5"
4
4
  description = "A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao..."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -284,24 +284,34 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any]
284
284
  final_text_parts = text_parts
285
285
 
286
286
  if tool_uses:
287
+ tool_calls: list[dict[str, Any]] = []
288
+ for block in tool_uses:
289
+ raw_input = block.get("input")
290
+ if not isinstance(raw_input, dict):
291
+ logger.debug(
292
+ "copilot: tool_use id=%s name=%s has non-dict input (type=%s), "
293
+ "defaulting to empty dict",
294
+ block.get("id", ""),
295
+ block.get("name", ""),
296
+ type(raw_input).__name__,
297
+ )
298
+ raw_input = {}
299
+ tool_calls.append(
300
+ {
301
+ "id": block.get("id", ""),
302
+ "type": "function",
303
+ "function": {
304
+ "name": block.get("name", ""),
305
+ "arguments": json.dumps(raw_input, ensure_ascii=False),
306
+ },
307
+ }
308
+ )
287
309
  return [
288
310
  {
289
311
  "role": "assistant",
290
312
  "content": "\n\n".join(part for part in final_text_parts if part)
291
313
  or None,
292
- "tool_calls": [
293
- {
294
- "id": block.get("id", ""),
295
- "type": "function",
296
- "function": {
297
- "name": block.get("name", ""),
298
- "arguments": json.dumps(
299
- block.get("input", {}), ensure_ascii=False
300
- ),
301
- },
302
- }
303
- for block in tool_uses
304
- ],
314
+ "tool_calls": tool_calls,
305
315
  }
306
316
  ]
307
317
 
@@ -322,6 +322,36 @@ def _enforce_pairing_sanity_pass(messages_list: list[Any]) -> list[str]:
322
322
  return sanity_synthesized
323
323
 
324
324
 
325
+ def _inject_tool_result_id_for_zhipu(body: dict[str, Any]) -> int:
326
+ """为 tool_result 块注入 ``id`` 字段以兼容 zhipu GLM-5 后端.
327
+
328
+ zhipu 的 Anthropic 兼容端点在解析 ``tool_result`` 块时会访问 ``.id`` 属性,
329
+ 但 Anthropic API 规范中 ``tool_result`` 只有 ``tool_use_id`` 字段而没有 ``id``。
330
+ 此函数在所有 ``tool_result`` 块上补设 ``id``(值等于 ``tool_use_id``),
331
+ 避免触发 ``'ClaudeContentBlockToolResult' object has no attribute 'id'`` 500 错误。
332
+
333
+ Returns:
334
+ 被注入 ``id`` 字段的 tool_result 块数量。
335
+ """
336
+ injected = 0
337
+ for message in body.get("messages", []):
338
+ if not isinstance(message, dict):
339
+ continue
340
+ content = message.get("content")
341
+ if not isinstance(content, list):
342
+ continue
343
+ for block in content:
344
+ if (
345
+ isinstance(block, dict)
346
+ and block.get("type") == "tool_result"
347
+ and "id" not in block
348
+ and block.get("tool_use_id")
349
+ ):
350
+ block["id"] = block["tool_use_id"]
351
+ injected += 1
352
+ return injected
353
+
354
+
325
355
  def _strip_cache_control(body: dict[str, Any]) -> int:
326
356
  """从 system/messages/tools 中移除 cache_control 字段(就地).
327
357
 
@@ -577,6 +607,11 @@ def prepare_copilot_to_zhipu(
577
607
  if pairing_fixes:
578
608
  adaptations.extend(pairing_fixes)
579
609
 
610
+ # Step 5: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround)
611
+ injected = _inject_tool_result_id_for_zhipu(prepared)
612
+ if injected:
613
+ adaptations.append(f"injected_{injected}_tool_result_id_fields")
614
+
580
615
  return prepared, adaptations
581
616
 
582
617
 
@@ -632,6 +667,11 @@ def prepare_anthropic_to_zhipu(
632
667
  if pairing_fixes:
633
668
  adaptations.extend(pairing_fixes)
634
669
 
670
+ # Step 6: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround)
671
+ injected = _inject_tool_result_id_for_zhipu(prepared)
672
+ if injected:
673
+ adaptations.append(f"injected_{injected}_tool_result_id_fields")
674
+
635
675
  return prepared, adaptations
636
676
 
637
677
 
@@ -760,6 +800,7 @@ def prepare_zhipu_self_cleanup(
760
800
  1. 剥离 ``server_tool_use_delta`` 流式残块
761
801
  2. 强制 tool_use/tool_result 配对(关键: 把 assistant 内联的 tool_result
762
802
  搬迁到紧随的 user 消息)
803
+ 3. 为 ``tool_result`` 块注入 ``id`` 字段(zhipu 后端错误访问 ``.id`` 属性)
763
804
 
764
805
  Returns:
765
806
  (prepared_body, adaptations) — adaptations 为应用的变换描述列表。
@@ -777,6 +818,11 @@ def prepare_zhipu_self_cleanup(
777
818
  if pairing_fixes:
778
819
  adaptations.extend(pairing_fixes)
779
820
 
821
+ # Step 3: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround)
822
+ injected = _inject_tool_result_id_for_zhipu(prepared)
823
+ if injected:
824
+ adaptations.append(f"injected_{injected}_tool_result_id_fields")
825
+
780
826
  return prepared, adaptations
781
827
 
782
828
 
@@ -135,6 +135,10 @@ def _is_likely_request_format_error(
135
135
  # 非结构化响应体(非 JSON)
136
136
  if not trimmed.startswith("{") and len(trimmed) < 200:
137
137
  return True
138
+ # 结构化 JSON 400 但含 tool_call 格式错误码 → 格式不兼容
139
+ # (如 Copilot 返回 {"error":{"code":"invalid_tool_call_format",...}})
140
+ if "invalid_tool_call_format" in trimmed:
141
+ return True
138
142
  return False
139
143
 
140
144
 
@@ -795,6 +799,27 @@ class _RouteExecutor:
795
799
  tier.name,
796
800
  )
797
801
 
802
+ # 补充检测:zhipu 500 — tool_result 块触发上游 AttributeError
803
+ # zhipu 后端在 tool_result 块上错误访问 .id 属性(应为 .tool_use_id),
804
+ # 此为已知的上游格式缺陷,应视为 format incompatibility 而非真实服务器故障。
805
+ if (
806
+ not semantic_rejection
807
+ and exc.response.status_code == 500
808
+ and request_body is not None
809
+ and _has_tool_results(request_body)
810
+ ):
811
+ err_text = (exc.response.text or "")[:500]
812
+ if (
813
+ "'ClaudeContentBlockToolResult'" in err_text
814
+ and "has no attribute 'id'" in err_text
815
+ ):
816
+ semantic_rejection = True
817
+ logger.warning(
818
+ "Tier %s zhipu tool_result format error (500), "
819
+ "treating as format incompatibility without circuit breaker penalty",
820
+ tier.name,
821
+ )
822
+
798
823
  if semantic_rejection and not is_last:
799
824
  return True, tier.name, exc
800
825
 
@@ -210,6 +210,9 @@ def parse_usage_from_chunk(
210
210
  request_id=data.get("id"),
211
211
  model_served=data.get("model"),
212
212
  )
213
+ model_name = data.get("model")
214
+ if model_name:
215
+ usage["model_served"] = model_name
213
216
 
214
217
  # Gemini SSE 格式: data.usageMetadata.{promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount, toolUsePromptTokenCount}
215
218
  # Gemini 的流式响应在最后一帧(或每一帧)携带 usageMetadata;字段命名与
@@ -243,6 +246,9 @@ def parse_usage_from_chunk(
243
246
  request_id=data.get("responseId") or data.get("id"),
244
247
  model_served=data.get("modelVersion") or data.get("model"),
245
248
  )
249
+ model_name = data.get("modelVersion") or data.get("model")
250
+ if model_name:
251
+ usage["model_served"] = model_name
246
252
 
247
253
  # request_id fallback (OpenAI 格式下 id 在顶层, Gemini 顶层为 responseId)
248
254
  if not usage.get("request_id"):
@@ -472,3 +472,132 @@ def test_image_block_converted_to_image_url():
472
472
  image_part = [p for p in user_msg["content"] if p.get("type") == "image_url"]
473
473
  assert len(image_part) == 1
474
474
  assert "data:image/png;base64,abc123" in image_part[0]["image_url"]["url"]
475
+
476
+
477
+ # === Defensive tool_use.input serialization ===
478
+
479
+
480
+ def test_tool_use_input_none_defaults_to_empty_dict():
481
+ """input=None 应被降级为 {} 而非序列化为 'null'."""
482
+ body = {
483
+ "model": "claude-sonnet-4-20250514",
484
+ "messages": [
485
+ {
486
+ "role": "assistant",
487
+ "content": [
488
+ {
489
+ "type": "tool_use",
490
+ "id": "toolu_001",
491
+ "name": "read_file",
492
+ "input": None,
493
+ }
494
+ ],
495
+ }
496
+ ],
497
+ }
498
+ result = convert_request(body)
499
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
500
+ assert len(assistant_msgs) == 1
501
+ assert "tool_calls" in assistant_msgs[0]
502
+ tc = assistant_msgs[0]["tool_calls"][0]
503
+ assert tc["function"]["arguments"] == "{}"
504
+
505
+
506
+ def test_tool_use_input_string_defaults_to_empty_dict():
507
+ """input='some string' 应被降级为 {} 而非序列化为 '"some string"'."""
508
+ body = {
509
+ "model": "claude-sonnet-4-20250514",
510
+ "messages": [
511
+ {
512
+ "role": "assistant",
513
+ "content": [
514
+ {
515
+ "type": "tool_use",
516
+ "id": "toolu_002",
517
+ "name": "run_cmd",
518
+ "input": "not a dict",
519
+ }
520
+ ],
521
+ }
522
+ ],
523
+ }
524
+ result = convert_request(body)
525
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
526
+ tc = assistant_msgs[0]["tool_calls"][0]
527
+ assert tc["function"]["arguments"] == "{}"
528
+
529
+
530
+ def test_tool_use_input_missing_defaults_to_empty_dict():
531
+ """input key 不存在时,block.get('input') 返回 None,应降级为 {}."""
532
+ body = {
533
+ "model": "claude-sonnet-4-20250514",
534
+ "messages": [
535
+ {
536
+ "role": "assistant",
537
+ "content": [
538
+ {
539
+ "type": "tool_use",
540
+ "id": "toolu_003",
541
+ "name": "search",
542
+ }
543
+ ],
544
+ }
545
+ ],
546
+ }
547
+ result = convert_request(body)
548
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
549
+ tc = assistant_msgs[0]["tool_calls"][0]
550
+ assert tc["function"]["arguments"] == "{}"
551
+
552
+
553
+ def test_tool_use_input_int_defaults_to_empty_dict():
554
+ """input=42 应被降级为 {} 而非序列化为 '42'."""
555
+ body = {
556
+ "model": "claude-sonnet-4-20250514",
557
+ "messages": [
558
+ {
559
+ "role": "assistant",
560
+ "content": [
561
+ {
562
+ "type": "tool_use",
563
+ "id": "toolu_004",
564
+ "name": "calc",
565
+ "input": 42,
566
+ }
567
+ ],
568
+ }
569
+ ],
570
+ }
571
+ result = convert_request(body)
572
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
573
+ tc = assistant_msgs[0]["tool_calls"][0]
574
+ assert tc["function"]["arguments"] == "{}"
575
+
576
+
577
+ def test_tool_use_valid_dict_input_preserved():
578
+ """正常 dict input 应保持原样."""
579
+ body = {
580
+ "model": "claude-sonnet-4-20250514",
581
+ "messages": [
582
+ {
583
+ "role": "assistant",
584
+ "content": [
585
+ {
586
+ "type": "tool_use",
587
+ "id": "toolu_005",
588
+ "name": "read_file",
589
+ "input": {"path": "/tmp/test.txt", "offset": 10},
590
+ }
591
+ ],
592
+ }
593
+ ],
594
+ }
595
+ result = convert_request(body)
596
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
597
+ tc = assistant_msgs[0]["tool_calls"][0]
598
+ import json
599
+
600
+ assert json.loads(tc["function"]["arguments"]) == {
601
+ "path": "/tmp/test.txt",
602
+ "offset": 10,
603
+ }
@@ -117,6 +117,34 @@ def test_openai_zhipu_final_chunk():
117
117
  assert usage["input_tokens"] == 200
118
118
  assert usage["output_tokens"] == 80
119
119
  assert usage["request_id"] == "chatcmpl-1"
120
+ assert usage["model_served"] == "glm-5.1"
121
+
122
+
123
+ def test_openai_final_chunk_with_model():
124
+ """OpenAI 最终 chunk 有 model 字段时应提取到 model_served."""
125
+ usage: dict = {}
126
+ parse_usage_from_chunk(
127
+ _sse(
128
+ '{"id":"chatcmpl-2","model":"gpt-4o-2024-08-06",'
129
+ '"usage":{"prompt_tokens":50,"completion_tokens":20}}'
130
+ ),
131
+ usage,
132
+ )
133
+ assert usage["input_tokens"] == 50
134
+ assert usage["output_tokens"] == 20
135
+ assert usage["model_served"] == "gpt-4o-2024-08-06"
136
+
137
+
138
+ def test_openai_final_chunk_without_model():
139
+ """OpenAI 最终 chunk 无 model 字段时不应设置 model_served."""
140
+ usage: dict = {}
141
+ parse_usage_from_chunk(
142
+ _sse('{"id":"chatcmpl-3","usage":{"prompt_tokens":30,"completion_tokens":10}}'),
143
+ usage,
144
+ )
145
+ assert usage["input_tokens"] == 30
146
+ assert usage["output_tokens"] == 10
147
+ assert "model_served" not in usage
120
148
 
121
149
 
122
150
  def test_openai_final_chunk_with_cache_tokens():
@@ -35,6 +35,7 @@ def test_gemini_usage_metadata_basic_fields():
35
35
  assert usage["output_tokens"] == 42
36
36
  assert usage.get("cache_read_tokens", 0) == 0
37
37
  assert usage["request_id"] == "resp_abc"
38
+ assert usage["model_served"] == "gemini-2.0-flash"
38
39
 
39
40
 
40
41
  def test_gemini_usage_metadata_with_cached_content():
@@ -194,3 +195,18 @@ def test_gemini_partial_fields_ok():
194
195
  )
195
196
  assert usage["input_tokens"] == 77
196
197
  assert "output_tokens" not in usage
198
+
199
+
200
+ def test_gemini_model_fallback_to_data_model():
201
+ """当 modelVersion 不存在时,应回退到 data.model."""
202
+ usage: dict = {}
203
+ parse_usage_from_chunk(
204
+ _sse(
205
+ '{"usageMetadata":{"promptTokenCount":80,"candidatesTokenCount":20},'
206
+ '"model":"gemini-1.5-flash"}'
207
+ ),
208
+ usage,
209
+ )
210
+ assert usage["input_tokens"] == 80
211
+ assert usage["output_tokens"] == 20
212
+ assert usage["model_served"] == "gemini-1.5-flash"
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from unittest.mock import AsyncMock, MagicMock
13
13
 
14
+ import httpx
14
15
  import pytest
15
16
 
16
17
  from coding.proxy.compat.canonical import (
@@ -1229,6 +1230,31 @@ class TestIsLikelyRequestFormatError:
1229
1230
  is False
1230
1231
  )
1231
1232
 
1233
+ def test_returns_true_for_invalid_tool_call_format(self):
1234
+ """400 + 结构化 JSON 含 invalid_tool_call_format + tool_result → 格式不兼容."""
1235
+ json_body = '{"error":{"message":"Invalid JSON format in tool call arguments","code":"invalid_tool_call_format"}}'
1236
+ assert (
1237
+ _is_likely_request_format_error(
1238
+ status_code=400,
1239
+ error_body_text=json_body,
1240
+ body=self._body_with_tool_results(),
1241
+ )
1242
+ is True
1243
+ )
1244
+
1245
+ def test_returns_false_for_invalid_tool_call_format_without_tool_results(self):
1246
+ """invalid_tool_call_format 但无 tool_result → 不应匹配."""
1247
+ json_body = '{"error":{"message":"Invalid JSON format in tool call arguments","code":"invalid_tool_call_format"}}'
1248
+ body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]}
1249
+ assert (
1250
+ _is_likely_request_format_error(
1251
+ status_code=400,
1252
+ error_body_text=json_body,
1253
+ body=body,
1254
+ )
1255
+ is False
1256
+ )
1257
+
1232
1258
 
1233
1259
  # ── TokenAcquireError 永久性凭证错误测试 ────────────────────
1234
1260
 
@@ -2101,3 +2127,157 @@ class TestPrepareBodyForTierSelfTransition:
2101
2127
  b for b in result["messages"][0]["content"] if b.get("type") == "thinking"
2102
2128
  )
2103
2129
  assert thinking_block["signature"] == "zhipu_sig"
2130
+
2131
+
2132
+ # ── zhipu 500 tool_result 格式错误检测测试 ──────────────────────
2133
+
2134
+
2135
+ class TestZhipu500ToolResultFormatError:
2136
+ """验证 _handle_http_error 对 zhipu 500 'ClaudeContentBlockToolResult' 错误的处理.
2137
+
2138
+ zhipu 后端在 tool_result 块上错误访问 .id 属性(应为 .tool_use_id),
2139
+ 此为已知的上游格式缺陷,应视为 format incompatibility(semantic rejection)
2140
+ 而非真实服务器故障,不应计入熔断器。
2141
+ """
2142
+
2143
+ @pytest.mark.asyncio
2144
+ async def test_zhipu_500_tool_result_error_triggers_semantic_rejection(self):
2145
+ """zhipu 500 + 'ClaudeContentBlockToolResult' + tool_result → semantic rejection."""
2146
+ from coding.proxy.routing.circuit_breaker import CircuitBreaker
2147
+
2148
+ vendor = _mock_vendor("zhipu")
2149
+ error_body = (
2150
+ b'{"error":{"code":"500","message":"\'ClaudeContentBlockToolResult\' '
2151
+ b"object has no attribute 'id'\"}}"
2152
+ )
2153
+ response = httpx.Response(
2154
+ status_code=500,
2155
+ content=error_body,
2156
+ request=httpx.Request("POST", "https://example.com"),
2157
+ )
2158
+ exc = httpx.HTTPStatusError(
2159
+ "zhipu API error: 500", request=response.request, response=response
2160
+ )
2161
+
2162
+ cb = CircuitBreaker(failure_threshold=3)
2163
+ tier = _make_tier(vendor, circuit_breaker=cb)
2164
+ exec_inst = _executor([tier, _make_tier(_mock_vendor("copilot"))])
2165
+
2166
+ body = {
2167
+ "model": "claude-opus-4-6",
2168
+ "messages": [
2169
+ {
2170
+ "role": "user",
2171
+ "content": [
2172
+ {
2173
+ "type": "tool_result",
2174
+ "tool_use_id": "tu_1",
2175
+ "content": "result",
2176
+ }
2177
+ ],
2178
+ },
2179
+ ],
2180
+ }
2181
+
2182
+ should_continue, failed_name, _ = await exec_inst._handle_http_error(
2183
+ tier,
2184
+ exc,
2185
+ is_last=False,
2186
+ failed_tier_name=None,
2187
+ last_exc=None,
2188
+ is_stream=True,
2189
+ request_body=body,
2190
+ )
2191
+
2192
+ assert should_continue is True
2193
+ assert failed_name == "zhipu"
2194
+ # 不应计入熔断器
2195
+ assert cb.get_info()["failure_count"] == 0
2196
+
2197
+ @pytest.mark.asyncio
2198
+ async def test_zhipu_500_generic_error_records_failure(self):
2199
+ """zhipu 500 但非 tool_result 格式错误 → 正常记录熔断器."""
2200
+ from coding.proxy.routing.circuit_breaker import CircuitBreaker
2201
+
2202
+ vendor = _mock_vendor("zhipu")
2203
+ error_body = b'{"error":{"code":"500","message":"Internal Server Error"}}'
2204
+ response = httpx.Response(
2205
+ status_code=500,
2206
+ content=error_body,
2207
+ request=httpx.Request("POST", "https://example.com"),
2208
+ )
2209
+ exc = httpx.HTTPStatusError(
2210
+ "zhipu API error: 500", request=response.request, response=response
2211
+ )
2212
+
2213
+ cb = CircuitBreaker(failure_threshold=3)
2214
+ tier = _make_tier(vendor, circuit_breaker=cb)
2215
+ exec_inst = _executor([tier])
2216
+
2217
+ body = {
2218
+ "model": "claude-opus-4-6",
2219
+ "messages": [
2220
+ {
2221
+ "role": "user",
2222
+ "content": [
2223
+ {
2224
+ "type": "tool_result",
2225
+ "tool_use_id": "tu_1",
2226
+ "content": "result",
2227
+ }
2228
+ ],
2229
+ },
2230
+ ],
2231
+ }
2232
+
2233
+ should_continue, _, _ = await exec_inst._handle_http_error(
2234
+ tier,
2235
+ exc,
2236
+ is_last=True,
2237
+ failed_tier_name=None,
2238
+ last_exc=None,
2239
+ is_stream=True,
2240
+ request_body=body,
2241
+ )
2242
+
2243
+ # 非 last tier 时 should_continue=False,且应记录熔断器失败
2244
+ assert should_continue is False
2245
+ assert cb.get_info()["failure_count"] == 1
2246
+
2247
+ @pytest.mark.asyncio
2248
+ async def test_zhipu_500_tool_result_error_without_tool_results_body(self):
2249
+ """zhipu 500 tool_result 错误但请求体无 tool_result → 不触发特殊处理."""
2250
+ from coding.proxy.routing.circuit_breaker import CircuitBreaker
2251
+
2252
+ vendor = _mock_vendor("zhipu")
2253
+ error_body = (
2254
+ b'{"error":{"code":"500","message":"\'ClaudeContentBlockToolResult\' '
2255
+ b"object has no attribute 'id'\"}}"
2256
+ )
2257
+ response = httpx.Response(
2258
+ status_code=500,
2259
+ content=error_body,
2260
+ request=httpx.Request("POST", "https://example.com"),
2261
+ )
2262
+ exc = httpx.HTTPStatusError(
2263
+ "zhipu API error: 500", request=response.request, response=response
2264
+ )
2265
+
2266
+ cb = CircuitBreaker(failure_threshold=3)
2267
+ tier = _make_tier(vendor, circuit_breaker=cb)
2268
+ exec_inst = _executor([tier])
2269
+
2270
+ body = {"model": "test", "messages": [{"role": "user", "content": "hello"}]}
2271
+
2272
+ should_continue, _, _ = await exec_inst._handle_http_error(
2273
+ tier,
2274
+ exc,
2275
+ is_last=True,
2276
+ failed_tier_name=None,
2277
+ last_exc=None,
2278
+ is_stream=True,
2279
+ request_body=body,
2280
+ )
2281
+
2282
+ assert should_continue is False
2283
+ assert cb.get_info()["failure_count"] == 1