coding-proxy 0.1.0__py3-none-any.whl

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 (77) hide show
  1. coding/__init__.py +0 -0
  2. coding/proxy/__init__.py +3 -0
  3. coding/proxy/__main__.py +5 -0
  4. coding/proxy/auth/__init__.py +13 -0
  5. coding/proxy/auth/providers/__init__.py +6 -0
  6. coding/proxy/auth/providers/base.py +35 -0
  7. coding/proxy/auth/providers/github.py +133 -0
  8. coding/proxy/auth/providers/google.py +237 -0
  9. coding/proxy/auth/runtime.py +122 -0
  10. coding/proxy/auth/store.py +74 -0
  11. coding/proxy/cli/__init__.py +151 -0
  12. coding/proxy/cli/auth_commands.py +224 -0
  13. coding/proxy/compat/__init__.py +30 -0
  14. coding/proxy/compat/canonical.py +193 -0
  15. coding/proxy/compat/session_store.py +137 -0
  16. coding/proxy/config/__init__.py +6 -0
  17. coding/proxy/config/auth_schema.py +24 -0
  18. coding/proxy/config/loader.py +139 -0
  19. coding/proxy/config/resiliency.py +46 -0
  20. coding/proxy/config/routing.py +279 -0
  21. coding/proxy/config/schema.py +280 -0
  22. coding/proxy/config/server.py +23 -0
  23. coding/proxy/config/vendors.py +53 -0
  24. coding/proxy/convert/__init__.py +14 -0
  25. coding/proxy/convert/anthropic_to_gemini.py +352 -0
  26. coding/proxy/convert/anthropic_to_openai.py +352 -0
  27. coding/proxy/convert/gemini_sse_adapter.py +169 -0
  28. coding/proxy/convert/gemini_to_anthropic.py +98 -0
  29. coding/proxy/convert/openai_to_anthropic.py +88 -0
  30. coding/proxy/logging/__init__.py +49 -0
  31. coding/proxy/logging/db.py +308 -0
  32. coding/proxy/logging/stats.py +129 -0
  33. coding/proxy/model/__init__.py +93 -0
  34. coding/proxy/model/auth.py +32 -0
  35. coding/proxy/model/compat.py +153 -0
  36. coding/proxy/model/constants.py +21 -0
  37. coding/proxy/model/pricing.py +70 -0
  38. coding/proxy/model/token.py +64 -0
  39. coding/proxy/model/vendor.py +218 -0
  40. coding/proxy/pricing.py +100 -0
  41. coding/proxy/routing/__init__.py +47 -0
  42. coding/proxy/routing/circuit_breaker.py +152 -0
  43. coding/proxy/routing/error_classifier.py +67 -0
  44. coding/proxy/routing/executor.py +453 -0
  45. coding/proxy/routing/model_mapper.py +90 -0
  46. coding/proxy/routing/quota_guard.py +169 -0
  47. coding/proxy/routing/rate_limit.py +159 -0
  48. coding/proxy/routing/retry.py +82 -0
  49. coding/proxy/routing/router.py +84 -0
  50. coding/proxy/routing/session_manager.py +62 -0
  51. coding/proxy/routing/tier.py +171 -0
  52. coding/proxy/routing/usage_parser.py +193 -0
  53. coding/proxy/routing/usage_recorder.py +131 -0
  54. coding/proxy/server/__init__.py +1 -0
  55. coding/proxy/server/app.py +142 -0
  56. coding/proxy/server/factory.py +175 -0
  57. coding/proxy/server/request_normalizer.py +139 -0
  58. coding/proxy/server/responses.py +74 -0
  59. coding/proxy/server/routes.py +264 -0
  60. coding/proxy/streaming/__init__.py +1 -0
  61. coding/proxy/streaming/anthropic_compat.py +484 -0
  62. coding/proxy/vendors/__init__.py +29 -0
  63. coding/proxy/vendors/anthropic.py +44 -0
  64. coding/proxy/vendors/antigravity.py +328 -0
  65. coding/proxy/vendors/base.py +353 -0
  66. coding/proxy/vendors/copilot.py +702 -0
  67. coding/proxy/vendors/copilot_models.py +438 -0
  68. coding/proxy/vendors/copilot_token_manager.py +167 -0
  69. coding/proxy/vendors/copilot_urls.py +16 -0
  70. coding/proxy/vendors/mixins.py +71 -0
  71. coding/proxy/vendors/token_manager.py +128 -0
  72. coding/proxy/vendors/zhipu.py +243 -0
  73. coding_proxy-0.1.0.dist-info/METADATA +184 -0
  74. coding_proxy-0.1.0.dist-info/RECORD +77 -0
  75. coding_proxy-0.1.0.dist-info/WHEEL +4 -0
  76. coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
  77. coding_proxy-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,702 @@
1
+ """GitHub Copilot 供应商 — 内置 token 交换与 Anthropic 兼容转发."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, AsyncIterator
7
+ from uuid import uuid4
8
+
9
+ import httpx
10
+
11
+ from ..config.schema import CopilotConfig, FailoverConfig
12
+ from ..compat.canonical import CompatibilityProfile, CompatibilityStatus
13
+ from ..convert.anthropic_to_openai import convert_request as convert_openai_request
14
+ from ..convert.openai_to_anthropic import convert_response as convert_openai_response
15
+ from ..streaming.anthropic_compat import normalize_anthropic_compatible_stream
16
+ from ..routing.model_mapper import ModelMapper
17
+ from .base import (
18
+ PROXY_SKIP_HEADERS,
19
+ BaseVendor,
20
+ CapabilityLossReason,
21
+ RequestCapabilities,
22
+ UsageInfo,
23
+ VendorCapabilities,
24
+ VendorResponse,
25
+ _decode_json_body,
26
+ _extract_error_message,
27
+ )
28
+ from .copilot_models import ( # noqa: F401
29
+ CopilotMisdirectedRequest,
30
+ CopilotModelResolver,
31
+ _copilot_model_family,
32
+ _copilot_model_major,
33
+ _copilot_model_version_rank,
34
+ _select_copilot_model,
35
+ normalize_copilot_requested_model,
36
+ )
37
+ from .copilot_token_manager import CopilotTokenManager
38
+ from .copilot_urls import ( # noqa: F401
39
+ _EDITOR_PLUGIN_VERSION,
40
+ _EDITOR_VERSION,
41
+ _GITHUB_API_VERSION,
42
+ _USER_AGENT,
43
+ _normalize_base_url,
44
+ build_copilot_candidate_base_urls,
45
+ resolve_copilot_base_url,
46
+ )
47
+ # Copilot421RetryHandler 已从 copilot_retry.py 合并至本文件末尾
48
+ from .mixins import TokenBackendMixin
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ── Copilot 421 Misdirected 重试处理器(原 copilot_retry.py) ──
54
+
55
+
56
+ class Copilot421RetryHandler:
57
+ """封装 Copilot 421 Misdirected 重试策略.
58
+
59
+ GitHub Copilot API 在某些情况下返回 421 Misdirected Request,
60
+ 表示当前端点不可用,需尝试其他候选 URL。此处理器统一了
61
+ 同步请求和流式请求的 421 重试逻辑。
62
+ """
63
+
64
+ def __init__(self, backend: Any) -> None:
65
+ self._backend = backend
66
+
67
+ async def execute_request_with_retry(
68
+ self,
69
+ method: str,
70
+ endpoint: str,
71
+ *,
72
+ headers: dict[str, str],
73
+ json_body: dict[str, Any] | None = None,
74
+ ) -> httpx.Response:
75
+ """同步请求的 421 重试."""
76
+ current_base_url = self._backend._resolved_base_url
77
+ self._backend._begin_request(current_base_url)
78
+
79
+ response = await self._backend._get_client().request(
80
+ method, endpoint, json=json_body, headers=headers,
81
+ )
82
+ if response.status_code != 421:
83
+ return response
84
+
85
+ self._backend._last_421_base_url = current_base_url
86
+ last_response = response
87
+
88
+ for retry_base_url in self._backend._retry_base_urls(current_base_url):
89
+ self._backend._last_retry_base_url = retry_base_url
90
+ async with self._backend._create_fresh_client(retry_base_url) as retry_client:
91
+ retry_response = await retry_client.request(
92
+ method, endpoint, json=json_body, headers=headers,
93
+ )
94
+ last_response = retry_response
95
+ if retry_response.status_code != 421:
96
+ await self._backend._activate_base_url(retry_base_url)
97
+ return retry_response
98
+ self._backend._last_421_base_url = retry_base_url
99
+
100
+ return last_response
101
+
102
+ async def execute_stream_with_retry(
103
+ self,
104
+ stream_fn: Any,
105
+ ) -> AsyncIterator[bytes]:
106
+ """流式请求的 421 重试(异步生成器).
107
+
108
+ Args:
109
+ stream_fn: 接受 httpx.AsyncClient 并返回 AsyncIterator[bytes] 的可调用对象
110
+ """
111
+ current_base_url = self._backend._resolved_base_url
112
+ self._backend._begin_request(current_base_url)
113
+ last_exc: httpx.HTTPStatusError | None = None
114
+
115
+ try:
116
+ async for chunk in stream_fn(self._backend._get_client()):
117
+ yield chunk
118
+ return
119
+ except httpx.HTTPStatusError as exc:
120
+ if exc.response is None or exc.response.status_code != 421:
121
+ raise
122
+ self._backend._last_421_base_url = _normalize_base_url(current_base_url)
123
+ last_exc = exc
124
+
125
+ for retry_base_url in self._backend._retry_base_urls(current_base_url):
126
+ self._backend._last_retry_base_url = retry_base_url
127
+ async with self._backend._create_fresh_client(retry_base_url) as retry_client:
128
+ try:
129
+ async for chunk in stream_fn(retry_client):
130
+ yield chunk
131
+ await self._backend._activate_base_url(retry_base_url)
132
+ return
133
+ except httpx.HTTPStatusError as retry_exc:
134
+ last_exc = retry_exc
135
+ if retry_exc.response is None or retry_exc.response.status_code != 421:
136
+ raise
137
+ self._backend._last_421_base_url = retry_base_url
138
+
139
+ if last_exc:
140
+ raise last_exc
141
+
142
+
143
+ class CopilotVendor(TokenBackendMixin, BaseVendor):
144
+ """GitHub Copilot API 供应商.
145
+
146
+ 通过内置 token 交换访问 GitHub Copilot 的 Anthropic 兼容端点.
147
+ 模型解析:优先使用配置规则(model_mapping),其次依赖内部家族匹配策略.
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ config: CopilotConfig,
153
+ failover_config: FailoverConfig,
154
+ model_mapper: ModelMapper | None = None,
155
+ ) -> None:
156
+ self._account_type = (config.account_type or "individual").strip().lower()
157
+ self._configured_base_url = config.base_url
158
+ self._candidate_base_urls = build_copilot_candidate_base_urls(self._account_type, config.base_url)
159
+ self._resolved_base_url = resolve_copilot_base_url(self._account_type, config.base_url)
160
+ # 模型解析委托给 CopilotModelResolver 策略类
161
+ self._model_resolver = CopilotModelResolver(
162
+ models_cache_ttl_seconds=int(config.models_cache_ttl_seconds),
163
+ model_mapper=model_mapper,
164
+ )
165
+ # Copilot 特有诊断字段(不在 Mixin 中)
166
+ self._last_request_base_url = ""
167
+ self._last_421_base_url = ""
168
+ self._last_retry_base_url = ""
169
+ # 421 重试处理器
170
+ self._421_handler = Copilot421RetryHandler(self)
171
+ # TokenBackendMixin 诊断字段(_last_requested_model / _last_resolved_model /
172
+ # _last_model_resolution_reason / _last_request_adaptations)由 Mixin 提供
173
+ token_manager = CopilotTokenManager(config.github_token, config.token_url)
174
+ TokenBackendMixin.__init__(self, token_manager)
175
+ BaseVendor.__init__(self, self._resolved_base_url, config.timeout_ms, failover_config)
176
+
177
+ def get_name(self) -> str:
178
+ return "copilot"
179
+
180
+ def get_capabilities(self) -> VendorCapabilities:
181
+ return VendorCapabilities(
182
+ supports_tools=True,
183
+ supports_thinking=True,
184
+ supports_images=True,
185
+ emits_vendor_tool_events=False,
186
+ supports_metadata=True,
187
+ )
188
+
189
+ def get_compatibility_profile(self) -> CompatibilityProfile:
190
+ return CompatibilityProfile(
191
+ thinking=CompatibilityStatus.SIMULATED,
192
+ tool_calling=CompatibilityStatus.NATIVE,
193
+ tool_streaming=CompatibilityStatus.NATIVE,
194
+ mcp_tools=CompatibilityStatus.UNKNOWN,
195
+ images=CompatibilityStatus.NATIVE,
196
+ metadata=CompatibilityStatus.SIMULATED,
197
+ json_output=CompatibilityStatus.NATIVE,
198
+ usage_tokens=CompatibilityStatus.SIMULATED,
199
+ )
200
+
201
+ def supports_request(
202
+ self, request_caps: RequestCapabilities,
203
+ ) -> tuple[bool, list[CapabilityLossReason]]:
204
+ """Copilot 可通过适配层吸收 thinking 语义,不在路由阶段直接拒绝."""
205
+ supported, reasons = super().supports_request(request_caps)
206
+ if not supported:
207
+ reasons = [reason for reason in reasons if reason is not CapabilityLossReason.THINKING]
208
+ return len(reasons) == 0, reasons
209
+
210
+ def _get_endpoint(self) -> str:
211
+ return "/chat/completions"
212
+
213
+ def _build_copilot_headers(self) -> dict[str, str]:
214
+ return {
215
+ "copilot-integration-id": "vscode-chat",
216
+ "editor-version": _EDITOR_VERSION,
217
+ "editor-plugin-version": _EDITOR_PLUGIN_VERSION,
218
+ "user-agent": _USER_AGENT,
219
+ "openai-intent": "conversation-panel",
220
+ "x-github-api-version": _GITHUB_API_VERSION,
221
+ "x-request-id": str(uuid4()),
222
+ "x-vscode-user-agent-library-version": "electron-fetch",
223
+ "content-type": "application/json",
224
+ }
225
+
226
+ @staticmethod
227
+ def _resolve_initiator(request_body: dict[str, Any]) -> str:
228
+ for message in request_body.get("messages", []):
229
+ if message.get("role") in {"assistant", "tool"}:
230
+ return "agent"
231
+ return "user"
232
+
233
+ @staticmethod
234
+ def _collect_request_adaptations(request_body: dict[str, Any]) -> list[str]:
235
+ adaptations: list[str] = []
236
+
237
+ extended_thinking = request_body.get("extended_thinking")
238
+ thinking = request_body.get("thinking")
239
+
240
+ if isinstance(extended_thinking, dict):
241
+ effort = extended_thinking.get("effort", "unknown")
242
+ budget = extended_thinking.get("budget_tokens")
243
+ label = f"extended_thinking_mapped_to_reasoning_effort(effort={effort})"
244
+ if isinstance(budget, int) and budget > 0:
245
+ label += f",budget_tokens_not_supported({budget})"
246
+ adaptations.append(label)
247
+ elif thinking is True or isinstance(thinking, dict):
248
+ adaptations.append("thinking_mapped_to_reasoning_effort(medium)")
249
+
250
+ for message in request_body.get("messages", []):
251
+ content = message.get("content")
252
+ if not isinstance(content, list):
253
+ continue
254
+ has_thinking_block = any(
255
+ isinstance(block, dict) and block.get("type") == "thinking"
256
+ for block in content
257
+ )
258
+ has_text_block = any(
259
+ isinstance(block, dict) and block.get("type") == "text"
260
+ for block in content
261
+ )
262
+ if has_thinking_block and has_text_block:
263
+ adaptations.append("thinking_block_prefixed_as_context")
264
+ elif has_thinking_block:
265
+ adaptations.append("thinking_block_used_as_content_fallback")
266
+ break
267
+
268
+ return adaptations
269
+
270
+ def _create_fresh_client(self, base_url: str) -> httpx.AsyncClient:
271
+ return httpx.AsyncClient(
272
+ base_url=base_url,
273
+ timeout=httpx.Timeout(self._timeout_ms / 1000.0),
274
+ )
275
+
276
+ async def _activate_base_url(self, base_url: str) -> None:
277
+ normalized = _normalize_base_url(base_url)
278
+ self._resolved_base_url = normalized
279
+ self._base_url = normalized
280
+ if self._client is not None and not self._client.is_closed:
281
+ await self._client.aclose()
282
+ self._client = None
283
+
284
+ def _begin_request(self, base_url: str) -> None:
285
+ self._last_request_base_url = _normalize_base_url(base_url)
286
+ self._last_421_base_url = ""
287
+ self._last_retry_base_url = ""
288
+
289
+ def _retry_base_urls(self, base_url: str) -> list[str]:
290
+ """构建 421 后的重试候选:同 authority fresh connection + 备选域名."""
291
+ normalized = _normalize_base_url(base_url)
292
+ retry_urls = [normalized]
293
+ if not self._configured_base_url.strip():
294
+ retry_urls.extend(
295
+ candidate for candidate in self._candidate_base_urls
296
+ if candidate != normalized
297
+ )
298
+ return retry_urls
299
+
300
+ async def _request_chat_with_model_retry(
301
+ self,
302
+ *,
303
+ body: dict[str, Any],
304
+ prepared_headers: dict[str, str],
305
+ ) -> httpx.Response:
306
+ response = await self._request_with_421_retry(
307
+ "POST",
308
+ self._get_endpoint(),
309
+ json_body=body,
310
+ headers=prepared_headers,
311
+ )
312
+ if not CopilotModelResolver.is_model_not_supported_response(response):
313
+ return response
314
+
315
+ retried_body = dict(body)
316
+ retried_body["model"] = await self._resolve_model_via_resolver(
317
+ self._last_requested_model or body.get("model", ""),
318
+ force_refresh=True,
319
+ refresh_reason="model_not_supported_retry",
320
+ )
321
+ return await self._request_with_421_retry(
322
+ "POST",
323
+ self._get_endpoint(),
324
+ json_body=retried_body,
325
+ headers=prepared_headers,
326
+ )
327
+
328
+ @staticmethod
329
+ def _build_misdirected_request(response: httpx.Response, body: bytes, base_url: str) -> CopilotMisdirectedRequest:
330
+ return CopilotMisdirectedRequest(
331
+ base_url=_normalize_base_url(base_url),
332
+ status_code=response.status_code,
333
+ request=response.request,
334
+ headers=response.headers,
335
+ body=body,
336
+ )
337
+
338
+ @staticmethod
339
+ def _build_http_status_error_from_misdirected(error: CopilotMisdirectedRequest) -> httpx.HTTPStatusError:
340
+ return httpx.HTTPStatusError(
341
+ f"copilot API error: {error.status_code}",
342
+ request=error.request,
343
+ response=httpx.Response(
344
+ error.status_code,
345
+ content=error.body,
346
+ headers=error.headers,
347
+ request=error.request,
348
+ ),
349
+ )
350
+
351
+ async def _request_with_421_retry(
352
+ self,
353
+ method: str,
354
+ endpoint: str,
355
+ *,
356
+ headers: dict[str, str],
357
+ json_body: dict[str, Any] | None = None,
358
+ ) -> httpx.Response:
359
+ """同步请求的 421 Misdirected 重试 — 委托给 Copilot421RetryHandler."""
360
+ return await self._421_handler.execute_request_with_retry(
361
+ method, endpoint, headers=headers, json_body=json_body,
362
+ )
363
+
364
+ async def _stream_from_client(
365
+ self,
366
+ client: httpx.AsyncClient,
367
+ *,
368
+ base_url: str,
369
+ body: dict[str, Any],
370
+ prepared_headers: dict[str, str],
371
+ request_model: str,
372
+ ) -> AsyncIterator[bytes]:
373
+ async with client.stream(
374
+ "POST",
375
+ self._get_endpoint(),
376
+ json=body,
377
+ headers=prepared_headers,
378
+ ) as response:
379
+ if response.status_code == 421:
380
+ error_body = await response.aread()
381
+ self._last_421_base_url = _normalize_base_url(base_url)
382
+ raise self._build_http_status_error_from_misdirected(
383
+ self._build_misdirected_request(response, error_body, base_url),
384
+ )
385
+ if response.status_code >= 400:
386
+ self._on_error_status(response.status_code)
387
+ error_body = await response.aread()
388
+ logger.warning(
389
+ "%s stream error: status=%d body=%s",
390
+ self.get_name(), response.status_code, error_body[:500],
391
+ )
392
+ raise httpx.HTTPStatusError(
393
+ f"{self.get_name()} API error: {response.status_code}",
394
+ request=response.request,
395
+ response=httpx.Response(
396
+ response.status_code,
397
+ content=error_body,
398
+ headers=response.headers,
399
+ request=response.request,
400
+ ),
401
+ )
402
+
403
+ async def _upstream() -> AsyncIterator[bytes]:
404
+ async for chunk in response.aiter_bytes():
405
+ yield chunk
406
+
407
+ async for chunk in normalize_anthropic_compatible_stream(
408
+ _upstream(),
409
+ model=body.get("model", request_model),
410
+ ):
411
+ yield chunk
412
+
413
+ async def _prepare_request(
414
+ self,
415
+ request_body: dict[str, Any],
416
+ headers: dict[str, str],
417
+ *,
418
+ force_model_refresh: bool = False,
419
+ model_refresh_reason: str = "request_prepare",
420
+ ) -> tuple[dict[str, Any], dict[str, str]]:
421
+ """透传请求体,过滤 hop-by-hop 头并注入 Copilot token."""
422
+ filtered = {k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS}
423
+ prepared = self._build_copilot_headers()
424
+ for key, value in filtered.items():
425
+ if key.lower() not in {item.lower() for item in prepared}:
426
+ prepared[key] = value
427
+ token = await self._token_manager.get_token()
428
+ prepared["authorization"] = f"Bearer {token}"
429
+ prepared["x-initiator"] = self._resolve_initiator(request_body)
430
+ self._last_request_adaptations = self._collect_request_adaptations(request_body)
431
+ translated_body = convert_openai_request(request_body)
432
+ requested_model = str(request_body.get("model", ""))
433
+ translated_body["model"] = await self._resolve_model_via_resolver(
434
+ requested_model,
435
+ force_refresh=force_model_refresh,
436
+ refresh_reason=model_refresh_reason,
437
+ )
438
+ return translated_body, prepared
439
+
440
+ async def _resolve_request_model(
441
+ self,
442
+ requested_model: str,
443
+ *,
444
+ force_refresh: bool,
445
+ refresh_reason: str,
446
+ ) -> str:
447
+ """向后兼容接口:委托 CopilotModelResolver 解析模型名."""
448
+ return await self._resolve_model_via_resolver(
449
+ requested_model,
450
+ force_refresh=force_refresh,
451
+ refresh_reason=refresh_reason,
452
+ )
453
+
454
+ async def _resolve_model_via_resolver(
455
+ self,
456
+ requested_model: str,
457
+ *,
458
+ force_refresh: bool,
459
+ refresh_reason: str,
460
+ ) -> str:
461
+ """委托 CopilotModelResolver 解析模型名,并回写诊断到 Mixin 字段."""
462
+ diagnostics: dict[str, str] = {}
463
+ resolved = await self._model_resolver.resolve(
464
+ requested_model,
465
+ force_refresh=force_refresh,
466
+ request_fn=self._request_with_421_retry,
467
+ headers_fn=self._build_copilot_headers,
468
+ refresh_reason=refresh_reason,
469
+ diagnostics=diagnostics,
470
+ )
471
+ # 回写诊断到 TokenBackendMixin 提供的字段
472
+ if "requested_model" in diagnostics:
473
+ self._last_requested_model = diagnostics["requested_model"]
474
+ if "resolved_model" in diagnostics:
475
+ self._last_resolved_model = diagnostics["resolved_model"]
476
+ if "resolution_reason" in diagnostics:
477
+ self._last_model_resolution_reason = diagnostics["resolution_reason"]
478
+ return resolved
479
+
480
+ # _on_error_status / check_health 由 TokenBackendMixin 提供
481
+
482
+ def get_diagnostics(self) -> dict[str, Any]:
483
+ diagnostics: dict[str, Any] = {
484
+ "account_type": self._account_type,
485
+ "base_url": self._resolved_base_url,
486
+ "configured_base_url": self._configured_base_url,
487
+ "resolved_base_url": self._resolved_base_url,
488
+ "candidate_base_urls": self._candidate_base_urls,
489
+ "available_models_cache": self._model_resolver.catalog.available_models,
490
+ }
491
+ diagnostics.update(BaseVendor.get_diagnostics(self))
492
+ # TokenBackendMixin 提供标准诊断(token_manager / request_adaptations /
493
+ # requested_model / resolved_model / model_resolution_reason)
494
+ diagnostics.update(self._get_token_diagnostics())
495
+ # Copilot 特有诊断字段
496
+ exchange = self._token_manager.get_exchange_diagnostics()
497
+ if exchange:
498
+ diagnostics["exchange"] = exchange
499
+ if self._last_request_base_url:
500
+ diagnostics["last_request_base_url"] = self._last_request_base_url
501
+ if self._last_421_base_url:
502
+ diagnostics["last_421_base_url"] = self._last_421_base_url
503
+ if self._last_retry_base_url:
504
+ diagnostics["last_retry_base_url"] = self._last_retry_base_url
505
+ if self._model_resolver.last_normalized_model:
506
+ diagnostics["normalized_model"] = self._model_resolver.last_normalized_model
507
+ if self._model_resolver.last_model_refresh_reason:
508
+ diagnostics["last_model_refresh_reason"] = self._model_resolver.last_model_refresh_reason
509
+ cache_age = self._model_resolver.catalog.age_seconds()
510
+ if cache_age is not None:
511
+ diagnostics["available_models_cache_age_seconds"] = cache_age
512
+ return diagnostics
513
+
514
+ async def send_message(
515
+ self,
516
+ request_body: dict[str, Any],
517
+ headers: dict[str, str],
518
+ ) -> VendorResponse:
519
+ body, prepared_headers = await self._prepare_request(request_body, headers)
520
+ response = await self._request_chat_with_model_retry(
521
+ body=body,
522
+ prepared_headers=prepared_headers,
523
+ )
524
+
525
+ raw_content = response.content
526
+ resp_body = _decode_json_body(response)
527
+
528
+ if response.status_code >= 400:
529
+ if CopilotModelResolver.is_model_not_supported_response(response):
530
+ response = CopilotModelResolver.build_model_not_supported_response(
531
+ response,
532
+ requested_model=self._last_requested_model,
533
+ normalized_model=self._model_resolver.last_normalized_model,
534
+ resolved_model=self._last_resolved_model,
535
+ available_models=list(self._model_resolver.catalog.available_models),
536
+ )
537
+ raw_content = response.content
538
+ resp_body = _decode_json_body(response)
539
+ self._on_error_status(response.status_code)
540
+ return VendorResponse(
541
+ status_code=response.status_code,
542
+ raw_body=raw_content,
543
+ error_type=resp_body.get("error", {}).get("type") if isinstance(resp_body, dict) and isinstance(resp_body.get("error"), dict) else None,
544
+ error_message=_extract_error_message(response, resp_body),
545
+ response_headers=dict(response.headers),
546
+ )
547
+
548
+ if not isinstance(resp_body, dict):
549
+ return VendorResponse(
550
+ status_code=502,
551
+ raw_body=raw_content,
552
+ error_type="api_error",
553
+ error_message="Copilot non-stream response is not valid JSON",
554
+ response_headers=dict(response.headers),
555
+ )
556
+
557
+ anthropic_resp = convert_openai_response(resp_body)
558
+ usage = anthropic_resp.get("usage", {})
559
+ return VendorResponse(
560
+ status_code=response.status_code,
561
+ raw_body=httpx.Response(
562
+ response.status_code,
563
+ json=anthropic_resp,
564
+ ).content,
565
+ usage=UsageInfo(
566
+ input_tokens=usage.get("input_tokens", 0),
567
+ output_tokens=usage.get("output_tokens", 0),
568
+ cache_read_tokens=usage.get("cache_read_input_tokens", 0),
569
+ cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
570
+ request_id=anthropic_resp.get("id", ""),
571
+ ),
572
+ model_served=anthropic_resp.get("model"),
573
+ response_headers=dict(response.headers),
574
+ )
575
+
576
+ async def send_message_stream(
577
+ self,
578
+ request_body: dict[str, Any],
579
+ headers: dict[str, str],
580
+ ) -> AsyncIterator[bytes]:
581
+ body, prepared_headers = await self._prepare_request(request_body, headers)
582
+ request_model = request_body.get("model", "unknown")
583
+
584
+ # 首次尝试(含 421 重试)
585
+ try:
586
+ async for chunk in self._stream_with_421_retry(body, prepared_headers, request_model):
587
+ yield chunk
588
+ return
589
+ except httpx.HTTPStatusError as exc:
590
+ if not CopilotModelResolver.is_model_not_supported_response(exc.response):
591
+ raise
592
+
593
+ # 模型不支持时强制刷新模型列表后重试
594
+ async for chunk in self._retry_stream_with_fresh_model(request_body, headers, request_model):
595
+ yield chunk
596
+
597
+ async def _stream_with_421_retry(
598
+ self,
599
+ stream_body: dict[str, Any],
600
+ prepared_headers: dict[str, str],
601
+ request_model: str,
602
+ ) -> AsyncIterator[bytes]:
603
+ """带 421 Misdirected 重试的流式请求."""
604
+ current_base_url = self._resolved_base_url
605
+ self._begin_request(current_base_url)
606
+ last_exc: httpx.HTTPStatusError | None = None
607
+
608
+ try:
609
+ async for chunk in self._stream_from_client(
610
+ self._get_client(),
611
+ base_url=current_base_url,
612
+ body=stream_body,
613
+ prepared_headers=prepared_headers,
614
+ request_model=stream_body.get("model", request_model),
615
+ ):
616
+ yield chunk
617
+ return
618
+ except httpx.HTTPStatusError as exc:
619
+ if exc.response is None or exc.response.status_code != 421:
620
+ raise
621
+ last_exc = exc
622
+
623
+ for retry_base_url in self._retry_base_urls(current_base_url):
624
+ self._last_retry_base_url = retry_base_url
625
+ async with self._create_fresh_client(retry_base_url) as retry_client:
626
+ try:
627
+ async for chunk in self._stream_from_client(
628
+ retry_client,
629
+ base_url=retry_base_url,
630
+ body=stream_body,
631
+ prepared_headers=prepared_headers,
632
+ request_model=stream_body.get("model", request_model),
633
+ ):
634
+ yield chunk
635
+ await self._activate_base_url(retry_base_url)
636
+ return
637
+ except httpx.HTTPStatusError as retry_exc:
638
+ last_exc = retry_exc
639
+ if retry_exc.response is None or retry_exc.response.status_code != 421:
640
+ raise
641
+
642
+ if last_exc:
643
+ raise last_exc
644
+
645
+ async def _retry_stream_with_fresh_model(
646
+ self,
647
+ request_body: dict[str, Any],
648
+ headers: dict[str, str],
649
+ request_model: str,
650
+ ) -> AsyncIterator[bytes]:
651
+ """模型不支持时强制刷新模型列表后重试流式请求."""
652
+ retried_body, retried_headers = await self._prepare_request(
653
+ request_body, headers, force_model_refresh=True, model_refresh_reason="model_not_supported_retry",
654
+ )
655
+ try:
656
+ async for chunk in self._stream_with_421_retry(retried_body, retried_headers, request_model):
657
+ yield chunk
658
+ return
659
+ except httpx.HTTPStatusError as exc:
660
+ if CopilotModelResolver.is_model_not_supported_response(exc.response) and exc.response is not None:
661
+ raise httpx.HTTPStatusError(
662
+ "copilot API error: 400",
663
+ request=exc.request,
664
+ response=CopilotModelResolver.build_model_not_supported_response(
665
+ exc.response,
666
+ requested_model=self._last_requested_model,
667
+ normalized_model=self._model_resolver.last_normalized_model,
668
+ resolved_model=self._last_resolved_model,
669
+ available_models=list(self._model_resolver.catalog.available_models),
670
+ ),
671
+ ) from exc
672
+ raise
673
+
674
+ async def probe_models(self) -> dict[str, Any]:
675
+ """探测当前 Copilot 会话可见模型列表."""
676
+ available_models = await self._model_resolver.fetch_available(
677
+ request_fn=self._request_with_421_retry,
678
+ headers_fn=self._build_copilot_headers,
679
+ refresh_reason="probe_models",
680
+ )
681
+ probe: dict[str, Any] = {
682
+ "probe_status": "ok" if available_models else "error",
683
+ "status_code": 200 if available_models else 502,
684
+ "account_type": self._account_type,
685
+ "base_url": self._resolved_base_url,
686
+ "resolved_base_url": self._resolved_base_url,
687
+ "candidate_base_urls": self._candidate_base_urls,
688
+ }
689
+ if not available_models:
690
+ probe["failure_reason"] = "Copilot models probe returned empty directory"
691
+ return probe
692
+ probe["available_models"] = available_models
693
+ probe["has_claude_opus_4_6"] = any("opus" in model and "4.6" in model for model in available_models)
694
+ return probe
695
+
696
+ async def close(self) -> None:
697
+ await self._token_manager.close()
698
+ await super().close()
699
+
700
+
701
+ # 向后兼容别名
702
+ CopilotBackend = CopilotVendor