langchain-githubcopilot-chat 0.4.0__tar.gz → 0.5.1__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.4.0
3
+ Version: 0.5.1
4
4
  Summary: An integration package connecting GithubcopilotChat and LangChain
5
5
  Home-page: https://github.com/langchain-ai/langchain
6
6
  License: MIT
@@ -4,12 +4,16 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import json
7
+ import logging
7
8
  import os
9
+ import threading
8
10
  import time
9
11
  from typing import Callable, Dict, Optional, Tuple, Union
10
12
 
11
13
  import httpx
12
14
 
15
+ logger = logging.getLogger(__name__)
16
+
13
17
  CLIENT_ID = "Iv1.b507a08c87ecfe98"
14
18
  CACHE_PATH = os.path.expanduser("~/.github-copilot-chat.json")
15
19
 
@@ -31,7 +35,7 @@ COPILOT_DEFAULT_HEADERS = {
31
35
 
32
36
  # In-memory lock for token refresh to prevent concurrent refresh attempts
33
37
  _token_refresh_lock: Optional[asyncio.Lock] = None
34
- _sync_token_refresh_lock: bool = False
38
+ _sync_token_refresh_lock: threading.Lock = threading.Lock()
35
39
 
36
40
 
37
41
  def _get_token_refresh_lock() -> asyncio.Lock:
@@ -59,8 +63,8 @@ def save_tokens_to_cache(
59
63
  f,
60
64
  indent=2,
61
65
  )
62
- except Exception:
63
- pass
66
+ except OSError as exc:
67
+ logger.warning("Failed to save Copilot token cache to %s: %s", CACHE_PATH, exc)
64
68
 
65
69
 
66
70
  def load_tokens_from_cache() -> Dict[str, str]:
@@ -74,7 +78,12 @@ def load_tokens_from_cache() -> Dict[str, str]:
74
78
  # Token expired, return empty
75
79
  return {}
76
80
  return data
77
- except Exception:
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
+ )
78
87
  return {}
79
88
 
80
89
 
@@ -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,
@@ -46,17 +50,19 @@ from pydantic import Field, PrivateAttr, SecretStr, model_validator
46
50
 
47
51
  from langchain_githubcopilot_chat.auth import (
48
52
  COPILOT_DEFAULT_HEADERS,
49
- COPILOT_EDITOR_VERSION,
50
- COPILOT_INTEGRATION_ID,
51
- COPILOT_PLUGIN_VERSION,
52
- COPILOT_USER_AGENT,
53
53
  _get_token_refresh_lock,
54
+ _sync_token_refresh_lock,
54
55
  afetch_copilot_token,
55
56
  fetch_copilot_token,
56
57
  load_tokens_from_cache,
57
58
  save_tokens_to_cache,
58
59
  )
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
65
+
60
66
  # ---------------------------------------------------------------------------
61
67
  # Helpers
62
68
  # ---------------------------------------------------------------------------
@@ -204,37 +210,11 @@ def _build_ai_message(
204
210
 
205
211
  usage_metadata: Optional[UsageMetadata] = None
206
212
  if usage:
207
- input_token_details: Dict[str, Any] = {}
208
- if "prompt_tokens_details" in usage:
209
- if "cached_tokens" in usage["prompt_tokens_details"]:
210
- input_token_details["cache_read"] = usage["prompt_tokens_details"][
211
- "cached_tokens"
212
- ]
213
-
214
- output_token_details: Dict[str, Any] = {}
215
- if "reasoning_tokens" in usage:
216
- output_token_details["reasoning"] = usage["reasoning_tokens"]
217
- if "completion_tokens_details" in usage:
218
- if "accepted_prediction_tokens" in usage["completion_tokens_details"]:
219
- output_token_details["accepted_prediction"] = usage[
220
- "completion_tokens_details"
221
- ]["accepted_prediction_tokens"]
222
- if "rejected_prediction_tokens" in usage["completion_tokens_details"]:
223
- output_token_details["rejected_prediction"] = usage[
224
- "completion_tokens_details"
225
- ]["rejected_prediction_tokens"]
226
-
227
- kwargs = {
228
- "input_tokens": usage.get("prompt_tokens", 0),
229
- "output_tokens": usage.get("completion_tokens", 0),
230
- "total_tokens": usage.get("total_tokens", 0),
231
- }
232
- if input_token_details:
233
- kwargs["input_token_details"] = input_token_details
234
- if output_token_details:
235
- kwargs["output_token_details"] = output_token_details
236
-
237
- usage_metadata = UsageMetadata(**kwargs)
213
+ usage_metadata = UsageMetadata(
214
+ input_tokens=usage.get("prompt_tokens", 0),
215
+ output_tokens=usage.get("completion_tokens", 0),
216
+ total_tokens=usage.get("total_tokens", 0),
217
+ )
238
218
 
239
219
  response_metadata: Dict[str, Any] = {
240
220
  "finish_reason": finish_reason,
@@ -480,6 +460,7 @@ class ChatGithubCopilot(BaseChatModel):
480
460
  # ------------------------------------------------------------------
481
461
 
482
462
  _cached_copilot_token: Optional[str] = PrivateAttr(default=None)
463
+ _cached_copilot_token_expires_at: Optional[float] = PrivateAttr(default=None)
483
464
 
484
465
  @model_validator(mode="before")
485
466
  @classmethod
@@ -510,8 +491,19 @@ class ChatGithubCopilot(BaseChatModel):
510
491
  @property
511
492
  def _token(self) -> str:
512
493
  """Return the raw GitHub token string."""
513
- if self._cached_copilot_token:
514
- return self._cached_copilot_token
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
515
507
 
516
508
  token = None
517
509
  if self.github_token:
@@ -522,6 +514,10 @@ class ChatGithubCopilot(BaseChatModel):
522
514
  tokens = load_tokens_from_cache()
523
515
  if "copilot_token" in tokens:
524
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
+ )
525
521
  return tokens["copilot_token"]
526
522
  elif "github_token" in tokens:
527
523
  token = tokens["github_token"]
@@ -533,20 +529,28 @@ class ChatGithubCopilot(BaseChatModel):
533
529
  "to authenticate."
534
530
  )
535
531
 
536
- # If the token is a standard GitHub token, exchange it
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.
537
535
  if token.startswith(("gho_", "ghp_", "ghu_")):
538
- self._refresh_token_sync(token)
539
- if self._cached_copilot_token:
540
- return self._cached_copilot_token
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
+ )
541
547
 
542
548
  return token
543
549
 
544
550
  def _refresh_token_sync(self, github_token: Optional[str] = None) -> None:
545
- # Use lock to prevent concurrent token refresh
546
- global _sync_token_refresh_lock
547
- if _sync_token_refresh_lock:
551
+ # Non-blocking acquire: if another thread is already refreshing, skip
552
+ if not _sync_token_refresh_lock.acquire(blocking=False):
548
553
  return
549
- _sync_token_refresh_lock = True
550
554
  try:
551
555
  token_to_use = github_token or (
552
556
  self.github_token.get_secret_value() if self.github_token else None
@@ -559,9 +563,10 @@ class ChatGithubCopilot(BaseChatModel):
559
563
  new_token, expires_at = fetch_copilot_token(token_to_use)
560
564
  if new_token:
561
565
  self._cached_copilot_token = new_token
566
+ self._cached_copilot_token_expires_at = expires_at
562
567
  save_tokens_to_cache(token_to_use, new_token, expires_at)
563
568
  finally:
564
- _sync_token_refresh_lock = False
569
+ _sync_token_refresh_lock.release()
565
570
 
566
571
  async def _refresh_token_async(self, github_token: Optional[str] = None) -> None:
567
572
  lock = _get_token_refresh_lock()
@@ -577,6 +582,7 @@ class ChatGithubCopilot(BaseChatModel):
577
582
  new_token, expires_at = await afetch_copilot_token(token_to_use)
578
583
  if new_token:
579
584
  self._cached_copilot_token = new_token
585
+ self._cached_copilot_token_expires_at = expires_at
580
586
  save_tokens_to_cache(token_to_use, new_token, expires_at)
581
587
 
582
588
  @property
@@ -673,8 +679,6 @@ class ChatGithubCopilot(BaseChatModel):
673
679
 
674
680
  def _do_request(self, payload: Dict[str, Any]) -> Dict[str, Any]:
675
681
  """Perform a synchronous (non-streaming) HTTP POST with retries."""
676
- import time
677
-
678
682
  headers = self._build_headers()
679
683
  last_exc: Optional[Exception] = None
680
684
  for attempt in range(self.max_retries + 1):
@@ -710,35 +714,56 @@ class ChatGithubCopilot(BaseChatModel):
710
714
  if attempt == self.max_retries:
711
715
  raise
712
716
  if attempt < self.max_retries:
713
- time.sleep(2**attempt)
717
+ backoff = 2**attempt
718
+ time.sleep(backoff + random.uniform(0, backoff * 0.25))
714
719
  raise RuntimeError("Unexpected retry loop exit") from last_exc
715
720
 
716
721
  def _do_stream(self, payload: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
717
722
  """Perform a synchronous streaming HTTP POST and yield parsed SSE chunks."""
718
723
  headers = self._build_headers()
719
- with httpx.stream(
720
- "POST",
721
- self._inference_url,
722
- headers=headers,
723
- json=payload,
724
- timeout=self.timeout,
725
- ) as response:
726
- response.raise_for_status()
727
- for line in response.iter_lines():
728
- line = line.strip()
729
- if not line or line == "data: [DONE]":
730
- continue
731
- if line.startswith("data: "):
732
- line = line[len("data: ") :]
733
- try:
734
- yield json.loads(line)
735
- except json.JSONDecodeError:
736
- continue
724
+ last_exc: Optional[Exception] = None
725
+ for attempt in range(self.max_retries + 1):
726
+ try:
727
+ with httpx.stream(
728
+ "POST",
729
+ self._inference_url,
730
+ headers=headers,
731
+ json=payload,
732
+ timeout=self.timeout,
733
+ ) as response:
734
+ if response.status_code == 401:
735
+ self._refresh_token_sync()
736
+ headers = self._build_headers()
737
+ raise httpx.TransportError("401 — token refreshed, retrying")
738
+ response.raise_for_status()
739
+ for line in response.iter_lines():
740
+ line = line.strip()
741
+ if not line or line == "data: [DONE]":
742
+ continue
743
+ if line.startswith("data: "):
744
+ line = line[len("data: ") :]
745
+ try:
746
+ yield json.loads(line)
747
+ except json.JSONDecodeError:
748
+ continue
749
+ return
750
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
751
+ last_exc = exc
752
+ if attempt == self.max_retries:
753
+ raise
754
+ except httpx.HTTPStatusError as exc:
755
+ if exc.response.status_code < 500:
756
+ raise
757
+ last_exc = exc
758
+ if attempt == self.max_retries:
759
+ raise
760
+ if attempt < self.max_retries:
761
+ backoff = 2**attempt
762
+ time.sleep(backoff + random.uniform(0, backoff * 0.25))
763
+ raise RuntimeError("Unexpected retry loop exit") from last_exc
737
764
 
738
765
  async def _do_request_async(self, payload: Dict[str, Any]) -> Dict[str, Any]:
739
766
  """Perform an asynchronous (non-streaming) HTTP POST with retries."""
740
- import asyncio
741
-
742
767
  headers = self._build_headers()
743
768
  last_exc: Optional[Exception] = None
744
769
  async with httpx.AsyncClient(timeout=self.timeout) as client:
@@ -772,7 +797,8 @@ class ChatGithubCopilot(BaseChatModel):
772
797
  if attempt == self.max_retries:
773
798
  raise
774
799
  if attempt < self.max_retries:
775
- await asyncio.sleep(2**attempt)
800
+ backoff = 2**attempt
801
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
776
802
  raise RuntimeError("Unexpected retry loop exit") from last_exc
777
803
 
778
804
  async def _do_stream_async(
@@ -780,24 +806,48 @@ class ChatGithubCopilot(BaseChatModel):
780
806
  ) -> AsyncIterator[Dict[str, Any]]:
781
807
  """Perform an asynchronous streaming HTTP POST and yield parsed SSE chunks."""
782
808
  headers = self._build_headers()
783
- async with httpx.AsyncClient(timeout=self.timeout) as client:
784
- async with client.stream(
785
- "POST",
786
- self._inference_url,
787
- headers=headers,
788
- json=payload,
789
- ) as response:
790
- response.raise_for_status()
791
- async for line in response.aiter_lines():
792
- line = line.strip()
793
- if not line or line == "data: [DONE]":
794
- continue
795
- if line.startswith("data: "):
796
- line = line[len("data: ") :]
797
- try:
798
- yield json.loads(line)
799
- except json.JSONDecodeError:
800
- continue
809
+ last_exc: Optional[Exception] = None
810
+ for attempt in range(self.max_retries + 1):
811
+ try:
812
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
813
+ async with client.stream(
814
+ "POST",
815
+ self._inference_url,
816
+ headers=headers,
817
+ json=payload,
818
+ ) as response:
819
+ if response.status_code == 401:
820
+ await self._refresh_token_async()
821
+ headers = self._build_headers()
822
+ raise httpx.TransportError(
823
+ "401 — token refreshed, retrying"
824
+ )
825
+ response.raise_for_status()
826
+ async for line in response.aiter_lines():
827
+ line = line.strip()
828
+ if not line or line == "data: [DONE]":
829
+ continue
830
+ if line.startswith("data: "):
831
+ line = line[len("data: ") :]
832
+ try:
833
+ yield json.loads(line)
834
+ except json.JSONDecodeError:
835
+ continue
836
+ return
837
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
838
+ last_exc = exc
839
+ if attempt == self.max_retries:
840
+ raise
841
+ except httpx.HTTPStatusError as exc:
842
+ if exc.response.status_code < 500:
843
+ raise
844
+ last_exc = exc
845
+ if attempt == self.max_retries:
846
+ raise
847
+ if attempt < self.max_retries:
848
+ backoff = 2**attempt
849
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
850
+ raise RuntimeError("Unexpected retry loop exit") from last_exc
801
851
 
802
852
  # ------------------------------------------------------------------
803
853
  # Stream delta → AIMessageChunk helpers
@@ -850,6 +900,21 @@ class ChatGithubCopilot(BaseChatModel):
850
900
  usage_metadata=usage_metadata,
851
901
  )
852
902
 
903
+ @staticmethod
904
+ def _make_usage_chunk(usage: Dict[str, Any]) -> ChatGenerationChunk:
905
+ """Build a usage-only final ``ChatGenerationChunk`` from a usage dict."""
906
+ return ChatGenerationChunk(
907
+ message=AIMessageChunk(
908
+ content="",
909
+ usage_metadata=UsageMetadata(
910
+ input_tokens=usage.get("prompt_tokens", 0),
911
+ output_tokens=usage.get("completion_tokens", 0),
912
+ total_tokens=usage.get("total_tokens", 0),
913
+ ),
914
+ response_metadata={"usage": usage},
915
+ )
916
+ )
917
+
853
918
  # ------------------------------------------------------------------
854
919
  # LangChain BaseChatModel interface
855
920
  # ------------------------------------------------------------------
@@ -924,17 +989,7 @@ class ChatGithubCopilot(BaseChatModel):
924
989
 
925
990
  if not choices and usage:
926
991
  # Final usage-only chunk
927
- chunk = ChatGenerationChunk(
928
- message=AIMessageChunk(
929
- content="",
930
- usage_metadata=UsageMetadata(
931
- input_tokens=usage.get("prompt_tokens", 0),
932
- output_tokens=usage.get("completion_tokens", 0),
933
- total_tokens=usage.get("total_tokens", 0),
934
- ),
935
- response_metadata={"usage": usage},
936
- )
937
- )
992
+ chunk = self._make_usage_chunk(usage)
938
993
  if run_manager:
939
994
  run_manager.on_llm_new_token("", chunk=chunk)
940
995
  yield chunk
@@ -990,17 +1045,7 @@ class ChatGithubCopilot(BaseChatModel):
990
1045
  usage = raw_chunk.get("usage")
991
1046
 
992
1047
  if not choices and usage:
993
- chunk = ChatGenerationChunk(
994
- message=AIMessageChunk(
995
- content="",
996
- usage_metadata=UsageMetadata(
997
- input_tokens=usage.get("prompt_tokens", 0),
998
- output_tokens=usage.get("completion_tokens", 0),
999
- total_tokens=usage.get("total_tokens", 0),
1000
- ),
1001
- response_metadata={"usage": usage},
1002
- )
1003
- )
1048
+ chunk = self._make_usage_chunk(usage)
1004
1049
  if run_manager:
1005
1050
  await run_manager.on_llm_new_token("", chunk=chunk)
1006
1051
  yield chunk
@@ -2,7 +2,10 @@
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
@@ -211,6 +214,9 @@ class GithubcopilotChatEmbeddings(BaseModel, Embeddings):
211
214
  last_exc = exc
212
215
  if attempt == self.max_retries:
213
216
  raise
217
+ if attempt < self.max_retries:
218
+ backoff = 2**attempt
219
+ time.sleep(backoff + random.uniform(0, backoff * 0.25))
214
220
  raise RuntimeError("Unexpected retry loop exit") from last_exc
215
221
 
216
222
  async def _do_request_async(self, payload: Dict[str, Any]) -> Dict[str, Any]:
@@ -237,6 +243,9 @@ class GithubcopilotChatEmbeddings(BaseModel, Embeddings):
237
243
  last_exc = exc
238
244
  if attempt == self.max_retries:
239
245
  raise
246
+ if attempt < self.max_retries:
247
+ backoff = 2**attempt
248
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
240
249
  raise RuntimeError("Unexpected retry loop exit") from last_exc
241
250
 
242
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.4.0"
7
+ version = "0.5.1"
8
8
  description = "An integration package connecting GithubcopilotChat and LangChain"
9
9
  authors = ["YIhan Wu <iumm@ibat.ac.cn>"]
10
10
  readme = "README.md"