coding-proxy 0.3.1a8__tar.gz → 0.3.1a9__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 (183) hide show
  1. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/PKG-INFO +1 -1
  2. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/pyproject.toml +1 -1
  3. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/cli/__init__.py +95 -0
  4. coding_proxy-0.3.1a9/src/coding/proxy/routing/session_policy.py +116 -0
  5. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/dashboard.py +225 -33
  6. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/routes.py +91 -0
  7. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_session_aware.py +141 -0
  8. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/uv.lock +1 -1
  9. coding_proxy-0.3.1a8/src/coding/proxy/routing/session_policy.py +0 -56
  10. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/.github/workflows/ci.yml +0 -0
  11. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/.github/workflows/coverage.yml +0 -0
  12. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/.github/workflows/release.yml +0 -0
  13. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/.gitignore +0 -0
  14. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/.pre-commit-config.yaml +0 -0
  15. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/AGENTS.md +0 -0
  16. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/CHANGELOG.md +0 -0
  17. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/CLAUDE.md +0 -0
  18. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/LICENSE +0 -0
  19. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/README.md +0 -0
  20. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/assets/dashboard-v0.2.4.png +0 -0
  21. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/config-reference.md +0 -0
  22. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/convert.md +0 -0
  23. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/design-patterns.md +0 -0
  24. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/routing.md +0 -0
  25. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/testing.md +0 -0
  26. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/arch/vendors.md +0 -0
  27. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/ci-cd.md +0 -0
  28. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/framework.md +0 -0
  29. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/api-reference.md +0 -0
  30. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/cli-reference.md +0 -0
  31. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/dashboard.md +0 -0
  32. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/monitoring.md +0 -0
  33. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/quickstart.md +0 -0
  34. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/guide/vendors.md +0 -0
  35. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/issue.md +0 -0
  36. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/user-guide.md +0 -0
  37. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/docs/zh-CN/README.md +0 -0
  38. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/__init__.py +0 -0
  39. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/__init__.py +0 -0
  40. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/__main__.py +0 -0
  41. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/__init__.py +0 -0
  42. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/providers/__init__.py +0 -0
  43. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/providers/base.py +0 -0
  44. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/providers/github.py +0 -0
  45. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/providers/google.py +0 -0
  46. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/runtime.py +0 -0
  47. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/auth/store.py +0 -0
  48. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/cli/auth_commands.py +0 -0
  49. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/cli/banner.py +0 -0
  50. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/compat/__init__.py +0 -0
  51. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/compat/canonical.py +0 -0
  52. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/compat/session_store.py +0 -0
  53. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/__init__.py +0 -0
  54. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/auth_schema.py +0 -0
  55. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/config.default.yaml +0 -0
  56. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/loader.py +0 -0
  57. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/resiliency.py +0 -0
  58. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/routing.py +0 -0
  59. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/schema.py +0 -0
  60. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/server.py +0 -0
  61. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/session_policy.py +0 -0
  62. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/config/vendors.py +0 -0
  63. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/__init__.py +0 -0
  64. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  65. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  66. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  67. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  68. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  69. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/convert/vendor_channels.py +0 -0
  70. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/logging/__init__.py +0 -0
  71. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/logging/db.py +0 -0
  72. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/logging/formatters.py +0 -0
  73. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/logging/stats.py +0 -0
  74. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/__init__.py +0 -0
  75. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/auth.py +0 -0
  76. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/compat.py +0 -0
  77. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/constants.py +0 -0
  78. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/pricing.py +0 -0
  79. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/token.py +0 -0
  80. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/model/vendor.py +0 -0
  81. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/__init__.py +0 -0
  82. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/config.py +0 -0
  83. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/extractors/__init__.py +0 -0
  84. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/extractors/anthropic.py +0 -0
  85. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/extractors/gemini.py +0 -0
  86. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/extractors/openai.py +0 -0
  87. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/handler.py +0 -0
  88. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/operation.py +0 -0
  89. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/routes.py +0 -0
  90. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/native_api/usage_registry.py +0 -0
  91. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/pricing.py +0 -0
  92. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/__init__.py +0 -0
  93. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  94. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/error_classifier.py +0 -0
  95. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/executor.py +0 -0
  96. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/model_mapper.py +0 -0
  97. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/quota_guard.py +0 -0
  98. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/rate_limit.py +0 -0
  99. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/retry.py +0 -0
  100. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/router.py +0 -0
  101. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/session_manager.py +0 -0
  102. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/tier.py +0 -0
  103. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/usage_parser.py +0 -0
  104. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/routing/usage_recorder.py +0 -0
  105. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/__init__.py +0 -0
  106. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/app.py +0 -0
  107. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/factory.py +0 -0
  108. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/server/responses.py +0 -0
  109. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/streaming/__init__.py +0 -0
  110. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  111. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/__init__.py +0 -0
  112. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/alibaba.py +0 -0
  113. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/anthropic.py +0 -0
  114. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/antigravity.py +0 -0
  115. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/base.py +0 -0
  116. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/copilot.py +0 -0
  117. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/copilot_models.py +0 -0
  118. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  119. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  120. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/doubao.py +0 -0
  121. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/kimi.py +0 -0
  122. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/minimax.py +0 -0
  123. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/mixins.py +0 -0
  124. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  125. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/token_manager.py +0 -0
  126. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/xiaomi.py +0 -0
  127. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/src/coding/proxy/vendors/zhipu.py +0 -0
  128. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/__init__.py +0 -0
  129. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_antigravity.py +0 -0
  130. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_app_routes.py +0 -0
  131. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_auto_login.py +0 -0
  132. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_banner.py +0 -0
  133. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_circuit_breaker.py +0 -0
  134. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_cli_usage.py +0 -0
  135. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_compat.py +0 -0
  136. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_config_init.py +0 -0
  137. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_config_loader.py +0 -0
  138. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_convert_request.py +0 -0
  139. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_convert_response.py +0 -0
  140. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_convert_sse.py +0 -0
  141. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_copilot.py +0 -0
  142. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_copilot_convert_request.py +0 -0
  143. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_copilot_convert_response.py +0 -0
  144. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_copilot_models.py +0 -0
  145. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_copilot_urls.py +0 -0
  146. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_currency.py +0 -0
  147. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_error_classifier.py +0 -0
  148. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_logging_dual_write.py +0 -0
  149. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_mixins.py +0 -0
  150. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_auth.py +0 -0
  151. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_compat.py +0 -0
  152. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_constants.py +0 -0
  153. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_mapper.py +0 -0
  154. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_pricing.py +0 -0
  155. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_token.py +0 -0
  156. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_model_vendor.py +0 -0
  157. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_api_base_url_override.py +0 -0
  158. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_api_extractors.py +0 -0
  159. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_api_handler.py +0 -0
  160. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_api_operation.py +0 -0
  161. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_api_routes.py +0 -0
  162. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_native_vendors.py +0 -0
  163. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_parse_usage.py +0 -0
  164. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_parse_usage_gemini.py +0 -0
  165. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_pricing.py +0 -0
  166. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_quota_guard.py +0 -0
  167. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_rate_limit.py +0 -0
  168. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_router_chain.py +0 -0
  169. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_router_executor.py +0 -0
  170. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_runtime_reauth.py +0 -0
  171. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_schema.py +0 -0
  172. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_streaming_anthropic_compat.py +0 -0
  173. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_tier.py +0 -0
  174. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_tiers_config.py +0 -0
  175. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_time_range.py +0 -0
  176. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_token_logger.py +0 -0
  177. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_token_logger_native_columns.py +0 -0
  178. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_token_manager.py +0 -0
  179. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_types.py +0 -0
  180. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_vendor_channels.py +0 -0
  181. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_vendor_streaming.py +0 -0
  182. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/tests/test_vendors.py +0 -0
  183. {coding_proxy-0.3.1a8 → coding_proxy-0.3.1a9}/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.1a8
3
+ Version: 0.3.1a9
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.3.1a8"
3
+ version = "0.3.1a9"
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"
@@ -30,6 +30,10 @@ logger = logging.getLogger(__name__)
30
30
  # 注册 Auth 子应用
31
31
  app.add_typer(auth_app, name="auth")
32
32
 
33
+ # 注册 Session 子应用
34
+ session_app = typer.Typer(name="session", help="管理 Session-Vendor 运行时绑定")
35
+ app.add_typer(session_app, name="session")
36
+
33
37
 
34
38
  def _build_token_store(cfg_path: Path | None = None):
35
39
  """按配置解析 Token Store 路径并完成加载."""
@@ -264,6 +268,97 @@ def reset(
264
268
  console.print("[red]代理服务未运行[/red]")
265
269
 
266
270
 
271
+ # ── Session 子命令 ───────────────────────────────────────────────
272
+
273
+
274
+ @session_app.command("bind")
275
+ def session_bind(
276
+ key: str = typer.Option(..., "--key", "-k", help="Session key"),
277
+ vendor: str = typer.Option(
278
+ ..., "--vendor", "-v", help="绑定 vendor(逗号分隔多个)"
279
+ ),
280
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
281
+ ) -> None:
282
+ """为指定 Session 绑定 vendor 优先级."""
283
+ import httpx as _httpx
284
+
285
+ vendors = [v.strip() for v in vendor.split(",") if v.strip()]
286
+ try:
287
+ resp = _httpx.put(
288
+ f"http://127.0.0.1:{port}/api/session-vendor",
289
+ json={"session_key": key, "vendors": vendors},
290
+ timeout=5,
291
+ )
292
+ if resp.status_code == 200:
293
+ data = resp.json()
294
+ console.print(
295
+ f"[green]绑定成功:[/] session [cyan]{key[:16]}…[/cyan] → "
296
+ + " → ".join(data.get("vendors", vendors))
297
+ )
298
+ else:
299
+ try:
300
+ err = resp.json()
301
+ msg = err.get("error", {}).get("message", resp.text)
302
+ except Exception:
303
+ msg = resp.text
304
+ console.print(f"[red]绑定失败: {msg}[/red]")
305
+ except _httpx.ConnectError:
306
+ console.print("[red]代理服务未运行[/red]")
307
+
308
+
309
+ @session_app.command("unbind")
310
+ def session_unbind(
311
+ key: str = typer.Option(..., "--key", "-k", help="Session key"),
312
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
313
+ ) -> None:
314
+ """解除指定 Session 的 vendor 绑定."""
315
+ from urllib.parse import quote
316
+
317
+ import httpx as _httpx
318
+
319
+ try:
320
+ resp = _httpx.delete(
321
+ f"http://127.0.0.1:{port}/api/session-vendor/{quote(key, safe='')}",
322
+ timeout=5,
323
+ )
324
+ if resp.status_code == 200:
325
+ console.print(f"[green]已解除绑定:[/] session [cyan]{key[:16]}…[/cyan]")
326
+ elif resp.status_code == 404:
327
+ console.print(f"[yellow]未找到绑定:[/] session [cyan]{key[:16]}…[/cyan]")
328
+ else:
329
+ console.print(f"[red]解除失败: {resp.status_code} {resp.text}[/red]")
330
+ except _httpx.ConnectError:
331
+ console.print("[red]代理服务未运行[/red]")
332
+
333
+
334
+ @session_app.command("list")
335
+ def session_list(
336
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
337
+ ) -> None:
338
+ """列出所有运行时 Session-Vendor 绑定."""
339
+ import httpx as _httpx
340
+
341
+ try:
342
+ resp = _httpx.get(
343
+ f"http://127.0.0.1:{port}/api/session-vendor",
344
+ timeout=5,
345
+ )
346
+ if resp.status_code == 200:
347
+ data = resp.json()
348
+ bindings = data.get("bindings", [])
349
+ if not bindings:
350
+ console.print("[dim]当前无运行时绑定[/dim]")
351
+ return
352
+ for b in bindings:
353
+ key = b.get("session_key", "?")
354
+ vendors = b.get("vendors", [])
355
+ console.print(f" [cyan]{key[:24]}…[/cyan] → " + " → ".join(vendors))
356
+ else:
357
+ console.print(f"[red]查询失败: {resp.status_code} {resp.text}[/red]")
358
+ except _httpx.ConnectError:
359
+ console.print("[red]代理服务未运行[/red]")
360
+
361
+
267
362
  def _resolve_config_path(config: str | Path | None = None) -> Path | None:
268
363
  """标准化配置路径输入."""
269
364
  if config is None:
@@ -0,0 +1,116 @@
1
+ """Session Policy 解析引擎 — 根据 session_key + client_category 解析适用的路由策略."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+
8
+ from ..config.session_policy import SessionPolicy, SessionPolicyMatch
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class SessionPolicyResolver:
14
+ """根据 session_key + client_category 解析适用的 SessionPolicy.
15
+
16
+ 设计要点:
17
+ - 启动时构建索引,运行时 O(1) 查找
18
+ - 精确匹配优先:session_key > client_category > 无策略
19
+ - 无侵入性:不匹配时返回 None,路由行为与现有一致
20
+ - 运行时可变:支持 API 动态 upsert/remove session → vendor 绑定
21
+ """
22
+
23
+ def __init__(self, policies: list[SessionPolicy] | None = None) -> None:
24
+ self._policies = policies or []
25
+ self._key_index: dict[str, SessionPolicy] = {}
26
+ self._category_index: dict[str, SessionPolicy] = {}
27
+ self._config_key_backup: dict[str, SessionPolicy] = {}
28
+ self._lock = threading.Lock()
29
+ self._build_index()
30
+
31
+ def _build_index(self) -> None:
32
+ """构建 session_key / client_category → SessionPolicy 的查找索引.
33
+
34
+ 按定义顺序遍历,首次出现的 key/category 获得最高优先级。
35
+ """
36
+ for policy in self._policies:
37
+ for key in policy.match.session_keys:
38
+ if key not in self._key_index:
39
+ self._key_index[key] = policy
40
+ if (
41
+ policy.match.client_category
42
+ and policy.match.client_category not in self._category_index
43
+ ):
44
+ self._category_index[policy.match.client_category] = policy
45
+
46
+ if self._key_index or self._category_index:
47
+ logger.info(
48
+ "SessionPolicyResolver initialized: %d key rules, %d category rules",
49
+ len(self._key_index),
50
+ len(self._category_index),
51
+ )
52
+
53
+ def resolve(
54
+ self, session_key: str, client_category: str = "cc"
55
+ ) -> SessionPolicy | None:
56
+ """返回匹配的策略,优先精确 session_key 匹配,其次 category 匹配.
57
+
58
+ 返回的 SessionPolicy 对象应为不可变引用;调用方不应修改其内部属性,
59
+ 否则在并发 upsert/remove 场景下可能产生竞态。
60
+ """
61
+ with self._lock:
62
+ policy = self._key_index.get(session_key)
63
+ if policy:
64
+ return policy
65
+ return self._category_index.get(client_category)
66
+
67
+ # ── 运行时 session → vendor 绑定 ──────────────────────────────
68
+
69
+ def upsert(self, session_key: str, tier_names: list[str]) -> SessionPolicy:
70
+ """为指定 session key 创建或替换运行时 vendor 绑定.
71
+
72
+ 运行时策略使用 ``runtime:`` 名称前缀,与配置文件驱动的策略区分。
73
+ """
74
+ policy = SessionPolicy(
75
+ name=f"runtime:{session_key}",
76
+ match=SessionPolicyMatch(session_keys=[session_key]),
77
+ tiers=tier_names,
78
+ )
79
+ with self._lock:
80
+ existing = self._key_index.get(session_key)
81
+ if existing and not existing.name.startswith("runtime:"):
82
+ self._config_key_backup[session_key] = existing
83
+ self._key_index[session_key] = policy
84
+ logger.info(
85
+ "Session vendor binding upserted: session_key=%s → %s",
86
+ session_key,
87
+ tier_names,
88
+ )
89
+ return policy
90
+
91
+ def remove(self, session_key: str) -> bool:
92
+ """删除指定 session key 的运行时 vendor 绑定.
93
+
94
+ Returns:
95
+ True 如果找到并删除了绑定,False 如果不存在。
96
+ """
97
+ with self._lock:
98
+ policy = self._key_index.get(session_key)
99
+ if policy is None or not policy.name.startswith("runtime:"):
100
+ return False
101
+ del self._key_index[session_key]
102
+ # 恢复被运行时绑定覆盖的配置策略
103
+ backup = self._config_key_backup.pop(session_key, None)
104
+ if backup is not None:
105
+ self._key_index[session_key] = backup
106
+ logger.info("Session vendor binding removed: session_key=%s", session_key)
107
+ return True
108
+
109
+ def list_runtime_bindings(self) -> list[dict[str, str | list[str]]]:
110
+ """返回所有运行时注入的绑定快照(仅 API 创建的,不含配置文件驱动的)."""
111
+ with self._lock:
112
+ return [
113
+ {"session_key": key, "vendors": policy.tiers}
114
+ for key, policy in self._key_index.items()
115
+ if policy.name.startswith("runtime:")
116
+ ]
@@ -417,6 +417,19 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
417
417
  }
418
418
  .success-bar { width: 56px; height: 4px; border-radius: 2px; background: rgba(255,255,255,.06); display: inline-block; vertical-align: middle; margin-left: 6px; }
419
419
  .success-bar-fill { height: 100%; border-radius: 2px; }
420
+ /* ── Vendor Bind 选择器 ── */
421
+ .bind-select {
422
+ padding: 3px 6px; border-radius: 6px;
423
+ background: rgba(48,54,61,.6); border: 1px solid rgba(255,255,255,.1);
424
+ color: var(--text-secondary); font-size: 12px;
425
+ font-family: 'JetBrains Mono', monospace;
426
+ cursor: pointer; outline: none;
427
+ transition: all .2s ease;
428
+ max-width: 120px;
429
+ }
430
+ .bind-select:hover { border-color: rgba(88,166,255,.4); color: var(--text-primary); }
431
+ .bind-select:focus { border-color: rgba(88,166,255,.6); box-shadow: 0 0 0 2px rgba(88,166,255,.1); }
432
+ .bind-select option { background: var(--bg-card); color: var(--text-primary); }
420
433
  /* ── 加载态 ── */
421
434
  .loading { opacity: .4; pointer-events: none; }
422
435
  /* ── 图表标签截断 ── */
@@ -457,6 +470,34 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
457
470
  margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-subtle);
458
471
  font-weight: 500; font-size: 12px; color: var(--text-secondary);
459
472
  }
473
+ /* ── Tabs ─────────────────────────────────────────────────── */
474
+ .tabs {
475
+ display: flex;
476
+ gap: 4px;
477
+ margin-bottom: 16px;
478
+ border-bottom: 1px solid var(--border);
479
+ padding: 0 2px;
480
+ }
481
+ .tab-btn {
482
+ appearance: none;
483
+ background: transparent;
484
+ border: none;
485
+ border-bottom: 2px solid transparent;
486
+ color: var(--text-secondary);
487
+ cursor: pointer;
488
+ font-family: inherit;
489
+ font-size: 14px;
490
+ font-weight: 500;
491
+ padding: 10px 16px;
492
+ margin-bottom: -1px;
493
+ transition: color .15s ease, border-color .15s ease, background .15s ease;
494
+ border-radius: 6px 6px 0 0;
495
+ }
496
+ .tab-btn:hover { color: var(--text-primary); background: var(--bg-card-hover); }
497
+ .tab-btn.active { color: var(--text-primary); border-bottom-color: var(--accent-blue); }
498
+ .tab-btn:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
499
+ .tab-pane { display: none; }
500
+ .tab-pane.active { display: block; }
460
501
  </style>
461
502
  </head>
462
503
  <body>
@@ -473,6 +514,14 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
473
514
  </header>
474
515
 
475
516
  <main>
517
+ <!-- 页签导航 -->
518
+ <nav class="tabs" role="tablist" aria-label="Dashboard sections">
519
+ <button type="button" class="tab-btn active" id="tab-btn-overview" role="tab" aria-controls="tab-pane-overview" aria-selected="true" data-tab="overview" onclick="switchTab('overview')">Overview</button>
520
+ <button type="button" class="tab-btn" id="tab-btn-sessions" role="tab" aria-controls="tab-pane-sessions" aria-selected="false" data-tab="sessions" onclick="switchTab('sessions')">Recent Active Sessions</button>
521
+ </nav>
522
+
523
+ <!-- Overview 页签 -->
524
+ <section class="tab-pane active" id="tab-pane-overview" role="tabpanel" aria-labelledby="tab-btn-overview" data-tab="overview">
476
525
  <!-- 时间区间选择器 -->
477
526
  <div class="time-range-bar">
478
527
  <span class="time-range-label">时间区间</span>
@@ -562,7 +611,10 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
562
611
  <div class="html-legend-wrap" id="model-token-legend" style="display:none"></div>
563
612
  </div>
564
613
  </div>
614
+ </section>
565
615
 
616
+ <!-- Recent Active Sessions 页签 -->
617
+ <section class="tab-pane" id="tab-pane-sessions" role="tabpanel" aria-labelledby="tab-btn-sessions" data-tab="sessions">
566
618
  <!-- Recent Active Sessions -->
567
619
  <div class="card sessions-card">
568
620
  <div class="card-title">
@@ -581,15 +633,17 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
581
633
  <th>Vendors</th>
582
634
  <th>Avg Latency</th>
583
635
  <th>Success</th>
636
+ <th>Vendor Bind</th>
584
637
  <th>Client</th>
585
638
  </tr>
586
639
  </thead>
587
640
  <tbody id="sessions-tbody">
588
- <tr><td colspan="9" class="empty">Loading...</td></tr>
641
+ <tr><td colspan="10" class="empty">Loading...</td></tr>
589
642
  </tbody>
590
643
  </table>
591
644
  </div>
592
645
  </div>
646
+ </section>
593
647
 
594
648
  </main>
595
649
 
@@ -1363,16 +1417,31 @@ function formatVendorTags(vendors) {
1363
1417
  }
1364
1418
  async function updateSessions() {
1365
1419
  try {
1366
- var data = await fetchJSON('/api/dashboard/sessions?hours=24&limit=20');
1420
+ var results = await Promise.allSettled([
1421
+ fetchJSON('/api/dashboard/sessions?hours=24&limit=20'),
1422
+ fetchJSON('/api/session-vendor'),
1423
+ fetchJSON('/api/status'),
1424
+ ]);
1425
+ if (results[0].status === 'rejected') throw results[0].reason;
1426
+ var data = results[0].value;
1427
+ var bindData = results[1].status === 'fulfilled' ? results[1].value : {bindings: []};
1428
+ var statusData = results[2].status === 'fulfilled' ? results[2].value : {tiers: []};
1367
1429
  var sessions = data.sessions || [];
1430
+ var bindings = bindData.bindings || [];
1431
+ var availableVendors = (statusData.tiers || []).map(function(t) { return t.name; });
1368
1432
  var tbody = document.getElementById('sessions-tbody');
1369
1433
  var subtitle = document.getElementById('sessions-subtitle');
1370
1434
  if (subtitle) subtitle.textContent = 'Last ' + data.hours + 'h';
1371
1435
  if (!sessions.length) {
1372
- tbody.innerHTML = '<tr><td colspan="9" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
1436
+ tbody.innerHTML = '<tr><td colspan="10" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
1373
1437
  return;
1374
1438
  }
1439
+ // Build binding lookup: session_key → vendors list
1440
+ var bindMap = {};
1441
+ bindings.forEach(function(b) { bindMap[b.session_key] = b.vendors; });
1375
1442
  tbody.innerHTML = sessions.map(function(s) {
1443
+ var boundVendors = bindMap[s.session_key];
1444
+ var selectHtml = buildBindSelect(s.session_key, boundVendors, availableVendors);
1376
1445
  return '<tr>' +
1377
1446
  '<td class="session-key" title="' + escapeHtml(s.session_key) + '">' + truncateKey(s.session_key, 22) + '</td>' +
1378
1447
  '<td>' + relativeTime(s.last_active_ts) + '</td>' +
@@ -1382,6 +1451,7 @@ async function updateSessions() {
1382
1451
  '<td>' + formatVendorTags(s.vendors) + '</td>' +
1383
1452
  '<td style="font-family:JetBrains Mono,monospace">' + (s.avg_duration_ms ? Math.round(s.avg_duration_ms) + 'ms' : '–') + '</td>' +
1384
1453
  '<td>' + successBarHtml(s.success_rate) + '</td>' +
1454
+ '<td>' + selectHtml + '</td>' +
1385
1455
  '<td>' + formatCategories(s.client_categories) + '</td>' +
1386
1456
  '</tr>';
1387
1457
  }).join('');
@@ -1390,48 +1460,170 @@ async function updateSessions() {
1390
1460
  }
1391
1461
  }
1392
1462
 
1393
- // ── 主刷新逻辑 ────────────────────────────────────────────
1463
+ function buildBindSelect(sessionKey, boundVendors, availableVendors) {
1464
+ var isBound = boundVendors && boundVendors.length > 0;
1465
+ var multiBound = isBound && boundVendors.length > 1;
1466
+ var selected = isBound ? boundVendors[0] : '';
1467
+ var html = '<select class="bind-select" data-session-key="' + escapeHtml(sessionKey) + '">';
1468
+ html += '<option value=""' + (!isBound ? ' selected' : '') + '>Default</option>';
1469
+ availableVendors.forEach(function(v) {
1470
+ var label = multiBound && v === selected ? escapeHtml(v) + ' (+' + (boundVendors.length - 1) + ')' : escapeHtml(v);
1471
+ html += '<option value="' + escapeHtml(v) + '"' + (v === selected ? ' selected' : '') + '>' + label + '</option>';
1472
+ });
1473
+ html += '</select>';
1474
+ return html;
1475
+ }
1476
+
1477
+ async function handleBindChange(sel) {
1478
+ var sessionKey = sel.getAttribute('data-session-key');
1479
+ var vendor = sel.value;
1480
+ var previousValue = sel.getAttribute('data-previous') || '';
1481
+ try {
1482
+ var resp;
1483
+ if (vendor) {
1484
+ resp = await fetch('/api/session-vendor', {
1485
+ method: 'PUT',
1486
+ headers: {'Content-Type': 'application/json'},
1487
+ body: JSON.stringify({session_key: sessionKey, vendors: [vendor]}),
1488
+ });
1489
+ } else {
1490
+ resp = await fetch('/api/session-vendor/' + encodeURIComponent(sessionKey), {method: 'DELETE'});
1491
+ }
1492
+ if (!resp.ok) {
1493
+ sel.value = previousValue;
1494
+ console.error('Bind change rejected:', resp.status, await resp.text());
1495
+ }
1496
+ } catch (e) {
1497
+ sel.value = previousValue;
1498
+ console.error('Bind change failed:', e);
1499
+ }
1500
+ }
1501
+
1502
+ var sessionsTbody = document.getElementById('sessions-tbody');
1503
+ sessionsTbody.addEventListener('focus', function(e) {
1504
+ if (e.target.classList.contains('bind-select')) {
1505
+ e.target.setAttribute('data-previous', e.target.value);
1506
+ }
1507
+ }, true);
1508
+ sessionsTbody.addEventListener('change', function(e) {
1509
+ if (e.target.classList.contains('bind-select')) {
1510
+ handleBindChange(e.target);
1511
+ }
1512
+ });
1513
+
1514
+ // ── 主刷新逻辑(按 Tab 分发) ──────────────────────────────
1394
1515
  let refreshing = false;
1516
+ let currentTab = 'overview';
1517
+ const tabLoaded = { overview: false, sessions: false };
1518
+ const TAB_LABELS = { overview: 'Overview', sessions: 'Recent Active Sessions' };
1519
+
1520
+ async function refreshOverview() {
1521
+ const days = currentDays > 0 ? currentDays : 7;
1522
+ const [summary, timeline, status] = await Promise.all([
1523
+ fetchJSON('/api/dashboard/summary?days=' + days),
1524
+ fetchJSON('/api/dashboard/timeline?days=' + days),
1525
+ fetchJSON('/api/status'),
1526
+ ]);
1527
+
1528
+ if (summary.version) {
1529
+ document.getElementById('version-badge').textContent = 'v' + summary.version;
1530
+ }
1531
+
1532
+ updateKPI(summary);
1533
+ updateVendorStatus(status);
1534
+ updateChartTitles(days);
1535
+
1536
+ const rows = timeline.rows || [];
1537
+ const tierOrder = (status.tiers || []).map(t => t.name);
1538
+ buildTimeline(rows, tierOrder);
1539
+ buildVendorDist(rows, tierOrder);
1540
+ buildTokenTimeline(rows, tierOrder);
1541
+ buildModelTokenTimeline(rows);
1542
+ }
1543
+
1544
+ async function refreshSessions() {
1545
+ await updateSessions();
1546
+ }
1547
+
1395
1548
  async function refresh() {
1396
1549
  if (refreshing) return;
1397
1550
  refreshing = true;
1398
- document.getElementById('refresh-time').textContent = '刷新中…';
1399
1551
  try {
1400
- const days = currentDays > 0 ? currentDays : 7;
1401
- const [summary, timeline, status] = await Promise.all([
1402
- fetchJSON('/api/dashboard/summary?days=' + days),
1403
- fetchJSON('/api/dashboard/timeline?days=' + days),
1404
- fetchJSON('/api/status'),
1405
- ]);
1406
-
1407
- if (summary.version) {
1408
- document.getElementById('version-badge').textContent = 'v' + summary.version;
1552
+ // 循环:若 await 期间用户切到了尚未加载的另一页签,补一次刷新,避免 tabLoaded 错位。
1553
+ while (true) {
1554
+ const tab = currentTab;
1555
+ document.getElementById('refresh-time').textContent = '刷新中…';
1556
+ try {
1557
+ if (tab === 'sessions') {
1558
+ await refreshSessions();
1559
+ } else {
1560
+ await refreshOverview();
1561
+ }
1562
+ tabLoaded[tab] = true;
1563
+ if (tab === currentTab) {
1564
+ document.getElementById('refresh-time').textContent =
1565
+ '上次刷新: ' + now() + '(' + TAB_LABELS[tab] + ')';
1566
+ }
1567
+ } catch (e) {
1568
+ console.error('Dashboard refresh error:', e);
1569
+ document.getElementById('refresh-time').textContent = '刷新失败 ' + now();
1570
+ }
1571
+ if (currentTab !== tab && !tabLoaded[currentTab]) continue;
1572
+ break;
1409
1573
  }
1574
+ } finally {
1575
+ refreshing = false;
1576
+ }
1577
+ }
1410
1578
 
1411
- updateKPI(summary);
1412
- updateVendorStatus(status);
1413
- updateChartTitles(days);
1579
+ // ── 页签切换(懒加载 + URL 同步) ─────────────────────────
1580
+ function syncTabUrl(name) {
1581
+ try {
1582
+ const url = new URL(window.location.href);
1583
+ if (url.searchParams.get('tab') === name) return;
1584
+ url.searchParams.set('tab', name);
1585
+ window.history.replaceState({}, '', url);
1586
+ } catch (e) { /* no-op */ }
1587
+ }
1414
1588
 
1415
- const rows = timeline.rows || [];
1416
- const tierOrder = (status.tiers || []).map(t => t.name);
1417
- buildTimeline(rows, tierOrder);
1418
- buildVendorDist(rows, tierOrder);
1419
- buildTokenTimeline(rows, tierOrder);
1420
- buildModelTokenTimeline(rows);
1421
- updateSessions();
1589
+ function applyTabState(name) {
1590
+ document.querySelectorAll('.tab-btn').forEach(function (b) {
1591
+ const active = b.getAttribute('data-tab') === name;
1592
+ b.classList.toggle('active', active);
1593
+ b.setAttribute('aria-selected', active ? 'true' : 'false');
1594
+ });
1595
+ document.querySelectorAll('.tab-pane').forEach(function (p) {
1596
+ p.classList.toggle('active', p.getAttribute('data-tab') === name);
1597
+ });
1598
+ }
1422
1599
 
1423
- document.getElementById('refresh-time').textContent = '上次刷新: ' + now();
1424
- } catch (e) {
1425
- console.error('Dashboard refresh error:', e);
1426
- document.getElementById('refresh-time').textContent = '刷新失败 ' + now();
1427
- } finally {
1428
- refreshing = false;
1600
+ function switchTab(name) {
1601
+ if (name !== 'overview' && name !== 'sessions') name = 'overview';
1602
+ if (name === currentTab) {
1603
+ syncTabUrl(name);
1604
+ return;
1605
+ }
1606
+ currentTab = name;
1607
+ applyTabState(name);
1608
+ syncTabUrl(name);
1609
+ if (!tabLoaded[name]) {
1610
+ refresh();
1429
1611
  }
1430
1612
  }
1431
1613
 
1432
- // 页面加载 + 每 30 秒自动刷新
1433
- refresh();
1434
- setInterval(refresh, 600000);
1614
+ // ── 初始化 ────────────────────────────────────────────────
1615
+ (function bootstrap() {
1616
+ let initial = 'overview';
1617
+ try {
1618
+ const t = new URL(window.location.href).searchParams.get('tab');
1619
+ if (t === 'sessions') initial = 'sessions';
1620
+ } catch (e) { /* no-op */ }
1621
+ currentTab = initial;
1622
+ applyTabState(initial);
1623
+ syncTabUrl(initial);
1624
+ refresh(); // 仅加载初始页签的数据
1625
+ setInterval(refresh, 600000); // 每 10 分钟刷新当前页签
1626
+ })();
1435
1627
  </script>
1436
1628
  </body>
1437
1629
  </html>