langchain-githubcopilot-chat 0.4.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.4.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
@@ -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,7 +714,8 @@ 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]]:
@@ -737,8 +742,6 @@ class ChatGithubCopilot(BaseChatModel):
737
742
 
738
743
  async def _do_request_async(self, payload: Dict[str, Any]) -> Dict[str, Any]:
739
744
  """Perform an asynchronous (non-streaming) HTTP POST with retries."""
740
- import asyncio
741
-
742
745
  headers = self._build_headers()
743
746
  last_exc: Optional[Exception] = None
744
747
  async with httpx.AsyncClient(timeout=self.timeout) as client:
@@ -772,7 +775,8 @@ class ChatGithubCopilot(BaseChatModel):
772
775
  if attempt == self.max_retries:
773
776
  raise
774
777
  if attempt < self.max_retries:
775
- await asyncio.sleep(2**attempt)
778
+ backoff = 2**attempt
779
+ await asyncio.sleep(backoff + random.uniform(0, backoff * 0.25))
776
780
  raise RuntimeError("Unexpected retry loop exit") from last_exc
777
781
 
778
782
  async def _do_stream_async(
@@ -850,6 +854,21 @@ class ChatGithubCopilot(BaseChatModel):
850
854
  usage_metadata=usage_metadata,
851
855
  )
852
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
+
853
872
  # ------------------------------------------------------------------
854
873
  # LangChain BaseChatModel interface
855
874
  # ------------------------------------------------------------------
@@ -924,17 +943,7 @@ class ChatGithubCopilot(BaseChatModel):
924
943
 
925
944
  if not choices and usage:
926
945
  # 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
- )
946
+ chunk = self._make_usage_chunk(usage)
938
947
  if run_manager:
939
948
  run_manager.on_llm_new_token("", chunk=chunk)
940
949
  yield chunk
@@ -990,17 +999,7 @@ class ChatGithubCopilot(BaseChatModel):
990
999
  usage = raw_chunk.get("usage")
991
1000
 
992
1001
  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
- )
1002
+ chunk = self._make_usage_chunk(usage)
1004
1003
  if run_manager:
1005
1004
  await run_manager.on_llm_new_token("", chunk=chunk)
1006
1005
  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.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"