coding-proxy 0.2.4a2__tar.gz → 0.2.4a4__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 (149) hide show
  1. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/.github/workflows/release.yml +2 -133
  2. coding_proxy-0.2.4a4/.pre-commit-config.yaml +57 -0
  3. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/AGENTS.md +1 -0
  4. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/CHANGELOG.md +5 -3
  5. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/PKG-INFO +3 -3
  6. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/README.md +2 -2
  7. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/docs/zh-CN/README.md +2 -2
  8. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/pyproject.toml +2 -1
  9. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/config.default.yaml +1 -1
  10. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/executor.py +42 -10
  11. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/dashboard.py +23 -9
  12. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/request_normalizer.py +144 -0
  13. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_request_normalizer.py +511 -0
  14. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/uv.lock +101 -1
  15. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/.github/workflows/ci.yml +0 -0
  16. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/.github/workflows/coverage.yml +0 -0
  17. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/.gitignore +0 -0
  18. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/CLAUDE.md +0 -0
  19. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/LICENSE +0 -0
  20. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/assets/dashboard-v0.2.3.png +0 -0
  21. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/docs/ci-cd.md +0 -0
  22. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/docs/framework.md +0 -0
  23. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/docs/user-guide.md +0 -0
  24. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/__init__.py +0 -0
  25. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/__init__.py +0 -0
  26. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/__main__.py +0 -0
  27. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/__init__.py +0 -0
  28. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/providers/__init__.py +0 -0
  29. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/providers/base.py +0 -0
  30. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/providers/github.py +0 -0
  31. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/providers/google.py +0 -0
  32. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/runtime.py +0 -0
  33. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/auth/store.py +0 -0
  34. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/cli/__init__.py +0 -0
  35. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/cli/auth_commands.py +0 -0
  36. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/cli/banner.py +0 -0
  37. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/compat/__init__.py +0 -0
  38. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/compat/canonical.py +0 -0
  39. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/compat/session_store.py +0 -0
  40. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/__init__.py +0 -0
  41. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/auth_schema.py +0 -0
  42. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/loader.py +0 -0
  43. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/resiliency.py +0 -0
  44. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/routing.py +0 -0
  45. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/schema.py +0 -0
  46. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/server.py +0 -0
  47. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/config/vendors.py +0 -0
  48. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/__init__.py +0 -0
  49. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/anthropic_to_gemini.py +0 -0
  50. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/anthropic_to_openai.py +0 -0
  51. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/gemini_sse_adapter.py +0 -0
  52. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/gemini_to_anthropic.py +0 -0
  53. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/convert/openai_to_anthropic.py +0 -0
  54. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/logging/__init__.py +0 -0
  55. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/logging/db.py +0 -0
  56. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/logging/formatters.py +0 -0
  57. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/logging/stats.py +0 -0
  58. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/__init__.py +0 -0
  59. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/auth.py +0 -0
  60. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/compat.py +0 -0
  61. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/constants.py +0 -0
  62. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/pricing.py +0 -0
  63. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/token.py +0 -0
  64. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/model/vendor.py +0 -0
  65. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/pricing.py +0 -0
  66. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/__init__.py +0 -0
  67. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/circuit_breaker.py +0 -0
  68. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/error_classifier.py +0 -0
  69. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/model_mapper.py +0 -0
  70. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/quota_guard.py +0 -0
  71. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/rate_limit.py +0 -0
  72. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/retry.py +0 -0
  73. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/router.py +0 -0
  74. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/session_manager.py +0 -0
  75. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/tier.py +0 -0
  76. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/usage_parser.py +0 -0
  77. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/routing/usage_recorder.py +0 -0
  78. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/__init__.py +0 -0
  79. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/app.py +0 -0
  80. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/factory.py +0 -0
  81. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/responses.py +0 -0
  82. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/server/routes.py +0 -0
  83. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/streaming/__init__.py +0 -0
  84. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/streaming/anthropic_compat.py +0 -0
  85. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/__init__.py +0 -0
  86. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/alibaba.py +0 -0
  87. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/anthropic.py +0 -0
  88. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/antigravity.py +0 -0
  89. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/base.py +0 -0
  90. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/copilot.py +0 -0
  91. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/copilot_models.py +0 -0
  92. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/copilot_token_manager.py +0 -0
  93. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/copilot_urls.py +0 -0
  94. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/doubao.py +0 -0
  95. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/kimi.py +0 -0
  96. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/minimax.py +0 -0
  97. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/mixins.py +0 -0
  98. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/native_anthropic.py +0 -0
  99. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/token_manager.py +0 -0
  100. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/xiaomi.py +0 -0
  101. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/src/coding/proxy/vendors/zhipu.py +0 -0
  102. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/__init__.py +0 -0
  103. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_antigravity.py +0 -0
  104. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_app_routes.py +0 -0
  105. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_auto_login.py +0 -0
  106. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_banner.py +0 -0
  107. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_circuit_breaker.py +0 -0
  108. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_cli_usage.py +0 -0
  109. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_compat.py +0 -0
  110. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_config_init.py +0 -0
  111. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_config_loader.py +0 -0
  112. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_convert_request.py +0 -0
  113. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_convert_response.py +0 -0
  114. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_convert_sse.py +0 -0
  115. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_copilot.py +0 -0
  116. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_copilot_convert_request.py +0 -0
  117. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_copilot_convert_response.py +0 -0
  118. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_copilot_models.py +0 -0
  119. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_copilot_urls.py +0 -0
  120. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_currency.py +0 -0
  121. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_error_classifier.py +0 -0
  122. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_logging_dual_write.py +0 -0
  123. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_mixins.py +0 -0
  124. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_auth.py +0 -0
  125. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_compat.py +0 -0
  126. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_constants.py +0 -0
  127. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_mapper.py +0 -0
  128. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_pricing.py +0 -0
  129. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_token.py +0 -0
  130. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_model_vendor.py +0 -0
  131. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_native_vendors.py +0 -0
  132. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_parse_usage.py +0 -0
  133. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_pricing.py +0 -0
  134. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_quota_guard.py +0 -0
  135. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_rate_limit.py +0 -0
  136. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_router_chain.py +0 -0
  137. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_router_executor.py +0 -0
  138. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_runtime_reauth.py +0 -0
  139. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_schema.py +0 -0
  140. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_streaming_anthropic_compat.py +0 -0
  141. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_tier.py +0 -0
  142. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_tiers_config.py +0 -0
  143. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_time_range.py +0 -0
  144. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_token_logger.py +0 -0
  145. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_token_manager.py +0 -0
  146. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_types.py +0 -0
  147. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_vendor_streaming.py +0 -0
  148. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_vendors.py +0 -0
  149. {coding_proxy-0.2.4a2 → coding_proxy-0.2.4a4}/tests/test_zhipu.py +0 -0
@@ -1,15 +1,14 @@
1
1
  # =============================================================================
2
- # coding-proxy: PyPI Publishing Pipeline (4-Stage Unified Pipeline)
2
+ # coding-proxy: PyPI Publishing Pipeline (3-Stage Unified Pipeline)
3
3
  # =============================================================================
4
4
  # Trigger: GitHub Release publication event (release.types: [published])
5
5
  #
6
- # Architecture: 4-Stage Serial Pipeline (prerelease only — 所有生产发布均走预发布)
6
+ # Architecture: 3-Stage Serial Pipeline (prerelease only — 所有生产发布均走预发布)
7
7
  #
8
8
  # Stage 1 (build): 矩阵构建 3.12/3.13/3.14,上传 artifacts
9
9
  # Stage 2 (publish-testpypi): 发布到 TestPyPI 供验证
10
10
  # Stage 3 (publish-to-pypi): ⏸ Production Approval Gate (environment: pypi)
11
11
  # 审批通过后自动:Promote Release + Publish to PyPI
12
- # Stage 4 (update-changelog): 从 Release notes 生成 Changelog PR → master
13
12
  #
14
13
  # 设计决策 — Stage 3 合并审批与发布的理由:
15
14
  # pypa/gh-action-pypi-publish@release/v1 始终优先尝试 OIDC Trusted Publishing。
@@ -21,9 +20,6 @@
21
20
  # Pre-requisites — 需要在 GitHub Settings 一次性手动配置:
22
21
  # 1. repo → Settings → Environments → "pypi"
23
22
  # → Required reviewers: 添加审批人员(Production Approval Gate 所需)
24
- # 2. repo → Settings → Actions → General → Workflow permissions
25
- # → "Read and write permissions"(Stage 4 推分支所需)
26
- # → 勾选 "Allow GitHub Actions to create and approve pull requests"
27
23
  #
28
24
  # Authentication:
29
25
  # - TEST_PYPI_API_TOKEN: TestPyPI API Token(repository secret)
@@ -197,130 +193,3 @@ jobs:
197
193
  with:
198
194
  skip-existing: true
199
195
  verbose: true
200
-
201
- # ===========================================================================
202
- # Stage 5: UPDATE CHANGELOG -- 自动生成 Changelog PR → master
203
- # ===========================================================================
204
- update-changelog:
205
- name: Update Changelog
206
- runs-on: ubuntu-latest
207
- needs: publish-to-pypi
208
- if: github.event.release.prerelease == true
209
- timeout-minutes: 10
210
- permissions:
211
- contents: write # git push 新分支
212
- pull-requests: write # 创建 PR
213
-
214
- steps:
215
- - name: Checkout repository
216
- uses: actions/checkout@v4
217
- with:
218
- fetch-depth: 0
219
- persist-credentials: true
220
-
221
- - name: Extract stable version from prerelease tag
222
- id: version
223
- run: |
224
- PRERELEASE_TAG="${{ github.ref_name }}"
225
- # v0.2.0a1 → v0.2.0(移除预发布后缀 a*/b*/rc*)
226
- STABLE_VERSION=$(echo "$PRERELEASE_TAG" | sed 's/\(v[0-9]*\.[0-9]*\.[0-9]*\).*/\1/')
227
- echo "prerelease_tag=$PRERELEASE_TAG" >> "$GITHUB_OUTPUT"
228
- echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT"
229
- echo "branch_name=chore/changelog-$STABLE_VERSION" >> "$GITHUB_OUTPUT"
230
- echo "Extracted: $PRERELEASE_TAG → $STABLE_VERSION"
231
-
232
- - name: Check if Changelog entry already exists
233
- id: check
234
- run: |
235
- STABLE="${{ steps.version.outputs.stable_version }}"
236
- if grep -qF "[$STABLE]" CHANGELOG.md; then
237
- echo "exists=true" >> "$GITHUB_OUTPUT"
238
- echo "ℹ️ Changelog entry for $STABLE already exists, skipping PR creation"
239
- else
240
- echo "exists=false" >> "$GITHUB_OUTPUT"
241
- echo "✅ No existing entry for $STABLE, will create PR"
242
- fi
243
-
244
- - name: Fetch release body and update CHANGELOG.md
245
- if: steps.check.outputs.exists == 'false'
246
- uses: actions/github-script@v7
247
- with:
248
- script: |
249
- const fs = require('fs');
250
- const { owner, repo } = context.repo;
251
-
252
- // 通过 API 获取 Release body,避免 shell 特殊字符注入问题
253
- const release = await github.rest.repos.getReleaseByTag({
254
- owner,
255
- repo,
256
- tag: '${{ steps.version.outputs.prerelease_tag }}',
257
- });
258
-
259
- const stable = '${{ steps.version.outputs.stable_version }}';
260
- const date = new Date().toISOString().slice(0, 10);
261
- const releaseUrl = `https://github.com/${owner}/${repo}/releases/tag/${{ steps.version.outputs.prerelease_tag }}`;
262
- const body = release.data.body || '';
263
-
264
- // 构建新条目
265
- const newEntry = `\n## [${stable}](${releaseUrl}) — ${date}\n\n${body}\n`;
266
-
267
- // 读取 CHANGELOG.md,在 ## [Unreleased] 行后插入新条目
268
- let content = fs.readFileSync('CHANGELOG.md', 'utf8');
269
- const marker = '## [Unreleased]';
270
- const idx = content.indexOf(marker);
271
- if (idx === -1) {
272
- core.setFailed('CHANGELOG.md 中未找到 ## [Unreleased] 标记,无法插入条目');
273
- return;
274
- }
275
- const insertPos = idx + marker.length;
276
- content = content.slice(0, insertPos) + newEntry + content.slice(insertPos);
277
- fs.writeFileSync('CHANGELOG.md', content, 'utf8');
278
- core.info(`✅ CHANGELOG.md updated with entry for ${stable}`);
279
-
280
- - name: Create branch and commit
281
- if: steps.check.outputs.exists == 'false'
282
- run: |
283
- BRANCH="${{ steps.version.outputs.branch_name }}"
284
- STABLE="${{ steps.version.outputs.stable_version }}"
285
-
286
- git config user.name "github-actions[bot]"
287
- git config user.email "github-actions[bot]@users.noreply.github.com"
288
-
289
- git checkout -b "$BRANCH"
290
- git add CHANGELOG.md
291
- git commit -m "docs(changelog): add entry for $STABLE"
292
- git push origin "$BRANCH"
293
-
294
- - name: Create Pull Request
295
- if: steps.check.outputs.exists == 'false'
296
- uses: actions/github-script@v7
297
- with:
298
- script: |
299
- const { owner, repo } = context.repo;
300
- const stable = '${{ steps.version.outputs.stable_version }}';
301
- const branch = '${{ steps.version.outputs.branch_name }}';
302
- const prereleaseTag = '${{ steps.version.outputs.prerelease_tag }}';
303
- const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${{ github.run_id }}`;
304
-
305
- const pr = await github.rest.pulls.create({
306
- owner,
307
- repo,
308
- title: `docs(changelog): 补充 ${stable} 发版说明`,
309
- body: [
310
- `## 摘要`,
311
- ``,
312
- `自动生成的 PR,为 **${stable}** 正式版补充 CHANGELOG 条目。`,
313
- ``,
314
- `- 来源:[\`${prereleaseTag}\`](https://github.com/${owner}/${repo}/releases/tag/${prereleaseTag}) Release notes`,
315
- `- 目标分支:\`master\``,
316
- ``,
317
- `请审阅 CHANGELOG 条目后合并。`,
318
- ``,
319
- `> 🤖 由 [发布流水线](${runUrl}) 自动生成`,
320
- ].join('\n'),
321
- head: branch,
322
- base: 'master',
323
- });
324
-
325
- core.notice(`✅ PR created: ${pr.data.html_url}`);
326
-
@@ -0,0 +1,57 @@
1
+ # =============================================================================
2
+ # coding-proxy: Pre-commit Hooks Configuration
3
+ # =============================================================================
4
+ # 目的: 在代码提交前自动执行 Ruff lint/format 及通用卫生检查,作为 CI 的前置守护
5
+ #
6
+ # 设计决策:使用 local hook(language: system + uv run)而非 astral-sh/ruff-pre-commit
7
+ # - 遵循 Single Source of Truth 原则:Ruff 版本由 uv.lock 唯一管控,消除版本漂移
8
+ # - 与 CI 命令形态完全镜像(uv run ruff check/format)
9
+ # - 符合 AGENTS.md 包管理规范(统一使用 uv)
10
+ #
11
+ # 安装:uv run pre-commit install
12
+ # 手动全量检查:uv run pre-commit run --all-files
13
+ # =============================================================================
14
+
15
+ minimum_pre_commit_version: "4.0.0"
16
+
17
+ repos:
18
+ # ---------------------------------------------------------------------------
19
+ # 通用代码卫生检查(pre-commit-hooks 官方钩子集)
20
+ # 轻量级、无额外依赖、执行极快
21
+ # ---------------------------------------------------------------------------
22
+ - repo: https://github.com/pre-commit/pre-commit-hooks
23
+ rev: v5.0.0
24
+ hooks:
25
+ - id: trailing-whitespace # 去除行尾空白
26
+ - id: end-of-file-fixer # 确保文件以换行符结尾
27
+ - id: check-yaml
28
+ args: ["--unsafe"] # 兼容 GitHub Actions ${{ }} 表达式语法
29
+ - id: check-toml # 校验 TOML 语法(pyproject.toml)
30
+ - id: check-merge-conflict # 阻止提交含未解决 merge conflict 标记的文件
31
+ - id: check-added-large-files
32
+ args: ["--maxkb=1024"] # 阈值 1MB(assets/dashboard-v0.2.3.png 为 944KB)
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Ruff: local hook,直接调用 uv 安装的 Ruff(与 CI 版本完全一致)
36
+ # 前置条件:uv sync --dev 已执行
37
+ # ---------------------------------------------------------------------------
38
+ - repo: local
39
+ hooks:
40
+ # 对应 CI: uv run ruff check .
41
+ # --fix: 自动修复安全可修复问题(isort、UP 现代化等),降低开发摩擦
42
+ # --exit-non-zero-on-fix: 修复后返回非零码,触发 git 重新暂存已修复文件
43
+ - id: ruff-lint
44
+ name: Ruff (lint + auto-fix)
45
+ language: system
46
+ entry: uv run ruff check --fix --exit-non-zero-on-fix
47
+ types_or: [python, pyi]
48
+ require_serial: false
49
+
50
+ # 对应 CI: uv run ruff format --check .
51
+ # 本地直接 format(自动修复格式),CI 保留 --check 模式作为最终防线
52
+ - id: ruff-format
53
+ name: Ruff (format)
54
+ language: system
55
+ entry: uv run ruff format
56
+ types_or: [python, pyi]
57
+ require_serial: false
@@ -46,6 +46,7 @@
46
46
  2. **Temp Management**: 临时产物(执行计划等)一律收敛至 `.temp/` 并及时清理;
47
47
  3. **Link Validity**: 确保所有引用的 URL 可访问且具备明确的上下文价值;
48
48
  4. **Git Commit**: 在需要提交变更到 Git 时,一律使用 Shell 调用 Claude Code 的自定义 Slash Command: `/commit` 进行 git commit 操作(若环境中未安装 Claude Code,则直接读取 `~/.claude/commands/commit.md`,按照其中的规则进行 git commit 操作)。不要执行 Rebase。
49
+ 5. **Pre-commit Hooks**: 克隆仓库后执行 `uv run pre-commit install` 激活本地 Git hooks,使 Ruff lint(含 auto-fix)、Ruff format 及通用代码卫生检查在每次 commit 前自动运行。若 hooks 自动修复了问题,提交会被中断,执行 `git add -p` 审阅修复内容后重新提交即可。
49
50
  - **Package Management Standardization (包管理规范)**:
50
51
  1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`);
51
52
  2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行。
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ - fix(request-normalizer): 重设计 zhipu→anthropic 跨供应商 tool_use/tool_result 配对修复——以单遍自包含 `enforce_anthropic_tool_pairing` 替代原有多步串联管线(剥离→重定位→孤儿修复),消除步骤间隐式依赖导致的孤儿 tool_use 漏修问题,彻底根治 `tool_use ids were found without tool_result blocks` 400 异常;
8
+
7
9
  ## [v0.2.3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.2.3) — 2026-04-16
8
10
 
9
11
  - feat(dashboard): 新增实时 Web Dashboard 页面,聚合展示流量与用量统计;
@@ -71,9 +73,9 @@
71
73
  ## [v0.1.1](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.1.1) — 2026-04-05
72
74
 
73
75
  > [!IMPORTANT]
74
- >
75
- > **🎉 coding-proxy MVP 惊艳登场!**
76
- >
76
+ >
77
+ > **🎉 coding-proxy MVP 惊艳登场!**
78
+ >
77
79
  > 仅需配置一行环境变量,立刻为你的 Claude Code 接入“永不宕机”的多源智能引擎。主供应商打盹?毫秒级自动无缝切换备用通道,全天候护航你的编码心流,向打断大声说不!
78
80
 
79
81
  ### ✨ 核心亮点
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.2.4a2
3
+ Version: 0.2.4a4
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
@@ -145,7 +145,7 @@ graph RL
145
145
 
146
146
  subgraph CodingProxy["⚡ coding-proxy"]
147
147
  direction RL
148
-
148
+
149
149
  Router["RequestRouter<br/><code>routing/router.py</code>"]:::router
150
150
 
151
151
  Router -->NTier
@@ -178,7 +178,7 @@ graph RL
178
178
  Tier2 -. "🆘 Safety Net Downgrade" .-> TierN
179
179
  end
180
180
 
181
- end
181
+ end
182
182
 
183
183
  Client -->|"POST /v1/messages"| CodingProxy
184
184
  ```
@@ -118,7 +118,7 @@ graph RL
118
118
 
119
119
  subgraph CodingProxy["⚡ coding-proxy"]
120
120
  direction RL
121
-
121
+
122
122
  Router["RequestRouter<br/><code>routing/router.py</code>"]:::router
123
123
 
124
124
  Router -->NTier
@@ -151,7 +151,7 @@ graph RL
151
151
  Tier2 -. "🆘 Safety Net Downgrade" .-> TierN
152
152
  end
153
153
 
154
- end
154
+ end
155
155
 
156
156
  Client -->|"POST /v1/messages"| CodingProxy
157
157
  ```
@@ -118,7 +118,7 @@ graph RL
118
118
 
119
119
  subgraph CodingProxy["⚡ coding-proxy"]
120
120
  direction RL
121
-
121
+
122
122
  Router["RequestRouter<br/><code>routing/router.py</code>"]:::router
123
123
 
124
124
  Router -->NTier
@@ -151,7 +151,7 @@ graph RL
151
151
  Tier2 -. "🆘 Safety Net Downgrade" .-> TierN
152
152
  end
153
153
 
154
- end
154
+ end
155
155
 
156
156
  Client -->|"POST /v1/messages"| CodingProxy
157
157
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "coding-proxy"
3
- version = "0.2.4a2"
3
+ version = "0.2.4a4"
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"
@@ -47,6 +47,7 @@ dev = [
47
47
  "pytest-asyncio>=1.3",
48
48
  "pytest-cov>=6.0",
49
49
  "ruff>=0.9.0",
50
+ "pre-commit>=4.0",
50
51
  ]
51
52
 
52
53
  # ── Ruff: Linter & Formatter(替代 flake8 + isort + Black + pyupgrade 等 6+ 工具) ──
@@ -52,7 +52,7 @@ vendors:
52
52
  success_threshold: 2
53
53
  quota_guard:
54
54
  enabled: false
55
- token_budget: 60000000 # 5 小时 token 预算(根据订阅计划调整)
55
+ token_budget: 65000000 # 5 小时 token 预算(根据订阅计划调整)
56
56
  window_hours: 5.0
57
57
  threshold_percent: 99.0
58
58
  probe_interval_seconds: 300
@@ -233,7 +233,7 @@ class _RouteExecutor:
233
233
  """为指定 tier 准备请求体,必要时应用 Anthropic 专属修复(Phase 2).
234
234
 
235
235
  仅当 tier 为 Anthropic 时才执行以下处理:
236
- 1. tool_result 重定位 + 孤儿修复(需 normalization.has_anthropic_fixes)
236
+ 1. 跨供应商 tool_use/tool_result 配对强制修复(单遍自包含扫描)
237
237
  2. 条件化 thinking block 剥离(仅跨供应商场景)
238
238
 
239
239
  确保 Zhipu 等其他 vendor 不受影响。
@@ -241,30 +241,28 @@ class _RouteExecutor:
241
241
  if tier.name != "anthropic":
242
242
  return body
243
243
 
244
- needs_tool_fixes = (
245
- normalization is not None and normalization.has_anthropic_fixes
244
+ needs_tool_pairing = self._needs_tool_pairing_enforcement(
245
+ normalization, session_record
246
246
  )
247
247
  needs_thinking_strip = self._needs_thinking_strip(normalization, session_record)
248
248
 
249
- if not needs_tool_fixes and not needs_thinking_strip:
249
+ if not needs_tool_pairing and not needs_thinking_strip:
250
250
  return body
251
251
 
252
252
  from ..server.request_normalizer import (
253
- apply_anthropic_specific_fixes,
253
+ enforce_anthropic_tool_pairing,
254
254
  strip_thinking_blocks,
255
255
  )
256
256
 
257
257
  body_for_vendor = copy.deepcopy(body)
258
258
 
259
- if needs_tool_fixes:
260
- fixes = apply_anthropic_specific_fixes(
259
+ if needs_tool_pairing:
260
+ fixes = enforce_anthropic_tool_pairing(
261
261
  body_for_vendor.get("messages", []),
262
- normalization.misplaced_tool_results,
263
- normalization.misplaced_log_info,
264
262
  )
265
263
  if fixes:
266
264
  logger.debug(
267
- "Applied Anthropic-specific fixes for tier %s: %s",
265
+ "Applied tool pairing enforcement for tier %s: %s",
268
266
  tier.name,
269
267
  ", ".join(fixes),
270
268
  )
@@ -279,6 +277,40 @@ class _RouteExecutor:
279
277
 
280
278
  return body_for_vendor
281
279
 
280
+ @staticmethod
281
+ def _needs_tool_pairing_enforcement(
282
+ normalization: Any, session_record: Any
283
+ ) -> bool:
284
+ """判断是否需要强制执行 Anthropic tool_use/tool_result 配对修复.
285
+
286
+ 此方法扩展了原有 ``has_anthropic_fixes`` 的触发条件,覆盖以下场景:
287
+
288
+ 1. 请求体中检测到跨供应商产物(如非标准 ID、misplaced tool_result)
289
+ 2. Phase 1 检测到需要 Anthropic 修复(misplaced 或 ID 重写)
290
+ 3. 会话历史中存在非 Anthropic 供应商记录(如 zhipu)
291
+ 4. 无会话追踪能力时安全回退
292
+
293
+ 条件 3 和 4 确保即使请求体本身无跨供应商产物(如 zhipu 使用标准
294
+ ``toolu_*`` ID 时),只要会话曾经过非 Anthropic 供应商,仍会执行配对修复。
295
+ """
296
+ # Signal 1: 当前请求体有跨供应商产物
297
+ if normalization is not None and normalization.has_cross_vendor_signals:
298
+ return True
299
+ # Signal 2: Phase 1 检测到需要 Anthropic 修复
300
+ if normalization is not None and normalization.has_anthropic_fixes:
301
+ return True
302
+ # Signal 3: 无会话追踪 → 安全回退
303
+ if session_record is None:
304
+ return True
305
+ # Signal 4: 会话历史中有非 Anthropic 供应商
306
+ if session_record.provider_state:
307
+ non_anthropic = {
308
+ v for v in session_record.provider_state if v != "anthropic"
309
+ }
310
+ if non_anthropic:
311
+ return True
312
+ return False
313
+
282
314
  @staticmethod
283
315
  def _needs_thinking_strip(normalization: Any, session_record: Any) -> bool:
284
316
  """判断是否需要剥离 thinking blocks(仅跨供应商场景).
@@ -911,8 +911,21 @@ function updateVendorStatus(status) {
911
911
  }).join('');
912
912
  }
913
913
 
914
+ // ── 按 tiers 顺序排序 vendor 列表 ─────────────────────────
915
+ function sortByTierOrder(vendors, tierOrder) {
916
+ if (!tierOrder || !tierOrder.length) return vendors.sort();
917
+ const orderMap = {};
918
+ tierOrder.forEach((name, i) => { orderMap[name] = i; });
919
+ const maxIdx = tierOrder.length;
920
+ return vendors.sort((a, b) => {
921
+ const ia = orderMap[a] ?? maxIdx;
922
+ const ib = orderMap[b] ?? maxIdx;
923
+ return ia !== ib ? ia - ib : a.localeCompare(b);
924
+ });
925
+ }
926
+
914
927
  // ── 时序折线图(请求量,按 vendor)────────────────────────
915
- function buildTimeline(rows) {
928
+ function buildTimeline(rows, tierOrder) {
916
929
  const vendorDateMap = {};
917
930
  const allDates = new Set();
918
931
  for (const r of rows) {
@@ -923,7 +936,7 @@ function buildTimeline(rows) {
923
936
  allDates.add(d);
924
937
  }
925
938
  const dates = [...allDates].sort();
926
- const vendors = Object.keys(vendorDateMap).sort();
939
+ const vendors = sortByTierOrder(Object.keys(vendorDateMap), tierOrder);
927
940
 
928
941
  if (chartTimeline) chartTimeline.destroy();
929
942
  const ctx = document.getElementById('chart-timeline').getContext('2d');
@@ -962,14 +975,14 @@ function buildTimeline(rows) {
962
975
  }
963
976
 
964
977
  // ── 供应商分布环形图 ──────────────────────────────────────
965
- function buildVendorDist(rows) {
978
+ function buildVendorDist(rows, tierOrder) {
966
979
  const vendorTotals = {};
967
980
  for (const r of rows) {
968
981
  const v = r.vendor;
969
982
  if (!isValidLabel(v)) continue;
970
983
  vendorTotals[v] = (vendorTotals[v] || 0) + (r.total_requests || 0);
971
984
  }
972
- const labels = Object.keys(vendorTotals).sort((a,b) => vendorTotals[b]-vendorTotals[a]);
985
+ const labels = sortByTierOrder(Object.keys(vendorTotals), tierOrder);
973
986
  const data = labels.map(v => vendorTotals[v]);
974
987
 
975
988
  if (chartVendorDist) chartVendorDist.destroy();
@@ -1027,7 +1040,7 @@ function buildVendorDist(rows) {
1027
1040
  }
1028
1041
 
1029
1042
  // ── Token 量趋势折线图(按 vendor)───────────────────────
1030
- function buildTokenTimeline(rows) {
1043
+ function buildTokenTimeline(rows, tierOrder) {
1031
1044
  const vendorDateMap = {};
1032
1045
  const allDates = new Set();
1033
1046
  for (const r of rows) {
@@ -1040,7 +1053,7 @@ function buildTokenTimeline(rows) {
1040
1053
  allDates.add(d);
1041
1054
  }
1042
1055
  const dates = [...allDates].sort();
1043
- const vendors = Object.keys(vendorDateMap).sort();
1056
+ const vendors = sortByTierOrder(Object.keys(vendorDateMap), tierOrder);
1044
1057
 
1045
1058
  if (chartTokenTimeline) chartTokenTimeline.destroy();
1046
1059
  const ctx = document.getElementById('chart-token-timeline').getContext('2d');
@@ -1258,9 +1271,10 @@ async function refresh() {
1258
1271
  updateChartTitles(days);
1259
1272
 
1260
1273
  const rows = timeline.rows || [];
1261
- buildTimeline(rows);
1262
- buildVendorDist(rows);
1263
- buildTokenTimeline(rows);
1274
+ const tierOrder = (status.tiers || []).map(t => t.name);
1275
+ buildTimeline(rows, tierOrder);
1276
+ buildVendorDist(rows, tierOrder);
1277
+ buildTokenTimeline(rows, tierOrder);
1264
1278
  buildModelTokenTimeline(rows);
1265
1279
 
1266
1280
  document.getElementById('refresh-time').textContent = '上次刷新: ' + now();
@@ -487,6 +487,150 @@ def _repair_orphaned_tool_use(
487
487
  return repaired
488
488
 
489
489
 
490
+ # ── Phase 2: 跨供应商 tool_use/tool_result 配对强制修复 ─────────
491
+
492
+
493
+ def enforce_anthropic_tool_pairing(
494
+ messages_list: list[dict[str, Any]],
495
+ ) -> list[str]:
496
+ """为跨供应商场景强制保证 Anthropic tool_use/tool_result 配对约束.
497
+
498
+ 单次正向遍历所有消息,对每个 assistant 消息执行:
499
+
500
+ 1. 剥离所有 tool_result 块(跨供应商产物,如 GLM-5 内联的 tool_result)
501
+ 2. 收集所有 tool_use ID
502
+ 3. 确保紧邻的下一条消息是 user 消息且包含所有必需的 tool_result
503
+ 4. 将剥离的 tool_result 重定位到正确的 user 消息
504
+ 5. 为仍缺失的 tool_result 合成 ``is_error=True`` 的占位块
505
+
506
+ 此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息,
507
+ 通过直接扫描消息列表确保处理的完备性。替代此前多步串联管线
508
+ (剥离 → 重定位 → 孤儿修复)因步骤间隐式依赖导致的漏修问题。
509
+
510
+ 仅在请求实际发送给 Anthropic tier 且检测到跨供应商信号时调用,
511
+ 确保 Zhipu 等其他 vendor 不受影响。
512
+
513
+ Args:
514
+ messages_list: 消息列表(就地修改)。
515
+
516
+ Returns:
517
+ 新增的 adaptation 标签列表。
518
+ """
519
+ adaptations: list[str] = []
520
+ relocated_count = 0
521
+ synthesized_ids: list[str] = []
522
+
523
+ i = 0
524
+ while i < len(messages_list):
525
+ msg = messages_list[i]
526
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
527
+ i += 1
528
+ continue
529
+
530
+ content = msg.get("content")
531
+ if not isinstance(content, list):
532
+ i += 1
533
+ continue
534
+
535
+ # ── A. 从 assistant 消息中剥离所有 tool_result 块 ─────────
536
+ extracted_tool_results: dict[str, dict[str, Any]] = {} # tool_use_id → block
537
+ retained_content: list[Any] = []
538
+ for block in content:
539
+ if isinstance(block, dict) and block.get("type") == "tool_result":
540
+ tid = block.get("tool_use_id")
541
+ if tid:
542
+ extracted_tool_results[tid] = block
543
+ relocated_count += 1
544
+ # 无 tool_use_id 的 tool_result 直接丢弃(无效块)
545
+ else:
546
+ retained_content.append(block)
547
+
548
+ if extracted_tool_results:
549
+ msg["content"] = retained_content
550
+
551
+ # ── B. 收集所有 tool_use ID ───────────────────────────────
552
+ tool_use_ids: list[str] = [
553
+ b["id"]
554
+ for b in (
555
+ msg.get("content") if isinstance(msg.get("content"), list) else []
556
+ )
557
+ if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
558
+ ]
559
+ if not tool_use_ids:
560
+ # 无 tool_use 块:若剥离后 content 为空,插入占位
561
+ current_content = msg.get("content")
562
+ if isinstance(current_content, list) and not current_content:
563
+ msg["content"] = [{"type": "text", "text": ""}]
564
+ i += 1
565
+ continue
566
+
567
+ # ── C. 确保 messages[i+1] 是 user 消息 ───────────────────
568
+ next_idx = i + 1
569
+ if (
570
+ next_idx < len(messages_list)
571
+ and isinstance(messages_list[next_idx], dict)
572
+ and messages_list[next_idx].get("role") == "user"
573
+ ):
574
+ user_msg = messages_list[next_idx]
575
+ else:
576
+ # 插入合成 user 消息
577
+ user_msg: dict[str, Any] = {"role": "user", "content": []}
578
+ messages_list.insert(next_idx, user_msg)
579
+
580
+ # ── D. 确保 user_msg.content 是 list ─────────────────────
581
+ user_content = user_msg.get("content")
582
+ if isinstance(user_content, str):
583
+ user_msg["content"] = [{"type": "text", "text": user_content}]
584
+ elif not isinstance(user_content, list):
585
+ user_msg["content"] = []
586
+
587
+ # ── E. 收集 user 消息中已有的 tool_result IDs ─────────────
588
+ existing_result_ids: set[str] = {
589
+ b["tool_use_id"]
590
+ for b in user_msg["content"]
591
+ if isinstance(b, dict)
592
+ and b.get("type") == "tool_result"
593
+ and b.get("tool_use_id")
594
+ }
595
+
596
+ # ── F. 为每个 tool_use_id 确保 tool_result 存在 ──────────
597
+ for uid in tool_use_ids:
598
+ if uid in existing_result_ids:
599
+ continue # 已有匹配的 tool_result
600
+
601
+ if uid in extracted_tool_results:
602
+ # 从 assistant 剥离的 tool_result 重定位到 user
603
+ user_msg["content"].append(extracted_tool_results[uid])
604
+ else:
605
+ # 完全缺失:合成 is_error=True 占位块
606
+ user_msg["content"].append(
607
+ {
608
+ "type": "tool_result",
609
+ "tool_use_id": uid,
610
+ "content": "",
611
+ "is_error": True,
612
+ }
613
+ )
614
+ synthesized_ids.append(uid)
615
+
616
+ i += 1
617
+
618
+ # ── 构建 adaptation 标签与日志 ────────────────────────────
619
+ if relocated_count:
620
+ adaptations.append("misplaced_tool_result_relocated")
621
+ if synthesized_ids:
622
+ adaptations.append("orphaned_tool_use_repaired")
623
+ logger.warning(
624
+ "Vendor degradation adaptation: synthesized %d tool_result block(s) "
625
+ "for orphaned tool_use to satisfy Anthropic pairing constraint. "
626
+ "Affected tool_use_ids: %s",
627
+ len(synthesized_ids),
628
+ ", ".join(synthesized_ids),
629
+ )
630
+
631
+ return adaptations
632
+
633
+
490
634
  # ── Phase 2: Thinking block 剥离 ──────────────────────────────
491
635
 
492
636
  # 需要从 assistant messages 中剥离的 thinking block 类型