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.
- coding/__init__.py +0 -0
- coding/proxy/__init__.py +3 -0
- coding/proxy/__main__.py +5 -0
- coding/proxy/auth/__init__.py +13 -0
- coding/proxy/auth/providers/__init__.py +6 -0
- coding/proxy/auth/providers/base.py +35 -0
- coding/proxy/auth/providers/github.py +133 -0
- coding/proxy/auth/providers/google.py +237 -0
- coding/proxy/auth/runtime.py +122 -0
- coding/proxy/auth/store.py +74 -0
- coding/proxy/cli/__init__.py +151 -0
- coding/proxy/cli/auth_commands.py +224 -0
- coding/proxy/compat/__init__.py +30 -0
- coding/proxy/compat/canonical.py +193 -0
- coding/proxy/compat/session_store.py +137 -0
- coding/proxy/config/__init__.py +6 -0
- coding/proxy/config/auth_schema.py +24 -0
- coding/proxy/config/loader.py +139 -0
- coding/proxy/config/resiliency.py +46 -0
- coding/proxy/config/routing.py +279 -0
- coding/proxy/config/schema.py +280 -0
- coding/proxy/config/server.py +23 -0
- coding/proxy/config/vendors.py +53 -0
- coding/proxy/convert/__init__.py +14 -0
- coding/proxy/convert/anthropic_to_gemini.py +352 -0
- coding/proxy/convert/anthropic_to_openai.py +352 -0
- coding/proxy/convert/gemini_sse_adapter.py +169 -0
- coding/proxy/convert/gemini_to_anthropic.py +98 -0
- coding/proxy/convert/openai_to_anthropic.py +88 -0
- coding/proxy/logging/__init__.py +49 -0
- coding/proxy/logging/db.py +308 -0
- coding/proxy/logging/stats.py +129 -0
- coding/proxy/model/__init__.py +93 -0
- coding/proxy/model/auth.py +32 -0
- coding/proxy/model/compat.py +153 -0
- coding/proxy/model/constants.py +21 -0
- coding/proxy/model/pricing.py +70 -0
- coding/proxy/model/token.py +64 -0
- coding/proxy/model/vendor.py +218 -0
- coding/proxy/pricing.py +100 -0
- coding/proxy/routing/__init__.py +47 -0
- coding/proxy/routing/circuit_breaker.py +152 -0
- coding/proxy/routing/error_classifier.py +67 -0
- coding/proxy/routing/executor.py +453 -0
- coding/proxy/routing/model_mapper.py +90 -0
- coding/proxy/routing/quota_guard.py +169 -0
- coding/proxy/routing/rate_limit.py +159 -0
- coding/proxy/routing/retry.py +82 -0
- coding/proxy/routing/router.py +84 -0
- coding/proxy/routing/session_manager.py +62 -0
- coding/proxy/routing/tier.py +171 -0
- coding/proxy/routing/usage_parser.py +193 -0
- coding/proxy/routing/usage_recorder.py +131 -0
- coding/proxy/server/__init__.py +1 -0
- coding/proxy/server/app.py +142 -0
- coding/proxy/server/factory.py +175 -0
- coding/proxy/server/request_normalizer.py +139 -0
- coding/proxy/server/responses.py +74 -0
- coding/proxy/server/routes.py +264 -0
- coding/proxy/streaming/__init__.py +1 -0
- coding/proxy/streaming/anthropic_compat.py +484 -0
- coding/proxy/vendors/__init__.py +29 -0
- coding/proxy/vendors/anthropic.py +44 -0
- coding/proxy/vendors/antigravity.py +328 -0
- coding/proxy/vendors/base.py +353 -0
- coding/proxy/vendors/copilot.py +702 -0
- coding/proxy/vendors/copilot_models.py +438 -0
- coding/proxy/vendors/copilot_token_manager.py +167 -0
- coding/proxy/vendors/copilot_urls.py +16 -0
- coding/proxy/vendors/mixins.py +71 -0
- coding/proxy/vendors/token_manager.py +128 -0
- coding/proxy/vendors/zhipu.py +243 -0
- coding_proxy-0.1.0.dist-info/METADATA +184 -0
- coding_proxy-0.1.0.dist-info/RECORD +77 -0
- coding_proxy-0.1.0.dist-info/WHEEL +4 -0
- coding_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|