coding-proxy 0.3.1a1__tar.gz → 0.3.1a3__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.1a1 → coding_proxy-0.3.1a3}/CHANGELOG.md +10 -0
  2. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/PKG-INFO +9 -1
  3. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/README.md +8 -0
  4. coding_proxy-0.3.1a3/docs/issue.md +143 -0
  5. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/zh-CN/README.md +8 -0
  6. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/pyproject.toml +1 -1
  7. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/vendor_channels.py +281 -38
  8. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/error_classifier.py +14 -0
  9. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/executor.py +13 -11
  10. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/usage_parser.py +18 -20
  11. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/dashboard.py +3 -3
  12. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_error_classifier.py +38 -0
  13. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_parse_usage.py +45 -0
  14. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_router_executor.py +205 -15
  15. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_vendor_channels.py +1047 -25
  16. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/uv.lock +1 -1
  17. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/.github/workflows/ci.yml +0 -0
  18. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/.github/workflows/coverage.yml +0 -0
  19. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/.github/workflows/release.yml +0 -0
  20. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/.gitignore +0 -0
  21. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/.pre-commit-config.yaml +0 -0
  22. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/AGENTS.md +0 -0
  23. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/CLAUDE.md +0 -0
  24. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/LICENSE +0 -0
  25. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/assets/dashboard-v0.2.4.png +0 -0
  26. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/config-reference.md +0 -0
  27. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/convert.md +0 -0
  28. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/design-patterns.md +0 -0
  29. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/routing.md +0 -0
  30. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/testing.md +0 -0
  31. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/arch/vendors.md +0 -0
  32. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/ci-cd.md +0 -0
  33. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/framework.md +0 -0
  34. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/api-reference.md +0 -0
  35. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/cli-reference.md +0 -0
  36. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/dashboard.md +0 -0
  37. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/monitoring.md +0 -0
  38. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/quickstart.md +0 -0
  39. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/guide/vendors.md +0 -0
  40. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/docs/user-guide.md +0 -0
  41. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/__init__.py +0 -0
  42. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/__init__.py +0 -0
  43. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/__main__.py +0 -0
  44. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/__init__.py +0 -0
  45. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/providers/__init__.py +0 -0
  46. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/providers/base.py +0 -0
  47. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/providers/github.py +0 -0
  48. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/providers/google.py +0 -0
  49. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/runtime.py +0 -0
  50. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/auth/store.py +0 -0
  51. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/cli/__init__.py +0 -0
  52. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/cli/auth_commands.py +0 -0
  53. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/cli/banner.py +0 -0
  54. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/compat/__init__.py +0 -0
  55. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/compat/canonical.py +0 -0
  56. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/compat/session_store.py +0 -0
  57. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/__init__.py +0 -0
  58. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/auth_schema.py +0 -0
  59. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/config.default.yaml +0 -0
  60. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/loader.py +0 -0
  61. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/resiliency.py +0 -0
  62. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/routing.py +0 -0
  63. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/schema.py +0 -0
  64. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/server.py +0 -0
  65. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/config/vendors.py +0 -0
  66. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/__init__.py +0 -0
  67. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  68. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  69. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  70. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  71. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  72. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/logging/__init__.py +0 -0
  73. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/logging/db.py +0 -0
  74. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/logging/formatters.py +0 -0
  75. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/logging/stats.py +0 -0
  76. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/__init__.py +0 -0
  77. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/auth.py +0 -0
  78. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/compat.py +0 -0
  79. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/constants.py +0 -0
  80. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/pricing.py +0 -0
  81. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/token.py +0 -0
  82. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/model/vendor.py +0 -0
  83. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/__init__.py +0 -0
  84. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/config.py +0 -0
  85. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  86. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  87. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  88. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  89. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/handler.py +0 -0
  90. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/operation.py +0 -0
  91. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/routes.py +0 -0
  92. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/native_api/usage_registry.py +0 -0
  93. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/pricing.py +0 -0
  94. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/__init__.py +0 -0
  95. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  96. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/model_mapper.py +0 -0
  97. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/quota_guard.py +0 -0
  98. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/rate_limit.py +0 -0
  99. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/retry.py +0 -0
  100. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/router.py +0 -0
  101. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/session_manager.py +0 -0
  102. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/tier.py +0 -0
  103. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/routing/usage_recorder.py +0 -0
  104. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/__init__.py +0 -0
  105. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/app.py +0 -0
  106. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/factory.py +0 -0
  107. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/responses.py +0 -0
  108. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/server/routes.py +0 -0
  109. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/streaming/__init__.py +0 -0
  110. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  111. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/__init__.py +0 -0
  112. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/alibaba.py +0 -0
  113. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/anthropic.py +0 -0
  114. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/antigravity.py +0 -0
  115. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/base.py +0 -0
  116. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/copilot.py +0 -0
  117. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/copilot_models.py +0 -0
  118. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  119. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  120. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/doubao.py +0 -0
  121. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/kimi.py +0 -0
  122. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/minimax.py +0 -0
  123. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/mixins.py +0 -0
  124. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  125. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/token_manager.py +0 -0
  126. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/xiaomi.py +0 -0
  127. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/src/coding/proxy/vendors/zhipu.py +0 -0
  128. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/__init__.py +0 -0
  129. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_antigravity.py +0 -0
  130. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_app_routes.py +0 -0
  131. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_auto_login.py +0 -0
  132. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_banner.py +0 -0
  133. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_circuit_breaker.py +0 -0
  134. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_cli_usage.py +0 -0
  135. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_compat.py +0 -0
  136. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_config_init.py +0 -0
  137. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_config_loader.py +0 -0
  138. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_convert_request.py +0 -0
  139. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_convert_response.py +0 -0
  140. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_convert_sse.py +0 -0
  141. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_copilot.py +0 -0
  142. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_copilot_convert_request.py +0 -0
  143. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_copilot_convert_response.py +0 -0
  144. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_copilot_models.py +0 -0
  145. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_copilot_urls.py +0 -0
  146. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_currency.py +0 -0
  147. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_logging_dual_write.py +0 -0
  148. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_mixins.py +0 -0
  149. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_auth.py +0 -0
  150. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_compat.py +0 -0
  151. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_constants.py +0 -0
  152. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_mapper.py +0 -0
  153. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_pricing.py +0 -0
  154. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_token.py +0 -0
  155. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_model_vendor.py +0 -0
  156. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_api_base_url_override.py +0 -0
  157. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_api_extractors.py +0 -0
  158. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_api_handler.py +0 -0
  159. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_api_operation.py +0 -0
  160. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_api_routes.py +0 -0
  161. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_native_vendors.py +0 -0
  162. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_parse_usage_gemini.py +0 -0
  163. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_pricing.py +0 -0
  164. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_quota_guard.py +0 -0
  165. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_rate_limit.py +0 -0
  166. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_router_chain.py +0 -0
  167. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_runtime_reauth.py +0 -0
  168. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_schema.py +0 -0
  169. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_streaming_anthropic_compat.py +0 -0
  170. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_tier.py +0 -0
  171. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_tiers_config.py +0 -0
  172. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_time_range.py +0 -0
  173. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_token_logger.py +0 -0
  174. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_token_logger_native_columns.py +0 -0
  175. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_token_manager.py +0 -0
  176. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_types.py +0 -0
  177. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_vendor_streaming.py +0 -0
  178. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_vendors.py +0 -0
  179. {coding_proxy-0.3.1a1 → coding_proxy-0.3.1a3}/tests/test_zhipu.py +0 -0
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ - fix(vendor-channels): 新增 zhipu 同 vendor 自清理通道,修复 GLM-5 自循环 400 + tool_results 偶发降级;
8
+ - 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
+ - fix(vendor-channels): `enforce_anthropic_tool_pairing` 增加全局 sanity check pass,主循环边角错位让 dangling tool_use 漏过校验时兜底合成 is_error 占位并打 `pairing_sanity_repaired` 标签,避免 anthropic 二次报错;
10
+
11
+ ### Bug Fixes
12
+
13
+ - fix(vendor-channels): 新增 `anthropic → zhipu` 跨供应商转换通道,修复 Anthropic beta 功能(web search, computer use)产生的 `server_tool_use` 块导致 zhipu 400 错误的问题;
14
+ - fix(error-classifier): 增强语义拒绝检测,识别 zhipu 等供应商返回的中文错误消息(如「API 调用参数有误」code=1210),确保正确触发故障转移;
15
+ - fix(vendor-channels): `_remove_vendor_blocks` 增加空内容占位保护,防止内容块全部剥离后消息结构不合法。
16
+
7
17
  ## [v0.3.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.3.0) — 2026-04-20
8
18
 
9
19
  > [!IMPORTANT]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.3.1a1
3
+ Version: 0.3.1a3
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
@@ -113,6 +113,14 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:3392
113
113
  claude
114
114
  ```
115
115
 
116
+ ### 5. Peek at the Dashboard
117
+
118
+ Curious about your real-time token spend, latency, and circuit breaker pulse? Pop open your browser and head to:
119
+
120
+ ```bash
121
+ open http://127.0.0.1:3392/dashboard
122
+ ```
123
+
116
124
  ---
117
125
 
118
126
  ## 🛠️ The CLI Console Guide
@@ -86,6 +86,14 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:3392
86
86
  claude
87
87
  ```
88
88
 
89
+ ### 5. Peek at the Dashboard
90
+
91
+ Curious about your real-time token spend, latency, and circuit breaker pulse? Pop open your browser and head to:
92
+
93
+ ```bash
94
+ open http://127.0.0.1:3392/dashboard
95
+ ```
96
+
89
97
  ---
90
98
 
91
99
  ## 🛠️ The CLI Console Guide
@@ -0,0 +1,143 @@
1
+ # Issue 处理档案
2
+
3
+ > 维护已处理过的 Issue 摘要(问题描述、表因根因、处理方式、后续防范、同类问题影响与处理注意事项),便于同类问题的跨上下文处理。识别相同 Issue 时应在原条目追加复盘,避免同 Issue 多处维护。
4
+
5
+ ---
6
+
7
+ ## streaming usage parse failed: 'NoneType' object has no attribute 'get'
8
+
9
+ **问题描述**
10
+
11
+ OpenAI 兼容 SSE 流式响应过程中,单次请求日志反复刷出数十条 WARNING:
12
+
13
+ ```
14
+ WARNING streaming usage parse failed: 'NoneType' object has no attribute 'get'
15
+ ```
16
+
17
+ 警告本身被上层 `try/except` 吞掉不影响主链路,但日志噪声严重,且每帧都丢失了 usage 累加。
18
+
19
+ **表因**
20
+
21
+ `StreamingUsageAccumulator.feed` 调用 `parse_usage_from_chunk` 解析 SSE chunk 时抛出 `AttributeError`。
22
+
23
+ **根因**
24
+
25
+ `src/coding/proxy/routing/usage_parser.py::parse_usage_from_chunk` 中 Anthropic message_start 与 Anthropic message_delta / OpenAI 两条分支都使用了脆弱的判空模式:
26
+
27
+ ```python
28
+ if "usage" in data: # 仅判断 key 存在
29
+ u = data["usage"] # 但值可能是 null
30
+ u.get("output_tokens", 0) # AttributeError
31
+ ```
32
+
33
+ 部分上游(含某些 OpenAI 兼容供应商)在中间 chunk 显式发送 `"usage": null` 占位帧,`in` 检查通过但取出的是 `None`。
34
+
35
+ **处理方式**
36
+
37
+ 将两处 guard 统一改为 `u = container.get("usage"); if isinstance(u, dict):`,既排除缺省也排除 null,并顺手移除内部冗余的 `if isinstance(u, dict):` 包装层(已被外层 guard 覆盖)。同时新增三个回归用例覆盖 `data.usage = null` / `message.usage = null` / null 帧后跟有效帧三种场景。
38
+
39
+ **后续防范**
40
+
41
+ - 解析外部 SSE / JSON 结构时, 不要单独使用 `if key in data` 作为安全 guard, 应统一采用 `value = data.get(key); if isinstance(value, dict):` 的双重保护, 同时排除缺省与显式 null。
42
+ - 对 try/except 包裹的 WARNING 路径要保持警觉: 异常被吞不代表无害,重复刷屏的同类警告往往暗示防御性 guard 过窄,需要回溯至根因修复,而非依赖 except 兜底。
43
+
44
+ **同类问题影响与处理注意事项**
45
+
46
+ - 本仓库内 `parse_usage_from_chunk` 的 Gemini `usageMetadata` 分支 (line ~219) 已经使用 `isinstance(um, dict)` 防御, 不受影响, 可作为参考实现。
47
+ - 检查其他解析器 (如 routing / vendor adapter 层) 是否还有 `if "key" in data: v = data["key"]; v.get(...)` 这种模式, 必要时同步加固。
48
+
49
+ ---
50
+
51
+ ## zhipu 自循环 400 + tool_results 偶发降级
52
+
53
+ **问题描述**
54
+
55
+ 生产日志反复出现下述链路: 请求一开始命中 zhipu 主 tier, 但在含 `tool_results` 的多轮工具调用场景下偶发返回 400, 触发到 copilot 二级 tier。具体日志特征:
56
+
57
+ ```
58
+ WARNING Tier zhipu likely format incompatibility (400 + tool_results), trying next tier without recording failure
59
+ WARNING Tier zhipu semantic rejection (400), trying next tier without recording failure
60
+ DEBUG Applied transition channel zhipu → copilot: rewritten_38_srvtoolu_ids, stripped_16_thinking_blocks, removed_3_cache_control_fields, misplaced_tool_result_relocated
61
+ ```
62
+
63
+ zhipu → copilot 通道的 adaptations 列表暴露了上一轮 zhipu 响应中存在的非标准产物 (`srvtoolu_*` ID、自签 thinking、错位的 `tool_result`)。
64
+
65
+ **表因**
66
+
67
+ zhipu 自身偶发返回 400, 但错误体非 JSON 结构, 由 `_is_likely_request_format_error()` 判定为「格式不兼容」并跳过当前 tier。
68
+
69
+ **根因**
70
+
71
+ 1. zhipu 是 `NativeAnthropicVendor` 薄透传供应商, **不做任何请求体预处理**。
72
+ 2. `executor._determine_source_vendor` 三条优先级路径均以 `source != target_name` 过滤掉了同 vendor 自转换。
73
+ 3. `VENDOR_TRANSITIONS` 注册表中无 `("zhipu", "zhipu")` 条目。
74
+
75
+ 后果: GLM-5 偶发产出非标准产物 (assistant 内联 `tool_result`、`server_tool_use_delta` 流式残块) 后, Claude Code 把这些产物原样回送下一轮请求时, **没有任何清洗发生**, 直接被转发到 zhipu 自身, 命中 zhipu 端的输入校验返回 400。
76
+
77
+ **处理方式**
78
+
79
+ - 在 `vendor_channels.py` 新增 `prepare_zhipu_self_cleanup` 函数, 仅修复 zhipu 自身拒绝的两类产物:
80
+ 1. 剥离 `server_tool_use_delta` 流式残块
81
+ 2. `enforce_anthropic_tool_pairing` 把 assistant 内联 `tool_result` 重定位到紧随 user 消息
82
+ - 显式 **保留** zhipu 原生支持的特性: `srvtoolu_*` ID、`server_tool_use` 类型、自签 thinking signature、`cache_control` (cache_read 已在生产实证)、顶层 `thinking` 参数。
83
+ - 在 `VENDOR_TRANSITIONS` 注册 `("zhipu", "zhipu") = prepare_zhipu_self_cleanup`。
84
+ - 在 `executor._determine_source_vendor` 三条优先级路径中, 把「`source != target`」过滤替换为「通道已注册」门控 (`get_transition_channel(...) is not None`), 让自转换通道在显式注册时启用, 未注册时退化为原行为。
85
+
86
+ **后续防范**
87
+
88
+ - 新增 `NativeAnthropicVendor` 子类 (minimax / kimi / doubao / xiaomi / alibaba 等) 时, 若上游 vendor 偶发产出违反 Anthropic 规范的产物, 可按需注册同名自清理通道, executor 无需任何额外改动。
89
+ - 同 vendor 自转换通道应**精确剪裁**: 仅修复 vendor 自身拒绝的产物, 不要套用跨 vendor 通道的全量清理 (会误伤 vendor 原生支持的特性, 如 cache_control 损失带来 cache_read miss)。
90
+
91
+ **同类问题影响与处理注意事项**
92
+
93
+ - `enforce_anthropic_tool_pairing` 仅识别 `tool_use` 类型 (不含 `server_tool_use`), 因为 `server_tool_use` 由 vendor 自身执行, 不需要客户端 `tool_result`。构造测试或类似清洗逻辑时需注意此差别。
94
+ - `_is_likely_request_format_error()` 把「400 + tool_results + 非结构化错误体」一律标记为格式不兼容并跳过 tier 不计熔断器, 这层兜底虽能维持可用性但会**掩盖** vendor 自身的间歇性问题, 让根因更难发现。处理类似 400 偶发时, 应优先看 `Applied transition channel` 日志中的 adaptations 列表, 它能精确暴露上游响应中的非标准产物。
95
+
96
+ ---
97
+
98
+ ## anthropic 报 messages.X tool_use 缺 tool_result (zhipu→anthropic 故障转移失败)
99
+
100
+ **问题描述**
101
+
102
+ zhipu 完成响应后, executor 故障转移至 anthropic 时反复失败 (HTTP 400):
103
+
104
+ ```
105
+ DEBUG Applied transition channel zhipu → anthropic: rewritten_86_srvtoolu_ids, misplaced_tool_result_relocated, stripped_18_thinking_blocks
106
+ WARNING anthropic stream error: status=400 ... messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_normalized_3.
107
+ INFO Failover: anthropic → zhipu (reason: HTTP 400)
108
+ ```
109
+
110
+ adaptations 列表显示 `misplaced_tool_result_relocated` 但**没有** `orphaned_tool_use_repaired`, 即 enforce 单遍扫描视角下认为所有 tool_use 都已配对; 但 anthropic 仍报 messages.X 缺 tool_result, 导致请求级 cascade failover 反复回到 zhipu。
111
+
112
+ **表因**
113
+
114
+ `prepare_zhipu_to_anthropic` 链路输出的请求体中, 某个 assistant 的 `tool_use` 在紧邻的 user 消息中没有匹配的 `tool_result` 块。
115
+
116
+ **根因**
117
+
118
+ `_rewrite_srvtoolu_ids` 采用单遍正向扫描: 在同一次循环中一边收集 srvtoolu_* → toolu_normalized_* 的 id_map, 一边改写遇到的 `tool_result.tool_use_id`。GLM-5 流式偶发将 inline tool_result 输出在本消息 `server_tool_use` 之前 (block 顺序异常), 导致:
119
+
120
+ 1. 处理 inline tool_result 时, id_map 尚未填入对应 srvtoolu_* → 漏改名, inline 仍保留 `srvtoolu_X`
121
+ 2. 处理本消息 server_tool_use 时, 填入 id_map 并把 tool_use 改名为 `toolu_normalized_X`
122
+ 3. 进入 `enforce_anthropic_tool_pairing` 时:
123
+ - A 步 extracted dict key = `srvtoolu_X` (inline 保留的旧 ID)
124
+ - B 步 tool_use_ids = `[toolu_normalized_X]` (已改名)
125
+ - F 步 `uid in extracted` 检查失败 (key 错位), 但若 next user 已含其他 stale tool_result 让 existing_result_ids "巧合" 命中, F 步会跳过 synth → 不触发 orphan 标签
126
+ - 最终 anthropic 看到 messages.X 真的缺 toolu_normalized_X 的 tool_result → 400
127
+
128
+ **处理方式**
129
+
130
+ - `_rewrite_srvtoolu_ids` 改为**两遍扫描**: Pass 1 仅遍历 assistant 消息, 收集 id_map 并改写 tool_use 自身的 id 与 type; Pass 2 全量遍历所有消息 (含 user / 异常 assistant 内联), 统一改写所有 `tool_result.tool_use_id` 引用。彻底消除 block 顺序敏感性。
131
+ - `enforce_anthropic_tool_pairing` 主循环结束后追加**全局 sanity check pass**: 重新遍历每条 assistant, 验证其 tool_use_ids 全部在 next user 的 tool_result 中存在; 发现遗漏直接合成 is_error 占位并打 `pairing_sanity_repaired` 标签。作为防御深度抵御未来其他主循环边角错位。
132
+ - A 步对 `tool_use_id` 缺失的破损 inline tool_result 也计入 relocated_count (避免 silent drop 影响 adaptations 标签可观测性)。
133
+
134
+ **后续防范**
135
+
136
+ - 任何"按出现顺序填充字典 + 同遍引用查询"的两阶段操作都应警惕**顺序耦合**问题。两遍扫描 (collect → apply) 是消除此类 bug 的标准 pattern。
137
+ - 关键校验函数应有**主循环 + 全局 sanity check** 的双层结构, 单层校验在边角场景下容易被绕过。
138
+ - 处理 anthropic `tool_use ids without tool_result blocks immediately after` 类 cascade failover 时, **adaptations 标签能否复现日志**是定位 root cause 的强信号: 若 enforce 视角与 anthropic 视角不一致 (有 misplaced 但无 orphan, anthropic 仍报错), 必有上游 _rewrite / id 改写阶段的隐藏漏洞。
139
+
140
+ **同类问题影响与处理注意事项**
141
+
142
+ - 任何对 messages 进行 ID 重写的转换链 (如 `_rewrite_srvtoolu_ids`、`anthropic_to_openai`、`anthropic_to_gemini`) 都应使用两遍扫描或一次性收集后再批量改写, 以保证 block 顺序无关性。
143
+ - enforce 类校验函数若依赖 dict key 与 list 元素的**等同性**, 必须先确保两者在同一参考系下 (改名前 vs 改名后); 否则错位会以 "看起来 OK 实际有漏" 的方式静默泄漏到下游。
@@ -86,6 +86,14 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:3392
86
86
  claude
87
87
  ```
88
88
 
89
+ ### 5. 打开监控看板
90
+
91
+ 想实时观测 Token 消耗、延迟以及各层熔断状态?在浏览器中打开:
92
+
93
+ ```bash
94
+ open http://127.0.0.1:3392/dashboard
95
+ ```
96
+
89
97
  ---
90
98
 
91
99
  ## 🛠️ CLI 控制台指南
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.3.1a1"
3
+ version = "0.3.1a3"
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"
@@ -10,6 +10,8 @@ executor 层通过 ``get_transition_channel()`` 查表分发,无需感知具
10
10
  zhipu → anthropic : prepare_zhipu_to_anthropic (剥离 thinking + tool pairing)
11
11
  zhipu → copilot : prepare_zhipu_to_copilot (剥离 thinking + cache_control + tool pairing)
12
12
  copilot → zhipu : prepare_copilot_to_zhipu (剥离 thinking + cache_control + 移除 thinking 参数 + tool pairing)
13
+ zhipu → zhipu : prepare_zhipu_self_cleanup (剥离 server_tool_use_delta + tool pairing)
14
+ anthropic → zhipu : prepare_anthropic_to_zhipu (剥离 server_tool_use + thinking + cache_control + 移除 thinking 参数 + tool pairing)
13
15
  """
14
16
 
15
17
  from __future__ import annotations
@@ -108,6 +110,10 @@ def enforce_anthropic_tool_pairing(
108
110
 
109
111
  此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息。
110
112
 
113
+ 最终在主循环之后执行一次幂等的全局 sanity check pass, 防御主循环的边角
114
+ 错位 (如 inline tool_result 引用未在本消息出现的 tool_use_id, 导致 extracted
115
+ 字典 key 与 tool_use_ids 集合错位) 让 dangling tool_use 漏过校验。
116
+
111
117
  Args:
112
118
  messages_list: 消息列表(就地修改)。
113
119
 
@@ -139,10 +145,13 @@ def enforce_anthropic_tool_pairing(
139
145
  if tid:
140
146
  extracted_tool_results[tid] = block
141
147
  relocated_count += 1
148
+ else:
149
+ # 缺 tool_use_id 的破损 tool_result 也视作错位剥离
150
+ relocated_count += 1
142
151
  else:
143
152
  retained_content.append(block)
144
153
 
145
- if extracted_tool_results:
154
+ if extracted_tool_results or len(retained_content) != len(content):
146
155
  msg["content"] = retained_content
147
156
 
148
157
  # B. 收集所有 tool_use ID
@@ -207,10 +216,17 @@ def enforce_anthropic_tool_pairing(
207
216
 
208
217
  i += 1
209
218
 
219
+ # G. 最终全局 sanity check pass (抽出为独立函数便于单测验证正向兜底路径).
220
+ sanity_synthesized = _enforce_pairing_sanity_pass(messages_list)
221
+
210
222
  if relocated_count:
211
223
  adaptations.append("misplaced_tool_result_relocated")
212
- if synthesized_ids:
224
+ if synthesized_ids or sanity_synthesized:
213
225
  adaptations.append("orphaned_tool_use_repaired")
226
+
227
+ # 主循环 F 段与 sanity G 段分别打日志, 避免 main=0/sanity=N 时把 sanity
228
+ # 兜底误归因为主循环工作 (运维在线日志聚合时易混淆 cross-pass id-map drift).
229
+ if synthesized_ids:
214
230
  logger.warning(
215
231
  "Vendor degradation adaptation: synthesized %d tool_result block(s) "
216
232
  "for orphaned tool_use to satisfy Anthropic pairing constraint. "
@@ -218,10 +234,94 @@ def enforce_anthropic_tool_pairing(
218
234
  len(synthesized_ids),
219
235
  ", ".join(synthesized_ids),
220
236
  )
237
+ if sanity_synthesized:
238
+ adaptations.append("pairing_sanity_repaired")
239
+ logger.warning(
240
+ "Pairing sanity check repaired %d dangling tool_use(s) missed by "
241
+ "main pass (likely cross-pass id-map drift). Affected tool_use_ids: %s",
242
+ len(sanity_synthesized),
243
+ ", ".join(sanity_synthesized),
244
+ )
221
245
 
222
246
  return adaptations
223
247
 
224
248
 
249
+ def _enforce_pairing_sanity_pass(messages_list: list[Any]) -> list[str]:
250
+ """全局 sanity check pass: 防御主循环边角错位让 dangling tool_use 漏过.
251
+
252
+ 例如: extracted dict key 与 _rewrite 后的 tool_use_ids 错位、user_msg
253
+ 中已有 stale tool_result 让 F 步误判 existing 命中等场景。
254
+
255
+ 扫描所有 assistant 消息, 验证每个 ``tool_use`` block ID 在紧随的 user 消息
256
+ 中均存在对应 ``tool_result``; 漏掉的合成 ``is_error`` 占位。
257
+
258
+ 抽取为独立函数的目的: 主循环 F 步在当前实现下能覆盖所有 dangling tool_use,
259
+ 导致 sanity 实际兜底分支在公开 API 测试中无法被触发; 独立函数便于直接
260
+ 构造「绕过主循环」的输入, 对兜底合成路径建立正向回归保护。
261
+
262
+ Args:
263
+ messages_list: 消息列表 (就地修改, 必要时插入空 user 消息).
264
+
265
+ Returns:
266
+ sanity 兜底合成的 tool_use_id 列表 (空表示主循环已完成所有配对).
267
+ """
268
+ sanity_synthesized: list[str] = []
269
+ j = 0
270
+ while j < len(messages_list):
271
+ msg_j = messages_list[j]
272
+ if not isinstance(msg_j, dict) or msg_j.get("role") != "assistant":
273
+ j += 1
274
+ continue
275
+ content_j = msg_j.get("content")
276
+ if not isinstance(content_j, list):
277
+ j += 1
278
+ continue
279
+ tu_ids = [
280
+ b["id"]
281
+ for b in content_j
282
+ if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
283
+ ]
284
+ if not tu_ids:
285
+ j += 1
286
+ continue
287
+ next_j = j + 1
288
+ if (
289
+ next_j < len(messages_list)
290
+ and isinstance(messages_list[next_j], dict)
291
+ and messages_list[next_j].get("role") == "user"
292
+ ):
293
+ next_user = messages_list[next_j]
294
+ else:
295
+ next_user = {"role": "user", "content": []}
296
+ messages_list.insert(next_j, next_user)
297
+ nu_content = next_user.get("content")
298
+ if isinstance(nu_content, str):
299
+ next_user["content"] = [{"type": "text", "text": nu_content}]
300
+ elif not isinstance(nu_content, list):
301
+ next_user["content"] = []
302
+ nu_result_ids = {
303
+ b["tool_use_id"]
304
+ for b in next_user["content"]
305
+ if isinstance(b, dict)
306
+ and b.get("type") == "tool_result"
307
+ and b.get("tool_use_id")
308
+ }
309
+ for uid in tu_ids:
310
+ if uid in nu_result_ids:
311
+ continue
312
+ next_user["content"].append(
313
+ {
314
+ "type": "tool_result",
315
+ "tool_use_id": uid,
316
+ "content": "",
317
+ "is_error": True,
318
+ }
319
+ )
320
+ sanity_synthesized.append(uid)
321
+ j += 1
322
+ return sanity_synthesized
323
+
324
+
225
325
  def _strip_cache_control(body: dict[str, Any]) -> int:
226
326
  """从 system/messages/tools 中移除 cache_control 字段(就地).
227
327
 
@@ -284,7 +384,13 @@ def _remove_vendor_blocks(body: dict[str, Any], block_types: set[str]) -> int:
284
384
  removed += 1
285
385
  continue
286
386
  new_content.append(block)
287
- if removed:
387
+ if content != new_content:
388
+ if not new_content:
389
+ new_content = [{"type": "text", "text": "[vendor_block_removed]"}]
390
+ logger.info(
391
+ "Inserted placeholder text block after stripping "
392
+ "vendor blocks to avoid empty message content",
393
+ )
288
394
  message["content"] = new_content
289
395
  return removed
290
396
 
@@ -294,8 +400,12 @@ def _rewrite_srvtoolu_ids(body: dict[str, Any]) -> tuple[int, dict[str, str]]:
294
400
 
295
401
  Anthropic API 要求 tool_use 类型与 ``toolu_*`` 格式的 ID。Zhipu 的
296
402
  ``server_tool_use`` + ``srvtoolu_*`` 在上游 Anthropic 兼容端点可用,但无法
297
- 透传至其他供应商;同时还需重写紧随其后 user 消息中 ``tool_result.tool_use_id``
298
- 引用,保持配对关系。
403
+ 透传至其他供应商;同时还需重写所有 ``tool_result.tool_use_id`` 引用,
404
+ 保持配对关系。
405
+
406
+ 采用**两遍扫描**避免块顺序敏感性: GLM-5 偶发将 inline tool_result 输出在
407
+ 本消息 tool_use 之前, 单遍扫描会因 id_map 尚未填入而漏改 inline tool_result
408
+ 的 tool_use_id, 导致后续 enforce 步骤无法将其与 tool_use 配对。
299
409
 
300
410
  Returns:
301
411
  (rewritten_count, id_map) — 重写次数与 {原 ID: 新 ID} 映射。
@@ -308,45 +418,59 @@ def _rewrite_srvtoolu_ids(body: dict[str, Any]) -> tuple[int, dict[str, str]]:
308
418
  counter += 1
309
419
  return f"toolu_normalized_{counter}"
310
420
 
421
+ # Pass 1: 收集所有 assistant tool_use / server_tool_use 的 ID 映射
422
+ # 不修改 tool_result, 仅建立 id_map; 同时改写 tool_use 自身的 id 与 type
311
423
  for message in body.get("messages", []):
312
424
  if not isinstance(message, dict):
313
425
  continue
314
426
  content = message.get("content")
315
427
  if not isinstance(content, list):
316
428
  continue
317
- role = message.get("role")
429
+ if message.get("role") != "assistant":
430
+ continue
318
431
  for block in content:
319
432
  if not isinstance(block, dict):
320
433
  continue
321
434
  block_type = block.get("type")
322
435
  block_id = block.get("id")
436
+ if block_type not in {"tool_use", "server_tool_use"}:
437
+ continue
323
438
 
324
- # Case A: assistant 消息里的 server_tool_use / srvtoolu_* → 改写
325
- if role == "assistant" and block_type in {"tool_use", "server_tool_use"}:
326
- if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
327
- block_id
328
- ):
329
- new_id = next_id()
330
- id_map[block_id] = new_id
331
- block["id"] = new_id
332
- block["type"] = "tool_use"
333
- elif (
334
- isinstance(block_id, str)
335
- and block_id
336
- and not _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
337
- and block.get("name")
338
- ):
339
- # 非标准 ID(非 toolu_ / srvtoolu_),且具备 name 可改写
340
- new_id = next_id()
341
- id_map[block_id] = new_id
342
- block["id"] = new_id
343
- block["type"] = "tool_use"
344
- elif block_type == "server_tool_use" and isinstance(block_id, str):
345
- # 兜底: 类型是 server_tool_use 但 ID 已是标准 toolu_ 形式,仅纠正类型
346
- block["type"] = "tool_use"
347
-
348
- # Case B: user 消息里的 tool_result.tool_use_id 同步重写
349
- if block_type == "tool_result":
439
+ if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
440
+ block_id
441
+ ):
442
+ new_id = next_id()
443
+ id_map[block_id] = new_id
444
+ block["id"] = new_id
445
+ block["type"] = "tool_use"
446
+ elif (
447
+ isinstance(block_id, str)
448
+ and block_id
449
+ and not _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
450
+ and block.get("name")
451
+ ):
452
+ # 非标准 ID(非 toolu_ / srvtoolu_),且具备 name 可改写
453
+ new_id = next_id()
454
+ id_map[block_id] = new_id
455
+ block["id"] = new_id
456
+ block["type"] = "tool_use"
457
+ elif block_type == "server_tool_use" and isinstance(block_id, str):
458
+ # 兜底: 类型是 server_tool_use 但 ID 已是标准 toolu_ 形式,仅纠正类型
459
+ block["type"] = "tool_use"
460
+
461
+ # Pass 2: 全量同步所有 tool_result.tool_use_id 引用 (含 user/assistant 内联)
462
+ if id_map:
463
+ for message in body.get("messages", []):
464
+ if not isinstance(message, dict):
465
+ continue
466
+ content = message.get("content")
467
+ if not isinstance(content, list):
468
+ continue
469
+ for block in content:
470
+ if not isinstance(block, dict):
471
+ continue
472
+ if block.get("type") != "tool_result":
473
+ continue
350
474
  tool_use_id = block.get("tool_use_id")
351
475
  if isinstance(tool_use_id, str) and tool_use_id in id_map:
352
476
  block["tool_use_id"] = id_map[tool_use_id]
@@ -358,8 +482,9 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
358
482
  """从请求 body 内容推断源供应商(仅在无会话上下文时作为兜底).
359
483
 
360
484
  启发式(按置信度排序):
361
- - 出现 ``srvtoolu_*`` 格式的 ``tool_use.id`` → zhipu
362
- - 出现 ``server_tool_use`` / ``server_tool_use_delta`` 类型的 content block → zhipu
485
+ - 出现 ``srvtoolu_*`` 格式的 ID → zhipu
486
+ - 出现 ``server_tool_use_delta`` 类型的 content block → zhipu
487
+ - 出现 ``server_tool_use`` 块 + ``toolu_*`` ID → anthropic(beta 功能产物)
363
488
 
364
489
  原则: 只读扫描不修改 body;无匹配返回 None(视作纯净无需跨供应商清洗)。
365
490
 
@@ -367,7 +492,7 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
367
492
  body: Anthropic Messages 请求体。
368
493
 
369
494
  Returns:
370
- 推断的源供应商名称(当前仅支持 ``"zhipu"``),无法推断返回 None。
495
+ 推断的源供应商名称(``"zhipu"`` ``"anthropic"``),无法推断返回 None。
371
496
  """
372
497
  for message in body.get("messages", []):
373
498
  if not isinstance(message, dict):
@@ -379,18 +504,35 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
379
504
  if not isinstance(block, dict):
380
505
  continue
381
506
  block_type = block.get("type")
382
- if block_type in _ZHIPU_SERVER_TOOL_USE_TYPES:
383
- return "zhipu"
384
507
  block_id = block.get("id")
508
+ tool_use_id = block.get("tool_use_id")
509
+
510
+ # Zhipu: server_tool_use_delta 是 zhipu 私有流式块(无歧义)
511
+ if block_type == "server_tool_use_delta":
512
+ return "zhipu"
513
+
514
+ # srvtoolu_* ID(无论 block type)→ zhipu
385
515
  if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
386
516
  block_id
387
517
  ):
388
518
  return "zhipu"
389
- tool_use_id = block.get("tool_use_id")
390
519
  if isinstance(tool_use_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
391
520
  tool_use_id
392
521
  ):
393
522
  return "zhipu"
523
+
524
+ # server_tool_use 块 + toolu_* ID → Anthropic beta 功能
525
+ if (
526
+ block_type == "server_tool_use"
527
+ and isinstance(block_id, str)
528
+ and _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
529
+ ):
530
+ return "anthropic"
531
+
532
+ # server_tool_use 块 + 非 toolu_/srvtoolu_ ID → 按类型兜底归 zhipu
533
+ if block_type == "server_tool_use":
534
+ return "zhipu"
535
+
394
536
  return None
395
537
 
396
538
 
@@ -438,6 +580,61 @@ def prepare_copilot_to_zhipu(
438
580
  return prepared, adaptations
439
581
 
440
582
 
583
+ # ── anthropic → zhipu 转换通道 ────────────────────────────────────
584
+
585
+ # Anthropic beta 特有的 server_tool_use 块类型(web search, computer use 等).
586
+ # 这些块在 Anthropic API 中有效,但 zhipu GLM-5 的兼容端点不支持。
587
+ # 注意: 这与 zhipu 自己的 server_tool_use(使用 srvtoolu_* ID)是不同的概念,
588
+ # 但它们共用同一个 type 名称 "server_tool_use"。
589
+ _ANTHROPIC_BETA_BLOCK_TYPES = {"server_tool_use"}
590
+
591
+
592
+ def prepare_anthropic_to_zhipu(
593
+ body: dict[str, Any],
594
+ ) -> tuple[dict[str, Any], list[str]]:
595
+ """anthropic → zhipu 转换: 清理 anthropic 产物以适配 GLM-5.
596
+
597
+ Anthropic API 可能产生的非兼容产物:
598
+ - ``server_tool_use`` blocks(web search / computer use 等 beta 功能)
599
+ - ``thinking`` / ``redacted_thinking`` blocks(含 Anthropic 签发的 signature)
600
+ - ``cache_control`` 字段
601
+ - 顶层 ``thinking`` / ``extended_thinking`` 参数
602
+
603
+ Returns:
604
+ (prepared_body, adaptations) — adaptations 为应用的变换描述列表。
605
+ """
606
+ prepared = copy.deepcopy(body)
607
+ adaptations: list[str] = []
608
+
609
+ # Step 1: 剥离 anthropic 的 server_tool_use blocks(web search, computer use 等)
610
+ removed_stu = _remove_vendor_blocks(prepared, _ANTHROPIC_BETA_BLOCK_TYPES)
611
+ if removed_stu:
612
+ adaptations.append(f"removed_{removed_stu}_server_tool_use_blocks")
613
+
614
+ # Step 2: 剥离 thinking/redacted_thinking blocks
615
+ stripped = strip_thinking_blocks(prepared)
616
+ if stripped:
617
+ adaptations.append(f"stripped_{stripped}_thinking_blocks")
618
+
619
+ # Step 3: 移除 cache_control 字段
620
+ removed_cc = _strip_cache_control(prepared)
621
+ if removed_cc:
622
+ adaptations.append(f"removed_{removed_cc}_cache_control_fields")
623
+
624
+ # Step 4: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持)
625
+ for param in ("thinking", "extended_thinking"):
626
+ if param in prepared:
627
+ del prepared[param]
628
+ adaptations.append(f"removed_{param}_param")
629
+
630
+ # Step 5: 强制 tool_use/tool_result 配对
631
+ pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
632
+ if pairing_fixes:
633
+ adaptations.extend(pairing_fixes)
634
+
635
+ return prepared, adaptations
636
+
637
+
441
638
  # ── zhipu → copilot 转换通道 ─────────────────────────────────────
442
639
 
443
640
 
@@ -539,8 +736,54 @@ def prepare_zhipu_to_anthropic(
539
736
  return prepared, adaptations
540
737
 
541
738
 
739
+ # ── zhipu → zhipu 自清理通道 ──────────────────────────────────────
740
+
741
+
742
+ def prepare_zhipu_self_cleanup(
743
+ body: dict[str, Any],
744
+ ) -> tuple[dict[str, Any], list[str]]:
745
+ """zhipu → zhipu 自清理: 仅修复 zhipu 自身无法消化的产物.
746
+
747
+ GLM-5 偶发地在 assistant 消息中输出 ``tool_result`` 块(违反 Anthropic 规范),
748
+ 或在流式响应中暴露 ``server_tool_use_delta`` 私有块。当 Claude Code 将这些
749
+ 产物原样回送下一轮请求时,zhipu 的 Anthropic 兼容端点会以 400 拒绝
750
+ (表现为 "400 + tool_results" 偶发,进而触发到 copilot 的降级)。
751
+
752
+ 本通道仅修复 zhipu 自身拒绝的两类产物,**保留** 所有 zhipu 原生支持的特性:
753
+
754
+ - ✓ ``srvtoolu_*`` ID 与 ``server_tool_use`` 类型(zhipu 原生)
755
+ - ✓ thinking blocks 的 zhipu 自签 signature
756
+ - ✓ ``cache_control`` 字段(GLM Anthropic 端点支持,cache_read 已实证)
757
+ - ✓ 顶层 ``thinking`` / ``extended_thinking`` 参数
758
+
759
+ 清理操作(顺序、就地、幂等):
760
+ 1. 剥离 ``server_tool_use_delta`` 流式残块
761
+ 2. 强制 tool_use/tool_result 配对(关键: 把 assistant 内联的 tool_result
762
+ 搬迁到紧随的 user 消息)
763
+
764
+ Returns:
765
+ (prepared_body, adaptations) — adaptations 为应用的变换描述列表。
766
+ """
767
+ prepared = copy.deepcopy(body)
768
+ adaptations: list[str] = []
769
+
770
+ # Step 1: 剥离 zhipu 私有流式块类型(input 中不应出现)
771
+ removed_vendor_blocks = _remove_vendor_blocks(prepared, _ZHIPU_VENDOR_BLOCK_TYPES)
772
+ if removed_vendor_blocks:
773
+ adaptations.append(f"removed_{removed_vendor_blocks}_zhipu_vendor_blocks")
774
+
775
+ # Step 2: 强制 tool_use/tool_result 配对
776
+ pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
777
+ if pairing_fixes:
778
+ adaptations.extend(pairing_fixes)
779
+
780
+ return prepared, adaptations
781
+
782
+
542
783
  # ── 注册所有转换通道 ──────────────────────────────────────────────
543
784
 
544
785
  VENDOR_TRANSITIONS[("zhipu", "anthropic")] = prepare_zhipu_to_anthropic
545
786
  VENDOR_TRANSITIONS[("zhipu", "copilot")] = prepare_zhipu_to_copilot
546
787
  VENDOR_TRANSITIONS[("copilot", "zhipu")] = prepare_copilot_to_zhipu
788
+ VENDOR_TRANSITIONS[("zhipu", "zhipu")] = prepare_zhipu_self_cleanup
789
+ VENDOR_TRANSITIONS[("anthropic", "zhipu")] = prepare_anthropic_to_zhipu