coding-proxy 0.3.1a10__tar.gz → 0.4.1a1__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 (190) hide show
  1. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/.gitignore +3 -0
  2. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/CHANGELOG.md +19 -8
  3. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/PKG-INFO +2 -2
  4. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/README.md +1 -1
  5. coding_proxy-0.4.1a1/assets/dashboard-v0.4.0.png +0 -0
  6. coding_proxy-0.4.1a1/assets/session-v0.4.0.png +0 -0
  7. coding_proxy-0.4.1a1/docs/issue.md +134 -0
  8. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/zh-CN/README.md +1 -1
  9. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/pyproject.toml +5 -2
  10. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/vendor_channels.py +161 -31
  11. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/handler.py +11 -2
  12. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/operation.py +8 -7
  13. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/dashboard.py +164 -61
  14. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/routes.py +3 -2
  15. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/antigravity.py +35 -16
  16. coding_proxy-0.4.1a1/tests/e2e/__init__.py +0 -0
  17. coding_proxy-0.4.1a1/tests/e2e/conftest.py +199 -0
  18. coding_proxy-0.4.1a1/tests/e2e/test_e2e_http.py +263 -0
  19. coding_proxy-0.4.1a1/tests/e2e/test_e2e_token.py +93 -0
  20. coding_proxy-0.4.1a1/tests/e2e/test_e2e_vendor.py +327 -0
  21. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_antigravity.py +8 -9
  22. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_app_routes.py +70 -0
  23. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_api_handler.py +71 -0
  24. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_api_operation.py +17 -0
  25. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_vendor_channels.py +418 -0
  26. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/uv.lock +1 -1
  27. coding_proxy-0.3.1a10/assets/dashboard-v0.2.4.png +0 -0
  28. coding_proxy-0.3.1a10/docs/issue.md +0 -47
  29. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/.github/workflows/ci.yml +0 -0
  30. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/.github/workflows/coverage.yml +0 -0
  31. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/.github/workflows/release.yml +0 -0
  32. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/.pre-commit-config.yaml +0 -0
  33. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/AGENTS.md +0 -0
  34. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/CLAUDE.md +0 -0
  35. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/LICENSE +0 -0
  36. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/config-reference.md +0 -0
  37. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/convert.md +0 -0
  38. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/design-patterns.md +0 -0
  39. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/routing.md +0 -0
  40. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/testing.md +0 -0
  41. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/arch/vendors.md +0 -0
  42. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/ci-cd.md +0 -0
  43. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/framework.md +0 -0
  44. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/api-reference.md +0 -0
  45. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/cli-reference.md +0 -0
  46. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/dashboard.md +0 -0
  47. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/monitoring.md +0 -0
  48. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/quickstart.md +0 -0
  49. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/guide/vendors.md +0 -0
  50. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/docs/user-guide.md +0 -0
  51. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/__init__.py +0 -0
  52. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/__init__.py +0 -0
  53. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/__main__.py +0 -0
  54. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/__init__.py +0 -0
  55. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/__init__.py +0 -0
  56. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/base.py +0 -0
  57. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/github.py +0 -0
  58. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/google.py +0 -0
  59. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/runtime.py +0 -0
  60. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/store.py +0 -0
  61. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/__init__.py +0 -0
  62. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/auth_commands.py +0 -0
  63. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/banner.py +0 -0
  64. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/__init__.py +0 -0
  65. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/canonical.py +0 -0
  66. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/session_store.py +0 -0
  67. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/__init__.py +0 -0
  68. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/auth_schema.py +0 -0
  69. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/config.default.yaml +0 -0
  70. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/loader.py +0 -0
  71. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/resiliency.py +0 -0
  72. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/routing.py +0 -0
  73. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/schema.py +0 -0
  74. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/server.py +0 -0
  75. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/session_policy.py +0 -0
  76. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/config/vendors.py +0 -0
  77. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/__init__.py +0 -0
  78. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  79. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  80. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  81. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  82. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  83. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/__init__.py +0 -0
  84. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/db.py +0 -0
  85. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/formatters.py +0 -0
  86. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/stats.py +0 -0
  87. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/__init__.py +0 -0
  88. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/auth.py +0 -0
  89. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/compat.py +0 -0
  90. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/constants.py +0 -0
  91. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/pricing.py +0 -0
  92. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/token.py +0 -0
  93. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/model/vendor.py +0 -0
  94. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/__init__.py +0 -0
  95. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/config.py +0 -0
  96. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  97. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  98. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  99. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  100. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/routes.py +0 -0
  101. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/usage_registry.py +0 -0
  102. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/pricing.py +0 -0
  103. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/__init__.py +0 -0
  104. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  105. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/error_classifier.py +0 -0
  106. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/executor.py +0 -0
  107. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/model_mapper.py +0 -0
  108. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/quota_guard.py +0 -0
  109. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/rate_limit.py +0 -0
  110. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/retry.py +0 -0
  111. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/router.py +0 -0
  112. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/session_manager.py +0 -0
  113. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/session_policy.py +0 -0
  114. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/tier.py +0 -0
  115. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/usage_parser.py +0 -0
  116. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/usage_recorder.py +0 -0
  117. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/__init__.py +0 -0
  118. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/app.py +0 -0
  119. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/factory.py +0 -0
  120. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/server/responses.py +0 -0
  121. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/streaming/__init__.py +0 -0
  122. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  123. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/__init__.py +0 -0
  124. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/alibaba.py +0 -0
  125. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/anthropic.py +0 -0
  126. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/base.py +0 -0
  127. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot.py +0 -0
  128. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_models.py +0 -0
  129. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  130. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  131. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/doubao.py +0 -0
  132. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/kimi.py +0 -0
  133. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/minimax.py +0 -0
  134. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/mixins.py +0 -0
  135. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  136. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/token_manager.py +0 -0
  137. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/xiaomi.py +0 -0
  138. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/zhipu.py +0 -0
  139. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/__init__.py +0 -0
  140. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_auto_login.py +0 -0
  141. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_banner.py +0 -0
  142. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_circuit_breaker.py +0 -0
  143. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_cli_usage.py +0 -0
  144. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_compat.py +0 -0
  145. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_config_init.py +0 -0
  146. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_config_loader.py +0 -0
  147. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_convert_request.py +0 -0
  148. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_convert_response.py +0 -0
  149. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_convert_sse.py +0 -0
  150. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_copilot.py +0 -0
  151. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_copilot_convert_request.py +0 -0
  152. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_copilot_convert_response.py +0 -0
  153. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_copilot_models.py +0 -0
  154. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_copilot_urls.py +0 -0
  155. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_currency.py +0 -0
  156. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_error_classifier.py +0 -0
  157. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_logging_dual_write.py +0 -0
  158. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_mixins.py +0 -0
  159. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_auth.py +0 -0
  160. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_compat.py +0 -0
  161. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_constants.py +0 -0
  162. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_mapper.py +0 -0
  163. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_pricing.py +0 -0
  164. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_token.py +0 -0
  165. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_model_vendor.py +0 -0
  166. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_api_base_url_override.py +0 -0
  167. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_api_extractors.py +0 -0
  168. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_api_routes.py +0 -0
  169. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_native_vendors.py +0 -0
  170. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_parse_usage.py +0 -0
  171. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_parse_usage_gemini.py +0 -0
  172. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_pricing.py +0 -0
  173. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_quota_guard.py +0 -0
  174. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_rate_limit.py +0 -0
  175. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_router_chain.py +0 -0
  176. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_router_executor.py +0 -0
  177. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_runtime_reauth.py +0 -0
  178. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_schema.py +0 -0
  179. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_session_aware.py +0 -0
  180. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_streaming_anthropic_compat.py +0 -0
  181. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_tier.py +0 -0
  182. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_tiers_config.py +0 -0
  183. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_time_range.py +0 -0
  184. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_token_logger.py +0 -0
  185. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_token_logger_native_columns.py +0 -0
  186. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_token_manager.py +0 -0
  187. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_types.py +0 -0
  188. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_vendor_streaming.py +0 -0
  189. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_vendors.py +0 -0
  190. {coding_proxy-0.3.1a10 → coding_proxy-0.4.1a1}/tests/test_zhipu.py +0 -0
@@ -23,5 +23,8 @@ config.yaml
23
23
  .claude/.prompts.md
24
24
  .python-version
25
25
 
26
+ # Playwright MCP
27
+ .playwright-mcp/
28
+
26
29
  # Log files (dual-write logging)
27
30
  coding-proxy.log*
@@ -4,16 +4,27 @@
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 的会话;
8
- - fix(vendor-channels): 新增 zhipu 同 vendor 自清理通道,修复 GLM-5 自循环 400 + tool_results 偶发降级;
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 引用);
10
- - fix(vendor-channels): `enforce_anthropic_tool_pairing` 增加全局 sanity check pass,主循环边角错位让 dangling tool_use 漏过校验时兜底合成 is_error 占位并打 `pairing_sanity_repaired` 标签,避免 anthropic 二次报错;
7
+ ## [v0.4.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.4.0) 2026-05-01
11
8
 
12
- ### Bug Fixes
9
+ > [!IMPORTANT]
10
+ >
11
+ > **🚀 Session 级专属路由策略!**
12
+ >
13
+ > 给每个 Session 指定专属的 vendor,动态调节不同 vendors 间的 LLM 流量。
14
+
15
+ ![session](assets/session-v0.4.0.png)
16
+
17
+ ### ✨ 核心亮点
18
+
19
+ - feat(session-policy): 新增 Session 级专属路由策略 (#219)
20
+ - feat(dashboard): 新增会话活动面板 (#222)
21
+
22
+ ### 🔧 更多特性
13
23
 
14
- - fix(vendor-channels): 新增 `anthropic zhipu` 跨供应商转换通道,修复 Anthropic beta 功能(web search, computer use)产生的 `server_tool_use` 块导致 zhipu 400 错误的问题;
15
- - fix(error-classifier): 增强语义拒绝检测,识别 zhipu 等供应商返回的中文错误消息(如「API 调用参数有误」code=1210),确保正确触发故障转移;
16
- - fix(vendor-channels): `_remove_vendor_blocks` 增加空内容占位保护,防止内容块全部剥离后消息结构不合法。
24
+ - refactor(logging): 移除已被 ModelCall 汇总行覆盖的冗余 DEBUG 日志 (#203)
25
+ - style(dashboard): 加宽图表 tooltip 令模型名称与用量值单行显示 (#211)
26
+ - fix(usage-parser): 补充 OpenAI/Gemini SSE 流式分支的 model_served 提取 (#214)
27
+ - fix(usage-parser): 兼容 SSE chunk 中 usage 字段为 null 的极端格式 (#212)
17
28
 
18
29
  ## [v0.3.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.3.0) — 2026-04-20
19
30
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.3.1a10
3
+ Version: 0.4.1a1
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
@@ -57,7 +57,7 @@ When you're deeply immersed in your coding "zone" with **Claude Code** (or any A
57
57
  ## 🌟 Core Features
58
58
 
59
59
  <div align="center">
60
- <img src="assets/dashboard-v0.2.4.png">
60
+ <img src="assets/dashboard-v0.4.0.png">
61
61
  </div>
62
62
 
63
63
  - **⛓️ N-tier Chained Failover**: Autonomous descending sequence, supporting Claude's official plans, as well as Coding Plans from GitHub Copilot, Google Antigravity, Z AI, MiniMax, Alibaba Qwen, Xiaomi, Kimi, Doubao, etc.
@@ -30,7 +30,7 @@ When you're deeply immersed in your coding "zone" with **Claude Code** (or any A
30
30
  ## 🌟 Core Features
31
31
 
32
32
  <div align="center">
33
- <img src="assets/dashboard-v0.2.4.png">
33
+ <img src="assets/dashboard-v0.4.0.png">
34
34
  </div>
35
35
 
36
36
  - **⛓️ N-tier Chained Failover**: Autonomous descending sequence, supporting Claude's official plans, as well as Coding Plans from GitHub Copilot, Google Antigravity, Z AI, MiniMax, Alibaba Qwen, Xiaomi, Kimi, Doubao, etc.
@@ -0,0 +1,134 @@
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
+ ## anthropic 400: `tool_use` ids were found without `tool_result` blocks immediately after
52
+
53
+ **问题描述**
54
+
55
+ zhipu → anthropic 通道流式请求偶发 400, 错误形如:
56
+
57
+ ```
58
+ WARNING anthropic stream error: status=400 body=...
59
+ messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_normalized_2.
60
+ INFO Failover: anthropic → zhipu (reason: HTTP 400)
61
+ INFO Tier zhipu stream succeeded (took over from failed tier: anthropic)
62
+ ```
63
+
64
+ 同一请求伴随 `Applied transition channel zhipu → anthropic: rewritten_N_srvtoolu_ids, misplaced_tool_result_relocated, stripped_M_thinking_blocks` 的 adaptations 但**没有 `orphaned_tool_use_repaired`**, 即转换层主观上认为已配对、但 Anthropic 仍判定结构不合规。Failover 至 zhipu 后请求成功, 证明上游消息体本身没有损坏, 问题出在 zhipu→anthropic 通道转换过程引入了不一致。
65
+
66
+ **表因**
67
+
68
+ `src/coding/proxy/convert/vendor_channels.py::_rewrite_srvtoolu_ids` 在单遍循环中同时承担 Case A (assistant 端 `server_tool_use` → `tool_use` 与 `srvtoolu_*` ID 重写) 与 Case B (任意位置 `tool_result.tool_use_id` 同步重写)。Case B 依赖 `id_map` 已被 Case A 填入。
69
+
70
+ **根因**
71
+
72
+ Zhipu GLM-5 流式响应偶发将 inline `tool_result` 块输出在**对应的 `server_tool_use` 块之前** (同 assistant content 内乱序), 或将 `tool_result` 放在更早的 user 消息中而对应 `tool_use` 在更晚的 assistant 消息。两种乱序下, 单遍扫描遍历到 `tool_result` 时 `id_map` 还是空 → `tool_result.tool_use_id` 不被改写, 停留在 `srvtoolu_X`; 随后 Case A 把对应 `tool_use.id` 改写为 `toolu_normalized_N`。
73
+
74
+ 后续 `enforce_anthropic_tool_pairing` Step A 提取这条 misplaced tool_result 时使用**旧 ID** 作为 `extracted_tool_results` 字典 key, Step F 用新 ID 去查 → 不命中 → 走 `existing_result_ids` 分支, 因为相邻 user 的 tool_result 已经被改写到新 ID, 该 uid 命中 `existing_result_ids` 被 continue 跳过, 于是 enforce 错误地认为完成配对、不产生 `orphaned_tool_use_repaired` 标签, 而被默默丢弃的 misplaced tool_result 本应填补到的 user 槽位实际上**仍然缺位**。最终 body 中某条 assistant 的 tool_use 在下一条 user 中找不到对应 tool_result → Anthropic 400。
75
+
76
+ **处理方式**
77
+
78
+ 1. `_rewrite_srvtoolu_ids` 改为**两遍扫描**: Pass 1 仅遍历 assistant 消息收集 `id_map` (按 assistant 出现顺序分配, 保持序号兼容性); Pass 2 全量遍历改写任意 `tool_result.tool_use_id`。以"先建表、后改写"的次序消除时序耦合。
79
+ 2. 在 `enforce_anthropic_tool_pairing` 主循环末尾追加独立 helper `_enforce_pairing_sanity_pass`, 仅做检测+合成 `is_error=True` 占位 (不剥离、不重定位), 命中追加 `pairing_sanity_repaired` adaptation 并打 WARNING (含 message index 与 uid)。这层作为纵深防御, 在主循环未来重构时仍能稳定守住 Anthropic 配对约束。
80
+ 3. 新增回归测试覆盖三类场景: 同 assistant content 内乱序、跨消息边界 tool_result 早于 tool_use、端到端复现日志故障形态。新增 `TestEnforcePairingSanityPass` 独立测试套件确保兜底分支具备正向回归保护。
81
+
82
+ **后续防范**
83
+
84
+ - 任何在多 content block 之间存在**前向引用** (后出现的块定义的标识符被前面的块引用) 的就地改写逻辑, 都必须采用两遍扫描或全局表先建后用, 不可依赖遍历位置上 "上一次循环已经写入" 的隐含次序。
85
+ - 纵深防御层 (sanity helper) 必须**独立可单测**, 而不是把 sanity 内嵌在主路径内部 — 否则主路径的快速通道会让 sanity 分支永远走不到正向测试, 缺乏回归保护。
86
+ - adaptations 标签 (`pairing_sanity_repaired`) 与主循环标签 (`orphaned_tool_use_repaired`) 分离, 便于运维聚合时按层归因。
87
+
88
+ **同类问题影响与处理注意事项**
89
+
90
+ - 历史教训: commit `9061cd0` 曾经实现"两遍扫描 + sanity helper"修复了正是这类问题, 但 commit `2bac9a7` revert 至 v0.3.0 时**连带回滚**了它 — revert 的真实目标是去除 `f497077` / `fdd4a92` / `43488a1` 引入的"zhipu 自清理通道"和"tool_result.id 注入"副作用, 两遍扫描属无辜方。**后续若再次需要 revert `vendor_channels.py`**, 必须先 `grep _enforce_pairing_sanity_pass` 与 `Pass 1` / `Pass 2` 注释, 确认这两段是核心修复而非可以一起回滚的实验性代码。
91
+ - 类似 "vendor 私有 ID 跨消息体改写" 场景 (如 doubao、minimax 未来若引入类似机制), 实现时同样应当遵循"先全局收集 id_map、后统一改写"的两阶段模式。
92
+ - 单元测试覆盖"块顺序敏感"类 bug 时, 建议在用例命名中显式标注顺序条件 (如 `test_two_pass_handles_inline_tool_result_before_server_tool_use`), 让未来 reviewer 一眼看出测试的边界价值。
93
+
94
+ ---
95
+
96
+ ## count_tokens 路由 `AttributeError: 'ZhipuVendor' object has no attribute 'name'`
97
+
98
+ **问题描述**
99
+
100
+ 后台日志反复出现 `POST /v1/messages/count_tokens?beta=true 500 Internal Server Error`,并伴随:
101
+
102
+ ```
103
+ File ".../coding/proxy/server/routes.py", line 153, in count_tokens
104
+ channel_fn = get_transition_channel(source, target_vendor.name)
105
+ AttributeError: 'ZhipuVendor' object has no attribute 'name'
106
+ ```
107
+
108
+ 同一时间窗口内大量请求 200 OK、少量请求 500,呈"间歇性"故障特征。
109
+
110
+ **表因**
111
+
112
+ `src/coding/proxy/server/routes.py` 的 `count_tokens` 在 153 / 160 两处访问 `target_vendor.name`,触发 `AttributeError` 被 ASGI 中间件捕获返回 500。
113
+
114
+ **根因**
115
+
116
+ `BaseVendor` 仅暴露**抽象方法** `get_name() -> str`(`src/coding/proxy/vendors/base.py:75-77`),所有派生类(`AnthropicVendor`、`ZhipuVendor`、`CopilotVendor`、`MinimaxVendor`、`DoubaoVendor`、`KimiVendor` 等)均通过 `_vendor_name` 类属性配合 `get_name()` 返回名称 —— **并无 `name` 实例属性**。该错误访问在 lint/类型检查阶段无告警(因 `BaseVendor` 未在类型系统中约束 `name` 字段),仅在运行时触发。
117
+
118
+ 间歇性原因:第 152 行 `if source:` 是守卫;`source` 由 `infer_source_vendor_from_body(body)`(`src/coding/proxy/convert/vendor_channels.py:357-394`)从请求体启发式推断,仅当出现 zhipu 私有产物(`srvtoolu_*` 形式的 `tool_use.id` 或 `server_tool_use` / `server_tool_use_delta` 类型 content block)时返回 `"zhipu"`,否则 `None`。纯净的首轮 count_tokens 请求 `source is None` 自然绕过 153 行,因此 200/500 共存。
119
+
120
+ **处理方式**
121
+
122
+ 1. `routes.py:153,160` 将 `target_vendor.name` 改为 `target_vendor.get_name()`,并将结果提取到局部变量 `target_name` 复用,避免重复方法调用与日志/调用点不一致风险。
123
+ 2. `tests/test_app_routes.py` 新增 `test_count_tokens_triggers_zhipu_to_target_channel`:通过注入 `server_tool_use` + `srvtoolu_*` 让 `infer_source_vendor_from_body` 返回 `"zhipu"`,断言返回 200 且 debug 日志含 `"count_tokens channel zhipu → anthropic"`,证明通道被实际触发。此前 6 个 count_tokens 测试的请求体都是纯净的、未触达该分支,是 bug 长期漏过的根因。
124
+
125
+ **后续防范**
126
+
127
+ - 跨模块引用 Vendor 实例字段时,**统一通过 `BaseVendor` 暴露的方法**(`get_name()`、`map_model()` 等),避免直接访问派生类未定义的"假属性"。
128
+ - 长期演进可考虑在 `BaseVendor` 增加 `@property name` 指向 `get_name()`,将契约前移到类型系统由 mypy / pyright 拦截 —— 该重构属"演进式设计"范畴,不在本次最小干预范围内。
129
+ - 测试覆盖原则:路由层涉及"内容感知"分支(如 `infer_source_vendor_from_body`)时,至少补一个让分支命中的最小用例,避免守卫掩盖代码缺陷。
130
+
131
+ **同类问题影响与处理注意事项**
132
+
133
+ - 已 `grep -rn "vendor\.name\b" src/` 全仓扫描,确认 `target_vendor.name | vendor.name` 误用仅 routes.py 的这两处,已随本次修复一并消除。`/v1/messages` 主链路在 executor 中调用 `tier.name`(`Tier` 对象的合法 dataclass 属性),与 vendor 实例 `name` 无关,不受影响。
134
+ - 若未来新增 Vendor 子类,仍只需实现 `get_name()` 抽象方法;外部调用方应遵循同一契约,本档案的修复模式可作为参考。
@@ -30,7 +30,7 @@
30
30
  ## 🌟 核心特性 (Core Features)
31
31
 
32
32
  <div align="center">
33
- <img src="../../assets/dashboard-v0.2.4.png">
33
+ <img src="../../assets/dashboard-v0.4.0.png">
34
34
  </div>
35
35
 
36
36
  - **⛓️ N-tier 链式故障转移 (Failover)**:自主降序序列,支持 Claude 官方 Plans,以及 GitHub Copilot、Google Antigravity、智谱、MiniMax、阿里千问、小米、Kimi、豆包等的 Coding Plan。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.3.1a10"
3
+ version = "0.4.1a1"
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"
@@ -84,7 +84,10 @@ docstring-code-format = true
84
84
  [tool.pytest.ini_options]
85
85
  asyncio_mode = "auto"
86
86
  testpaths = ["tests"]
87
- addopts = "-v --tb=short"
87
+ addopts = "-v --tb=short -m 'not e2e'"
88
+ markers = [
89
+ "e2e: marks tests as end-to-end (deselect with '-m \"not e2e\"')",
90
+ ]
88
91
  filterwarnings = [
89
92
  "ignore::DeprecationWarning",
90
93
  ]
@@ -219,9 +219,114 @@ def enforce_anthropic_tool_pairing(
219
219
  ", ".join(synthesized_ids),
220
220
  )
221
221
 
222
+ # 纵深防御: sanity 兜底,捕获主循环未覆盖的边角配对漏洞
223
+ adaptations.extend(_enforce_pairing_sanity_pass(messages_list))
224
+
222
225
  return adaptations
223
226
 
224
227
 
228
+ def _enforce_pairing_sanity_pass(
229
+ messages_list: list[dict[str, Any]],
230
+ ) -> list[str]:
231
+ """``enforce_anthropic_tool_pairing`` 主循环之后的纯检测兜底 helper.
232
+
233
+ 职责正交于主循环(不剥离 tool_result、不插入新 user 消息),仅做两件事:
234
+
235
+ 1. 遍历每个 ``role == "assistant"`` 且包含 ``tool_use`` 块的消息,
236
+ 检查 ``messages[i+1]`` 是否为 ``user`` 且包含所有 ``tool_use.id`` 对应
237
+ ``tool_result.tool_use_id``。
238
+ 2. 缺失项在该 user 消息末尾追加 ``is_error=True`` 占位块;如果 next 消息根本
239
+ 不是 user(主循环未触达此分支的退化场景),同样不做插入,仅记录 WARNING
240
+ 供运维定位 —— 该路径正常情况下永不命中(主循环已保证 next user 存在)。
241
+
242
+ 本 helper 单独抽出的目的有两个:
243
+
244
+ - 直接构造"绕过主循环"的输入做单元测试,确保 sanity 分支具备**正向回归保护**
245
+ (历史教训: ``9061cd0`` 引入两遍扫描+sanity 后被 ``2bac9a7`` 连带回滚,
246
+ 重要原因之一是缺乏对兜底路径的独立单测)。
247
+ - 在主循环 A-F 步骤未来重构时,sanity 仍能稳定守住 Anthropic 配对约束。
248
+
249
+ Args:
250
+ messages_list: 消息列表(就地修改)。
251
+
252
+ Returns:
253
+ 新增的 adaptation 标签列表(命中则为 ``["pairing_sanity_repaired"]``,否则空列表)。
254
+ """
255
+ repaired: list[tuple[int, str]] = []
256
+
257
+ for i, msg in enumerate(messages_list):
258
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
259
+ continue
260
+ content = msg.get("content")
261
+ if not isinstance(content, list):
262
+ continue
263
+ tool_use_ids = [
264
+ b["id"]
265
+ for b in content
266
+ if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
267
+ ]
268
+ if not tool_use_ids:
269
+ continue
270
+
271
+ next_idx = i + 1
272
+ if (
273
+ next_idx >= len(messages_list)
274
+ or not isinstance(messages_list[next_idx], dict)
275
+ or messages_list[next_idx].get("role") != "user"
276
+ ):
277
+ # 主循环正常情况下已保证 next 为 user;此处仅日志告警,不做隐式插入
278
+ # 以避免与主循环职责重叠。
279
+ logger.warning(
280
+ "Sanity pass: assistant at messages[%d] has tool_use without "
281
+ "user next message (tool_use_ids=%s). Main enforce loop may have a regression.",
282
+ i,
283
+ ", ".join(tool_use_ids),
284
+ )
285
+ continue
286
+
287
+ user_msg = messages_list[next_idx]
288
+ user_content = user_msg.get("content")
289
+ if not isinstance(user_content, list):
290
+ # 主循环 D 步已将 string content 归一化为 list;这里防御性兜底
291
+ user_msg["content"] = (
292
+ [{"type": "text", "text": user_content}]
293
+ if isinstance(user_content, str)
294
+ else []
295
+ )
296
+ user_content = user_msg["content"]
297
+
298
+ existing_result_ids = {
299
+ b["tool_use_id"]
300
+ for b in user_content
301
+ if isinstance(b, dict)
302
+ and b.get("type") == "tool_result"
303
+ and b.get("tool_use_id")
304
+ }
305
+ for uid in tool_use_ids:
306
+ if uid in existing_result_ids:
307
+ continue
308
+ user_content.append(
309
+ {
310
+ "type": "tool_result",
311
+ "tool_use_id": uid,
312
+ "content": "",
313
+ "is_error": True,
314
+ }
315
+ )
316
+ repaired.append((i, uid))
317
+
318
+ if not repaired:
319
+ return []
320
+
321
+ logger.warning(
322
+ "Sanity pass repaired %d unpaired tool_use(s) missed by main enforce loop. "
323
+ "Affected: %s",
324
+ len(repaired),
325
+ ", ".join(f"messages[{idx}]:{uid}" for idx, uid in repaired),
326
+ )
327
+ return ["pairing_sanity_repaired"]
328
+
329
+
225
330
  def _strip_cache_control(body: dict[str, Any]) -> int:
226
331
  """从 system/messages/tools 中移除 cache_control 字段(就地).
227
332
 
@@ -294,8 +399,22 @@ def _rewrite_srvtoolu_ids(body: dict[str, Any]) -> tuple[int, dict[str, str]]:
294
399
 
295
400
  Anthropic API 要求 tool_use 类型与 ``toolu_*`` 格式的 ID。Zhipu 的
296
401
  ``server_tool_use`` + ``srvtoolu_*`` 在上游 Anthropic 兼容端点可用,但无法
297
- 透传至其他供应商;同时还需重写紧随其后 user 消息中 ``tool_result.tool_use_id``
298
- 引用,保持配对关系。
402
+ 透传至其他供应商;同时还需重写所有 ``tool_result.tool_use_id`` 引用,保持配对关系。
403
+
404
+ **两遍扫描(消除块顺序敏感性)**:
405
+
406
+ - Pass 1: 仅遍历 ``role == "assistant"`` 的消息,按 assistant 出现顺序为每个
407
+ 待改写的 tool_use 分配 ``toolu_normalized_N`` 新 ID,建立完整 ``id_map``。
408
+ - Pass 2: 全量遍历消息,对任意 ``tool_result.tool_use_id ∈ id_map`` 的块
409
+ 原地改写为新 ID(不分 user / assistant,覆盖 misplaced 与跨消息边界场景)。
410
+
411
+ 单遍方案在 GLM-5 偶发将 inline ``tool_result`` 输出在对应 ``server_tool_use``
412
+ 之前的乱序场景下,会因 Case B 时 ``id_map`` 尚未填入而漏改 ``tool_use_id``,
413
+ 导致 ``enforce_anthropic_tool_pairing`` 后 ``extracted_tool_results`` 的 key
414
+ 与 ``tool_use_ids`` 不一致,进而把本应配对的 misplaced tool_result 默默丢弃,
415
+ 最终触发 Anthropic ``messages.x: tool_use ids were found without tool_result
416
+ blocks immediately after`` 400 错误。两遍扫描以"先建表、后改写"的次序消除该
417
+ 时序耦合。
299
418
 
300
419
  Returns:
301
420
  (rewritten_count, id_map) — 重写次数与 {原 ID: 新 ID} 映射。
@@ -308,45 +427,56 @@ def _rewrite_srvtoolu_ids(body: dict[str, Any]) -> tuple[int, dict[str, str]]:
308
427
  counter += 1
309
428
  return f"toolu_normalized_{counter}"
310
429
 
430
+ # Pass 1: 扫描 assistant 消息,改写 tool_use / server_tool_use 的 id 与 type,
431
+ # 按出现顺序填充 id_map(保持与单遍版本相同的序号分配,避免破坏既有断言)。
311
432
  for message in body.get("messages", []):
312
- if not isinstance(message, dict):
433
+ if not isinstance(message, dict) or message.get("role") != "assistant":
313
434
  continue
314
435
  content = message.get("content")
315
436
  if not isinstance(content, list):
316
437
  continue
317
- role = message.get("role")
318
438
  for block in content:
319
439
  if not isinstance(block, dict):
320
440
  continue
321
441
  block_type = block.get("type")
442
+ if block_type not in {"tool_use", "server_tool_use"}:
443
+ continue
322
444
  block_id = block.get("id")
323
-
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":
445
+ if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
446
+ block_id
447
+ ):
448
+ new_id = next_id()
449
+ id_map[block_id] = new_id
450
+ block["id"] = new_id
451
+ block["type"] = "tool_use"
452
+ elif (
453
+ isinstance(block_id, str)
454
+ and block_id
455
+ and not _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
456
+ and block.get("name")
457
+ ):
458
+ # 非标准 ID(非 toolu_ / srvtoolu_),且具备 name 可改写
459
+ new_id = next_id()
460
+ id_map[block_id] = new_id
461
+ block["id"] = new_id
462
+ block["type"] = "tool_use"
463
+ elif block_type == "server_tool_use" and isinstance(block_id, str):
464
+ # 兜底: 类型是 server_tool_use 但 ID 已是标准 toolu_ 形式,仅纠正类型
465
+ block["type"] = "tool_use"
466
+
467
+ # Pass 2: 全量扫描,对任意 tool_result.tool_use_id 命中 id_map 的块同步改写。
468
+ if id_map:
469
+ for message in body.get("messages", []):
470
+ if not isinstance(message, dict):
471
+ continue
472
+ content = message.get("content")
473
+ if not isinstance(content, list):
474
+ continue
475
+ for block in content:
476
+ if not isinstance(block, dict):
477
+ continue
478
+ if block.get("type") != "tool_result":
479
+ continue
350
480
  tool_use_id = block.get("tool_use_id")
351
481
  if isinstance(tool_use_id, str) and tool_use_id in id_map:
352
482
  block["tool_use_id"] = id_map[tool_use_id]
@@ -18,6 +18,7 @@ import logging
18
18
  import time
19
19
  from collections.abc import AsyncIterator
20
20
  from typing import TYPE_CHECKING
21
+ from urllib.parse import unquote
21
22
 
22
23
  import httpx
23
24
 
@@ -172,8 +173,16 @@ class NativeProxyHandler:
172
173
  )
173
174
 
174
175
  method = request.method.upper()
175
- operation = OperationClassifier.classify(provider, method, rest_path)
176
- endpoint = rest_path if rest_path.startswith("/") else f"/{rest_path}"
176
+ # 防御性 URL 解码:确保 %3A → : 以兼容 Gemini :verb 路径语法。
177
+ # ASGI 规范要求 scope["path"] 已解码,但部分服务器/反向代理对
178
+ # 合法路径字符(如冒号)可能保留编码形态。
179
+ decoded_rest_path = unquote(rest_path)
180
+ operation = OperationClassifier.classify(provider, method, decoded_rest_path)
181
+ endpoint = (
182
+ decoded_rest_path
183
+ if decoded_rest_path.startswith("/")
184
+ else f"/{decoded_rest_path}"
185
+ )
177
186
 
178
187
  upstream_headers = _filter_request_headers(dict(request.headers))
179
188
  # 强制 identity —— 阻止上游压缩(httpx 默认会自动补 gzip,deflate;
@@ -51,27 +51,27 @@ _OPENAI_RULES: tuple[_Rule, ...] = (
51
51
  # Gemini 的方法动词作为路径后缀(``:generateContent``),通过正则提取
52
52
  _GEMINI_RULES: tuple[_Rule, ...] = (
53
53
  _Rule(
54
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:streamGenerateContent/?$"),
54
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)streamGenerateContent/?$"),
55
55
  "generate_content",
56
56
  ),
57
57
  _Rule(
58
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:generateContent/?$"),
58
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)generateContent/?$"),
59
59
  "generate_content",
60
60
  ),
61
61
  _Rule(
62
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:countTokens/?$"),
62
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)countTokens/?$"),
63
63
  "count_tokens",
64
64
  ),
65
65
  _Rule(
66
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:embedContent/?$"),
66
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)embedContent/?$"),
67
67
  "embedding",
68
68
  ),
69
69
  _Rule(
70
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:batchEmbedContents/?$"),
70
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)batchEmbedContents/?$"),
71
71
  "embedding.batch",
72
72
  ),
73
73
  _Rule(
74
- re.compile(r"^/?v1(?:beta)?/models/[^/]+:predict/?$"),
74
+ re.compile(r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)predict/?$"),
75
75
  "predict",
76
76
  ),
77
77
  _Rule(
@@ -159,7 +159,8 @@ class OperationClassifier:
159
159
  normalized = path if path.startswith("/") else f"/{path}"
160
160
  return bool(
161
161
  re.match(
162
- r"^/?v1(?:beta)?/models/[^/]+:streamGenerateContent/?$", normalized
162
+ r"^/?v1(?:beta)?/models/[^/]+(?:%3A|:)streamGenerateContent/?$",
163
+ normalized,
163
164
  )
164
165
  )
165
166