coding-proxy 0.3.1a2__tar.gz → 0.3.1a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/PKG-INFO +1 -1
  2. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/issue.md +44 -0
  3. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/pyproject.toml +1 -1
  4. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/anthropic_to_openai.py +23 -13
  5. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/executor.py +25 -0
  6. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/usage_parser.py +24 -20
  7. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/dashboard.py +3 -3
  8. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_copilot_convert_request.py +129 -0
  9. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_parse_usage.py +73 -0
  10. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_parse_usage_gemini.py +16 -0
  11. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_router_executor.py +180 -0
  12. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/uv.lock +1 -1
  13. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/.github/workflows/ci.yml +0 -0
  14. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/.github/workflows/coverage.yml +0 -0
  15. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/.github/workflows/release.yml +0 -0
  16. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/.gitignore +0 -0
  17. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/.pre-commit-config.yaml +0 -0
  18. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/AGENTS.md +0 -0
  19. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/CHANGELOG.md +0 -0
  20. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/CLAUDE.md +0 -0
  21. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/LICENSE +0 -0
  22. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/README.md +0 -0
  23. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/assets/dashboard-v0.2.4.png +0 -0
  24. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/config-reference.md +0 -0
  25. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/convert.md +0 -0
  26. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/design-patterns.md +0 -0
  27. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/routing.md +0 -0
  28. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/testing.md +0 -0
  29. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/arch/vendors.md +0 -0
  30. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/ci-cd.md +0 -0
  31. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/framework.md +0 -0
  32. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/api-reference.md +0 -0
  33. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/cli-reference.md +0 -0
  34. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/dashboard.md +0 -0
  35. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/monitoring.md +0 -0
  36. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/quickstart.md +0 -0
  37. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/guide/vendors.md +0 -0
  38. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/user-guide.md +0 -0
  39. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/docs/zh-CN/README.md +0 -0
  40. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/__init__.py +0 -0
  41. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/__init__.py +0 -0
  42. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/__main__.py +0 -0
  43. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/__init__.py +0 -0
  44. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/providers/__init__.py +0 -0
  45. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/providers/base.py +0 -0
  46. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/providers/github.py +0 -0
  47. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/providers/google.py +0 -0
  48. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/runtime.py +0 -0
  49. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/auth/store.py +0 -0
  50. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/cli/__init__.py +0 -0
  51. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/cli/auth_commands.py +0 -0
  52. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/cli/banner.py +0 -0
  53. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/compat/__init__.py +0 -0
  54. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/compat/canonical.py +0 -0
  55. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/compat/session_store.py +0 -0
  56. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/__init__.py +0 -0
  57. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/auth_schema.py +0 -0
  58. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/config.default.yaml +0 -0
  59. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/loader.py +0 -0
  60. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/resiliency.py +0 -0
  61. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/routing.py +0 -0
  62. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/schema.py +0 -0
  63. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/server.py +0 -0
  64. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/config/vendors.py +0 -0
  65. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/__init__.py +0 -0
  66. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  67. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  68. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  69. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  70. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/convert/vendor_channels.py +0 -0
  71. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/logging/__init__.py +0 -0
  72. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/logging/db.py +0 -0
  73. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/logging/formatters.py +0 -0
  74. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/logging/stats.py +0 -0
  75. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/__init__.py +0 -0
  76. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/auth.py +0 -0
  77. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/compat.py +0 -0
  78. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/constants.py +0 -0
  79. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/pricing.py +0 -0
  80. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/token.py +0 -0
  81. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/model/vendor.py +0 -0
  82. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/__init__.py +0 -0
  83. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/config.py +0 -0
  84. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  85. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  86. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  87. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  88. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/handler.py +0 -0
  89. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/operation.py +0 -0
  90. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/routes.py +0 -0
  91. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/native_api/usage_registry.py +0 -0
  92. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/pricing.py +0 -0
  93. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/__init__.py +0 -0
  94. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  95. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/error_classifier.py +0 -0
  96. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/model_mapper.py +0 -0
  97. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/quota_guard.py +0 -0
  98. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/rate_limit.py +0 -0
  99. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/retry.py +0 -0
  100. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/router.py +0 -0
  101. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/session_manager.py +0 -0
  102. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/tier.py +0 -0
  103. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/routing/usage_recorder.py +0 -0
  104. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/__init__.py +0 -0
  105. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/app.py +0 -0
  106. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/factory.py +0 -0
  107. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/responses.py +0 -0
  108. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/server/routes.py +0 -0
  109. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/streaming/__init__.py +0 -0
  110. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  111. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/__init__.py +0 -0
  112. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/alibaba.py +0 -0
  113. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/anthropic.py +0 -0
  114. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/antigravity.py +0 -0
  115. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/base.py +0 -0
  116. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/copilot.py +0 -0
  117. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/copilot_models.py +0 -0
  118. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  119. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  120. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/doubao.py +0 -0
  121. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/kimi.py +0 -0
  122. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/minimax.py +0 -0
  123. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/mixins.py +0 -0
  124. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  125. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/token_manager.py +0 -0
  126. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/xiaomi.py +0 -0
  127. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/src/coding/proxy/vendors/zhipu.py +0 -0
  128. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/__init__.py +0 -0
  129. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_antigravity.py +0 -0
  130. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_app_routes.py +0 -0
  131. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_auto_login.py +0 -0
  132. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_banner.py +0 -0
  133. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_circuit_breaker.py +0 -0
  134. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_cli_usage.py +0 -0
  135. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_compat.py +0 -0
  136. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_config_init.py +0 -0
  137. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_config_loader.py +0 -0
  138. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_convert_request.py +0 -0
  139. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_convert_response.py +0 -0
  140. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_convert_sse.py +0 -0
  141. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_copilot.py +0 -0
  142. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_copilot_convert_response.py +0 -0
  143. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_copilot_models.py +0 -0
  144. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_copilot_urls.py +0 -0
  145. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_currency.py +0 -0
  146. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_error_classifier.py +0 -0
  147. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_logging_dual_write.py +0 -0
  148. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_mixins.py +0 -0
  149. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_auth.py +0 -0
  150. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_compat.py +0 -0
  151. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_constants.py +0 -0
  152. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_mapper.py +0 -0
  153. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_pricing.py +0 -0
  154. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_token.py +0 -0
  155. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_model_vendor.py +0 -0
  156. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_api_base_url_override.py +0 -0
  157. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_api_extractors.py +0 -0
  158. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_api_handler.py +0 -0
  159. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_api_operation.py +0 -0
  160. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_api_routes.py +0 -0
  161. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_native_vendors.py +0 -0
  162. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_pricing.py +0 -0
  163. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_quota_guard.py +0 -0
  164. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_rate_limit.py +0 -0
  165. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_router_chain.py +0 -0
  166. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_runtime_reauth.py +0 -0
  167. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_schema.py +0 -0
  168. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_streaming_anthropic_compat.py +0 -0
  169. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_tier.py +0 -0
  170. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_tiers_config.py +0 -0
  171. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_time_range.py +0 -0
  172. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_token_logger.py +0 -0
  173. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_token_logger_native_columns.py +0 -0
  174. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_token_manager.py +0 -0
  175. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_types.py +0 -0
  176. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_vendor_channels.py +0 -0
  177. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_vendor_streaming.py +0 -0
  178. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_vendors.py +0 -0
  179. {coding_proxy-0.3.1a2 → coding_proxy-0.3.1a4}/tests/test_zhipu.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.3.1a2
3
+ Version: 0.3.1a4
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
@@ -4,6 +4,50 @@
4
4
 
5
5
  ---
6
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
+
7
51
  ## zhipu 自循环 400 + tool_results 偶发降级
8
52
 
9
53
  **问题描述**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.3.1a2"
3
+ version = "0.3.1a4"
4
4
  description = "A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao..."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -284,24 +284,34 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any]
284
284
  final_text_parts = text_parts
285
285
 
286
286
  if tool_uses:
287
+ tool_calls: list[dict[str, Any]] = []
288
+ for block in tool_uses:
289
+ raw_input = block.get("input")
290
+ if not isinstance(raw_input, dict):
291
+ logger.debug(
292
+ "copilot: tool_use id=%s name=%s has non-dict input (type=%s), "
293
+ "defaulting to empty dict",
294
+ block.get("id", ""),
295
+ block.get("name", ""),
296
+ type(raw_input).__name__,
297
+ )
298
+ raw_input = {}
299
+ tool_calls.append(
300
+ {
301
+ "id": block.get("id", ""),
302
+ "type": "function",
303
+ "function": {
304
+ "name": block.get("name", ""),
305
+ "arguments": json.dumps(raw_input, ensure_ascii=False),
306
+ },
307
+ }
308
+ )
287
309
  return [
288
310
  {
289
311
  "role": "assistant",
290
312
  "content": "\n\n".join(part for part in final_text_parts if part)
291
313
  or None,
292
- "tool_calls": [
293
- {
294
- "id": block.get("id", ""),
295
- "type": "function",
296
- "function": {
297
- "name": block.get("name", ""),
298
- "arguments": json.dumps(
299
- block.get("input", {}), ensure_ascii=False
300
- ),
301
- },
302
- }
303
- for block in tool_uses
304
- ],
314
+ "tool_calls": tool_calls,
305
315
  }
306
316
  ]
307
317
 
@@ -135,6 +135,10 @@ def _is_likely_request_format_error(
135
135
  # 非结构化响应体(非 JSON)
136
136
  if not trimmed.startswith("{") and len(trimmed) < 200:
137
137
  return True
138
+ # 结构化 JSON 400 但含 tool_call 格式错误码 → 格式不兼容
139
+ # (如 Copilot 返回 {"error":{"code":"invalid_tool_call_format",...}})
140
+ if "invalid_tool_call_format" in trimmed:
141
+ return True
138
142
  return False
139
143
 
140
144
 
@@ -795,6 +799,27 @@ class _RouteExecutor:
795
799
  tier.name,
796
800
  )
797
801
 
802
+ # 补充检测:zhipu 500 — tool_result 块触发上游 AttributeError
803
+ # zhipu 后端在 tool_result 块上错误访问 .id 属性(应为 .tool_use_id),
804
+ # 此为已知的上游格式缺陷,应视为 format incompatibility 而非真实服务器故障。
805
+ if (
806
+ not semantic_rejection
807
+ and exc.response.status_code == 500
808
+ and request_body is not None
809
+ and _has_tool_results(request_body)
810
+ ):
811
+ err_text = (exc.response.text or "")[:500]
812
+ if (
813
+ "'ClaudeContentBlockToolResult'" in err_text
814
+ and "has no attribute 'id'" in err_text
815
+ ):
816
+ semantic_rejection = True
817
+ logger.warning(
818
+ "Tier %s zhipu tool_result format error (500), "
819
+ "treating as format incompatibility without circuit breaker penalty",
820
+ tier.name,
821
+ )
822
+
798
823
  if semantic_rejection and not is_last:
799
824
  return True, tier.name, exc
800
825
 
@@ -169,8 +169,8 @@ def parse_usage_from_chunk(
169
169
 
170
170
  # Anthropic 格式: message_start 事件 (data.message.usage)
171
171
  msg = data.get("message", {})
172
- if isinstance(msg, dict) and "usage" in msg:
173
- u = msg["usage"]
172
+ u = msg.get("usage") if isinstance(msg, dict) else None
173
+ if isinstance(u, dict):
174
174
  input_tokens = u.get("input_tokens", 0) or u.get("prompt_tokens", 0)
175
175
  _set_if_nonzero(usage, "input_tokens", input_tokens)
176
176
  _set_if_nonzero(
@@ -183,18 +183,17 @@ def parse_usage_from_chunk(
183
183
  usage["request_id"] = msg["id"]
184
184
  if "model" in msg:
185
185
  usage["model_served"] = msg["model"]
186
- if isinstance(u, dict):
187
- _append_usage_evidence(
188
- usage,
189
- evidence_kind="message_usage",
190
- raw_usage=dict(u),
191
- request_id=msg.get("id"),
192
- model_served=msg.get("model"),
193
- )
186
+ _append_usage_evidence(
187
+ usage,
188
+ evidence_kind="message_usage",
189
+ raw_usage=dict(u),
190
+ request_id=msg.get("id"),
191
+ model_served=msg.get("model"),
192
+ )
194
193
 
195
194
  # Anthropic message_delta / OpenAI 最后一个 chunk (data.usage)
196
- if "usage" in data:
197
- u = data["usage"]
195
+ u = data.get("usage")
196
+ if isinstance(u, dict):
198
197
  output_tokens = u.get("output_tokens", 0) or u.get("completion_tokens", 0)
199
198
  input_tokens = u.get("input_tokens", 0) or u.get("prompt_tokens", 0)
200
199
  cache_creation_tokens = u.get("cache_creation_input_tokens", 0)
@@ -204,14 +203,16 @@ def parse_usage_from_chunk(
204
203
  _set_if_nonzero(usage, "input_tokens", input_tokens)
205
204
  _set_if_nonzero(usage, "cache_creation_tokens", cache_creation_tokens)
206
205
  _set_if_nonzero(usage, "cache_read_tokens", cache_read_tokens)
207
- if isinstance(u, dict):
208
- _append_usage_evidence(
209
- usage,
210
- evidence_kind="data_usage",
211
- raw_usage=dict(u),
212
- request_id=data.get("id"),
213
- model_served=data.get("model"),
214
- )
206
+ _append_usage_evidence(
207
+ usage,
208
+ evidence_kind="data_usage",
209
+ raw_usage=dict(u),
210
+ request_id=data.get("id"),
211
+ model_served=data.get("model"),
212
+ )
213
+ model_name = data.get("model")
214
+ if model_name:
215
+ usage["model_served"] = model_name
215
216
 
216
217
  # Gemini SSE 格式: data.usageMetadata.{promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount, toolUsePromptTokenCount}
217
218
  # Gemini 的流式响应在最后一帧(或每一帧)携带 usageMetadata;字段命名与
@@ -245,6 +246,9 @@ def parse_usage_from_chunk(
245
246
  request_id=data.get("responseId") or data.get("id"),
246
247
  model_served=data.get("modelVersion") or data.get("model"),
247
248
  )
249
+ model_name = data.get("modelVersion") or data.get("model")
250
+ if model_name:
251
+ usage["model_served"] = model_name
248
252
 
249
253
  # request_id fallback (OpenAI 格式下 id 在顶层, Gemini 顶层为 responseId)
250
254
  if not usage.get("request_id"):
@@ -415,7 +415,7 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
415
415
  z-index: 1000;
416
416
  opacity: 0;
417
417
  transition: opacity .15s ease;
418
- max-width: 360px;
418
+ max-width: 520px;
419
419
  max-height: 60vh;
420
420
  overflow-y: auto;
421
421
  backdrop-filter: blur(8px);
@@ -429,8 +429,8 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
429
429
  #chart-tooltip-items { display: flex; flex-direction: column; gap: 3px; }
430
430
  .tt-item { display: flex; align-items: center; gap: 8px; line-height: 1.4; }
431
431
  .tt-color { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
432
- .tt-label { flex: 1; color: var(--text-primary); }
433
- .tt-value { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
432
+ .tt-label { flex: 1; color: var(--text-primary); white-space: nowrap; }
433
+ .tt-value { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
434
434
  #chart-tooltip-footer {
435
435
  margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-subtle);
436
436
  font-weight: 500; font-size: 12px; color: var(--text-secondary);
@@ -472,3 +472,132 @@ def test_image_block_converted_to_image_url():
472
472
  image_part = [p for p in user_msg["content"] if p.get("type") == "image_url"]
473
473
  assert len(image_part) == 1
474
474
  assert "data:image/png;base64,abc123" in image_part[0]["image_url"]["url"]
475
+
476
+
477
+ # === Defensive tool_use.input serialization ===
478
+
479
+
480
+ def test_tool_use_input_none_defaults_to_empty_dict():
481
+ """input=None 应被降级为 {} 而非序列化为 'null'."""
482
+ body = {
483
+ "model": "claude-sonnet-4-20250514",
484
+ "messages": [
485
+ {
486
+ "role": "assistant",
487
+ "content": [
488
+ {
489
+ "type": "tool_use",
490
+ "id": "toolu_001",
491
+ "name": "read_file",
492
+ "input": None,
493
+ }
494
+ ],
495
+ }
496
+ ],
497
+ }
498
+ result = convert_request(body)
499
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
500
+ assert len(assistant_msgs) == 1
501
+ assert "tool_calls" in assistant_msgs[0]
502
+ tc = assistant_msgs[0]["tool_calls"][0]
503
+ assert tc["function"]["arguments"] == "{}"
504
+
505
+
506
+ def test_tool_use_input_string_defaults_to_empty_dict():
507
+ """input='some string' 应被降级为 {} 而非序列化为 '"some string"'."""
508
+ body = {
509
+ "model": "claude-sonnet-4-20250514",
510
+ "messages": [
511
+ {
512
+ "role": "assistant",
513
+ "content": [
514
+ {
515
+ "type": "tool_use",
516
+ "id": "toolu_002",
517
+ "name": "run_cmd",
518
+ "input": "not a dict",
519
+ }
520
+ ],
521
+ }
522
+ ],
523
+ }
524
+ result = convert_request(body)
525
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
526
+ tc = assistant_msgs[0]["tool_calls"][0]
527
+ assert tc["function"]["arguments"] == "{}"
528
+
529
+
530
+ def test_tool_use_input_missing_defaults_to_empty_dict():
531
+ """input key 不存在时,block.get('input') 返回 None,应降级为 {}."""
532
+ body = {
533
+ "model": "claude-sonnet-4-20250514",
534
+ "messages": [
535
+ {
536
+ "role": "assistant",
537
+ "content": [
538
+ {
539
+ "type": "tool_use",
540
+ "id": "toolu_003",
541
+ "name": "search",
542
+ }
543
+ ],
544
+ }
545
+ ],
546
+ }
547
+ result = convert_request(body)
548
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
549
+ tc = assistant_msgs[0]["tool_calls"][0]
550
+ assert tc["function"]["arguments"] == "{}"
551
+
552
+
553
+ def test_tool_use_input_int_defaults_to_empty_dict():
554
+ """input=42 应被降级为 {} 而非序列化为 '42'."""
555
+ body = {
556
+ "model": "claude-sonnet-4-20250514",
557
+ "messages": [
558
+ {
559
+ "role": "assistant",
560
+ "content": [
561
+ {
562
+ "type": "tool_use",
563
+ "id": "toolu_004",
564
+ "name": "calc",
565
+ "input": 42,
566
+ }
567
+ ],
568
+ }
569
+ ],
570
+ }
571
+ result = convert_request(body)
572
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
573
+ tc = assistant_msgs[0]["tool_calls"][0]
574
+ assert tc["function"]["arguments"] == "{}"
575
+
576
+
577
+ def test_tool_use_valid_dict_input_preserved():
578
+ """正常 dict input 应保持原样."""
579
+ body = {
580
+ "model": "claude-sonnet-4-20250514",
581
+ "messages": [
582
+ {
583
+ "role": "assistant",
584
+ "content": [
585
+ {
586
+ "type": "tool_use",
587
+ "id": "toolu_005",
588
+ "name": "read_file",
589
+ "input": {"path": "/tmp/test.txt", "offset": 10},
590
+ }
591
+ ],
592
+ }
593
+ ],
594
+ }
595
+ result = convert_request(body)
596
+ assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"]
597
+ tc = assistant_msgs[0]["tool_calls"][0]
598
+ import json
599
+
600
+ assert json.loads(tc["function"]["arguments"]) == {
601
+ "path": "/tmp/test.txt",
602
+ "offset": 10,
603
+ }
@@ -117,6 +117,34 @@ def test_openai_zhipu_final_chunk():
117
117
  assert usage["input_tokens"] == 200
118
118
  assert usage["output_tokens"] == 80
119
119
  assert usage["request_id"] == "chatcmpl-1"
120
+ assert usage["model_served"] == "glm-5.1"
121
+
122
+
123
+ def test_openai_final_chunk_with_model():
124
+ """OpenAI 最终 chunk 有 model 字段时应提取到 model_served."""
125
+ usage: dict = {}
126
+ parse_usage_from_chunk(
127
+ _sse(
128
+ '{"id":"chatcmpl-2","model":"gpt-4o-2024-08-06",'
129
+ '"usage":{"prompt_tokens":50,"completion_tokens":20}}'
130
+ ),
131
+ usage,
132
+ )
133
+ assert usage["input_tokens"] == 50
134
+ assert usage["output_tokens"] == 20
135
+ assert usage["model_served"] == "gpt-4o-2024-08-06"
136
+
137
+
138
+ def test_openai_final_chunk_without_model():
139
+ """OpenAI 最终 chunk 无 model 字段时不应设置 model_served."""
140
+ usage: dict = {}
141
+ parse_usage_from_chunk(
142
+ _sse('{"id":"chatcmpl-3","usage":{"prompt_tokens":30,"completion_tokens":10}}'),
143
+ usage,
144
+ )
145
+ assert usage["input_tokens"] == 30
146
+ assert usage["output_tokens"] == 10
147
+ assert "model_served" not in usage
120
148
 
121
149
 
122
150
  def test_openai_final_chunk_with_cache_tokens():
@@ -265,3 +293,48 @@ def test_multiple_sse_lines_in_single_chunk():
265
293
  parse_usage_from_chunk(chunk, usage)
266
294
  assert usage["input_tokens"] == 80
267
295
  assert usage["output_tokens"] == 20
296
+
297
+
298
+ # --- null usage 安全保护(防御上游 SSE 字段为 null 的极端格式) ---
299
+
300
+
301
+ def test_null_usage_at_top_level_does_not_raise():
302
+ """data.usage 显式为 null 时应被静默忽略,不抛异常、不产生 WARNING."""
303
+ usage: dict = {}
304
+ parse_usage_from_chunk(
305
+ _sse('{"id":"chatcmpl-null","choices":[{"delta":{}}],"usage":null}'),
306
+ usage,
307
+ )
308
+ # 不应写入任何 token 字段
309
+ assert usage.get("input_tokens", 0) == 0
310
+ assert usage.get("output_tokens", 0) == 0
311
+
312
+
313
+ def test_null_usage_in_message_does_not_raise():
314
+ """message.usage 显式为 null 时应被静默忽略."""
315
+ usage: dict = {}
316
+ parse_usage_from_chunk(
317
+ _sse('{"type":"message_start","message":{"id":"msg_null","usage":null}}'),
318
+ usage,
319
+ )
320
+ assert usage.get("input_tokens", 0) == 0
321
+
322
+
323
+ def test_null_usage_does_not_break_subsequent_valid_chunks():
324
+ """null usage 帧之后到来的有效帧仍能正确解析."""
325
+ usage: dict = {}
326
+ # 1. null usage 帧
327
+ parse_usage_from_chunk(
328
+ _sse('{"id":"chatcmpl-1","choices":[{"delta":{"content":"hi"}}],"usage":null}'),
329
+ usage,
330
+ )
331
+ # 2. 有效的最终帧
332
+ parse_usage_from_chunk(
333
+ _sse(
334
+ '{"id":"chatcmpl-1","choices":[{"finish_reason":"stop","delta":{}}],'
335
+ '"usage":{"prompt_tokens":12,"completion_tokens":3}}'
336
+ ),
337
+ usage,
338
+ )
339
+ assert usage["input_tokens"] == 12
340
+ assert usage["output_tokens"] == 3
@@ -35,6 +35,7 @@ def test_gemini_usage_metadata_basic_fields():
35
35
  assert usage["output_tokens"] == 42
36
36
  assert usage.get("cache_read_tokens", 0) == 0
37
37
  assert usage["request_id"] == "resp_abc"
38
+ assert usage["model_served"] == "gemini-2.0-flash"
38
39
 
39
40
 
40
41
  def test_gemini_usage_metadata_with_cached_content():
@@ -194,3 +195,18 @@ def test_gemini_partial_fields_ok():
194
195
  )
195
196
  assert usage["input_tokens"] == 77
196
197
  assert "output_tokens" not in usage
198
+
199
+
200
+ def test_gemini_model_fallback_to_data_model():
201
+ """当 modelVersion 不存在时,应回退到 data.model."""
202
+ usage: dict = {}
203
+ parse_usage_from_chunk(
204
+ _sse(
205
+ '{"usageMetadata":{"promptTokenCount":80,"candidatesTokenCount":20},'
206
+ '"model":"gemini-1.5-flash"}'
207
+ ),
208
+ usage,
209
+ )
210
+ assert usage["input_tokens"] == 80
211
+ assert usage["output_tokens"] == 20
212
+ assert usage["model_served"] == "gemini-1.5-flash"