coding-proxy 0.4.0__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.4.0 → coding_proxy-0.4.1a1}/PKG-INFO +2 -2
  2. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/README.md +1 -1
  3. coding_proxy-0.4.1a1/assets/dashboard-v0.4.0.png +0 -0
  4. coding_proxy-0.4.1a1/docs/issue.md +134 -0
  5. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/zh-CN/README.md +1 -1
  6. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/pyproject.toml +5 -2
  7. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/vendor_channels.py +161 -31
  8. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/handler.py +11 -2
  9. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/operation.py +8 -7
  10. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/routes.py +3 -2
  11. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/antigravity.py +35 -16
  12. coding_proxy-0.4.1a1/tests/e2e/__init__.py +0 -0
  13. coding_proxy-0.4.1a1/tests/e2e/conftest.py +199 -0
  14. coding_proxy-0.4.1a1/tests/e2e/test_e2e_http.py +263 -0
  15. coding_proxy-0.4.1a1/tests/e2e/test_e2e_token.py +93 -0
  16. coding_proxy-0.4.1a1/tests/e2e/test_e2e_vendor.py +327 -0
  17. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_antigravity.py +8 -9
  18. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_app_routes.py +70 -0
  19. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_api_handler.py +71 -0
  20. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_api_operation.py +17 -0
  21. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_vendor_channels.py +418 -0
  22. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/uv.lock +1 -1
  23. coding_proxy-0.4.0/assets/dashboard-v0.2.4.png +0 -0
  24. coding_proxy-0.4.0/docs/issue.md +0 -47
  25. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/.github/workflows/ci.yml +0 -0
  26. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/.github/workflows/coverage.yml +0 -0
  27. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/.github/workflows/release.yml +0 -0
  28. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/.gitignore +0 -0
  29. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/.pre-commit-config.yaml +0 -0
  30. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/AGENTS.md +0 -0
  31. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/CHANGELOG.md +0 -0
  32. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/CLAUDE.md +0 -0
  33. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/LICENSE +0 -0
  34. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/assets/session-v0.4.0.png +0 -0
  35. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/config-reference.md +0 -0
  36. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/convert.md +0 -0
  37. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/design-patterns.md +0 -0
  38. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/routing.md +0 -0
  39. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/testing.md +0 -0
  40. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/arch/vendors.md +0 -0
  41. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/ci-cd.md +0 -0
  42. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/framework.md +0 -0
  43. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/api-reference.md +0 -0
  44. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/cli-reference.md +0 -0
  45. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/dashboard.md +0 -0
  46. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/monitoring.md +0 -0
  47. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/quickstart.md +0 -0
  48. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/guide/vendors.md +0 -0
  49. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/docs/user-guide.md +0 -0
  50. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/__init__.py +0 -0
  51. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/__init__.py +0 -0
  52. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/__main__.py +0 -0
  53. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/__init__.py +0 -0
  54. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/__init__.py +0 -0
  55. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/base.py +0 -0
  56. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/github.py +0 -0
  57. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/providers/google.py +0 -0
  58. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/runtime.py +0 -0
  59. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/auth/store.py +0 -0
  60. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/__init__.py +0 -0
  61. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/auth_commands.py +0 -0
  62. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/cli/banner.py +0 -0
  63. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/__init__.py +0 -0
  64. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/canonical.py +0 -0
  65. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/compat/session_store.py +0 -0
  66. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/__init__.py +0 -0
  67. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/auth_schema.py +0 -0
  68. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/config.default.yaml +0 -0
  69. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/loader.py +0 -0
  70. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/resiliency.py +0 -0
  71. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/routing.py +0 -0
  72. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/schema.py +0 -0
  73. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/server.py +0 -0
  74. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/session_policy.py +0 -0
  75. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/config/vendors.py +0 -0
  76. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/__init__.py +0 -0
  77. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  78. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  79. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  80. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  81. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  82. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/__init__.py +0 -0
  83. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/db.py +0 -0
  84. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/formatters.py +0 -0
  85. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/logging/stats.py +0 -0
  86. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/__init__.py +0 -0
  87. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/auth.py +0 -0
  88. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/compat.py +0 -0
  89. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/constants.py +0 -0
  90. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/pricing.py +0 -0
  91. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/token.py +0 -0
  92. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/model/vendor.py +0 -0
  93. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/__init__.py +0 -0
  94. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/config.py +0 -0
  95. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  96. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  97. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  98. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  99. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/routes.py +0 -0
  100. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/native_api/usage_registry.py +0 -0
  101. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/pricing.py +0 -0
  102. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/__init__.py +0 -0
  103. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  104. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/error_classifier.py +0 -0
  105. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/executor.py +0 -0
  106. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/model_mapper.py +0 -0
  107. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/quota_guard.py +0 -0
  108. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/rate_limit.py +0 -0
  109. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/retry.py +0 -0
  110. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/router.py +0 -0
  111. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/session_manager.py +0 -0
  112. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/session_policy.py +0 -0
  113. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/tier.py +0 -0
  114. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/usage_parser.py +0 -0
  115. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/routing/usage_recorder.py +0 -0
  116. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/__init__.py +0 -0
  117. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/app.py +0 -0
  118. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/dashboard.py +0 -0
  119. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/factory.py +0 -0
  120. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/server/responses.py +0 -0
  121. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/streaming/__init__.py +0 -0
  122. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  123. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/__init__.py +0 -0
  124. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/alibaba.py +0 -0
  125. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/anthropic.py +0 -0
  126. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/base.py +0 -0
  127. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot.py +0 -0
  128. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_models.py +0 -0
  129. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  130. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  131. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/doubao.py +0 -0
  132. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/kimi.py +0 -0
  133. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/minimax.py +0 -0
  134. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/mixins.py +0 -0
  135. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  136. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/token_manager.py +0 -0
  137. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/xiaomi.py +0 -0
  138. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/src/coding/proxy/vendors/zhipu.py +0 -0
  139. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/__init__.py +0 -0
  140. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_auto_login.py +0 -0
  141. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_banner.py +0 -0
  142. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_circuit_breaker.py +0 -0
  143. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_cli_usage.py +0 -0
  144. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_compat.py +0 -0
  145. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_config_init.py +0 -0
  146. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_config_loader.py +0 -0
  147. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_convert_request.py +0 -0
  148. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_convert_response.py +0 -0
  149. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_convert_sse.py +0 -0
  150. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_copilot.py +0 -0
  151. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_copilot_convert_request.py +0 -0
  152. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_copilot_convert_response.py +0 -0
  153. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_copilot_models.py +0 -0
  154. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_copilot_urls.py +0 -0
  155. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_currency.py +0 -0
  156. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_error_classifier.py +0 -0
  157. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_logging_dual_write.py +0 -0
  158. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_mixins.py +0 -0
  159. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_auth.py +0 -0
  160. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_compat.py +0 -0
  161. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_constants.py +0 -0
  162. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_mapper.py +0 -0
  163. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_pricing.py +0 -0
  164. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_token.py +0 -0
  165. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_model_vendor.py +0 -0
  166. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_api_base_url_override.py +0 -0
  167. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_api_extractors.py +0 -0
  168. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_api_routes.py +0 -0
  169. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_native_vendors.py +0 -0
  170. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_parse_usage.py +0 -0
  171. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_parse_usage_gemini.py +0 -0
  172. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_pricing.py +0 -0
  173. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_quota_guard.py +0 -0
  174. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_rate_limit.py +0 -0
  175. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_router_chain.py +0 -0
  176. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_router_executor.py +0 -0
  177. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_runtime_reauth.py +0 -0
  178. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_schema.py +0 -0
  179. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_session_aware.py +0 -0
  180. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_streaming_anthropic_compat.py +0 -0
  181. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_tier.py +0 -0
  182. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_tiers_config.py +0 -0
  183. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_time_range.py +0 -0
  184. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_token_logger.py +0 -0
  185. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_token_logger_native_columns.py +0 -0
  186. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_token_manager.py +0 -0
  187. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_types.py +0 -0
  188. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_vendor_streaming.py +0 -0
  189. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_vendors.py +0 -0
  190. {coding_proxy-0.4.0 → coding_proxy-0.4.1a1}/tests/test_zhipu.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.4.0
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.4.0"
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
 
@@ -150,14 +150,15 @@ def register_core_routes(app: Any, router: Any) -> None:
150
150
 
151
151
  source = infer_source_vendor_from_body(body)
152
152
  if source:
153
- channel_fn = get_transition_channel(source, target_vendor.name)
153
+ target_name = target_vendor.get_name()
154
+ channel_fn = get_transition_channel(source, target_name)
154
155
  if channel_fn is not None:
155
156
  body, adaptations = channel_fn(body)
156
157
  if adaptations:
157
158
  logger.debug(
158
159
  "count_tokens channel %s → %s: %s",
159
160
  source,
160
- target_vendor.name,
161
+ target_name,
161
162
  ", ".join(adaptations),
162
163
  )
163
164
 
@@ -141,7 +141,14 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
141
141
  config.refresh_token,
142
142
  )
143
143
  TokenBackendMixin.__init__(self, token_manager)
144
- BaseVendor.__init__(self, config.base_url, config.timeout_ms, failover_config)
144
+ # v1internal 模式:base_url 需要去除 /v1internal 路径后缀,
145
+ # 因为 endpoint 使用完整路径 /v1internal:generateContent(冒号格式)。
146
+ # httpx 会将 base_url path 与 endpoint path 拼接,
147
+ # 如果 base_url 含 /v1internal 会导致路径重复。
148
+ init_base_url = config.base_url
149
+ if init_base_url.rstrip("/").endswith("/v1internal"):
150
+ init_base_url = init_base_url.rstrip("/").removesuffix("/v1internal")
151
+ BaseVendor.__init__(self, init_base_url, config.timeout_ms, failover_config)
145
152
  self._model_endpoint = config.model_endpoint
146
153
  self._model_mapper = model_mapper
147
154
  self._default_model = config.model_endpoint.removeprefix("models/")
@@ -149,6 +156,7 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
149
156
  self._safety_settings = config.safety_settings
150
157
  # v1internal 协议字段
151
158
  self._project_id: str = config.project_id
159
+ self._v1internal_enabled: bool = "v1internal" in config.base_url
152
160
  self._session_id: str = uuid.uuid4().hex[:16]
153
161
  self._message_count: int = 0
154
162
  # project_id 自动发现状态
@@ -159,8 +167,11 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
159
167
  return "antigravity"
160
168
 
161
169
  def _is_v1internal_mode(self) -> bool:
162
- """检测是否启用 v1internal 协议模式(与 Antigravity-Manager 对齐)."""
163
- return bool(self._effective_project_id) and "v1internal" in self._base_url
170
+ """检测是否启用 v1internal 协议模式(与 Antigravity-Manager 对齐).
171
+
172
+ v1internal 协议由原始配置的 base_url 路径或 project_id 自动发现触发。
173
+ """
174
+ return self._v1internal_enabled
164
175
 
165
176
  @property
166
177
  def _effective_project_id(self) -> str:
@@ -229,7 +240,11 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
229
240
  return ""
230
241
 
231
242
  # 发现成功:原子性切换到 v1internal 模式
232
- self._base_url = _V1INTERNAL_BASE_URL
243
+ # base_url 只保留域名部分(去除 /v1internal 路径后缀)
244
+ self._base_url = _V1INTERNAL_BASE_URL.rstrip("/").removesuffix(
245
+ "/v1internal"
246
+ )
247
+ self._v1internal_enabled = True
233
248
  self._project_id_discovered = project_id
234
249
 
235
250
  # 重建 HTTP 客户端(base_url 是初始化参数)
@@ -339,8 +354,13 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
339
354
  self._last_request_adaptations = converted.adaptations
340
355
  token = await self._token_manager.get_token()
341
356
 
342
- # 懒加载:未配置 project_id 时自动发现并切换 v1internal 模式
343
- if not self._project_id and not self._project_discovery_attempted:
357
+ # 懒加载:未配置 project_id 时尝试自动发现(仅标准 GLA 模式需要)
358
+ # v1internal 模式不依赖 project_id,跳过发现
359
+ if (
360
+ not self._project_id
361
+ and not self._project_discovery_attempted
362
+ and not self._v1internal_enabled
363
+ ):
344
364
  discovered = await self._discover_project_id(token)
345
365
  if discovered:
346
366
  logger.info(
@@ -450,11 +470,11 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
450
470
  body, prepared_headers = await self._prepare_request(request_body, headers)
451
471
  client = self._get_client()
452
472
  resolved_model = self._last_resolved_model
453
- endpoint = (
454
- ":generateContent"
455
- if self._is_v1internal_mode()
456
- else f"/models/{resolved_model}:generateContent"
457
- )
473
+ if self._is_v1internal_mode():
474
+ # v1internal 端点需要完整路径(冒号格式)覆盖 base_url 的 path 部分
475
+ endpoint = "/v1internal:generateContent"
476
+ else:
477
+ endpoint = f"/models/{resolved_model}:generateContent"
458
478
 
459
479
  logger.debug("send_message: POST %s", endpoint)
460
480
  response = await client.post(endpoint, json=body, headers=prepared_headers)
@@ -496,11 +516,10 @@ class AntigravityVendor(TokenBackendMixin, BaseVendor):
496
516
  body, prepared_headers = await self._prepare_request(request_body, headers)
497
517
  client = self._get_client()
498
518
  resolved_model = self._last_resolved_model
499
- endpoint = (
500
- ":streamGenerateContent?alt=sse"
501
- if self._is_v1internal_mode()
502
- else f"/models/{resolved_model}:streamGenerateContent?alt=sse"
503
- )
519
+ if self._is_v1internal_mode():
520
+ endpoint = "/v1internal:streamGenerateContent?alt=sse"
521
+ else:
522
+ endpoint = f"/models/{resolved_model}:streamGenerateContent?alt=sse"
504
523
 
505
524
  logger.debug("send_message_stream: POST %s", endpoint)
506
525
 
File without changes