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.
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/PKG-INFO +1 -1
- langchain_githubcopilot_chat-0.5.0/langchain_githubcopilot_chat/auth.py +225 -0
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/langchain_githubcopilot_chat/chat_models.py +164 -47
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/langchain_githubcopilot_chat/embeddings.py +13 -15
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/pyproject.toml +1 -1
- langchain_githubcopilot_chat-0.3.0/langchain_githubcopilot_chat/auth.py +0 -96
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/LICENSE +0 -0
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/LICENSE.langchain +0 -0
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/README.md +0 -0
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/langchain_githubcopilot_chat/__init__.py +0 -0
- {langchain_githubcopilot_chat-0.3.0 → langchain_githubcopilot_chat-0.5.0}/langchain_githubcopilot_chat/py.typed +0 -0
|
@@ -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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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.
|
|
488
|
-
"variable
|
|
489
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|