Skip to content

Commit e65a71e

Browse files
author
mirrobot-agent[bot]
committed
feat(provider): add GitHub Copilot as an OAuth provider
Implements GitHub Copilot integration with: - Device Flow OAuth authentication (no browser redirect needed) - Custom Copilot Chat API integration (bypasses LiteLLM) - Configurable X-Initiator header control (user vs agent mode) - Support for both github.com and GitHub Enterprise - Vision request support - Streaming and non-streaming responses Key features from the referenced implementations: - Based on https://github.com/sst/opencode-copilot-auth for OAuth flow - Agent header forcing feature from Tarquinen/dotfiles plugin - COPILOT_AGENT_PERCENTAGE env var for random user/agent ratio New files: - copilot_auth_base.py: GitHub Device Flow OAuth handler - copilot_provider.py: Custom API integration Closes #29
1 parent 8c2f222 commit e65a71e

6 files changed

Lines changed: 1211 additions & 32 deletions

File tree

.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,34 @@ QWEN_CODE_OAUTH_1=""
9898
# Path to your iFlow credential file (e.g., ~/.iflow/oauth_creds.json).
9999
IFLOW_OAUTH_1=""
100100

101+
# --- GitHub Copilot ---
102+
# GitHub Copilot uses Device Flow OAuth authentication.
103+
# After first-time setup, the proxy stores credentials in 'oauth_creds/' directory.
104+
# You can also pre-configure credentials via environment variables:
105+
#
106+
# COPILOT_GITHUB_TOKEN - Your GitHub OAuth token (long-lived, from Device Flow)
107+
# COPILOT_ENTERPRISE_URL - Optional: GitHub Enterprise URL (e.g., company.ghe.com)
108+
#
109+
# For multiple Copilot accounts, use numbered variables:
110+
# COPILOT_1_GITHUB_TOKEN="ghp_..."
111+
# COPILOT_2_GITHUB_TOKEN="ghp_..."
112+
COPILOT_GITHUB_TOKEN=""
113+
COPILOT_ENTERPRISE_URL=""
114+
115+
# --- Copilot X-Initiator Header Control ---
116+
# Controls the X-Initiator header behavior (affects Copilot's response style):
117+
# - COPILOT_FORCE_AGENT_HEADER: Always use "agent" mode (default: false)
118+
# - COPILOT_AGENT_PERCENTAGE: For first messages, % chance of "agent" (0-100, default: 100)
119+
# Set to 0 for always "user", 100 for always "agent", or a value in between for random.
120+
# Based on: https://github.com/Tarquinen/dotfiles/tree/main/.config/opencode/plugin/copilot-force-agent-header
121+
COPILOT_FORCE_AGENT_HEADER=false
122+
COPILOT_AGENT_PERCENTAGE=100
123+
124+
# --- Copilot Available Models ---
125+
# Comma-separated list of Copilot models to expose. Leave empty for defaults.
126+
# Default models: gpt-4o, gpt-4.1, gpt-4.1-mini, claude-3.5-sonnet, claude-sonnet-4, o3-mini, o1, gemini-2.0-flash-001
127+
COPILOT_MODELS=""
128+
101129

102130
# ------------------------------------------------------------------------------
103131
# | [ADVANCED] Provider-Specific Settings |

src/rotator_library/credential_manager.py

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from typing import Dict, List, Optional, Set
77

8-
lib_logger = logging.getLogger('rotator_library')
8+
lib_logger = logging.getLogger("rotator_library")
99

1010
OAUTH_BASE_DIR = Path.cwd() / "oauth_creds"
1111
OAUTH_BASE_DIR.mkdir(exist_ok=True)
@@ -16,6 +16,7 @@
1616
"qwen_code": Path.home() / ".qwen",
1717
"iflow": Path.home() / ".iflow",
1818
"antigravity": Path.home() / ".antigravity",
19+
"copilot": Path.home() / ".copilot",
1920
# Add other providers like 'claude' here if they have a standard CLI path
2021
}
2122

@@ -26,45 +27,47 @@
2627
"antigravity": "ANTIGRAVITY",
2728
"qwen_code": "QWEN_CODE",
2829
"iflow": "IFLOW",
30+
"copilot": "COPILOT",
2931
}
3032

3133

3234
class CredentialManager:
3335
"""
3436
Discovers OAuth credential files from standard locations, copies them locally,
3537
and updates the configuration to use the local paths.
36-
38+
3739
Also discovers environment variable-based OAuth credentials for stateless deployments.
3840
Supports two env var formats:
39-
41+
4042
1. Single credential (legacy): PROVIDER_ACCESS_TOKEN, PROVIDER_REFRESH_TOKEN
4143
2. Multiple credentials (numbered): PROVIDER_1_ACCESS_TOKEN, PROVIDER_2_ACCESS_TOKEN, etc.
42-
44+
4345
When env-based credentials are detected, virtual paths like "env://provider/1" are created.
4446
"""
47+
4548
def __init__(self, env_vars: Dict[str, str]):
4649
self.env_vars = env_vars
4750

4851
def _discover_env_oauth_credentials(self) -> Dict[str, List[str]]:
4952
"""
5053
Discover OAuth credentials defined via environment variables.
51-
54+
5255
Supports two formats:
5356
1. Single credential: ANTIGRAVITY_ACCESS_TOKEN + ANTIGRAVITY_REFRESH_TOKEN
5457
2. Multiple credentials: ANTIGRAVITY_1_ACCESS_TOKEN + ANTIGRAVITY_1_REFRESH_TOKEN, etc.
55-
58+
5659
Returns:
5760
Dict mapping provider name to list of virtual paths (e.g., "env://antigravity/1")
5861
"""
5962
env_credentials: Dict[str, Set[str]] = {}
60-
63+
6164
for provider, env_prefix in ENV_OAUTH_PROVIDERS.items():
6265
found_indices: Set[str] = set()
63-
66+
6467
# Check for numbered credentials (PROVIDER_N_ACCESS_TOKEN pattern)
6568
# Pattern: ANTIGRAVITY_1_ACCESS_TOKEN, ANTIGRAVITY_2_ACCESS_TOKEN, etc.
6669
numbered_pattern = re.compile(rf"^{env_prefix}_(\d+)_ACCESS_TOKEN$")
67-
70+
6871
for key in self.env_vars.keys():
6972
match = numbered_pattern.match(key)
7073
if match:
@@ -73,28 +76,34 @@ def _discover_env_oauth_credentials(self) -> Dict[str, List[str]]:
7376
refresh_key = f"{env_prefix}_{index}_REFRESH_TOKEN"
7477
if refresh_key in self.env_vars and self.env_vars[refresh_key]:
7578
found_indices.add(index)
76-
79+
7780
# Check for legacy single credential (PROVIDER_ACCESS_TOKEN pattern)
7881
# Only use this if no numbered credentials exist
7982
if not found_indices:
8083
access_key = f"{env_prefix}_ACCESS_TOKEN"
8184
refresh_key = f"{env_prefix}_REFRESH_TOKEN"
82-
if (access_key in self.env_vars and self.env_vars[access_key] and
83-
refresh_key in self.env_vars and self.env_vars[refresh_key]):
85+
if (
86+
access_key in self.env_vars
87+
and self.env_vars[access_key]
88+
and refresh_key in self.env_vars
89+
and self.env_vars[refresh_key]
90+
):
8491
# Use "0" as the index for legacy single credential
8592
found_indices.add("0")
86-
93+
8794
if found_indices:
8895
env_credentials[provider] = found_indices
89-
lib_logger.info(f"Found {len(found_indices)} env-based credential(s) for {provider}")
90-
96+
lib_logger.info(
97+
f"Found {len(found_indices)} env-based credential(s) for {provider}"
98+
)
99+
91100
# Convert to virtual paths
92101
result: Dict[str, List[str]] = {}
93102
for provider, indices in env_credentials.items():
94103
# Sort indices numerically for consistent ordering
95104
sorted_indices = sorted(indices, key=lambda x: int(x))
96105
result[provider] = [f"env://{provider}/{idx}" for idx in sorted_indices]
97-
106+
98107
return result
99108

100109
def discover_and_prepare(self) -> Dict[str, List[str]]:
@@ -105,7 +114,9 @@ def discover_and_prepare(self) -> Dict[str, List[str]]:
105114
# These take priority for stateless deployments
106115
env_oauth_creds = self._discover_env_oauth_credentials()
107116
for provider, virtual_paths in env_oauth_creds.items():
108-
lib_logger.info(f"Using {len(virtual_paths)} env-based credential(s) for {provider}")
117+
lib_logger.info(
118+
f"Using {len(virtual_paths)} env-based credential(s) for {provider}"
119+
)
109120
final_config[provider] = virtual_paths
110121

111122
# Extract OAuth file paths from environment variables
@@ -115,21 +126,29 @@ def discover_and_prepare(self) -> Dict[str, List[str]]:
115126
provider = key.split("_OAUTH_")[0].lower()
116127
if provider not in env_oauth_paths:
117128
env_oauth_paths[provider] = []
118-
if value: # Only consider non-empty values
129+
if value: # Only consider non-empty values
119130
env_oauth_paths[provider].append(value)
120131

121132
# PHASE 2: Discover file-based OAuth credentials
122133
for provider, default_dir in DEFAULT_OAUTH_DIRS.items():
123134
# Skip if already discovered from environment variables
124135
if provider in final_config:
125-
lib_logger.debug(f"Skipping file discovery for {provider} - using env-based credentials")
136+
lib_logger.debug(
137+
f"Skipping file discovery for {provider} - using env-based credentials"
138+
)
126139
continue
127-
140+
128141
# Check for existing local credentials first. If found, use them and skip discovery.
129-
local_provider_creds = sorted(list(OAUTH_BASE_DIR.glob(f"{provider}_oauth_*.json")))
142+
local_provider_creds = sorted(
143+
list(OAUTH_BASE_DIR.glob(f"{provider}_oauth_*.json"))
144+
)
130145
if local_provider_creds:
131-
lib_logger.info(f"Found {len(local_provider_creds)} existing local credential(s) for {provider}. Skipping discovery.")
132-
final_config[provider] = [str(p.resolve()) for p in local_provider_creds]
146+
lib_logger.info(
147+
f"Found {len(local_provider_creds)} existing local credential(s) for {provider}. Skipping discovery."
148+
)
149+
final_config[provider] = [
150+
str(p.resolve()) for p in local_provider_creds
151+
]
133152
continue
134153

135154
# If no local credentials exist, proceed with a one-time discovery and copy.
@@ -140,13 +159,13 @@ def discover_and_prepare(self) -> Dict[str, List[str]]:
140159
path = Path(path_str).expanduser()
141160
if path.exists():
142161
discovered_paths.add(path)
143-
162+
144163
# 2. If no overrides are provided via .env, scan the default directory
145164
# [MODIFIED] This logic is now disabled to prefer local-first credential management.
146165
# if not discovered_paths and default_dir.exists():
147166
# for json_file in default_dir.glob('*.json'):
148167
# discovered_paths.add(json_file)
149-
168+
150169
if not discovered_paths:
151170
lib_logger.debug(f"No credential files found for provider: {provider}")
152171
continue
@@ -161,13 +180,19 @@ def discover_and_prepare(self) -> Dict[str, List[str]]:
161180
try:
162181
# Since we've established no local files exist, we can copy directly.
163182
shutil.copy(source_path, local_path)
164-
lib_logger.info(f"Copied '{source_path.name}' to local pool at '{local_path}'.")
183+
lib_logger.info(
184+
f"Copied '{source_path.name}' to local pool at '{local_path}'."
185+
)
165186
prepared_paths.append(str(local_path.resolve()))
166187
except Exception as e:
167-
lib_logger.error(f"Failed to process OAuth file from '{source_path}': {e}")
168-
188+
lib_logger.error(
189+
f"Failed to process OAuth file from '{source_path}': {e}"
190+
)
191+
169192
if prepared_paths:
170-
lib_logger.info(f"Discovered and prepared {len(prepared_paths)} credential(s) for provider: {provider}")
193+
lib_logger.info(
194+
f"Discovered and prepared {len(prepared_paths)} credential(s) for provider: {provider}"
195+
)
171196
final_config[provider] = prepared_paths
172197

173198
lib_logger.info("OAuth credential discovery complete.")

src/rotator_library/provider_factory.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
from .providers.qwen_auth_base import QwenAuthBase
55
from .providers.iflow_auth_base import IFlowAuthBase
66
from .providers.antigravity_auth_base import AntigravityAuthBase
7+
from .providers.copilot_auth_base import CopilotAuthBase
78

89
PROVIDER_MAP = {
910
"gemini_cli": GeminiAuthBase,
1011
"qwen_code": QwenAuthBase,
1112
"iflow": IFlowAuthBase,
1213
"antigravity": AntigravityAuthBase,
14+
"copilot": CopilotAuthBase,
1315
}
1416

17+
1518
def get_provider_auth_class(provider_name: str):
1619
"""
1720
Returns the authentication class for a given provider.
@@ -21,8 +24,9 @@ def get_provider_auth_class(provider_name: str):
2124
raise ValueError(f"Unknown provider: {provider_name}")
2225
return provider_class
2326

27+
2428
def get_available_providers():
2529
"""
2630
Returns a list of available provider names.
2731
"""
28-
return list(PROVIDER_MAP.keys())
32+
return list(PROVIDER_MAP.keys())

src/rotator_library/providers/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ def _register_providers():
8989
provider_name = "nvidia_nim"
9090
PROVIDER_PLUGINS[provider_name] = attribute
9191
import logging
92-
logging.getLogger('rotator_library').debug(f"Registered provider: {provider_name}")
92+
93+
logging.getLogger("rotator_library").debug(
94+
f"Registered provider: {provider_name}"
95+
)
9396

9497
# Then, create dynamic plugins for custom OpenAI-compatible providers
9598
# Use environment variables directly (load_dotenv already called in main.py)
@@ -114,6 +117,7 @@ def _register_providers():
114117
"qwen_code",
115118
"gemini_cli",
116119
"antigravity",
120+
"copilot",
117121
]:
118122
continue
119123

@@ -129,7 +133,10 @@ def __init__(self):
129133
plugin_class = create_plugin_class(provider_name)
130134
PROVIDER_PLUGINS[provider_name] = plugin_class
131135
import logging
132-
logging.getLogger('rotator_library').debug(f"Registered dynamic provider: {provider_name}")
136+
137+
logging.getLogger("rotator_library").debug(
138+
f"Registered dynamic provider: {provider_name}"
139+
)
133140

134141

135142
# Discover and register providers when the package is imported

0 commit comments

Comments
 (0)