main
py 664 lines 23 KB
Raw
1 from __future__ import annotations
2
3 import secrets
4 import time
5 from pathlib import Path
6 from typing import Any
7 from urllib.parse import urlparse
8
9 from plugins._oauth.helpers.providers.base import (
10 DUMMY_API_KEY,
11 GITHUB_COPILOT_PROVIDER_ID,
12 CallbackResult,
13 LoginPollResult,
14 LoginStartResult,
15 OAuthProviderMetadata,
16 ProviderError,
17 provider_auth_path,
18 read_json_file,
19 write_private_json,
20 )
21 from plugins._oauth.helpers.state import (
22 DeviceAttempt,
23 get_device_attempt,
24 pop_device_attempt,
25 put_device_attempt,
26 )
27
28
29 CLIENT_ID = "Iv1.b507a08c87ecfe98"
30 DEFAULT_DOMAIN = "github.com"
31 COPILOT_HEADERS = {
32 "User-Agent": "GitHubCopilotChat/0.35.0",
33 "Editor-Version": "vscode/1.107.0",
34 "Editor-Plugin-Version": "copilot-chat/0.35.0",
35 "Copilot-Integration-Id": "vscode-chat",
36 }
37 CURATED_MODELS = [
38 "gpt-5.2",
39 "claude-sonnet-4.5",
40 "claude-opus-4.5",
41 "gemini-2.5-pro",
42 "grok-code-fast-1",
43 "gpt-4.1",
44 "gpt-4o",
45 ]
46 NOT_CONNECTED_MESSAGE = "GitHub Copilot OAuth is not connected yet."
47 ENTERPRISE_NOTE = "Leave Enterprise domain blank for github.com, or enter a GitHub Enterprise domain."
48 DEFAULT_COPILOT_BASE_URL = "https://api.individual.githubcopilot.com"
49 DEFAULT_COPILOT_HOST = "api.individual.githubcopilot.com"
50 GITHUB_COPILOT_API_HOSTS = {
51 DEFAULT_COPILOT_HOST,
52 "api.githubcopilot.com",
53 }
54 REFRESH_MARGIN_MS = 60_000
55
56
57 def normalize_enterprise_domain(value: Any) -> str:
58 text = "" if value is None else str(value).strip()
59 if not text:
60 return DEFAULT_DOMAIN
61 if any(char.isspace() for char in text):
62 raise ValueError("Invalid GitHub Enterprise URL/domain.")
63
64 parsed = urlparse(text if "://" in text else f"//{text}")
65 if parsed.scheme and parsed.scheme not in {"http", "https"}:
66 raise ValueError("Invalid GitHub Enterprise URL/domain.")
67 if "@" in parsed.netloc:
68 raise ValueError("Invalid GitHub Enterprise URL/domain.")
69
70 host = (parsed.hostname or "").strip().lower().rstrip(".")
71 if not host or "/" in host or "\\" in host:
72 raise ValueError("Invalid GitHub Enterprise URL/domain.")
73 if host.startswith("-") or host.endswith("-") or ".." in host:
74 raise ValueError("Invalid GitHub Enterprise URL/domain.")
75 return host
76
77
78 def github_urls(domain: Any) -> dict[str, str]:
79 normalized = normalize_enterprise_domain(domain)
80 web_base = f"https://{normalized}"
81 if normalized == DEFAULT_DOMAIN:
82 copilot_token = "https://api.github.com/copilot_internal/v2/token"
83 else:
84 copilot_token = f"https://{normalized}/api/v3/copilot_internal/v2/token"
85 return {
86 "device_code": f"{web_base}/login/device/code",
87 "access_token": f"{web_base}/login/oauth/access_token",
88 "copilot_token": copilot_token,
89 }
90
91
92 def copilot_base_url_from_token(token: str, enterprise_domain: Any = "") -> str:
93 domain = normalize_enterprise_domain(enterprise_domain)
94 for part in str(token or "").split(";"):
95 key, separator, value = part.partition("=")
96 if separator and key.strip() == "proxy-ep":
97 try:
98 return safe_copilot_base_url_from_proxy_endpoint(value, domain)
99 except ProviderError:
100 return DEFAULT_COPILOT_BASE_URL
101 if domain != DEFAULT_DOMAIN:
102 return f"https://copilot-api.{domain}"
103 return DEFAULT_COPILOT_BASE_URL
104
105
106 def safe_copilot_base_url(value: Any, enterprise_domain: Any = "") -> str:
107 text = str(value or "").strip().rstrip("/")
108 domain = normalize_enterprise_domain(enterprise_domain)
109 if not text:
110 return _default_copilot_base_url_for_domain(domain)
111
112 parsed = urlparse(text)
113 host = (parsed.hostname or "").strip().lower().rstrip(".")
114 if parsed.scheme == "https" and _is_allowed_copilot_api_host(host, domain):
115 return f"https://{host}"
116 return _default_copilot_base_url_for_domain(domain)
117
118
119 def safe_copilot_base_url_from_proxy_endpoint(value: Any, enterprise_domain: Any = "") -> str:
120 text = str(value or "").strip()
121 if not text:
122 raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
123
124 parsed = urlparse(text if "://" in text else f"//{text}")
125 if parsed.scheme and parsed.scheme != "https":
126 raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
127 try:
128 port = parsed.port
129 except ValueError as exc:
130 raise ProviderError(
131 "GitHub Copilot token returned an invalid proxy endpoint.",
132 code="invalid_proxy_endpoint",
133 status=502,
134 ) from exc
135 if parsed.username or parsed.password or port is not None:
136 raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
137 if parsed.path not in {"", "/"} or parsed.params or parsed.query or parsed.fragment:
138 raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
139
140 domain = normalize_enterprise_domain(enterprise_domain)
141 host = (parsed.hostname or "").strip().lower().rstrip(".")
142 if host.startswith("proxy."):
143 host = f"api.{host.removeprefix('proxy.')}"
144 if not _is_allowed_proxy_endpoint_host(host, domain):
145 raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
146 return f"https://{host}"
147
148
149 def _default_copilot_base_url_for_domain(domain: str) -> str:
150 if domain != DEFAULT_DOMAIN:
151 return f"https://copilot-api.{domain}"
152 return DEFAULT_COPILOT_BASE_URL
153
154
155 def _is_allowed_copilot_api_host(host: str, enterprise_domain: str) -> bool:
156 if host in GITHUB_COPILOT_API_HOSTS:
157 return True
158 return enterprise_domain != DEFAULT_DOMAIN and host == f"copilot-api.{enterprise_domain}"
159
160
161 def _is_allowed_proxy_endpoint_host(host: str, enterprise_domain: str) -> bool:
162 if host.endswith(".githubcopilot.com"):
163 return True
164 return enterprise_domain != DEFAULT_DOMAIN and host == f"copilot-api.{enterprise_domain}"
165
166
167 class GitHubCopilotOAuthProvider:
168 provider_id = GITHUB_COPILOT_PROVIDER_ID
169
170 def auth_path(self) -> Path:
171 return provider_auth_path("github_copilot")
172
173 def read_auth(self) -> dict[str, Any]:
174 return read_json_file(self.auth_path())
175
176 def write_auth(self, data: dict[str, Any]) -> None:
177 write_private_json(self.auth_path(), data)
178
179 def ensure_fresh_auth(self) -> dict[str, Any]:
180 auth = self.read_auth()
181 refresh = str(auth.get("refresh") or "")
182 access = str(auth.get("access") or "")
183 if not refresh or not access:
184 return auth
185
186 expires = _as_int(auth.get("expires"), 0)
187 if not expires or expires > int(time.time() * 1000) + REFRESH_MARGIN_MS:
188 return auth
189
190 domain = auth.get("enterprise_domain") or DEFAULT_DOMAIN
191 refreshed = refresh_copilot_token(refresh, domain)
192 if auth.get("models_warning") and not refreshed.get("models_warning"):
193 refreshed["models_warning"] = auth["models_warning"]
194 self.write_auth(refreshed)
195 return refreshed
196
197 def metadata(self) -> OAuthProviderMetadata:
198 return OAuthProviderMetadata(
199 provider_id=GITHUB_COPILOT_PROVIDER_ID,
200 display_name="GitHub Copilot",
201 short_name="GitHub Copilot",
202 model_provider_id=GITHUB_COPILOT_PROVIDER_ID,
203 icon="github",
204 auth_flow="device_code",
205 default_model=CURATED_MODELS[0],
206 default_models=list(CURATED_MODELS),
207 proxy_base_path="/oauth/github-copilot",
208 supports_enterprise_domain=True,
209 note=ENTERPRISE_NOTE,
210 )
211
212 def status(self) -> dict[str, Any]:
213 auth = self.read_auth()
214 access = str(auth.get("access") or "")
215 refresh = str(auth.get("refresh") or "")
216 enterprise_domain = str(auth.get("enterprise_domain") or "")
217 domain = enterprise_domain or DEFAULT_DOMAIN
218 result = {
219 **self.metadata().to_dict(),
220 "connected": bool(access and refresh),
221 "account_label": domain,
222 "enterprise_domain": enterprise_domain,
223 "base_url": str(auth.get("base_url") or ""),
224 "auth_file_path": str(self.auth_path()),
225 }
226 if auth.get("models_warning"):
227 result["models_warning"] = str(auth["models_warning"])
228 return result
229
230 def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
231 del request
232 data = input or {}
233 try:
234 domain = normalize_enterprise_domain(data.get("enterprise_domain", ""))
235 device = _post_device_code(domain)
236 device_code = str(device.get("device_code") or "")
237 user_code = str(device.get("user_code") or "")
238 verification_url = str(
239 device.get("verification_uri")
240 or device.get("verification_url")
241 or "https://github.com/login/device"
242 )
243 if not device_code or not user_code:
244 raise RuntimeError("GitHub device-code response was malformed.")
245 interval = _as_positive_int(device.get("interval"), 5)
246 expires_in = _as_positive_int(device.get("expires_in"), 900)
247 attempt = put_device_attempt(
248 secrets.token_urlsafe(24),
249 device_code,
250 user_code,
251 interval,
252 time.time() + expires_in,
253 provider_id=GITHUB_COPILOT_PROVIDER_ID,
254 extra={"domain": domain},
255 )
256 except Exception as exc:
257 message = str(exc)
258 return LoginStartResult(
259 ok=False,
260 provider_id=GITHUB_COPILOT_PROVIDER_ID,
261 flow="device_code",
262 message=message,
263 error=message,
264 )
265
266 return LoginStartResult(
267 ok=True,
268 provider_id=GITHUB_COPILOT_PROVIDER_ID,
269 flow="device_code",
270 attempt_id=attempt.attempt_id,
271 verification_url=verification_url,
272 user_code=attempt.user_code,
273 interval=attempt.interval,
274 expires_at=attempt.expires_at,
275 )
276
277 def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
278 del request
279 data = input or {}
280 attempt_id = str(data.get("attempt_id") or "").strip()
281 if not attempt_id:
282 return LoginPollResult(
283 ok=False,
284 provider_id=GITHUB_COPILOT_PROVIDER_ID,
285 error="Missing device authorization attempt.",
286 )
287
288 attempt = get_device_attempt(attempt_id)
289 if attempt is None:
290 return LoginPollResult(
291 ok=False,
292 provider_id=GITHUB_COPILOT_PROVIDER_ID,
293 expired=True,
294 error="Device authorization expired.",
295 )
296 if attempt.provider_id != GITHUB_COPILOT_PROVIDER_ID:
297 return LoginPollResult(
298 ok=False,
299 provider_id=GITHUB_COPILOT_PROVIDER_ID,
300 error="Device authorization provider mismatch.",
301 )
302
303 domain = normalize_enterprise_domain(attempt.extra.get("domain", ""))
304 try:
305 payload = _post_device_poll(domain, attempt.device_auth_id)
306 except Exception as exc:
307 return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=str(exc))
308
309 error = str(payload.get("error") or "")
310 if error == "authorization_pending":
311 return LoginPollResult(
312 ok=True,
313 provider_id=GITHUB_COPILOT_PROVIDER_ID,
314 completed=False,
315 interval=attempt.interval,
316 expires_at=attempt.expires_at,
317 )
318 if error == "slow_down":
319 interval = attempt.interval + 5
320 put_device_attempt(
321 attempt.attempt_id,
322 attempt.device_auth_id,
323 attempt.user_code,
324 interval,
325 attempt.expires_at,
326 provider_id=GITHUB_COPILOT_PROVIDER_ID,
327 extra=attempt.extra,
328 )
329 return LoginPollResult(
330 ok=True,
331 provider_id=GITHUB_COPILOT_PROVIDER_ID,
332 completed=False,
333 interval=interval,
334 expires_at=attempt.expires_at,
335 )
336 if error in {"expired_token", "access_denied"}:
337 pop_device_attempt(attempt_id)
338 return LoginPollResult(
339 ok=False,
340 provider_id=GITHUB_COPILOT_PROVIDER_ID,
341 expired=error == "expired_token",
342 error=error,
343 )
344 if error:
345 return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=error)
346
347 github_access_token = str(payload.get("access_token") or "")
348 if not github_access_token:
349 return LoginPollResult(
350 ok=False,
351 provider_id=GITHUB_COPILOT_PROVIDER_ID,
352 error="GitHub device poll response was malformed.",
353 )
354
355 try:
356 auth = refresh_copilot_token(github_access_token, domain)
357 except Exception as exc:
358 return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=str(exc))
359
360 warning = ""
361 try:
362 summary = enable_known_models(auth["access"], domain)
363 if summary.get("failed"):
364 auth["models_warning"] = "Some Copilot models could not be enabled."
365 warning = auth["models_warning"]
366 except Exception as exc:
367 auth["models_warning"] = str(exc)
368 warning = str(exc)
369
370 self.write_auth(auth)
371 pop_device_attempt(attempt_id)
372 return LoginPollResult(
373 ok=True,
374 provider_id=GITHUB_COPILOT_PROVIDER_ID,
375 completed=True,
376 account_label=domain,
377 warning=warning,
378 )
379
380 def complete_callback(
381 self,
382 args: dict[str, Any],
383 request: Any = None,
384 ) -> CallbackResult:
385 del args, request
386 return CallbackResult(
387 ok=False,
388 provider_id=GITHUB_COPILOT_PROVIDER_ID,
389 error="GitHub Copilot uses device-code login.",
390 )
391
392 def manual_callback(
393 self,
394 input: dict[str, Any],
395 request: Any = None,
396 ) -> LoginPollResult:
397 del input, request
398 return LoginPollResult(
399 ok=False,
400 provider_id=GITHUB_COPILOT_PROVIDER_ID,
401 error="GitHub Copilot uses device-code login.",
402 )
403
404 def models(self) -> list[str]:
405 try:
406 auth = self.ensure_fresh_auth()
407 except Exception:
408 return list(CURATED_MODELS)
409 access = str(auth.get("access") or "")
410 if not access:
411 return list(CURATED_MODELS)
412
413 try:
414 import requests
415
416 base_url = safe_copilot_base_url(auth.get("base_url"), auth.get("enterprise_domain"))
417 response = requests.get(
418 f"{base_url}/models",
419 headers={
420 **COPILOT_HEADERS,
421 "Accept": "application/json",
422 "Authorization": f"Bearer {access}",
423 },
424 timeout=30,
425 )
426 if not response.ok:
427 return list(CURATED_MODELS)
428 parsed = _models_from_payload(response.json())
429 return parsed or list(CURATED_MODELS)
430 except Exception:
431 return list(CURATED_MODELS)
432
433 def disconnect(self) -> dict[str, Any]:
434 path = self.auth_path()
435 existed = path.exists()
436 try:
437 path.unlink(missing_ok=True)
438 except FileNotFoundError:
439 pass
440 return {
441 "disconnected": existed,
442 "removed_auth_files": [str(path)] if existed else [],
443 }
444
445 def api_key(self) -> str:
446 return DUMMY_API_KEY
447
448 def register_routes(self, app: Any) -> None:
449 from plugins._oauth.helpers import routes
450
451 route_defs = [
452 ("/oauth/github-copilot/health", "oauth_github_copilot_health", routes.github_copilot_health, ["GET"]),
453 (
454 "/oauth/github-copilot/v1/models",
455 "oauth_github_copilot_models",
456 routes.github_copilot_models,
457 ["GET", "OPTIONS"],
458 ),
459 (
460 "/oauth/github-copilot/v1/chat/completions",
461 "oauth_github_copilot_chat_completions",
462 routes.github_copilot_chat_completions,
463 ["POST", "OPTIONS"],
464 ),
465 (
466 "/oauth/github-copilot/v1/responses",
467 "oauth_github_copilot_responses",
468 routes.github_copilot_responses,
469 ["POST", "OPTIONS"],
470 ),
471 ]
472 for rule, endpoint, view_func, methods in route_defs:
473 if endpoint in app.view_functions:
474 continue
475 app.add_url_rule(rule, endpoint, view_func, methods=methods)
476
477 def _store_device_attempt_for_test(
478 self,
479 domain: str,
480 device_code: str,
481 user_code: str,
482 interval: int,
483 ) -> DeviceAttempt:
484 return put_device_attempt(
485 secrets.token_urlsafe(24),
486 device_code,
487 user_code,
488 interval,
489 time.time() + 900,
490 provider_id=GITHUB_COPILOT_PROVIDER_ID,
491 extra={"domain": normalize_enterprise_domain(domain)},
492 )
493
494
495 def refresh_copilot_token(refresh: str, domain: Any) -> dict[str, Any]:
496 normalized = normalize_enterprise_domain(domain)
497 import requests
498
499 response = requests.get(
500 github_urls(normalized)["copilot_token"],
501 headers={
502 **COPILOT_HEADERS,
503 "Accept": "application/json",
504 "Authorization": f"Bearer {refresh}",
505 },
506 timeout=30,
507 )
508 if not response.ok:
509 raise RuntimeError(f"GitHub Copilot token refresh failed with status {response.status_code}.")
510
511 payload = response.json()
512 if not isinstance(payload, dict):
513 raise RuntimeError("GitHub Copilot token response was malformed.")
514 token = str(payload.get("token") or payload.get("access_token") or "")
515 expires_at = _expires_ms(payload)
516 if not token or not expires_at:
517 raise RuntimeError("GitHub Copilot token response was missing token data.")
518
519 return {
520 "provider": GITHUB_COPILOT_PROVIDER_ID,
521 "type": "oauth",
522 "refresh": refresh,
523 "access": token,
524 "expires": max(0, expires_at - 300_000),
525 "enterprise_domain": "" if normalized == DEFAULT_DOMAIN else normalized,
526 "base_url": copilot_base_url_from_token(token, normalized),
527 }
528
529
530 def enable_known_models(token: str, domain: Any) -> dict[str, Any]:
531 base_url = copilot_base_url_from_token(token, domain).rstrip("/")
532 summary: dict[str, Any] = {"attempted": len(CURATED_MODELS), "enabled": 0, "failed": []}
533 try:
534 import requests
535 except Exception as exc:
536 summary["failed"] = [{"model": model, "error": str(exc)} for model in CURATED_MODELS]
537 return summary
538
539 for model in CURATED_MODELS:
540 try:
541 response = requests.post(
542 f"{base_url}/models/{model}/policy",
543 headers={
544 **COPILOT_HEADERS,
545 "Accept": "application/json",
546 "Authorization": f"Bearer {token}",
547 "Content-Type": "application/json",
548 },
549 json={"state": "enabled"},
550 timeout=30,
551 )
552 if response.ok:
553 summary["enabled"] += 1
554 else:
555 summary["failed"].append({"model": model, "status": response.status_code})
556 except Exception as exc:
557 summary["failed"].append({"model": model, "error": str(exc)})
558 return summary
559
560
561 def _post_device_code(domain: str) -> dict[str, Any]:
562 import requests
563
564 response = requests.post(
565 github_urls(domain)["device_code"],
566 headers={
567 "Accept": "application/json",
568 "Content-Type": "application/x-www-form-urlencoded",
569 "User-Agent": COPILOT_HEADERS["User-Agent"],
570 },
571 data={"client_id": CLIENT_ID, "scope": "read:user"},
572 timeout=30,
573 )
574 payload = _json_payload(response)
575 if not response.ok:
576 raise RuntimeError(str(payload.get("error_description") or payload.get("error") or "GitHub device-code request failed."))
577 return payload
578
579
580 def _post_device_poll(domain: str, device_code: str) -> dict[str, Any]:
581 import requests
582
583 response = requests.post(
584 github_urls(domain)["access_token"],
585 headers={
586 "Accept": "application/json",
587 "Content-Type": "application/x-www-form-urlencoded",
588 "User-Agent": COPILOT_HEADERS["User-Agent"],
589 },
590 data={
591 "client_id": CLIENT_ID,
592 "device_code": device_code,
593 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
594 },
595 timeout=30,
596 )
597 payload = _json_payload(response)
598 if response.ok or payload.get("error"):
599 return payload
600 raise RuntimeError("GitHub device poll request failed.")
601
602
603 def _json_payload(response: Any) -> dict[str, Any]:
604 try:
605 payload = response.json()
606 except Exception:
607 payload = {}
608 if not isinstance(payload, dict):
609 return {}
610 return payload
611
612
613 def _models_from_payload(payload: Any) -> list[str]:
614 values: list[Any]
615 if isinstance(payload, dict) and isinstance(payload.get("data"), list):
616 values = payload["data"]
617 elif isinstance(payload, dict) and isinstance(payload.get("models"), list):
618 values = payload["models"]
619 elif isinstance(payload, list):
620 values = payload
621 else:
622 return []
623
624 models: list[str] = []
625 seen: set[str] = set()
626 for value in values:
627 model_id = ""
628 if isinstance(value, str):
629 model_id = value
630 elif isinstance(value, dict):
631 model_id = str(value.get("id") or value.get("name") or "")
632 model_id = model_id.strip()
633 if model_id and model_id not in seen:
634 seen.add(model_id)
635 models.append(model_id)
636 return models
637
638
639 def _expires_ms(payload: dict[str, Any]) -> int:
640 raw = payload.get("expires_at")
641 if raw is None and payload.get("expires_in") is not None:
642 return int((time.time() + float(payload["expires_in"])) * 1000)
643 try:
644 value = float(raw)
645 except (TypeError, ValueError):
646 return 0
647 if value < 1_000_000_000_000:
648 value *= 1000
649 return int(value)
650
651
652 def _as_positive_int(value: Any, default: int) -> int:
653 try:
654 parsed = int(value)
655 except (TypeError, ValueError):
656 return default
657 return parsed if parsed > 0 else default
658
659
660 def _as_int(value: Any, default: int) -> int:
661 try:
662 return int(value)
663 except (TypeError, ValueError):
664 return default