From cae6872792271f6fc7e5f894b3d712057ff407fb Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 17:15:32 +0200 Subject: [PATCH 1/9] fix(copilot): add credential setup and management flow --- .../providers/copilot_auth_base.py | 269 ++++++++++++++++-- 1 file changed, 246 insertions(+), 23 deletions(-) diff --git a/src/rotator_library/providers/copilot_auth_base.py b/src/rotator_library/providers/copilot_auth_base.py index 0b739266..7b4b1362 100644 --- a/src/rotator_library/providers/copilot_auth_base.py +++ b/src/rotator_library/providers/copilot_auth_base.py @@ -16,10 +16,13 @@ import time import asyncio import logging +import re from pathlib import Path from typing import Dict, Any, Optional, Union import tempfile import shutil +from dataclasses import dataclass, field +from glob import glob import httpx from rich.console import Console @@ -32,23 +35,26 @@ console = Console() +@dataclass +class CopilotCredentialSetupResult: + """Standardized result for Copilot credential setup operations.""" + + success: bool + file_path: Optional[str] = None + email: Optional[str] = None + tier: Optional[str] = None + project_id: Optional[str] = None + is_update: bool = False + error: Optional[str] = None + credentials: Optional[Dict[str, Any]] = field(default=None, repr=False) + + class CopilotAuthBase: """ GitHub Copilot OAuth2 authentication using Device Flow. This provider uses GitHub's Device Authorization Grant flow, which is more suitable for CLI applications than the web-based Authorization Code flow. - - Key differences from GoogleOAuthBase: - - Uses GitHub Device Flow (polls for authorization) - - Two-token system: GitHub OAuth token + Copilot API token - - Copilot API tokens expire quickly (~30 min) and need frequent refresh - - Subclasses may override: - - ENV_PREFIX: Prefix for environment variables (default: "COPILOT") - - REFRESH_EXPIRY_BUFFER_SECONDS: Time buffer before token expiry - - Supports both github.com and GitHub Enterprise deployments. """ # GitHub Copilot OAuth Client ID (from VS Code Copilot extension) @@ -273,7 +279,7 @@ def _is_token_expired(self, creds: Dict[str, Any]) -> bool: return True async def _refresh_copilot_token( - self, path: str, creds: Dict[str, Any], force: bool = False + self, path: Optional[str], creds: Dict[str, Any], force: bool = False ) -> Dict[str, Any]: """ Refresh the Copilot API token using the GitHub OAuth token. @@ -281,9 +287,12 @@ async def _refresh_copilot_token( The GitHub OAuth token (refresh_token) is long-lived. The Copilot API token (access_token) expires after ~30 minutes. """ - async with await self._get_lock(path): + lock_key = path or f"in-memory://copilot/{id(creds)}" + display_name = Path(path).name if path else "in-memory credential" + + async with await self._get_lock(lock_key): # Skip if token is still valid (unless forced) - cached_creds = self._credentials_cache.get(path, creds) + cached_creds = self._credentials_cache.get(lock_key, creds) if not force and not self._is_token_expired(cached_creds): return cached_creds @@ -302,7 +311,7 @@ async def _refresh_copilot_token( urls = self._get_urls(domain) lib_logger.debug( - f"Refreshing {self.ENV_PREFIX} Copilot API token for '{Path(path).name}' (forced: {force})..." + f"Refreshing {self.ENV_PREFIX} Copilot API token for '{display_name}' (forced: {force})..." ) async with httpx.AsyncClient() as client: @@ -319,10 +328,14 @@ async def _refresh_copilot_token( if response.status_code == 401: lib_logger.warning( - f"GitHub token invalid for '{Path(path).name}' (HTTP 401). " + f"GitHub token invalid for '{display_name}' (HTTP 401). " f"Token may have been revoked. Starting re-authentication..." ) - return await self.initialize_token(path) + if path: + return await self.initialize_token(path) + raise ValueError( + "GitHub token invalid for in-memory credential and cannot re-auth without a file path" + ) response.raise_for_status() token_data = response.json() @@ -338,9 +351,12 @@ async def _refresh_copilot_token( creds["_proxy_metadata"] = {} creds["_proxy_metadata"]["last_check_timestamp"] = time.time() - await self._save_credentials(path, creds) + if path: + await self._save_credentials(path, creds) + else: + self._credentials_cache[lock_key] = creds lib_logger.debug( - f"Successfully refreshed {self.ENV_PREFIX} Copilot API token for '{Path(path).name}'." + f"Successfully refreshed {self.ENV_PREFIX} Copilot API token for '{display_name}'." ) return creds @@ -396,9 +412,13 @@ async def initialize_token( ) try: - creds = ( - await self._load_credentials(creds_or_path) if path else creds_or_path - ) + if path: + creds: Dict[str, Any] = await self._load_credentials(path) + elif isinstance(creds_or_path, dict): + creds = creds_or_path + else: + raise ValueError("Invalid credential input for Copilot initialization") + needs_auth = False reason = "" @@ -591,7 +611,12 @@ async def get_user_info( ) -> Dict[str, Any]: """Get user info from cached metadata or API.""" path = creds_or_path if isinstance(creds_or_path, str) else None - creds = await self._load_credentials(creds_or_path) if path else creds_or_path + if path: + creds: Dict[str, Any] = await self._load_credentials(path) + elif isinstance(creds_or_path, dict): + creds = creds_or_path + else: + return {"email": "unknown"} if creds.get("_proxy_metadata", {}).get("email"): return {"email": creds["_proxy_metadata"]["email"]} @@ -629,3 +654,201 @@ async def get_user_info( lib_logger.warning(f"Failed to fetch user info: {e}") return {"email": "unknown"} + + def _get_oauth_base_dir(self) -> Path: + """Return the OAuth credentials base directory.""" + return Path.cwd() / "oauth_creds" + + def _get_provider_file_prefix(self) -> str: + """Return file prefix for Copilot credential files.""" + return "copilot" + + def _find_existing_credential_by_email( + self, email: str, base_dir: Optional[Path] = None + ) -> Optional[Path]: + """Find existing credential file by email for deduplication.""" + if base_dir is None: + base_dir = self._get_oauth_base_dir() + + for cred in self.list_credentials(base_dir): + if cred.get("email", "").lower() == email.lower(): + return Path(cred["file_path"]) + return None + + def _get_next_credential_number(self, base_dir: Optional[Path] = None) -> int: + """Get next available credential number.""" + if base_dir is None: + base_dir = self._get_oauth_base_dir() + + prefix = self._get_provider_file_prefix() + pattern = str(base_dir / f"{prefix}_oauth_*.json") + + existing_numbers = [] + for cred_file in glob(pattern): + match = re.search(r"_oauth_(\d+)\.json$", cred_file) + if match: + existing_numbers.append(int(match.group(1))) + + if not existing_numbers: + return 1 + return max(existing_numbers) + 1 + + def _build_credential_path( + self, base_dir: Optional[Path] = None, number: Optional[int] = None + ) -> Path: + """Build path for a new Copilot credential file.""" + if base_dir is None: + base_dir = self._get_oauth_base_dir() + + if number is None: + number = self._get_next_credential_number(base_dir) + + prefix = self._get_provider_file_prefix() + return base_dir / f"{prefix}_oauth_{number}.json" + + async def setup_credential( + self, base_dir: Optional[Path] = None + ) -> CopilotCredentialSetupResult: + """Complete credential setup flow: OAuth -> save.""" + if base_dir is None: + base_dir = self._get_oauth_base_dir() + + base_dir.mkdir(parents=True, exist_ok=True) + + try: + temp_creds = {"_proxy_metadata": {"display_name": "new Copilot credential"}} + new_creds = await self.initialize_token(temp_creds) + + user_info = await self.get_user_info(new_creds) + email = user_info.get("email") + if not email: + return CopilotCredentialSetupResult( + success=False, error="Could not retrieve email from OAuth response" + ) + + existing_path = self._find_existing_credential_by_email(email, base_dir) + is_update = existing_path is not None + + if is_update: + file_path = existing_path + lib_logger.info( + f"Found existing credential for {email}, updating {file_path.name}" + ) + else: + file_path = self._build_credential_path(base_dir) + lib_logger.info( + f"Creating new credential for {email} at {file_path.name}" + ) + + await self._save_credentials(str(file_path), new_creds) + + return CopilotCredentialSetupResult( + success=True, + file_path=str(file_path), + email=email, + is_update=is_update, + credentials=new_creds, + ) + + except Exception as e: + lib_logger.error(f"Copilot credential setup failed: {e}") + return CopilotCredentialSetupResult(success=False, error=str(e)) + + def build_env_lines(self, creds: Dict[str, Any], cred_number: int) -> list[str]: + """Generate .env file lines for a Copilot credential.""" + email = creds.get("_proxy_metadata", {}).get("email", "unknown") + prefix = f"COPILOT_{cred_number}" + + lines = [ + f"# COPILOT Credential #{cred_number} for: {email}", + f"# Exported from: copilot_oauth_{cred_number}.json", + f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}", + "", + f"{prefix}_GITHUB_TOKEN={creds.get('refresh_token', '')}", + f"{prefix}_ENTERPRISE_URL={creds.get('enterprise_url', '')}", + f"{prefix}_EMAIL={email}", + ] + + return lines + + def export_credential_to_env( + self, credential_path: str, output_dir: Optional[Path] = None + ) -> Optional[str]: + """Export a Copilot credential file to .env format.""" + try: + cred_path = Path(credential_path) + with open(cred_path, "r") as f: + creds = json.load(f) + + email = creds.get("_proxy_metadata", {}).get("email", "unknown") + match = re.search(r"_oauth_(\d+)\.json$", cred_path.name) + cred_number = int(match.group(1)) if match else 1 + + if output_dir is None: + output_dir = cred_path.parent + + safe_email = email.replace("@", "_at_").replace(".", "_") + env_filename = f"copilot_{cred_number}_{safe_email}.env" + env_path = output_dir / env_filename + + with open(env_path, "w") as f: + f.write("\n".join(self.build_env_lines(creds, cred_number))) + + return str(env_path) + except Exception as e: + lib_logger.error(f"Failed to export Copilot credential: {e}") + return None + + def list_credentials(self, base_dir: Optional[Path] = None) -> list[Dict[str, Any]]: + """List all Copilot credential files.""" + if base_dir is None: + base_dir = self._get_oauth_base_dir() + + prefix = self._get_provider_file_prefix() + pattern = str(base_dir / f"{prefix}_oauth_*.json") + + credentials = [] + for cred_file in sorted(glob(pattern)): + try: + with open(cred_file, "r") as f: + creds = json.load(f) + + metadata = creds.get("_proxy_metadata", {}) + match = re.search(r"_oauth_(\d+)\.json$", cred_file) + number = int(match.group(1)) if match else 0 + + credentials.append( + { + "file_path": cred_file, + "email": metadata.get("email", "unknown"), + "number": number, + } + ) + except Exception as e: + lib_logger.debug(f"Could not read credential file {cred_file}: {e}") + + return credentials + + def delete_credential(self, credential_path: str) -> bool: + """Delete a Copilot credential file.""" + try: + cred_path = Path(credential_path) + prefix = self._get_provider_file_prefix() + + if not cred_path.name.startswith(f"{prefix}_oauth_"): + lib_logger.error( + f"File {cred_path.name} does not appear to be a Copilot credential" + ) + return False + + if not cred_path.exists(): + lib_logger.warning(f"Credential file does not exist: {credential_path}") + return False + + self._credentials_cache.pop(credential_path, None) + cred_path.unlink() + lib_logger.info(f"Deleted credential file: {credential_path}") + return True + except Exception as e: + lib_logger.error(f"Failed to delete Copilot credential: {e}") + return False From b0c1aabf0f859ab6fc0cdf5850abca503deaaa12 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 17:24:04 +0200 Subject: [PATCH 2/9] fix(copilot): fallback to login when email is null --- .../providers/copilot_auth_base.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/rotator_library/providers/copilot_auth_base.py b/src/rotator_library/providers/copilot_auth_base.py index 7b4b1362..3ccce439 100644 --- a/src/rotator_library/providers/copilot_auth_base.py +++ b/src/rotator_library/providers/copilot_auth_base.py @@ -565,10 +565,13 @@ async def initialize_token( ) if user_response.is_success: user_info = user_response.json() + resolved_identity = ( + user_info.get("email") + or user_info.get("login") + or "unknown" + ) new_creds["_proxy_metadata"]["email"] = ( - user_info.get( - "email", user_info.get("login", "unknown") - ) + resolved_identity ) except Exception as e: lib_logger.warning(f"Failed to fetch user info: {e}") @@ -640,8 +643,10 @@ async def get_user_info( ) if response.is_success: user_info = response.json() - email = user_info.get( - "email", user_info.get("login", "unknown") + email = ( + user_info.get("email") + or user_info.get("login") + or "unknown" ) creds["_proxy_metadata"] = { "email": email, From 0fb0f150f56e480a42d5373b850c9ac07da357da Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 17:51:51 +0200 Subject: [PATCH 3/9] fix(copilot): return provider-prefixed models in model list --- src/rotator_library/providers/copilot_provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index f35cdac5..ae8b66b1 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -158,7 +158,8 @@ async def get_models(self, api_key: str, client: httpx.AsyncClient) -> List[str] The api_key here is actually the credential path for OAuth providers. For Copilot, models are configured via environment or defaults. """ - return self._available_models + # Always return provider-prefixed models so /v1/models entries are directly usable. + return [m if "/" in m else f"copilot/{m}" for m in self._available_models] def get_credential_priority(self, credential: str) -> Optional[int]: """ From 4ad36fba6645a90018337018c567b77ab7141a56 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 18:06:58 +0200 Subject: [PATCH 4/9] feat(copilot): expand default model catalog to current provider set --- .../providers/copilot_provider.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index ae8b66b1..dbefc51a 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -44,14 +44,26 @@ # Available Copilot models (these may vary based on subscription) DEFAULT_COPILOT_MODELS = [ - "gpt-4o", "gpt-4.1", - "gpt-4.1-mini", - "claude-3.5-sonnet", + "gpt-4o", + "gpt-5", + "gpt-5-mini", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", + "gpt-5.2", + "gpt-5.2-codex", "claude-sonnet-4", - "o3-mini", - "o1", - "gemini-2.0-flash-001", + "claude-sonnet-4.5", + "claude-opus-4.5", + "claude-opus-4.6", + "claude-opus-41", + "claude-haiku-4.5", + "gemini-2.5-pro", + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "grok-code-fast-1", ] # Responses API alternate input types for agent detection From 8fa837e70e487035e0ff294e17101d24f6d5d2cd Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 18:13:14 +0200 Subject: [PATCH 5/9] fix(copilot): read credential_identifier in custom completion path --- src/rotator_library/providers/copilot_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index dbefc51a..52789128 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -270,7 +270,7 @@ async def acompletion( 3. Makes direct API call to Copilot 4. Parses response into LiteLLM format """ - credential_path = kwargs.get("api_key", "") + credential_path = kwargs.pop("credential_identifier", kwargs.get("api_key", "")) model = kwargs.get("model", "gpt-4o") messages = kwargs.get("messages", []) stream = kwargs.get("stream", False) From 2eccf439a08e1def8f538e2b1856d237c795e71c Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 18:43:07 +0200 Subject: [PATCH 6/9] fix(copilot): await streaming handler in completion path --- src/rotator_library/providers/copilot_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index 52789128..9643f4ab 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -335,7 +335,7 @@ async def acompletion( ) if stream: - return self._handle_streaming_response( + return await self._handle_streaming_response( client, base_url, headers, body, model ) else: From 34b049098bf5115688a158599060bf40ea1f41af Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 18:53:30 +0200 Subject: [PATCH 7/9] fix(copilot): remove xhigh alias and stream delta compatibility --- .../providers/copilot_provider.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index 9643f4ab..e332896c 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -465,20 +465,20 @@ def _convert_to_litellm_chunk( choices = [] for choice in chunk_data.get("choices", []): delta = choice.get("delta", {}) - litellm_choice = litellm.Choices( - index=choice.get("index", 0), - delta=litellm.Delta( - role=delta.get("role"), - content=delta.get("content"), - ), - finish_reason=choice.get("finish_reason"), - ) - - # Handle tool call deltas + delta_payload = { + "role": delta.get("role"), + "content": delta.get("content"), + } if delta.get("tool_calls"): - litellm_choice.delta.tool_calls = delta["tool_calls"] - - choices.append(litellm_choice) + delta_payload["tool_calls"] = delta["tool_calls"] + + choices.append( + { + "index": choice.get("index", 0), + "delta": delta_payload, + "finish_reason": choice.get("finish_reason"), + } + ) return litellm.ModelResponse( id=chunk_data.get("id", f"copilot-{uuid.uuid4()}"), From 73ff3c93225d9c7dcbc043303a5adc5826bc905b Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 14 Feb 2026 19:24:12 +0200 Subject: [PATCH 8/9] fix(copilot): avoid ResponseNotRead when logging stream errors --- src/rotator_library/providers/copilot_provider.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/rotator_library/providers/copilot_provider.py b/src/rotator_library/providers/copilot_provider.py index e332896c..55cda1b0 100644 --- a/src/rotator_library/providers/copilot_provider.py +++ b/src/rotator_library/providers/copilot_provider.py @@ -413,8 +413,17 @@ async def stream_generator(): continue except httpx.HTTPStatusError as e: + response_text = "" + try: + # In streaming mode, response body is not automatically read. + # Read it explicitly so logging does not raise ResponseNotRead. + response_text = (await e.response.aread()).decode( + "utf-8", errors="replace" + ) + except Exception: + pass lib_logger.error( - f"Copilot streaming error (HTTP {e.response.status_code}): {e.response.text}" + f"Copilot streaming error (HTTP {e.response.status_code}): {response_text}" ) raise except Exception as e: From 229257ee27e6d3e99c6a67c6c89667ed66b5b407 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sun, 15 Feb 2026 12:13:44 +0200 Subject: [PATCH 9/9] fix(copilot): use centralized get_oauth_dir() instead of hardcoded Path.cwd() Avoids credentials scattering if run from different directories and respects PyInstaller/override modes via utils.paths. Co-Authored-By: Claude Opus 4.6 --- src/rotator_library/providers/copilot_auth_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rotator_library/providers/copilot_auth_base.py b/src/rotator_library/providers/copilot_auth_base.py index 3ccce439..4578ed82 100644 --- a/src/rotator_library/providers/copilot_auth_base.py +++ b/src/rotator_library/providers/copilot_auth_base.py @@ -18,6 +18,7 @@ import logging import re from pathlib import Path +from ..utils.paths import get_oauth_dir from typing import Dict, Any, Optional, Union import tempfile import shutil @@ -662,7 +663,7 @@ async def get_user_info( def _get_oauth_base_dir(self) -> Path: """Return the OAuth credentials base directory.""" - return Path.cwd() / "oauth_creds" + return get_oauth_dir() def _get_provider_file_prefix(self) -> str: """Return file prefix for Copilot credential files."""