langchain-githubcopilot-chat 0.3.0__tar.gz → 0.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langchain-githubcopilot-chat
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: An integration package connecting GithubcopilotChat and LangChain
5
5
  Home-page: https://github.com/langchain-ai/langchain
6
6
  License: MIT
@@ -0,0 +1,225 @@
1
+ """Authentication utilities for GitHub Copilot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import threading
10
+ import time
11
+ from typing import Callable, Dict, Optional, Tuple, Union
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ CLIENT_ID = "Iv1.b507a08c87ecfe98"
18
+ CACHE_PATH = os.path.expanduser("~/.github-copilot-chat.json")
19
+
20
+ # Shared Copilot headers
21
+ COPILOT_EDITOR_VERSION = "vscode/1.104.1"
22
+ COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"
23
+ COPILOT_INTEGRATION_ID = "vscode-chat"
24
+ COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"
25
+
26
+ COPILOT_DEFAULT_HEADERS = {
27
+ "Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
28
+ "User-Agent": COPILOT_USER_AGENT,
29
+ "Editor-Version": COPILOT_EDITOR_VERSION,
30
+ "Editor-Plugin-Version": COPILOT_PLUGIN_VERSION,
31
+ "editor-version": COPILOT_EDITOR_VERSION,
32
+ "editor-plugin-version": COPILOT_PLUGIN_VERSION,
33
+ "copilot-vision-request": "true",
34
+ }
35
+
36
+ # In-memory lock for token refresh to prevent concurrent refresh attempts
37
+ _token_refresh_lock: Optional[asyncio.Lock] = None
38
+ _sync_token_refresh_lock: threading.Lock = threading.Lock()
39
+
40
+
41
+ def _get_token_refresh_lock() -> asyncio.Lock:
42
+ """Get or create the async token refresh lock."""
43
+ global _token_refresh_lock
44
+ if _token_refresh_lock is None:
45
+ _token_refresh_lock = asyncio.Lock()
46
+ return _token_refresh_lock
47
+
48
+
49
+ def save_tokens_to_cache(
50
+ github_token: str,
51
+ copilot_token: str,
52
+ expires_at: Optional[float] = None,
53
+ ) -> None:
54
+ """Save tokens to cache with optional expiration time."""
55
+ try:
56
+ with open(CACHE_PATH, "w") as f:
57
+ json.dump(
58
+ {
59
+ "github_token": github_token,
60
+ "copilot_token": copilot_token,
61
+ "expires_at": expires_at,
62
+ },
63
+ f,
64
+ indent=2,
65
+ )
66
+ except OSError as exc:
67
+ logger.warning("Failed to save Copilot token cache to %s: %s", CACHE_PATH, exc)
68
+
69
+
70
+ def load_tokens_from_cache() -> Dict[str, str]:
71
+ """Load tokens from cache, checking expiration if present."""
72
+ try:
73
+ with open(CACHE_PATH, "r") as f:
74
+ data = json.load(f)
75
+ # Check if token has expired
76
+ if data.get("expires_at"):
77
+ if time.time() > data["expires_at"]:
78
+ # Token expired, return empty
79
+ return {}
80
+ return data
81
+ except FileNotFoundError:
82
+ return {} # cache doesn't exist yet — silently OK
83
+ except (OSError, json.JSONDecodeError, KeyError, ValueError) as exc:
84
+ logger.warning(
85
+ "Failed to load Copilot token cache from %s: %s", CACHE_PATH, exc
86
+ )
87
+ return {}
88
+
89
+
90
+ def fetch_copilot_token(github_token: str) -> Tuple[Optional[str], Optional[float]]:
91
+ """Fetch copilot token and return it with expiration time.
92
+
93
+ Returns:
94
+ Tuple of (token, expires_at_timestamp). expires_at is None if not provided.
95
+ """
96
+ headers = {
97
+ "Authorization": f"token {github_token}",
98
+ "Accept": "application/json",
99
+ **COPILOT_DEFAULT_HEADERS,
100
+ }
101
+ with httpx.Client() as client:
102
+ res = client.get(
103
+ "https://api.github.com/copilot_internal/v2/token",
104
+ headers=headers,
105
+ )
106
+ if res.status_code == 200:
107
+ data = res.json()
108
+ token = data.get("token")
109
+ # Copilot tokens typically expire in a few hours
110
+ # The API may return 'expires_at' as a Unix timestamp
111
+ expires_at = data.get("expires_at")
112
+ return token, expires_at
113
+ return None, None
114
+
115
+
116
+ async def afetch_copilot_token(
117
+ github_token: str,
118
+ ) -> Tuple[Optional[str], Optional[float]]:
119
+ """Async fetch copilot token and return it with expiration time.
120
+
121
+ Returns:
122
+ Tuple of (token, expires_at_timestamp). expires_at is None if not provided.
123
+ """
124
+ headers = {
125
+ "Authorization": f"token {github_token}",
126
+ "Accept": "application/json",
127
+ **COPILOT_DEFAULT_HEADERS,
128
+ }
129
+ async with httpx.AsyncClient() as client:
130
+ res = await client.get(
131
+ "https://api.github.com/copilot_internal/v2/token",
132
+ headers=headers,
133
+ )
134
+ if res.status_code == 200:
135
+ data = res.json()
136
+ token = data.get("token")
137
+ expires_at = data.get("expires_at")
138
+ return token, expires_at
139
+ return None, None
140
+
141
+
142
+ def get_copilot_token(
143
+ client_id: str = CLIENT_ID,
144
+ callback: Optional[Callable[[str], None]] = None,
145
+ return_both: bool = False,
146
+ ) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:
147
+ """
148
+ Authenticate via GitHub Device Flow to get a Copilot Token.
149
+ This function will block and wait for the user to complete the
150
+ authorization in their browser.
151
+
152
+ Args:
153
+ client_id: The GitHub OAuth App Client ID to use. Defaults
154
+ to the VS Code Copilot Chat client ID.
155
+ callback: Optional callable that receives status messages instead of
156
+ printing them. If None, messages are printed to stdout.
157
+
158
+ Returns:
159
+ The fetched Copilot Token string, or None if authentication failed.
160
+ """
161
+
162
+ def _print(msg: str) -> None:
163
+ if callback:
164
+ callback(msg)
165
+ else:
166
+ print(msg) # noqa: T201
167
+
168
+ _print("1. Requesting device code from GitHub...")
169
+ with httpx.Client() as client:
170
+ res = client.post(
171
+ "https://github.com/login/device/code",
172
+ headers={"Accept": "application/json"},
173
+ data={"client_id": client_id, "scope": "read:user"},
174
+ )
175
+ res.raise_for_status()
176
+ data = res.json()
177
+
178
+ device_code = data.get("device_code")
179
+ user_code = data.get("user_code")
180
+ verification_uri = data.get("verification_uri")
181
+ interval = data.get("interval", 5)
182
+
183
+ _print("\n==========================================")
184
+ _print(f"Please open your browser to: {verification_uri}")
185
+ _print(f"And enter the authorization code: {user_code}")
186
+ _print("==========================================\n")
187
+ _print(f"Waiting for authorization (checking every {interval} seconds)...")
188
+
189
+ access_token = None
190
+ with httpx.Client() as client:
191
+ while True:
192
+ token_res = client.post(
193
+ "https://github.com/login/oauth/access_token",
194
+ headers={"Accept": "application/json"},
195
+ data={
196
+ "client_id": client_id,
197
+ "device_code": device_code,
198
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
199
+ },
200
+ ).json()
201
+
202
+ if "access_token" in token_res:
203
+ access_token = token_res["access_token"]
204
+ _print("\n✅ Authorization successful! Exchanging for Copilot Token...")
205
+ break
206
+ elif token_res.get("error") == "authorization_pending":
207
+ time.sleep(interval)
208
+ else:
209
+ _print(f"\n❌ Authorization failed: {token_res}")
210
+ return None
211
+
212
+ # Exchange the standard access token for a Copilot internal token
213
+ copilot_token, expires_at = fetch_copilot_token(access_token)
214
+
215
+ if copilot_token:
216
+ save_tokens_to_cache(access_token, copilot_token, expires_at)
217
+ _print("🎉 Successfully acquired Copilot Token!")
218
+ if return_both:
219
+ return access_token, copilot_token
220
+ return copilot_token
221
+ else:
222
+ _print("❌ Failed to acquire Copilot Token!")
223
+ if return_both:
224
+ return access_token, None
225
+ return None
@@ -2,8 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
7
+ import logging
6
8
  import os
9
+ import random
10
+ import time
7
11
  from typing import (
8
12
  Any,
9
13
  AsyncIterator,
@@ -42,7 +46,22 @@ from langchain_core.output_parsers.openai_tools import (
42
46
  from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
43
47
  from langchain_core.tools import BaseTool
44
48
  from langchain_core.utils.function_calling import convert_to_openai_tool
45
- from pydantic import Field, SecretStr, model_validator
49
+ from pydantic import Field, PrivateAttr, SecretStr, model_validator
50
+
51
+ from langchain_githubcopilot_chat.auth import (
52
+ COPILOT_DEFAULT_HEADERS,
53
+ _get_token_refresh_lock,
54
+ _sync_token_refresh_lock,
55
+ afetch_copilot_token,
56
+ fetch_copilot_token,
57
+ load_tokens_from_cache,
58
+ save_tokens_to_cache,
59
+ )
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Buffer (seconds) before token expiry to trigger a proactive refresh
64
+ _TOKEN_REFRESH_BUFFER_SECS: int = 60
46
65
 
47
66
  # ---------------------------------------------------------------------------
48
67
  # Helpers
@@ -59,21 +78,6 @@ _ROLE_MAP = {
59
78
  _GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com"
60
79
  _INFERENCE_PATH = "/chat/completions"
61
80
 
62
- COPILOT_EDITOR_VERSION = "vscode/1.104.1"
63
- COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"
64
- COPILOT_INTEGRATION_ID = "vscode-chat"
65
- COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"
66
-
67
- COPILOT_DEFAULT_HEADERS = {
68
- "Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
69
- "User-Agent": COPILOT_USER_AGENT,
70
- "Editor-Version": COPILOT_EDITOR_VERSION,
71
- "Editor-Plugin-Version": COPILOT_PLUGIN_VERSION,
72
- "editor-version": COPILOT_EDITOR_VERSION,
73
- "editor-plugin-version": COPILOT_PLUGIN_VERSION,
74
- "copilot-vision-request": "true",
75
- }
76
-
77
81
 
78
82
  def _message_to_dict(message: BaseMessage) -> Dict[str, Any]:
79
83
  """Convert a LangChain message to the GitHub Models API message format."""
@@ -455,21 +459,29 @@ class ChatGithubCopilot(BaseChatModel):
455
459
  # Validators / setup
456
460
  # ------------------------------------------------------------------
457
461
 
462
+ _cached_copilot_token: Optional[str] = PrivateAttr(default=None)
463
+ _cached_copilot_token_expires_at: Optional[float] = PrivateAttr(default=None)
464
+
458
465
  @model_validator(mode="before")
459
466
  @classmethod
460
467
  def _validate_token(cls, values: Dict[str, Any]) -> Dict[str, Any]:
461
- """Resolve the GitHub token from the environment if not supplied.
468
+ """Resolve the GitHub token from the environment or cache if not supplied.
462
469
 
463
470
  Priority order:
464
471
  1. Explicitly passed ``github_token``
465
472
  2. Explicitly passed ``api_key`` alias
466
473
  3. ``GITHUB_TOKEN`` environment variable
474
+ 4. ``~/.github-copilot-chat.json`` cache file
467
475
  """
468
476
  token = values.get("github_token") or values.get("api_key")
469
477
  if not token:
470
478
  token = os.environ.get("GITHUB_TOKEN")
471
479
  if token:
472
480
  values["github_token"] = token
481
+ else:
482
+ tokens = load_tokens_from_cache()
483
+ if "github_token" in tokens:
484
+ values["github_token"] = tokens["github_token"]
473
485
  return values
474
486
 
475
487
  # ------------------------------------------------------------------
@@ -479,16 +491,99 @@ class ChatGithubCopilot(BaseChatModel):
479
491
  @property
480
492
  def _token(self) -> str:
481
493
  """Return the raw GitHub token string."""
494
+ # Use getattr to avoid triggering Pydantic's __getattr__ on PrivateAttr
495
+ # when instance is created via __new__ without proper initialization
496
+ cached = getattr(self, "_cached_copilot_token", None)
497
+ cached_exp = getattr(self, "_cached_copilot_token_expires_at", None)
498
+ if cached:
499
+ expires_ok = cached_exp is None or (
500
+ time.time() < cached_exp - _TOKEN_REFRESH_BUFFER_SECS
501
+ )
502
+ if expires_ok:
503
+ return cached
504
+ # Token is expired or within the refresh buffer — clear and refresh
505
+ self._cached_copilot_token = None
506
+ self._cached_copilot_token_expires_at = None
507
+
508
+ token = None
482
509
  if self.github_token:
483
- return self.github_token.get_secret_value()
484
- env_token = os.environ.get("GITHUB_TOKEN", "")
485
- if not env_token:
510
+ token = self.github_token.get_secret_value()
511
+ elif os.environ.get("GITHUB_TOKEN"):
512
+ token = os.environ.get("GITHUB_TOKEN")
513
+ else:
514
+ tokens = load_tokens_from_cache()
515
+ if "copilot_token" in tokens:
516
+ self._cached_copilot_token = tokens["copilot_token"]
517
+ raw_exp = tokens.get("expires_at")
518
+ self._cached_copilot_token_expires_at = (
519
+ float(raw_exp) if raw_exp is not None else None
520
+ )
521
+ return tokens["copilot_token"]
522
+ elif "github_token" in tokens:
523
+ token = tokens["github_token"]
524
+
525
+ if not token:
486
526
  raise ValueError(
487
- "A GitHub token is required. Set the GITHUB_TOKEN environment "
488
- "variable or pass ``github_token`` when instantiating "
489
- "ChatGithubCopilot."
527
+ "A GitHub token is required. Set the GITHUB_TOKEN environment "
528
+ "variable, pass ``github_token``, or run ``get_copilot_token()`` "
529
+ "to authenticate."
530
+ )
531
+
532
+ # If the token is a standard GitHub token, try to exchange it
533
+ # for a Copilot token. This may fail in environments without
534
+ # network access (e.g., CI), so we catch exceptions.
535
+ if token.startswith(("gho_", "ghp_", "ghu_")):
536
+ try:
537
+ self._refresh_token_sync(token)
538
+ cached = getattr(self, "_cached_copilot_token", None)
539
+ if cached:
540
+ return cached
541
+ except Exception as exc:
542
+ # Network unavailable, socket blocked, or other transient error.
543
+ # Fall back to using the raw GitHub token directly.
544
+ logger.debug(
545
+ "Token exchange failed (will use raw GitHub token): %s", exc
546
+ )
547
+
548
+ return token
549
+
550
+ def _refresh_token_sync(self, github_token: Optional[str] = None) -> None:
551
+ # Non-blocking acquire: if another thread is already refreshing, skip
552
+ if not _sync_token_refresh_lock.acquire(blocking=False):
553
+ return
554
+ try:
555
+ token_to_use = github_token or (
556
+ self.github_token.get_secret_value() if self.github_token else None
557
+ )
558
+ if not token_to_use:
559
+ tokens = load_tokens_from_cache()
560
+ token_to_use = tokens.get("github_token")
561
+
562
+ if token_to_use:
563
+ new_token, expires_at = fetch_copilot_token(token_to_use)
564
+ if new_token:
565
+ self._cached_copilot_token = new_token
566
+ self._cached_copilot_token_expires_at = expires_at
567
+ save_tokens_to_cache(token_to_use, new_token, expires_at)
568
+ finally:
569
+ _sync_token_refresh_lock.release()
570
+
571
+ async def _refresh_token_async(self, github_token: Optional[str] = None) -> None:
572
+ lock = _get_token_refresh_lock()
573
+ async with lock:
574
+ token_to_use = github_token or (
575
+ self.github_token.get_secret_value() if self.github_token else None
490
576
  )
491
- return env_token
577
+ if not token_to_use:
578
+ tokens = load_tokens_from_cache()
579
+ token_to_use = tokens.get("github_token")
580
+
581
+ if token_to_use:
582
+ new_token, expires_at = await afetch_copilot_token(token_to_use)
583
+ if new_token:
584
+ self._cached_copilot_token = new_token
585
+ self._cached_copilot_token_expires_at = expires_at
586
+ save_tokens_to_cache(token_to_use, new_token, expires_at)
492
587
 
493
588
  @property
494
589
  def _inference_url(self) -> str:
@@ -594,6 +689,18 @@ class ChatGithubCopilot(BaseChatModel):
594
689
  json=payload,
595
690
  timeout=self.timeout,
596
691
  )
692
+
693
+ # Handle 401 Unauthorized for token refresh
694
+ if response.status_code == 401:
695
+ self._refresh_token_sync()
696
+ headers = self._build_headers()
697
+ response = httpx.post(
698
+ self._inference_url,
699
+ headers=headers,
700
+ json=payload,
701
+ timeout=self.timeout,
702
+ )
703
+
597
704
  response.raise_for_status()
598
705
  return response.json()
599
706
  except (httpx.TimeoutException, httpx.TransportError) as exc:
@@ -601,12 +708,14 @@ class ChatGithubCopilot(BaseChatModel):
601
708
  if attempt == self.max_retries:
602
709
  raise
603
710
  except httpx.HTTPStatusError as exc:
604
- # Don't retry on 4xx client errors
605
711
  if exc.response.status_code < 500:
606
712
  raise
607
713
  last_exc = exc
608
714
  if attempt == self.max_retries:
609
715
  raise
716
+ if attempt < self.max_retries:
717
+ backoff = 2**attempt
718
+ time.sleep(backoff + random.uniform(0, backoff * 0.25))
610
719
  raise RuntimeError("Unexpected retry loop exit") from last_exc
611
720
 
612
721
  def _do_stream(self, payload: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
@@ -643,6 +752,16 @@ class ChatGithubCopilot(BaseChatModel):
643
752
  headers=headers,
644
753
  json=payload,
645
754
  )
755
+
756
+ if response.status_code == 401:
757
+ await self._refresh_token_async()
758
+ headers = self._build_headers()
759
+ response = await client.post(
760
+ self._inference_url,
761
+ headers=headers,
762
+ json=payload,
763
+ )
764
+
646
765
  response.raise_for_status()
647
766
  return response.json()
648
767
  except (httpx.TimeoutException, httpx.TransportError) as exc:
@@ -655,6 +774,9 @@ class ChatGithubCopilot(BaseChatModel):
655
774
  last_exc = exc
656
775
  if attempt == self.max_retries:
657
776
  raise
777
+ if attempt < self.max_retries:
778
+ backoff = 2**attempt
779
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
658
780
  raise RuntimeError("Unexpected retry loop exit") from last_exc
659
781
 
660
782
  async def _do_stream_async(
@@ -732,6 +854,21 @@ class ChatGithubCopilot(BaseChatModel):
732
854
  usage_metadata=usage_metadata,
733
855
  )
734
856
 
857
+ @staticmethod
858
+ def _make_usage_chunk(usage: Dict[str, Any]) -> ChatGenerationChunk:
859
+ """Build a usage-only final ``ChatGenerationChunk`` from a usage dict."""
860
+ return ChatGenerationChunk(
861
+ message=AIMessageChunk(
862
+ content="",
863
+ usage_metadata=UsageMetadata(
864
+ input_tokens=usage.get("prompt_tokens", 0),
865
+ output_tokens=usage.get("completion_tokens", 0),
866
+ total_tokens=usage.get("total_tokens", 0),
867
+ ),
868
+ response_metadata={"usage": usage},
869
+ )
870
+ )
871
+
735
872
  # ------------------------------------------------------------------
736
873
  # LangChain BaseChatModel interface
737
874
  # ------------------------------------------------------------------
@@ -806,17 +943,7 @@ class ChatGithubCopilot(BaseChatModel):
806
943
 
807
944
  if not choices and usage:
808
945
  # Final usage-only chunk
809
- chunk = ChatGenerationChunk(
810
- message=AIMessageChunk(
811
- content="",
812
- usage_metadata=UsageMetadata(
813
- input_tokens=usage.get("prompt_tokens", 0),
814
- output_tokens=usage.get("completion_tokens", 0),
815
- total_tokens=usage.get("total_tokens", 0),
816
- ),
817
- response_metadata={"usage": usage},
818
- )
819
- )
946
+ chunk = self._make_usage_chunk(usage)
820
947
  if run_manager:
821
948
  run_manager.on_llm_new_token("", chunk=chunk)
822
949
  yield chunk
@@ -872,17 +999,7 @@ class ChatGithubCopilot(BaseChatModel):
872
999
  usage = raw_chunk.get("usage")
873
1000
 
874
1001
  if not choices and usage:
875
- chunk = ChatGenerationChunk(
876
- message=AIMessageChunk(
877
- content="",
878
- usage_metadata=UsageMetadata(
879
- input_tokens=usage.get("prompt_tokens", 0),
880
- output_tokens=usage.get("completion_tokens", 0),
881
- total_tokens=usage.get("total_tokens", 0),
882
- ),
883
- response_metadata={"usage": usage},
884
- )
885
- )
1002
+ chunk = self._make_usage_chunk(usage)
886
1003
  if run_manager:
887
1004
  await run_manager.on_llm_new_token("", chunk=chunk)
888
1005
  yield chunk
@@ -2,31 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import os
7
+ import random
8
+ import time
6
9
  from typing import Any, Dict, List, Optional, Union
7
10
 
8
11
  import httpx
9
12
  from langchain_core.embeddings import Embeddings
10
13
  from pydantic import BaseModel, Field, SecretStr, model_validator
11
14
 
15
+ from langchain_githubcopilot_chat.auth import (
16
+ COPILOT_DEFAULT_HEADERS,
17
+ )
18
+
12
19
  _GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com"
13
20
  _EMBEDDINGS_PATH = "/embeddings"
14
21
 
15
- COPILOT_EDITOR_VERSION = "vscode/1.104.1"
16
- COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"
17
- COPILOT_INTEGRATION_ID = "vscode-chat"
18
- COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"
19
-
20
- COPILOT_DEFAULT_HEADERS = {
21
- "Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
22
- "User-Agent": COPILOT_USER_AGENT,
23
- "Editor-Version": COPILOT_EDITOR_VERSION,
24
- "Editor-Plugin-Version": COPILOT_PLUGIN_VERSION,
25
- "editor-version": COPILOT_EDITOR_VERSION,
26
- "editor-plugin-version": COPILOT_PLUGIN_VERSION,
27
- "copilot-vision-request": "true",
28
- }
29
-
30
22
 
31
23
  class GithubcopilotChatEmbeddings(BaseModel, Embeddings):
32
24
  """GitHub Copilot Chat embedding model integration via the GitHub Models API.
@@ -222,6 +214,9 @@ class GithubcopilotChatEmbeddings(BaseModel, Embeddings):
222
214
  last_exc = exc
223
215
  if attempt == self.max_retries:
224
216
  raise
217
+ if attempt < self.max_retries:
218
+ backoff = 2**attempt
219
+ time.sleep(backoff + random.uniform(0, backoff * 0.25))
225
220
  raise RuntimeError("Unexpected retry loop exit") from last_exc
226
221
 
227
222
  async def _do_request_async(self, payload: Dict[str, Any]) -> Dict[str, Any]:
@@ -248,6 +243,9 @@ class GithubcopilotChatEmbeddings(BaseModel, Embeddings):
248
243
  last_exc = exc
249
244
  if attempt == self.max_retries:
250
245
  raise
246
+ if attempt < self.max_retries:
247
+ backoff = 2**attempt
248
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
251
249
  raise RuntimeError("Unexpected retry loop exit") from last_exc
252
250
 
253
251
  @staticmethod
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "langchain-githubcopilot-chat"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "An integration package connecting GithubcopilotChat and LangChain"
9
9
  authors = ["YIhan Wu <iumm@ibat.ac.cn>"]
10
10
  readme = "README.md"
@@ -1,96 +0,0 @@
1
- import time
2
- from typing import Callable, Optional
3
-
4
- import httpx
5
-
6
- CLIENT_ID = "Iv1.b507a08c87ecfe98"
7
-
8
-
9
- def get_copilot_token(
10
- client_id: str = CLIENT_ID, callback: Optional[Callable[[str], None]] = None
11
- ) -> Optional[str]:
12
- """
13
- Authenticate via GitHub Device Flow to get a Copilot Token.
14
- This function will block and wait for the user to complete the
15
- authorization in their browser.
16
-
17
- Args:
18
- client_id: The GitHub OAuth App Client ID to use. Defaults
19
- to the VS Code Copilot Chat client ID.
20
- callback: Optional callable that receives status messages instead of
21
- printing them. If None, messages are printed to stdout.
22
-
23
- Returns:
24
- The fetched Copilot Token string, or None if authentication failed.
25
- """
26
-
27
- def _print(msg: str) -> None:
28
- if callback:
29
- callback(msg)
30
- else:
31
- print(msg) # noqa: T201
32
-
33
- _print("1. Requesting device code from GitHub...")
34
- with httpx.Client() as client:
35
- res = client.post(
36
- "https://github.com/login/device/code",
37
- headers={"Accept": "application/json"},
38
- data={"client_id": client_id, "scope": "read:user"},
39
- )
40
- res.raise_for_status()
41
- data = res.json()
42
-
43
- device_code = data.get("device_code")
44
- user_code = data.get("user_code")
45
- verification_uri = data.get("verification_uri")
46
- interval = data.get("interval", 5)
47
-
48
- _print("\n==========================================")
49
- _print(f"Please open your browser to: {verification_uri}")
50
- _print(f"And enter the authorization code: {user_code}")
51
- _print("==========================================\n")
52
- _print(f"Waiting for authorization (checking every {interval} seconds)...")
53
-
54
- access_token = None
55
- with httpx.Client() as client:
56
- while True:
57
- token_res = client.post(
58
- "https://github.com/login/oauth/access_token",
59
- headers={"Accept": "application/json"},
60
- data={
61
- "client_id": client_id,
62
- "device_code": device_code,
63
- "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
64
- },
65
- ).json()
66
-
67
- if "access_token" in token_res:
68
- access_token = token_res["access_token"]
69
- _print(
70
- "\n✅ Authorization successful! Exchanging for Copilot Token..."
71
- )
72
- break
73
- elif token_res.get("error") == "authorization_pending":
74
- time.sleep(interval)
75
- else:
76
- _print(f"\n❌ Authorization failed: {token_res}")
77
- return None
78
-
79
- # Exchange the standard access token for a Copilot internal token
80
- copilot_res = client.get(
81
- "https://api.github.com/copilot_internal/v2/token",
82
- headers={
83
- "Authorization": f"token {access_token}",
84
- "Accept": "application/json",
85
- "Editor-Version": "vscode/1.104.1",
86
- "Editor-Plugin-Version": "copilot-chat/0.26.7",
87
- },
88
- )
89
-
90
- if copilot_res.status_code == 200:
91
- copilot_token = copilot_res.json().get("token")
92
- _print("🎉 Successfully acquired Copilot Token!")
93
- return copilot_token
94
- else:
95
- _print(f"❌ Failed to acquire Copilot Token: {copilot_res.text}")
96
- return None