coding-proxy 0.5.1a5__tar.gz → 0.5.1a7__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 (196) hide show
  1. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/PKG-INFO +1 -1
  2. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/pyproject.toml +1 -1
  3. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/config.default.yaml +8 -1
  4. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/schema.py +2 -1
  5. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/session_policy.py +24 -0
  6. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/executor.py +77 -5
  7. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/router.py +3 -0
  8. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/app.py +1 -0
  9. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_router_executor.py +324 -0
  10. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/uv.lock +1 -1
  11. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/.github/workflows/ci.yml +0 -0
  12. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/.github/workflows/coverage.yml +0 -0
  13. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/.github/workflows/release.yml +0 -0
  14. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/.gitignore +0 -0
  15. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/.pre-commit-config.yaml +0 -0
  16. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/AGENTS.md +0 -0
  17. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/CHANGELOG.md +0 -0
  18. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/CLAUDE.md +0 -0
  19. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/LICENSE +0 -0
  20. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/README.md +0 -0
  21. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/assets/dashboard-v0.4.0.png +0 -0
  22. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/assets/model-calling-v0.5.0.png +0 -0
  23. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/assets/session-v0.4.0.png +0 -0
  24. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/.agents/browser-validation.md +0 -0
  25. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/.agents/issue.md +0 -0
  26. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/.agents/knowledge-map.md +0 -0
  27. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/.agents/reference-specifications.md +0 -0
  28. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/config-reference.md +0 -0
  29. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/convert.md +0 -0
  30. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/design-patterns.md +0 -0
  31. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/routing.md +0 -0
  32. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/testing.md +0 -0
  33. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/arch/vendors.md +0 -0
  34. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/framework.md +0 -0
  35. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/api-reference.md +0 -0
  36. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/cli-reference.md +0 -0
  37. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/dashboard.md +0 -0
  38. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/monitoring.md +0 -0
  39. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/quickstart.md +0 -0
  40. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/guide/vendors.md +0 -0
  41. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/ops/ci-cd.md +0 -0
  42. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/user-guide.md +0 -0
  43. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/docs/zh-CN/README.md +0 -0
  44. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/__init__.py +0 -0
  45. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/__init__.py +0 -0
  46. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/__main__.py +0 -0
  47. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/__init__.py +0 -0
  48. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/providers/__init__.py +0 -0
  49. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/providers/base.py +0 -0
  50. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/providers/github.py +0 -0
  51. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/providers/google.py +0 -0
  52. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/runtime.py +0 -0
  53. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/auth/store.py +0 -0
  54. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/cli/__init__.py +0 -0
  55. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/cli/auth_commands.py +0 -0
  56. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/cli/banner.py +0 -0
  57. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/compat/__init__.py +0 -0
  58. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/compat/canonical.py +0 -0
  59. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/compat/session_store.py +0 -0
  60. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/__init__.py +0 -0
  61. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/auth_schema.py +0 -0
  62. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/loader.py +0 -0
  63. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/resiliency.py +0 -0
  64. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/routing.py +0 -0
  65. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/server.py +0 -0
  66. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/config/vendors.py +0 -0
  67. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/__init__.py +0 -0
  68. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  69. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  70. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  71. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  72. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  73. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/convert/vendor_channels.py +0 -0
  74. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/logging/__init__.py +0 -0
  75. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/logging/db.py +0 -0
  76. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/logging/formatters.py +0 -0
  77. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/logging/stats.py +0 -0
  78. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/__init__.py +0 -0
  79. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/auth.py +0 -0
  80. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/compat.py +0 -0
  81. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/constants.py +0 -0
  82. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/pricing.py +0 -0
  83. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/token.py +0 -0
  84. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/model/vendor.py +0 -0
  85. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/__init__.py +0 -0
  86. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/config.py +0 -0
  87. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  88. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  89. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  90. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  91. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/handler.py +0 -0
  92. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/operation.py +0 -0
  93. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/routes.py +0 -0
  94. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/native_api/usage_registry.py +0 -0
  95. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/pricing.py +0 -0
  96. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/__init__.py +0 -0
  97. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  98. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/error_classifier.py +0 -0
  99. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/model_mapper.py +0 -0
  100. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/quota_guard.py +0 -0
  101. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/rate_limit.py +0 -0
  102. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/retry.py +0 -0
  103. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/session_manager.py +0 -0
  104. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/session_policy.py +0 -0
  105. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/tier.py +0 -0
  106. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/usage_parser.py +0 -0
  107. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/routing/usage_recorder.py +0 -0
  108. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/__init__.py +0 -0
  109. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/dashboard.py +0 -0
  110. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/factory.py +0 -0
  111. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/responses.py +0 -0
  112. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/server/routes.py +0 -0
  113. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/streaming/__init__.py +0 -0
  114. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  115. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/__init__.py +0 -0
  116. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/alibaba.py +0 -0
  117. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/anthropic.py +0 -0
  118. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/antigravity.py +0 -0
  119. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/base.py +0 -0
  120. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/concurrency.py +0 -0
  121. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/copilot.py +0 -0
  122. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/copilot_models.py +0 -0
  123. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  124. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  125. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/doubao.py +0 -0
  126. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/kimi.py +0 -0
  127. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/minimax.py +0 -0
  128. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/mixins.py +0 -0
  129. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  130. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/token_manager.py +0 -0
  131. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/xiaomi.py +0 -0
  132. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/src/coding/proxy/vendors/zhipu.py +0 -0
  133. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/__init__.py +0 -0
  134. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/e2e/__init__.py +0 -0
  135. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/e2e/conftest.py +0 -0
  136. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/e2e/test_e2e_http.py +0 -0
  137. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/e2e/test_e2e_token.py +0 -0
  138. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/e2e/test_e2e_vendor.py +0 -0
  139. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_antigravity.py +0 -0
  140. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_app_routes.py +0 -0
  141. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_auto_login.py +0 -0
  142. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_banner.py +0 -0
  143. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_circuit_breaker.py +0 -0
  144. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_cli_usage.py +0 -0
  145. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_compat.py +0 -0
  146. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_concurrency_monitor.py +0 -0
  147. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_config_init.py +0 -0
  148. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_config_loader.py +0 -0
  149. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_convert_request.py +0 -0
  150. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_convert_response.py +0 -0
  151. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_convert_sse.py +0 -0
  152. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_copilot.py +0 -0
  153. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_copilot_convert_request.py +0 -0
  154. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_copilot_convert_response.py +0 -0
  155. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_copilot_models.py +0 -0
  156. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_copilot_urls.py +0 -0
  157. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_currency.py +0 -0
  158. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_error_classifier.py +0 -0
  159. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_executor_in_flight_tracking.py +0 -0
  160. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_logging_dual_write.py +0 -0
  161. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_mixins.py +0 -0
  162. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_auth.py +0 -0
  163. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_compat.py +0 -0
  164. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_constants.py +0 -0
  165. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_mapper.py +0 -0
  166. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_pricing.py +0 -0
  167. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_token.py +0 -0
  168. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_model_vendor.py +0 -0
  169. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_api_base_url_override.py +0 -0
  170. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_api_extractors.py +0 -0
  171. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_api_handler.py +0 -0
  172. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_api_operation.py +0 -0
  173. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_api_routes.py +0 -0
  174. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_native_vendors.py +0 -0
  175. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_parse_usage.py +0 -0
  176. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_parse_usage_gemini.py +0 -0
  177. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_pricing.py +0 -0
  178. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_quota_guard.py +0 -0
  179. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_rate_limit.py +0 -0
  180. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_router_chain.py +0 -0
  181. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_runtime_reauth.py +0 -0
  182. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_schema.py +0 -0
  183. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_session_aware.py +0 -0
  184. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_streaming_anthropic_compat.py +0 -0
  185. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_tier.py +0 -0
  186. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_tiers_config.py +0 -0
  187. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_time_range.py +0 -0
  188. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_token_logger.py +0 -0
  189. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_token_logger_native_columns.py +0 -0
  190. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_token_manager.py +0 -0
  191. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_types.py +0 -0
  192. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_vendor_channels.py +0 -0
  193. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_vendor_streaming.py +0 -0
  194. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_vendors.py +0 -0
  195. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_zhipu.py +0 -0
  196. {coding_proxy-0.5.1a5 → coding_proxy-0.5.1a7}/tests/test_zhipu_concurrency.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.5.1a5
3
+ Version: 0.5.1a7
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.5.1a5"
3
+ version = "0.5.1a7"
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"
@@ -685,4 +685,11 @@ native_api:
685
685
  # tiers: ["copilot", "anthropic", "zhipu"]
686
686
  #
687
687
  # 未配置时(默认),所有 Session 使用全局 tiers 顺序。
688
- session_policies: []
688
+ session_policies:
689
+ policies: []
690
+ # 标题前缀 → 供应商自动绑定。
691
+ # 当 Session 标题以指定前缀开头时,自动将该 Session 绑定到对应供应商。
692
+ # 匹配规则按列表顺序求值,首次匹配生效。
693
+ title_vendor_bindings:
694
+ - prefix: "# 目标"
695
+ vendor: "zhipu"
@@ -44,7 +44,7 @@ from .routing import ( # noqa: F401
44
44
 
45
45
  # ── 子模块 re-export ────────────────────────────────────────────
46
46
  from .server import DatabaseConfig, LoggingConfig, ServerConfig # noqa: F401
47
- from .session_policy import SessionPoliciesConfig # noqa: F401
47
+ from .session_policy import SessionPoliciesConfig, TitleVendorBinding # noqa: F401
48
48
  from .vendors import ( # noqa: F401
49
49
  AlibabaConfig,
50
50
  AnthropicConfig,
@@ -350,4 +350,5 @@ __all__ = [
350
350
  "NativeApiConfig",
351
351
  # session policy
352
352
  "SessionPoliciesConfig",
353
+ "TitleVendorBinding",
353
354
  ]
@@ -50,6 +50,22 @@ class SessionPolicy(BaseModel):
50
50
  )
51
51
 
52
52
 
53
+ class TitleVendorBinding(BaseModel):
54
+ """标题前缀 → 供应商自动绑定规则."""
55
+
56
+ prefix: str = Field(
57
+ min_length=1,
58
+ description=(
59
+ "标题前缀匹配模式(大小写敏感的 startswith 匹配)。"
60
+ "禁止空字符串——空前缀会匹配所有标题,导致全量误绑定。"
61
+ ),
62
+ )
63
+ vendor: str = Field(
64
+ min_length=1,
65
+ description="匹配后绑定的目标供应商名称",
66
+ )
67
+
68
+
53
69
  class SessionPoliciesConfig(BaseModel):
54
70
  """顶层 Session 策略配置容器."""
55
71
 
@@ -57,3 +73,11 @@ class SessionPoliciesConfig(BaseModel):
57
73
  default_factory=list,
58
74
  description="Session 路由策略列表,按定义顺序求值,首次匹配生效",
59
75
  )
76
+ title_vendor_bindings: list[TitleVendorBinding] = Field(
77
+ default_factory=list,
78
+ description=(
79
+ "标题前缀 → 供应商自动绑定规则。"
80
+ "当 Session 标题以指定前缀开头时,自动绑定到对应供应商。"
81
+ "匹配规则按列表顺序求值,首次匹配生效。"
82
+ ),
83
+ )
@@ -11,10 +11,13 @@ import logging
11
11
  import re
12
12
  import time
13
13
  from collections.abc import AsyncIterator
14
- from typing import Any
14
+ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  import httpx
17
17
 
18
+ if TYPE_CHECKING:
19
+ from ..config.session_policy import TitleVendorBinding
20
+
18
21
  from ..vendors.base import (
19
22
  NoCompatibleVendorError,
20
23
  RequestCapabilities,
@@ -71,6 +74,11 @@ _NOISE_TAG_PATTERN = re.compile(
71
74
  flags=re.DOTALL | re.IGNORECASE,
72
75
  )
73
76
 
77
+ # <session> 标签需要特殊处理:当用户文本在 <session> 标签内部时,
78
+ # 完整块剥离会连同用户文本一起删除。此模式仅去除外壳标签(保留内容),
79
+ # 用于首轮完整剥离结果为空时的二次回退提取。
80
+ _SESSION_TAG_WRAPPER = re.compile(r"</?session\b[^>]*>", flags=re.IGNORECASE)
81
+
74
82
  # Slash command 子标签:用于识别 /commit、/review 等命令式调用,
75
83
  # 合成"命令 + 参数"式标题。
76
84
  _CMD_NAME_PATTERN = re.compile(r"<command-name>(.*?)</command-name>", flags=re.DOTALL)
@@ -80,6 +88,9 @@ _CMD_WRAPPER_PATTERN = re.compile(
80
88
  r"<command-[\w-]+>.*?</command-[\w-]+>", flags=re.DOTALL
81
89
  )
82
90
 
91
+ # 空白折叠
92
+ _WHITESPACE_PATTERN = re.compile(r"\s+")
93
+
83
94
 
84
95
  def _sanitize_user_text(raw: str) -> str:
85
96
  """剔除 Claude Code 注入的系统级 XML 块,还原真实用户输入。
@@ -88,8 +99,10 @@ def _sanitize_user_text(raw: str) -> str:
88
99
  1. Slash command 优先识别 — 若检测到 <command-name>,合成"命令 + 参数"
89
100
  式标题(因为残留文本通常为空,直接取标签内容更有意义)。
90
101
  2. 通用噪声剥离 — 移除已知白名单内的 system-reminder 等标签。
91
- 3. 残留 command-* 包裹清除 兜底去除 command-message 等次要标签。
92
- 4. 前后空白归一化 — 折叠连续空白为单空格,便于 30 字截断。
102
+ 3. <session> 二次回退若首轮剥离后为空,说明用户文本可能在 <session>
103
+ 标签内部;此时仅去除外壳标签,保留内部文本再做噪声剥离。
104
+ 4. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。
105
+ 5. 前后空白归一化 — 折叠连续空白为单空格。
93
106
  """
94
107
  if not raw:
95
108
  return ""
@@ -107,9 +120,22 @@ def _sanitize_user_text(raw: str) -> str:
107
120
  # 阶段二: 通用噪声剥离
108
121
  cleaned = _NOISE_TAG_PATTERN.sub("", raw)
109
122
  cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned)
123
+ cleaned = _WHITESPACE_PATTERN.sub(" ", cleaned).strip()
124
+ if cleaned:
125
+ return cleaned
126
+
127
+ # 阶段三: <session> 二次回退
128
+ # 当首轮全部剥离为空时,用户文本很可能被 <session> 标签完整包裹。
129
+ # 此时不去除 <session> 块,而是仅剥掉外壳标签,保留内部文本后重新剥离。
130
+ if "<session" in raw.lower():
131
+ inner = _SESSION_TAG_WRAPPER.sub("", raw)
132
+ cleaned = _NOISE_TAG_PATTERN.sub("", inner)
133
+ cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned)
134
+ cleaned = _WHITESPACE_PATTERN.sub(" ", cleaned).strip()
135
+ if cleaned:
136
+ return cleaned
110
137
 
111
- # 阶段三: 空白折叠
112
- return re.sub(r"\s+", " ", cleaned).strip()
138
+ return ""
113
139
 
114
140
 
115
141
  # ── Session 标题提取: 多层级回退策略 ──────────────────────────────
@@ -587,6 +613,7 @@ class _RouteExecutor:
587
613
  session_manager: RouteSessionManager,
588
614
  reauth_coordinator: Any | None = None,
589
615
  session_policy_resolver: SessionPolicyResolver | None = None,
616
+ title_vendor_bindings: list[TitleVendorBinding] | None = None,
590
617
  ) -> None:
591
618
  self._router = router
592
619
  self._tiers = tiers
@@ -594,6 +621,8 @@ class _RouteExecutor:
594
621
  self._session_mgr = session_manager
595
622
  self._reauth_coordinator = reauth_coordinator
596
623
  self._policy_resolver = session_policy_resolver or SessionPolicyResolver()
624
+ self._title_vendor_bindings = title_vendor_bindings or []
625
+ self._validate_title_vendor_bindings()
597
626
 
598
627
  # Tier 名称 → OAuth provider 名称的映射
599
628
  self._tier_provider_map: dict[str, str] = {
@@ -601,6 +630,26 @@ class _RouteExecutor:
601
630
  "antigravity": "google",
602
631
  }
603
632
 
633
+ def _validate_title_vendor_bindings(self) -> None:
634
+ """启动期校验标题绑定引用的 vendor 均存在,缺失则告警.
635
+
636
+ 与手动绑定 API(拒绝未知 vendor)的语义对齐:此处不硬失败,
637
+ 仅记录警告——避免单条误配置阻断整个代理启动;运行时
638
+ `_resolve_effective_tiers` 会静默跳过未知 vendor 回退默认顺序。
639
+ """
640
+ if not self._title_vendor_bindings:
641
+ return
642
+ valid = {t.name for t in self._tiers}
643
+ for binding in self._title_vendor_bindings:
644
+ if binding.vendor not in valid:
645
+ logger.warning(
646
+ "title_vendor_bindings 引用了未知 vendor %r(前缀 %r);"
647
+ "可用 vendor: %s。该绑定将在运行时被静默跳过。",
648
+ binding.vendor,
649
+ binding.prefix,
650
+ sorted(valid),
651
+ )
652
+
604
653
  # ── 公开执行入口 ──────────────────────────────────────
605
654
 
606
655
  def _resolve_effective_tiers(self, session_key: str) -> list[VendorTier]:
@@ -627,6 +676,27 @@ class _RouteExecutor:
627
676
  seen.add(tier.name)
628
677
  return ordered
629
678
 
679
+ def _apply_title_based_policy(self, session_key: str, title: str) -> None:
680
+ """根据 Session 标题前缀自动绑定供应商.
681
+
682
+ 当标题以预配置的前缀开头时,通过 SessionPolicyResolver.upsert()
683
+ 将该 Session 绑定到指定供应商,后续请求无需再走默认路由。
684
+
685
+ 仅在新 Session 首次提取标题时调用,避免覆盖手动绑定的策略。
686
+ """
687
+ if not title or not self._title_vendor_bindings:
688
+ return
689
+ for binding in self._title_vendor_bindings:
690
+ if title.startswith(binding.prefix):
691
+ self._policy_resolver.upsert(session_key, [binding.vendor])
692
+ logger.info(
693
+ "Session title prefix %r matched → auto-bind to %s (session=%s)",
694
+ binding.prefix,
695
+ binding.vendor,
696
+ session_key[:12],
697
+ )
698
+ return
699
+
630
700
  def _prepare_body_for_tier(
631
701
  self,
632
702
  body: dict[str, Any],
@@ -725,6 +795,7 @@ class _RouteExecutor:
725
795
  await self._recorder.set_session_title(
726
796
  canonical_request.session_key, title
727
797
  )
798
+ self._apply_title_based_policy(canonical_request.session_key, title)
728
799
  else:
729
800
  # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。
730
801
  title = _extract_session_title(canonical_request)
@@ -911,6 +982,7 @@ class _RouteExecutor:
911
982
  await self._recorder.set_session_title(
912
983
  canonical_request.session_key, title
913
984
  )
985
+ self._apply_title_based_policy(canonical_request.session_key, title)
914
986
  else:
915
987
  # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。
916
988
  title = _extract_session_title(canonical_request)
@@ -14,6 +14,7 @@ from collections.abc import AsyncIterator
14
14
  from typing import TYPE_CHECKING, Any
15
15
 
16
16
  if TYPE_CHECKING:
17
+ from ..config.session_policy import TitleVendorBinding
17
18
  from ..pricing import PricingTable
18
19
 
19
20
  from .executor import _RouteExecutor
@@ -38,6 +39,7 @@ class RequestRouter:
38
39
  reauth_coordinator: Any | None = None,
39
40
  compat_session_store: CompatSessionStore | None = None,
40
41
  session_policy_resolver: SessionPolicyResolver | None = None,
42
+ title_vendor_bindings: list[TitleVendorBinding] | None = None,
41
43
  ) -> None:
42
44
  if not tiers:
43
45
  raise ValueError("至少需要一个供应商层级")
@@ -56,6 +58,7 @@ class RequestRouter:
56
58
  session_manager=self._session_mgr,
57
59
  reauth_coordinator=reauth_coordinator,
58
60
  session_policy_resolver=session_policy_resolver,
61
+ title_vendor_bindings=title_vendor_bindings,
59
62
  )
60
63
 
61
64
  def set_pricing_table(self, table: PricingTable) -> None:
@@ -161,6 +161,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
161
161
  reauth_coordinator,
162
162
  compat_session_store,
163
163
  session_policy_resolver=SessionPolicyResolver(config.session_policies.policies),
164
+ title_vendor_bindings=config.session_policies.title_vendor_bindings,
164
165
  )
165
166
 
166
167
  app = FastAPI(title="coding-proxy", version=__version__, lifespan=lifespan)
@@ -19,6 +19,7 @@ from coding.proxy.compat.canonical import (
19
19
  CompatibilityStatus,
20
20
  build_canonical_request,
21
21
  )
22
+ from coding.proxy.config.session_policy import TitleVendorBinding
22
23
  from coding.proxy.routing.executor import (
23
24
  _FALLBACK_TITLE_MAX_LEN,
24
25
  _SESSION_TITLE_MAX_LEN,
@@ -36,6 +37,7 @@ from coding.proxy.routing.executor import (
36
37
  _sanitize_user_text,
37
38
  )
38
39
  from coding.proxy.routing.session_manager import RouteSessionManager
40
+ from coding.proxy.routing.session_policy import SessionPolicyResolver
39
41
  from coding.proxy.routing.tier import VendorTier
40
42
  from coding.proxy.routing.usage_recorder import UsageRecorder
41
43
  from coding.proxy.vendors.base import (
@@ -133,6 +135,19 @@ def _executor(tiers: list[VendorTier] | None = None, **kwargs) -> _RouteExecutor
133
135
  )
134
136
 
135
137
 
138
+ def _stub_session_manager(is_new: bool = True) -> MagicMock:
139
+ """构造返回指定 is_new 的 session manager stub.
140
+
141
+ 默认 RouteSessionManager(无 store) 的 get_or_create_record 恒返回
142
+ is_new=False;测试新 session 路径需显式 stub 返回 is_new=True。
143
+ """
144
+ mgr = MagicMock(spec=RouteSessionManager)
145
+ mgr.get_or_create_record = AsyncMock(return_value=(None, is_new))
146
+ mgr.apply_compat_context = MagicMock()
147
+ mgr.persist_session = AsyncMock()
148
+ return mgr
149
+
150
+
136
151
  # ── _VENDOR_PROTOCOL_LABEL_MAP ───────────────────────────
137
152
 
138
153
 
@@ -2277,6 +2292,40 @@ class TestSanitizeUserText:
2277
2292
  raw = "<thinking>\nline1\nline2\n</thinking>清理后文本"
2278
2293
  assert _sanitize_user_text(raw) == "清理后文本"
2279
2294
 
2295
+ # ── <session> 标签包裹用户文本的二次回退 ──
2296
+
2297
+ def test_session_tag_wrapping_user_text(self):
2298
+ """当 <session> 标签包裹用户文本时,二次回退应提取内部文本.
2299
+
2300
+ 注: session 元数据可能残留在标题前部,但用户文本现在可见,
2301
+ 远优于完全回退到 '[Session] model_name'.
2302
+ """
2303
+ raw = "<session>session metadata\n用户真实提问内容</session>"
2304
+ result = _sanitize_user_text(raw)
2305
+ assert "用户真实提问内容" in result
2306
+
2307
+ def test_session_tag_wrapping_with_inner_noise(self):
2308
+ """<session> 内部混合噪声标签时,二次回退应正确剥离噪声."""
2309
+ raw = (
2310
+ "<session>session_key: abc\n"
2311
+ "<system-reminder>噪声内容</system-reminder>"
2312
+ "用户真实输入"
2313
+ "</session>"
2314
+ )
2315
+ result = _sanitize_user_text(raw)
2316
+ assert "用户真实输入" in result
2317
+ assert "噪声内容" not in result
2318
+
2319
+ def test_session_tag_prefix_still_works(self):
2320
+ """用户文本在 <session> 标签之后(原有行为)仍正确."""
2321
+ raw = "<session>metadata</session>用户文本在外部"
2322
+ assert _sanitize_user_text(raw) == "用户文本在外部"
2323
+
2324
+ def test_all_noise_inside_session_tag(self):
2325
+ """<session> 内部全是噪声时,二次回退仍返回空."""
2326
+ raw = "<session><system-reminder>纯噪声</system-reminder></session>"
2327
+ assert _sanitize_user_text(raw) == ""
2328
+
2280
2329
 
2281
2330
  class TestExtractSessionTitle:
2282
2331
  """``_extract_session_title`` — 端到端从 CanonicalRequest 抽取标题."""
@@ -2622,3 +2671,278 @@ class TestExtractSessionTitleFallback:
2622
2671
  ]
2623
2672
  req = self._build_request(messages)
2624
2673
  assert _extract_session_title(req) == "[Session] test-model"
2674
+
2675
+
2676
+ class TestApplyTitleBasedPolicy:
2677
+ """``_apply_title_based_policy`` 标题前缀自动绑定测试."""
2678
+
2679
+ def test_prefix_match_triggers_upsert(self):
2680
+ """标题以配置前缀开头 → 触发 upsert 绑定到目标 vendor."""
2681
+ resolver = SessionPolicyResolver()
2682
+ executor = _executor(
2683
+ session_policy_resolver=resolver,
2684
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2685
+ )
2686
+ executor._apply_title_based_policy("sess-1", "# 目标 (Goal) 实现功能 X")
2687
+ policy = resolver.resolve("sess-1")
2688
+ assert policy is not None
2689
+ assert policy.tiers == ["zhipu"]
2690
+ assert policy.name == "runtime:sess-1"
2691
+
2692
+ def test_prefix_match_without_parenthesis(self):
2693
+ """前缀匹配不要求括号后缀,纯 '# 目标' 开头即命中."""
2694
+ resolver = SessionPolicyResolver()
2695
+ executor = _executor(
2696
+ session_policy_resolver=resolver,
2697
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2698
+ )
2699
+ executor._apply_title_based_policy("sess-2", "# 目标 详细计划")
2700
+ policy = resolver.resolve("sess-2")
2701
+ assert policy is not None
2702
+ assert policy.tiers == ["zhipu"]
2703
+
2704
+ def test_non_matching_title_no_binding(self):
2705
+ """非匹配标题 → 不创建绑定."""
2706
+ resolver = SessionPolicyResolver()
2707
+ executor = _executor(
2708
+ session_policy_resolver=resolver,
2709
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2710
+ )
2711
+ executor._apply_title_based_policy("sess-3", "普通会话标题")
2712
+ assert resolver.resolve("sess-3") is None
2713
+
2714
+ def test_empty_title_no_binding(self):
2715
+ """空标题 → 提前返回,不创建绑定."""
2716
+ resolver = SessionPolicyResolver()
2717
+ executor = _executor(
2718
+ session_policy_resolver=resolver,
2719
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2720
+ )
2721
+ executor._apply_title_based_policy("sess-4", "")
2722
+ assert resolver.resolve("sess-4") is None
2723
+
2724
+ def test_no_bindings_configured_no_binding(self):
2725
+ """未配置任何绑定规则 → 提前返回,等效禁用."""
2726
+ resolver = SessionPolicyResolver()
2727
+ executor = _executor(
2728
+ session_policy_resolver=resolver,
2729
+ title_vendor_bindings=[],
2730
+ )
2731
+ executor._apply_title_based_policy("sess-5", "# 目标 任意标题")
2732
+ assert resolver.resolve("sess-5") is None
2733
+
2734
+ def test_prefix_in_middle_no_match(self):
2735
+ """前缀出现在标题中间 → startswith 不匹配,不绑定."""
2736
+ resolver = SessionPolicyResolver()
2737
+ executor = _executor(
2738
+ session_policy_resolver=resolver,
2739
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2740
+ )
2741
+ executor._apply_title_based_policy("sess-6", "前缀 # 目标 在中间")
2742
+ assert resolver.resolve("sess-6") is None
2743
+
2744
+ def test_multiple_bindings_first_match_wins(self):
2745
+ """多条规则按顺序匹配,首次命中生效."""
2746
+ resolver = SessionPolicyResolver()
2747
+ executor = _executor(
2748
+ session_policy_resolver=resolver,
2749
+ title_vendor_bindings=[
2750
+ TitleVendorBinding(prefix="# 目标", vendor="zhipu"),
2751
+ TitleVendorBinding(prefix="# Review", vendor="anthropic"),
2752
+ ],
2753
+ )
2754
+ executor._apply_title_based_policy("sess-7", "# Review 代码审查")
2755
+ policy = resolver.resolve("sess-7")
2756
+ assert policy is not None
2757
+ assert policy.tiers == ["anthropic"]
2758
+
2759
+ def test_bound_tier_promoted_to_front(self):
2760
+ """绑定后 _resolve_effective_tiers 将目标 vendor 提升至首位."""
2761
+ resolver = SessionPolicyResolver()
2762
+ tiers = [
2763
+ _make_tier(_mock_vendor("anthropic")),
2764
+ _make_tier(_mock_vendor("zhipu")),
2765
+ ]
2766
+ executor = _executor(
2767
+ tiers=tiers,
2768
+ session_policy_resolver=resolver,
2769
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2770
+ )
2771
+ executor._apply_title_based_policy("sess-8", "# 目标 实现 X")
2772
+ effective = executor._resolve_effective_tiers("sess-8")
2773
+ assert effective[0].name == "zhipu"
2774
+ # 未提及的 vendor 仍保留在末尾
2775
+ assert {t.name for t in effective} == {"zhipu", "anthropic"}
2776
+
2777
+ def test_non_matching_session_uses_default_order(self):
2778
+ """非匹配 session 的 tier 顺序保持全局默认."""
2779
+ resolver = SessionPolicyResolver()
2780
+ tiers = [
2781
+ _make_tier(_mock_vendor("anthropic")),
2782
+ _make_tier(_mock_vendor("zhipu")),
2783
+ ]
2784
+ executor = _executor(
2785
+ tiers=tiers,
2786
+ session_policy_resolver=resolver,
2787
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2788
+ )
2789
+ executor._apply_title_based_policy("sess-9", "普通标题")
2790
+ effective = executor._resolve_effective_tiers("sess-9")
2791
+ assert [t.name for t in effective] == ["anthropic", "zhipu"]
2792
+
2793
+ def test_nonexistent_vendor_skipped_in_resolution(self):
2794
+ """绑定不存在的 vendor → upsert 成功但 tier 解析跳过该 vendor."""
2795
+ resolver = SessionPolicyResolver()
2796
+ tiers = [
2797
+ _make_tier(_mock_vendor("anthropic")),
2798
+ _make_tier(_mock_vendor("zhipu")),
2799
+ ]
2800
+ executor = _executor(
2801
+ tiers=tiers,
2802
+ session_policy_resolver=resolver,
2803
+ title_vendor_bindings=[
2804
+ TitleVendorBinding(prefix="# 目标", vendor="nonexistent")
2805
+ ],
2806
+ )
2807
+ executor._apply_title_based_policy("sess-10", "# 目标 X")
2808
+ effective = executor._resolve_effective_tiers("sess-10")
2809
+ # 不存在的 vendor 被跳过,回退到全局默认顺序
2810
+ assert [t.name for t in effective] == ["anthropic", "zhipu"]
2811
+
2812
+ @pytest.mark.asyncio
2813
+ async def test_execute_message_end_to_end_binding(self):
2814
+ """端到端: 新 session 首请求标题命中前缀 → 创建绑定并路由到 zhipu."""
2815
+ resolver = SessionPolicyResolver()
2816
+ tiers = [
2817
+ _make_tier(_mock_vendor("anthropic")),
2818
+ _make_tier(_mock_vendor("zhipu")),
2819
+ ]
2820
+ executor = _executor(
2821
+ tiers=tiers,
2822
+ session_mgr=_stub_session_manager(is_new=True),
2823
+ session_policy_resolver=resolver,
2824
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2825
+ )
2826
+ body = {
2827
+ "model": "test",
2828
+ "metadata": {"user_id": "session-abc"},
2829
+ "messages": [
2830
+ {"role": "user", "content": [{"type": "text", "text": "# 目标 实现 X"}]}
2831
+ ],
2832
+ }
2833
+ resp = await executor.execute_message(body, {})
2834
+ assert resp.status_code == 200
2835
+ # 从 body 解析出的 session_key 应已建立运行时绑定
2836
+ canonical = build_canonical_request(body, {})
2837
+ policy = resolver.resolve(canonical.session_key)
2838
+ assert policy is not None
2839
+ assert policy.tiers == ["zhipu"]
2840
+
2841
+ @pytest.mark.asyncio
2842
+ async def test_execute_message_existing_session_no_binding(self):
2843
+ """端到端: 已存在 session(is_new=False) 不触发标题绑定."""
2844
+ resolver = SessionPolicyResolver()
2845
+ tiers = [
2846
+ _make_tier(_mock_vendor("anthropic")),
2847
+ _make_tier(_mock_vendor("zhipu")),
2848
+ ]
2849
+ executor = _executor(
2850
+ tiers=tiers,
2851
+ session_mgr=_stub_session_manager(is_new=False),
2852
+ session_policy_resolver=resolver,
2853
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2854
+ )
2855
+ body = {
2856
+ "model": "test",
2857
+ "metadata": {"user_id": "session-existing"},
2858
+ "messages": [
2859
+ {"role": "user", "content": [{"type": "text", "text": "# 目标 实现 X"}]}
2860
+ ],
2861
+ }
2862
+ await executor.execute_message(body, {})
2863
+ canonical = build_canonical_request(body, {})
2864
+ # is_new=False → 不调用 _apply_title_based_policy,无运行时绑定
2865
+ assert resolver.resolve(canonical.session_key) is None
2866
+
2867
+ @pytest.mark.asyncio
2868
+ async def test_execute_stream_end_to_end_binding(self):
2869
+ """端到端(流式): 新 session 首请求标题命中前缀 → 创建绑定."""
2870
+ resolver = SessionPolicyResolver()
2871
+ zhipu_vendor = _mock_vendor("zhipu")
2872
+ zhipu_vendor.send_message_stream = MagicMock(
2873
+ return_value=_async_chunks([b'{"type":"message_stop"}'])
2874
+ )
2875
+ tiers = [
2876
+ _make_tier(_mock_vendor("anthropic")),
2877
+ _make_tier(zhipu_vendor),
2878
+ ]
2879
+ executor = _executor(
2880
+ tiers=tiers,
2881
+ session_mgr=_stub_session_manager(is_new=True),
2882
+ session_policy_resolver=resolver,
2883
+ title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")],
2884
+ )
2885
+ body = {
2886
+ "model": "test",
2887
+ "metadata": {"user_id": "session-stream"},
2888
+ "messages": [
2889
+ {
2890
+ "role": "user",
2891
+ "content": [{"type": "text", "text": "# 目标 流式任务"}],
2892
+ }
2893
+ ],
2894
+ }
2895
+ chunks = [chunk async for chunk, _ in executor.execute_stream(body, {})]
2896
+ assert chunks # 有数据返回
2897
+ canonical = build_canonical_request(body, {})
2898
+ policy = resolver.resolve(canonical.session_key)
2899
+ assert policy is not None
2900
+ assert policy.tiers == ["zhipu"]
2901
+
2902
+ def test_empty_prefix_rejected_by_validation(self):
2903
+ """空 prefix 在模型校验阶段即被拒绝,杜绝全量误绑定."""
2904
+ import pydantic
2905
+
2906
+ with pytest.raises(pydantic.ValidationError):
2907
+ TitleVendorBinding(prefix="", vendor="zhipu")
2908
+
2909
+ def test_empty_vendor_rejected_by_validation(self):
2910
+ """空 vendor 在模型校验阶段即被拒绝."""
2911
+ import pydantic
2912
+
2913
+ with pytest.raises(pydantic.ValidationError):
2914
+ TitleVendorBinding(prefix="# 目标", vendor="")
2915
+
2916
+ def test_unknown_vendor_warns_at_startup(self, caplog):
2917
+ """构造时引用未知 vendor → 记录启动告警."""
2918
+ import logging as _logging
2919
+
2920
+ tiers = [_make_tier(_mock_vendor("anthropic"))]
2921
+ with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"):
2922
+ _executor(
2923
+ tiers=tiers,
2924
+ session_policy_resolver=SessionPolicyResolver(),
2925
+ title_vendor_bindings=[
2926
+ TitleVendorBinding(prefix="# 目标", vendor="nonexistent")
2927
+ ],
2928
+ )
2929
+ warnings = [r for r in caplog.records if r.levelno == _logging.WARNING]
2930
+ assert any("nonexistent" in r.message for r in warnings)
2931
+
2932
+ def test_known_vendor_no_startup_warning(self, caplog):
2933
+ """构造时引用合法 vendor → 不产生告警."""
2934
+ import logging as _logging
2935
+
2936
+ tiers = [_make_tier(_mock_vendor("zhipu"))]
2937
+ with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"):
2938
+ _executor(
2939
+ tiers=tiers,
2940
+ session_policy_resolver=SessionPolicyResolver(),
2941
+ title_vendor_bindings=[
2942
+ TitleVendorBinding(prefix="# 目标", vendor="zhipu")
2943
+ ],
2944
+ )
2945
+ binding_warnings = [
2946
+ r for r in caplog.records if "title_vendor_bindings" in r.message
2947
+ ]
2948
+ assert not binding_warnings
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "coding-proxy"
77
- version = "0.5.1a5"
77
+ version = "0.5.1a7"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "aiosqlite" },
File without changes
File without changes
File without changes
File without changes