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,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