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,438 @@
|
|
|
1
|
+
"""Copilot 模型解析纯函数、诊断数据类与模型目录管理策略.
|
|
2
|
+
|
|
3
|
+
包含从 ``copilot_urls.py`` 合并的 URL 常量与解析函数。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
# ── Copilot URL / 版本常量(原 copilot_urls.py) ────────────
|
|
18
|
+
|
|
19
|
+
_COPILOT_VERSION = "0.26.7"
|
|
20
|
+
_EDITOR_VERSION = "vscode/1.98.0"
|
|
21
|
+
_EDITOR_PLUGIN_VERSION = f"copilot-chat/{_COPILOT_VERSION}"
|
|
22
|
+
_USER_AGENT = f"GitHubCopilotChat/{_COPILOT_VERSION}"
|
|
23
|
+
_GITHUB_API_VERSION = "2025-04-01"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_base_url(url: str) -> str:
|
|
27
|
+
return url.rstrip("/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_copilot_candidate_base_urls(account_type: str, configured_base_url: str) -> list[str]:
|
|
31
|
+
"""构建 Copilot 候选基础地址列表."""
|
|
32
|
+
if configured_base_url.strip():
|
|
33
|
+
return [_normalize_base_url(configured_base_url.strip())]
|
|
34
|
+
|
|
35
|
+
normalized = (account_type or "individual").strip().lower() or "individual"
|
|
36
|
+
candidates = [f"https://api.{normalized}.githubcopilot.com"]
|
|
37
|
+
candidates.append("https://api.githubcopilot.com")
|
|
38
|
+
|
|
39
|
+
unique_candidates: list[str] = []
|
|
40
|
+
for candidate in candidates:
|
|
41
|
+
normalized_candidate = _normalize_base_url(candidate)
|
|
42
|
+
if normalized_candidate not in unique_candidates:
|
|
43
|
+
unique_candidates.append(normalized_candidate)
|
|
44
|
+
return unique_candidates
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_copilot_base_url(account_type: str, configured_base_url: str) -> str:
|
|
48
|
+
"""解析 Copilot API 基础地址.
|
|
49
|
+
|
|
50
|
+
保留用户显式覆盖;仅当值为空时按账号类型回退到官方推荐域名。
|
|
51
|
+
"""
|
|
52
|
+
return build_copilot_candidate_base_urls(account_type, configured_base_url)[0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 回调协议 ────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── 回调协议 ────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _HttpRequestFn(Protocol):
|
|
64
|
+
"""HTTP 请求回调协议(由 CopilotBackend 注入)."""
|
|
65
|
+
|
|
66
|
+
async def __call__(
|
|
67
|
+
self,
|
|
68
|
+
method: str,
|
|
69
|
+
endpoint: str,
|
|
70
|
+
*,
|
|
71
|
+
headers: dict[str, str],
|
|
72
|
+
json_body: dict[str, Any] | None = None,
|
|
73
|
+
) -> httpx.Response: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── 纯函数(模型解析) ───────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize_copilot_requested_model(model: str) -> str:
|
|
80
|
+
"""将 Anthropic 请求模型规范化为 Copilot 可协商的家族模型."""
|
|
81
|
+
value = (model or "").strip()
|
|
82
|
+
if not value:
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
family_aliases = (
|
|
86
|
+
("claude-sonnet-", "claude-sonnet"),
|
|
87
|
+
("claude-opus-", "claude-opus"),
|
|
88
|
+
("claude-haiku-", "claude-haiku"),
|
|
89
|
+
)
|
|
90
|
+
for prefix, family in family_aliases:
|
|
91
|
+
if value.startswith(prefix):
|
|
92
|
+
remainder = value[len(prefix):]
|
|
93
|
+
major = remainder.split("-", 1)[0].split(".", 1)[0]
|
|
94
|
+
if major.isdigit():
|
|
95
|
+
return f"{family}-{major}"
|
|
96
|
+
return family
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def copilot_model_family(model: str) -> str:
|
|
101
|
+
normalized = normalize_copilot_requested_model(model)
|
|
102
|
+
parts = normalized.split("-")
|
|
103
|
+
if len(parts) >= 3 and parts[0] == "claude":
|
|
104
|
+
return "-".join(parts[:2])
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def copilot_model_major(model: str) -> int | None:
|
|
109
|
+
normalized = normalize_copilot_requested_model(model)
|
|
110
|
+
match = re.search(r"-(\d+)$", normalized)
|
|
111
|
+
if not match:
|
|
112
|
+
return None
|
|
113
|
+
return int(match.group(1))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def copilot_model_version_rank(model: str) -> tuple[int, ...]:
|
|
117
|
+
match = re.search(r"-(\d+(?:\.\d+)*)$", model)
|
|
118
|
+
if not match:
|
|
119
|
+
return ()
|
|
120
|
+
return tuple(int(part) for part in match.group(1).split("."))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def select_copilot_model(
|
|
124
|
+
requested_model: str,
|
|
125
|
+
available_models: list[str],
|
|
126
|
+
) -> tuple[str | None, str]:
|
|
127
|
+
"""基于 Copilot 目录选择最终模型,同家族优先,不跨家族静默降级."""
|
|
128
|
+
if not available_models:
|
|
129
|
+
return None, "available_models_empty"
|
|
130
|
+
|
|
131
|
+
unique_available = [model for model in dict.fromkeys(available_models) if model]
|
|
132
|
+
if requested_model in unique_available:
|
|
133
|
+
return requested_model, "exact_requested_model"
|
|
134
|
+
|
|
135
|
+
normalized_model = normalize_copilot_requested_model(requested_model)
|
|
136
|
+
if normalized_model in unique_available:
|
|
137
|
+
return normalized_model, "normalized_requested_model"
|
|
138
|
+
|
|
139
|
+
requested_family = copilot_model_family(requested_model)
|
|
140
|
+
requested_major = copilot_model_major(requested_model)
|
|
141
|
+
|
|
142
|
+
family_candidates = [
|
|
143
|
+
model for model in unique_available
|
|
144
|
+
if copilot_model_family(model) == requested_family
|
|
145
|
+
and (requested_major is None or copilot_model_major(model) == requested_major)
|
|
146
|
+
]
|
|
147
|
+
if not family_candidates:
|
|
148
|
+
family_candidates = [
|
|
149
|
+
model for model in unique_available
|
|
150
|
+
if copilot_model_family(model) == requested_family
|
|
151
|
+
]
|
|
152
|
+
if not family_candidates:
|
|
153
|
+
return None, "no_same_family_model_available"
|
|
154
|
+
|
|
155
|
+
ranked = sorted(
|
|
156
|
+
family_candidates,
|
|
157
|
+
key=lambda item: (
|
|
158
|
+
len(copilot_model_version_rank(item)) == 0,
|
|
159
|
+
copilot_model_version_rank(item),
|
|
160
|
+
item,
|
|
161
|
+
),
|
|
162
|
+
reverse=True,
|
|
163
|
+
)
|
|
164
|
+
return ranked[0], "same_family_highest_version"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── 诊断数据类 ────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class CopilotMisdirectedRequest:
|
|
172
|
+
base_url: str
|
|
173
|
+
status_code: int
|
|
174
|
+
request: Any # httpx.Request (avoid circular import at module level)
|
|
175
|
+
headers: Any # httpx.Headers
|
|
176
|
+
body: bytes
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class CopilotExchangeDiagnostics:
|
|
181
|
+
"""最近一次 Copilot token 交换的运行时诊断."""
|
|
182
|
+
|
|
183
|
+
raw_shape: str = ""
|
|
184
|
+
token_field: str = ""
|
|
185
|
+
expires_in_seconds: int = 0
|
|
186
|
+
expires_at_unix: int = 0
|
|
187
|
+
capabilities: dict[str, Any] = field(default_factory=dict)
|
|
188
|
+
updated_at_unix: int = 0
|
|
189
|
+
|
|
190
|
+
def to_dict(self) -> dict[str, Any]:
|
|
191
|
+
data: dict[str, Any] = {}
|
|
192
|
+
if self.raw_shape:
|
|
193
|
+
data["raw_shape"] = self.raw_shape
|
|
194
|
+
if self.token_field:
|
|
195
|
+
data["token_field"] = self.token_field
|
|
196
|
+
if self.expires_in_seconds:
|
|
197
|
+
data["expires_in_seconds"] = self.expires_in_seconds
|
|
198
|
+
if self.expires_at_unix:
|
|
199
|
+
data["expires_at_unix"] = self.expires_at_unix
|
|
200
|
+
data["ttl_seconds"] = max(self.expires_at_unix - int(time.time()), 0)
|
|
201
|
+
if self.capabilities:
|
|
202
|
+
data["capabilities"] = self.capabilities
|
|
203
|
+
if self.updated_at_unix:
|
|
204
|
+
data["updated_at_unix"] = self.updated_at_unix
|
|
205
|
+
return data
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class CopilotModelCatalog:
|
|
210
|
+
available_models: list[str] = field(default_factory=list)
|
|
211
|
+
fetched_at_unix: int = 0
|
|
212
|
+
|
|
213
|
+
def age_seconds(self) -> int | None:
|
|
214
|
+
if not self.fetched_at_unix:
|
|
215
|
+
return None
|
|
216
|
+
return max(int(time.time()) - self.fetched_at_unix, 0)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── CopilotModelResolver 策略类 ───────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CopilotModelResolver:
|
|
223
|
+
"""Copilot 模型目录管理与解析策略.
|
|
224
|
+
|
|
225
|
+
职责:
|
|
226
|
+
- 维护模型目录缓存(CopilotModelCatalog)及 TTL
|
|
227
|
+
- 通过注入的 HTTP 回调获取可用模型列表
|
|
228
|
+
- 基于配置规则或家族匹配策略解析最终模型名
|
|
229
|
+
|
|
230
|
+
设计: 不直接持有 HTTP client 或 Backend 引用,通过 ``request_fn`` 回调
|
|
231
|
+
注入请求能力,实现 Dependency Inversion.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
models_cache_ttl_seconds: int,
|
|
237
|
+
model_mapper: Any = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
self._catalog = CopilotModelCatalog()
|
|
240
|
+
self._ttl = max(models_cache_ttl_seconds, 0)
|
|
241
|
+
self._model_mapper = model_mapper
|
|
242
|
+
# 诊断字段
|
|
243
|
+
self.last_normalized_model = ""
|
|
244
|
+
self.last_model_refresh_reason = ""
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def catalog(self) -> CopilotModelCatalog:
|
|
248
|
+
return self._catalog
|
|
249
|
+
|
|
250
|
+
# ── 目录新鲜度 ─────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def is_fresh(self) -> bool:
|
|
253
|
+
if not self._catalog.available_models:
|
|
254
|
+
return False
|
|
255
|
+
if self._ttl == 0:
|
|
256
|
+
return False
|
|
257
|
+
age = self._catalog.age_seconds()
|
|
258
|
+
return age is not None and age < self._ttl
|
|
259
|
+
|
|
260
|
+
# ── 模型列表获取 ───────────────────────────────────
|
|
261
|
+
|
|
262
|
+
async def fetch_available(
|
|
263
|
+
self,
|
|
264
|
+
*,
|
|
265
|
+
request_fn: _HttpRequestFn,
|
|
266
|
+
headers_fn: Callable[[], dict[str, str]],
|
|
267
|
+
refresh_reason: str,
|
|
268
|
+
) -> list[str]:
|
|
269
|
+
"""从 Copilot API 获取可用模型列表并更新目录."""
|
|
270
|
+
response = await request_fn(
|
|
271
|
+
"GET",
|
|
272
|
+
"/models",
|
|
273
|
+
headers=headers_fn(),
|
|
274
|
+
)
|
|
275
|
+
from .base import _decode_json_body # 延迟导入避免循环依赖
|
|
276
|
+
|
|
277
|
+
payload = _decode_json_body(response)
|
|
278
|
+
if response.status_code >= 400:
|
|
279
|
+
self.last_model_refresh_reason = f"{refresh_reason}:probe_error"
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
available_models = extract_available_models(payload)
|
|
283
|
+
self._catalog = CopilotModelCatalog(
|
|
284
|
+
available_models=available_models,
|
|
285
|
+
fetched_at_unix=int(time.time()),
|
|
286
|
+
)
|
|
287
|
+
self.last_model_refresh_reason = refresh_reason
|
|
288
|
+
return available_models
|
|
289
|
+
|
|
290
|
+
async def get_available(
|
|
291
|
+
self,
|
|
292
|
+
*,
|
|
293
|
+
force_refresh: bool,
|
|
294
|
+
request_fn: _HttpRequestFn,
|
|
295
|
+
headers_fn: Callable[[], dict[str, str]],
|
|
296
|
+
refresh_reason: str,
|
|
297
|
+
) -> list[str]:
|
|
298
|
+
"""获取可用模型列表(带 TTL 缓存)."""
|
|
299
|
+
if force_refresh or not self.is_fresh():
|
|
300
|
+
self.last_model_refresh_reason = refresh_reason
|
|
301
|
+
available_models = await self.fetch_available(
|
|
302
|
+
request_fn=request_fn,
|
|
303
|
+
headers_fn=headers_fn,
|
|
304
|
+
refresh_reason=refresh_reason,
|
|
305
|
+
)
|
|
306
|
+
if available_models:
|
|
307
|
+
self._catalog = CopilotModelCatalog(
|
|
308
|
+
available_models=list(available_models),
|
|
309
|
+
fetched_at_unix=int(time.time()),
|
|
310
|
+
)
|
|
311
|
+
return available_models
|
|
312
|
+
return list(self._catalog.available_models)
|
|
313
|
+
|
|
314
|
+
# ── 模型解析 ───────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
async def resolve(
|
|
317
|
+
self,
|
|
318
|
+
requested_model: str,
|
|
319
|
+
*,
|
|
320
|
+
force_refresh: bool,
|
|
321
|
+
request_fn: _HttpRequestFn,
|
|
322
|
+
headers_fn: Callable[[], dict[str, str]],
|
|
323
|
+
refresh_reason: str,
|
|
324
|
+
# 以下为诊断回写目标(由调用方传入的可变对象)
|
|
325
|
+
diagnostics: dict[str, str],
|
|
326
|
+
) -> str:
|
|
327
|
+
"""解析请求模型名为最终模型名.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
解析后的模型名字符串. 同时将中间结果写入 *diagnostics* 字典.
|
|
331
|
+
"""
|
|
332
|
+
# 优先:配置规则显式映射
|
|
333
|
+
if self._model_mapper is not None:
|
|
334
|
+
mapped = self._model_mapper.map(
|
|
335
|
+
requested_model, vendor="copilot", default=requested_model,
|
|
336
|
+
)
|
|
337
|
+
if mapped != requested_model:
|
|
338
|
+
diagnostics["requested_model"] = requested_model
|
|
339
|
+
diagnostics["normalized_model"] = requested_model
|
|
340
|
+
diagnostics["resolved_model"] = mapped
|
|
341
|
+
diagnostics["resolution_reason"] = "config_model_mapping"
|
|
342
|
+
self.last_normalized_model = requested_model
|
|
343
|
+
return mapped
|
|
344
|
+
|
|
345
|
+
# 次级:内部家族匹配策略
|
|
346
|
+
normalized_model = normalize_copilot_requested_model(requested_model)
|
|
347
|
+
available_models = await self.get_available(
|
|
348
|
+
force_refresh=force_refresh,
|
|
349
|
+
request_fn=request_fn,
|
|
350
|
+
headers_fn=headers_fn,
|
|
351
|
+
refresh_reason=refresh_reason,
|
|
352
|
+
)
|
|
353
|
+
resolved_model, resolution_reason = select_copilot_model(
|
|
354
|
+
requested_model, available_models,
|
|
355
|
+
)
|
|
356
|
+
if not resolved_model:
|
|
357
|
+
resolved_model = normalized_model or requested_model
|
|
358
|
+
resolution_reason = (
|
|
359
|
+
"catalog_unavailable_fallback_to_normalized"
|
|
360
|
+
if not available_models else
|
|
361
|
+
"no_same_family_model_fallback_to_normalized"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
diagnostics["requested_model"] = requested_model
|
|
365
|
+
diagnostics["normalized_model"] = normalized_model
|
|
366
|
+
diagnostics["resolved_model"] = resolved_model
|
|
367
|
+
diagnostics["resolution_reason"] = resolution_reason
|
|
368
|
+
self.last_normalized_model = normalized_model
|
|
369
|
+
return resolved_model
|
|
370
|
+
|
|
371
|
+
# ── 错误响应构建 ───────────────────────────────────
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def build_model_not_supported_response(
|
|
375
|
+
response: httpx.Response,
|
|
376
|
+
*,
|
|
377
|
+
requested_model: str,
|
|
378
|
+
normalized_model: str,
|
|
379
|
+
resolved_model: str,
|
|
380
|
+
available_models: list[str],
|
|
381
|
+
) -> httpx.Response:
|
|
382
|
+
"""构建 model_not_supported 错误响应."""
|
|
383
|
+
payload = {
|
|
384
|
+
"error": {
|
|
385
|
+
"type": "invalid_request_error",
|
|
386
|
+
"message": "Copilot 当前账号未开放与请求同家族匹配的模型",
|
|
387
|
+
"code": "model_not_supported",
|
|
388
|
+
"param": "model",
|
|
389
|
+
"details": {
|
|
390
|
+
"requested_model": requested_model,
|
|
391
|
+
"normalized_model": normalized_model,
|
|
392
|
+
"resolved_model": resolved_model,
|
|
393
|
+
"available_models": available_models,
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return httpx.Response(
|
|
398
|
+
400,
|
|
399
|
+
content=json.dumps(payload, ensure_ascii=False).encode(),
|
|
400
|
+
headers={"content-type": "application/json"},
|
|
401
|
+
request=response.request,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def is_model_not_supported_response(response: httpx.Response | None) -> bool:
|
|
406
|
+
"""检测响应是否为 model_not_supported 错误."""
|
|
407
|
+
if response is None or response.status_code != 400:
|
|
408
|
+
return False
|
|
409
|
+
from .base import _decode_json_body # 延迟导入避免循环依赖
|
|
410
|
+
|
|
411
|
+
payload = _decode_json_body(response)
|
|
412
|
+
if not isinstance(payload, dict):
|
|
413
|
+
return False
|
|
414
|
+
error = payload.get("error")
|
|
415
|
+
if not isinstance(error, dict):
|
|
416
|
+
return False
|
|
417
|
+
return error.get("code") == "model_not_supported"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def extract_available_models(payload: dict[str, Any] | list[Any] | None) -> list[str]:
|
|
421
|
+
"""从 Copilot /models 响应中提取模型 ID 列表."""
|
|
422
|
+
if not isinstance(payload, dict):
|
|
423
|
+
return []
|
|
424
|
+
models = payload.get("data", [])
|
|
425
|
+
if not isinstance(models, list):
|
|
426
|
+
return []
|
|
427
|
+
return [
|
|
428
|
+
item.get("id")
|
|
429
|
+
for item in models
|
|
430
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str) and item.get("id")
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# 向后兼容别名(旧名称带下划线前缀)
|
|
435
|
+
_copilot_model_family = copilot_model_family
|
|
436
|
+
_copilot_model_major = copilot_model_major
|
|
437
|
+
_copilot_model_version_rank = copilot_model_version_rank
|
|
438
|
+
_select_copilot_model = select_copilot_model
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""GitHub Copilot token 交换管理器.
|
|
2
|
+
|
|
3
|
+
流程: GitHub token → GET copilot_internal/v2/token → Copilot access_token (~30 分钟有效期)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .copilot_models import CopilotExchangeDiagnostics
|
|
15
|
+
from .copilot_urls import (
|
|
16
|
+
_EDITOR_PLUGIN_VERSION,
|
|
17
|
+
_EDITOR_VERSION,
|
|
18
|
+
_GITHUB_API_VERSION,
|
|
19
|
+
_USER_AGENT,
|
|
20
|
+
)
|
|
21
|
+
from .token_manager import BaseTokenManager, TokenAcquireError, TokenErrorKind
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
__all__ = ["CopilotTokenManager"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CopilotTokenManager(BaseTokenManager):
|
|
29
|
+
"""GitHub Copilot token 交换管理.
|
|
30
|
+
|
|
31
|
+
流程: GitHub token → GET copilot_internal/v2/token → Copilot access_token (~30 分钟有效期)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, github_token: str, token_url: str) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._github_token = github_token
|
|
37
|
+
self._token_url = token_url
|
|
38
|
+
self._last_exchange = CopilotExchangeDiagnostics()
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _format_body_excerpt(data: Any) -> str:
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
for key in ("error_description", "error", "message"):
|
|
44
|
+
value = data.get(key)
|
|
45
|
+
if value:
|
|
46
|
+
return str(value)[:200]
|
|
47
|
+
return str(data)[:200]
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def _build_missing_token_error(
|
|
51
|
+
cls, data: Any, status_code: int,
|
|
52
|
+
) -> TokenAcquireError:
|
|
53
|
+
detail = cls._format_body_excerpt(data)
|
|
54
|
+
lowered = detail.lower()
|
|
55
|
+
capability_keys = {
|
|
56
|
+
"chat_enabled", "agent_mode_auto_approval", "chat_jetbrains_enabled",
|
|
57
|
+
"annotations_enabled", "code_quote_enabled",
|
|
58
|
+
}
|
|
59
|
+
if isinstance(data, dict) and capability_keys.intersection(data.keys()):
|
|
60
|
+
return TokenAcquireError.with_kind(
|
|
61
|
+
"Copilot 当前登录权限不足,需升级到可交换 chat token 的 GitHub 会话",
|
|
62
|
+
kind=TokenErrorKind.PERMISSION_UPGRADE_REQUIRED,
|
|
63
|
+
needs_reauth=True,
|
|
64
|
+
)
|
|
65
|
+
needs_reauth = status_code == 401 or any(
|
|
66
|
+
pattern in lowered for pattern in ("bad credentials", "invalid token", "unauthorized")
|
|
67
|
+
)
|
|
68
|
+
kind = TokenErrorKind.INVALID_CREDENTIALS if needs_reauth else TokenErrorKind.TEMPORARY
|
|
69
|
+
return TokenAcquireError.with_kind(
|
|
70
|
+
f"Copilot token 交换返回非预期响应: status={status_code}, detail={detail}",
|
|
71
|
+
kind=kind,
|
|
72
|
+
needs_reauth=needs_reauth,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _extract_capabilities(data: Any) -> dict[str, Any]:
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
return {}
|
|
79
|
+
capability_keys = (
|
|
80
|
+
"chat_enabled",
|
|
81
|
+
"chat_jetbrains_enabled",
|
|
82
|
+
"agent_mode_auto_approval",
|
|
83
|
+
"code_quote_enabled",
|
|
84
|
+
"annotations_enabled",
|
|
85
|
+
)
|
|
86
|
+
return {key: data[key] for key in capability_keys if key in data}
|
|
87
|
+
|
|
88
|
+
def _record_exchange(self, data: dict[str, Any], token_field: str, expires_in: int) -> None:
|
|
89
|
+
expires_at = int(time.time()) + max(expires_in, 0)
|
|
90
|
+
self._last_exchange = CopilotExchangeDiagnostics(
|
|
91
|
+
raw_shape="token_refresh_in" if "token" in data else "access_token_expires_in",
|
|
92
|
+
token_field=token_field,
|
|
93
|
+
expires_in_seconds=expires_in,
|
|
94
|
+
expires_at_unix=expires_at,
|
|
95
|
+
capabilities=self._extract_capabilities(data),
|
|
96
|
+
updated_at_unix=int(time.time()),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def get_exchange_diagnostics(self) -> dict[str, Any]:
|
|
100
|
+
return self._last_exchange.to_dict()
|
|
101
|
+
|
|
102
|
+
async def _acquire(self) -> tuple[str, float]:
|
|
103
|
+
"""通过 GitHub token 交换 Copilot token."""
|
|
104
|
+
client = self._get_client()
|
|
105
|
+
try:
|
|
106
|
+
response = await client.get(
|
|
107
|
+
self._token_url,
|
|
108
|
+
headers={
|
|
109
|
+
"authorization": f"token {self._github_token}",
|
|
110
|
+
"accept": "application/json",
|
|
111
|
+
"editor-version": _EDITOR_VERSION,
|
|
112
|
+
"editor-plugin-version": _EDITOR_PLUGIN_VERSION,
|
|
113
|
+
"user-agent": _USER_AGENT,
|
|
114
|
+
"x-github-api-version": _GITHUB_API_VERSION,
|
|
115
|
+
"x-vscode-user-agent-library-version": "electron-fetch",
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
except httpx.HTTPStatusError as exc:
|
|
119
|
+
if exc.response.status_code == 401:
|
|
120
|
+
raise TokenAcquireError.with_kind(
|
|
121
|
+
"GitHub token 无效或已过期",
|
|
122
|
+
kind=TokenErrorKind.INVALID_CREDENTIALS,
|
|
123
|
+
needs_reauth=True,
|
|
124
|
+
) from exc
|
|
125
|
+
raise TokenAcquireError.with_kind(
|
|
126
|
+
f"Copilot token 交换失败: {exc}",
|
|
127
|
+
kind=TokenErrorKind.TEMPORARY,
|
|
128
|
+
) from exc
|
|
129
|
+
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
|
130
|
+
raise TokenAcquireError.with_kind(
|
|
131
|
+
f"Copilot token 交换网络异常: {exc}",
|
|
132
|
+
kind=TokenErrorKind.TEMPORARY,
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
data = response.json()
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise TokenAcquireError(
|
|
139
|
+
f"Copilot token 交换返回非 JSON 响应: status={response.status_code}",
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
if response.status_code >= 400:
|
|
143
|
+
if response.status_code == 401:
|
|
144
|
+
raise TokenAcquireError.with_kind(
|
|
145
|
+
"GitHub token 无效或已过期",
|
|
146
|
+
kind=TokenErrorKind.INVALID_CREDENTIALS,
|
|
147
|
+
needs_reauth=True,
|
|
148
|
+
)
|
|
149
|
+
raise self._build_missing_token_error(data, response.status_code)
|
|
150
|
+
|
|
151
|
+
token_field = "token" if data.get("token") else "access_token"
|
|
152
|
+
access_token = data.get("token") or data.get("access_token")
|
|
153
|
+
if not access_token:
|
|
154
|
+
raise self._build_missing_token_error(data, response.status_code)
|
|
155
|
+
|
|
156
|
+
expires_in = data.get("refresh_in") or data.get("expires_in")
|
|
157
|
+
if expires_in is None and data.get("expires_at"):
|
|
158
|
+
expires_in = max(int(data["expires_at"]) - int(time.time()), 0)
|
|
159
|
+
expires_in = int(expires_in or 1800)
|
|
160
|
+
self._record_exchange(data, token_field, expires_in)
|
|
161
|
+
logger.info("Copilot token exchanged, expires_in=%ds", expires_in)
|
|
162
|
+
return str(access_token), float(expires_in)
|
|
163
|
+
|
|
164
|
+
def update_github_token(self, new_token: str) -> None:
|
|
165
|
+
"""运行时热更新 GitHub token(重认证后调用)."""
|
|
166
|
+
self._github_token = new_token
|
|
167
|
+
self.invalidate()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Copilot URL 管理纯函数 — 向后兼容 re-export shim.
|
|
2
|
+
|
|
3
|
+
所有常量与函数已合并至 :mod:`coding.proxy.backends.copilot_models`。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# noqa: F401
|
|
7
|
+
from .copilot_models import (
|
|
8
|
+
_COPILOT_VERSION,
|
|
9
|
+
_EDITOR_PLUGIN_VERSION,
|
|
10
|
+
_EDITOR_VERSION,
|
|
11
|
+
_GITHUB_API_VERSION,
|
|
12
|
+
_USER_AGENT,
|
|
13
|
+
_normalize_base_url,
|
|
14
|
+
build_copilot_candidate_base_urls,
|
|
15
|
+
resolve_copilot_base_url,
|
|
16
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""后端 Mixin — 消除 Token 后端间的重复模式."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .token_manager import BaseTokenManager
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenBackendMixin:
|
|
14
|
+
"""提供基于 TokenManager 的后端通用能力.
|
|
15
|
+
|
|
16
|
+
使用方式::
|
|
17
|
+
class MyBackend(TokenBackendMixin, BaseBackend):
|
|
18
|
+
def __init__(self, ...):
|
|
19
|
+
TokenBackendMixin.__init__(self, token_manager)
|
|
20
|
+
BaseBackend.__init__(self, ...)
|
|
21
|
+
|
|
22
|
+
提供:
|
|
23
|
+
- _on_error_status: 401/403 时自动 invalidate token
|
|
24
|
+
- check_health: 基于 token 可获取性的健康检查
|
|
25
|
+
- 标准诊断字段追踪(_last_requested_model / _last_resolved_model /
|
|
26
|
+
_last_model_resolution_reason / _last_request_adaptations)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_token_manager: BaseTokenManager
|
|
30
|
+
|
|
31
|
+
# 诊断追踪字段
|
|
32
|
+
_last_requested_model: str = ""
|
|
33
|
+
_last_resolved_model: str = ""
|
|
34
|
+
_last_model_resolution_reason: str = ""
|
|
35
|
+
_last_request_adaptations: list[str] = [] # type: ignore[assignment]
|
|
36
|
+
|
|
37
|
+
def __init__(self, token_manager: BaseTokenManager) -> None:
|
|
38
|
+
self._token_manager = token_manager
|
|
39
|
+
|
|
40
|
+
def _on_error_status(self, status_code: int) -> None:
|
|
41
|
+
"""401/403 时标记 token 失效以触发被动刷新."""
|
|
42
|
+
if status_code in (401, 403):
|
|
43
|
+
self._token_manager.invalidate()
|
|
44
|
+
|
|
45
|
+
async def check_health(self) -> bool:
|
|
46
|
+
"""基于 token 可用性的健康检查."""
|
|
47
|
+
try:
|
|
48
|
+
token = await self._token_manager.get_token()
|
|
49
|
+
return bool(token)
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"%s health check failed: token refresh error",
|
|
53
|
+
getattr(self, "get_name", lambda: "unknown")(),
|
|
54
|
+
)
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def _get_token_diagnostics(self) -> dict[str, Any]:
|
|
58
|
+
"""收集 token 相关诊断信息."""
|
|
59
|
+
diagnostics: dict[str, Any] = {}
|
|
60
|
+
tm_diag = self._token_manager.get_diagnostics()
|
|
61
|
+
if tm_diag:
|
|
62
|
+
diagnostics["token_manager"] = tm_diag
|
|
63
|
+
if self._last_request_adaptations:
|
|
64
|
+
diagnostics["request_adaptations"] = self._last_request_adaptations
|
|
65
|
+
if self._last_requested_model:
|
|
66
|
+
diagnostics["requested_model"] = self._last_requested_model
|
|
67
|
+
if self._last_resolved_model:
|
|
68
|
+
diagnostics["resolved_model"] = self._last_resolved_model
|
|
69
|
+
if self._last_model_resolution_reason:
|
|
70
|
+
diagnostics["model_resolution_reason"] = self._last_model_resolution_reason
|
|
71
|
+
return diagnostics
|