router-maestro 0.1.2__py3-none-any.whl
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.
- router_maestro/__init__.py +3 -0
- router_maestro/__main__.py +6 -0
- router_maestro/auth/__init__.py +18 -0
- router_maestro/auth/github_oauth.py +181 -0
- router_maestro/auth/manager.py +136 -0
- router_maestro/auth/storage.py +91 -0
- router_maestro/cli/__init__.py +1 -0
- router_maestro/cli/auth.py +167 -0
- router_maestro/cli/client.py +322 -0
- router_maestro/cli/config.py +132 -0
- router_maestro/cli/context.py +146 -0
- router_maestro/cli/main.py +42 -0
- router_maestro/cli/model.py +288 -0
- router_maestro/cli/server.py +117 -0
- router_maestro/cli/stats.py +76 -0
- router_maestro/config/__init__.py +72 -0
- router_maestro/config/contexts.py +29 -0
- router_maestro/config/paths.py +50 -0
- router_maestro/config/priorities.py +93 -0
- router_maestro/config/providers.py +34 -0
- router_maestro/config/server.py +115 -0
- router_maestro/config/settings.py +76 -0
- router_maestro/providers/__init__.py +31 -0
- router_maestro/providers/anthropic.py +203 -0
- router_maestro/providers/base.py +123 -0
- router_maestro/providers/copilot.py +346 -0
- router_maestro/providers/openai.py +188 -0
- router_maestro/providers/openai_compat.py +175 -0
- router_maestro/routing/__init__.py +5 -0
- router_maestro/routing/router.py +526 -0
- router_maestro/server/__init__.py +5 -0
- router_maestro/server/app.py +87 -0
- router_maestro/server/middleware/__init__.py +11 -0
- router_maestro/server/middleware/auth.py +66 -0
- router_maestro/server/oauth_sessions.py +159 -0
- router_maestro/server/routes/__init__.py +8 -0
- router_maestro/server/routes/admin.py +358 -0
- router_maestro/server/routes/anthropic.py +228 -0
- router_maestro/server/routes/chat.py +142 -0
- router_maestro/server/routes/models.py +34 -0
- router_maestro/server/schemas/__init__.py +57 -0
- router_maestro/server/schemas/admin.py +87 -0
- router_maestro/server/schemas/anthropic.py +246 -0
- router_maestro/server/schemas/openai.py +107 -0
- router_maestro/server/translation.py +636 -0
- router_maestro/stats/__init__.py +14 -0
- router_maestro/stats/heatmap.py +154 -0
- router_maestro/stats/storage.py +228 -0
- router_maestro/stats/tracker.py +73 -0
- router_maestro/utils/__init__.py +16 -0
- router_maestro/utils/logging.py +81 -0
- router_maestro/utils/tokens.py +51 -0
- router_maestro-0.1.2.dist-info/METADATA +383 -0
- router_maestro-0.1.2.dist-info/RECORD +57 -0
- router_maestro-0.1.2.dist-info/WHEEL +4 -0
- router_maestro-0.1.2.dist-info/entry_points.txt +2 -0
- router_maestro-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Auth module for router-maestro."""
|
|
2
|
+
|
|
3
|
+
from router_maestro.auth.manager import AuthManager, run_async
|
|
4
|
+
from router_maestro.auth.storage import (
|
|
5
|
+
ApiKeyCredential,
|
|
6
|
+
AuthStorage,
|
|
7
|
+
AuthType,
|
|
8
|
+
OAuthCredential,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthManager",
|
|
13
|
+
"AuthStorage",
|
|
14
|
+
"AuthType",
|
|
15
|
+
"OAuthCredential",
|
|
16
|
+
"ApiKeyCredential",
|
|
17
|
+
"run_async",
|
|
18
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""GitHub OAuth Device Flow implementation for Copilot."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
# GitHub OAuth constants (from copilot-api)
|
|
9
|
+
GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
|
10
|
+
GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
11
|
+
GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
12
|
+
COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
|
|
13
|
+
|
|
14
|
+
DEFAULT_POLL_INTERVAL = 5 # seconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeviceCodeResponse:
|
|
19
|
+
"""Response from device code request."""
|
|
20
|
+
|
|
21
|
+
device_code: str
|
|
22
|
+
user_code: str
|
|
23
|
+
verification_uri: str
|
|
24
|
+
expires_in: int
|
|
25
|
+
interval: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AccessTokenResponse:
|
|
30
|
+
"""Response from access token request."""
|
|
31
|
+
|
|
32
|
+
access_token: str
|
|
33
|
+
token_type: str
|
|
34
|
+
scope: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CopilotTokenResponse:
|
|
39
|
+
"""Response from Copilot token request."""
|
|
40
|
+
|
|
41
|
+
token: str
|
|
42
|
+
expires_at: int
|
|
43
|
+
refresh_in: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitHubOAuthError(Exception):
|
|
47
|
+
"""Error during GitHub OAuth flow."""
|
|
48
|
+
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def request_device_code(client: httpx.AsyncClient) -> DeviceCodeResponse:
|
|
53
|
+
"""Request a device code from GitHub.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
client: HTTP client
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Device code response with user_code and verification_uri
|
|
60
|
+
"""
|
|
61
|
+
response = await client.post(
|
|
62
|
+
GITHUB_DEVICE_CODE_URL,
|
|
63
|
+
json={"client_id": GITHUB_CLIENT_ID, "scope": "read:user"},
|
|
64
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
65
|
+
)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
data = response.json()
|
|
68
|
+
|
|
69
|
+
return DeviceCodeResponse(
|
|
70
|
+
device_code=data["device_code"],
|
|
71
|
+
user_code=data["user_code"],
|
|
72
|
+
verification_uri=data["verification_uri"],
|
|
73
|
+
expires_in=data["expires_in"],
|
|
74
|
+
interval=data.get("interval", DEFAULT_POLL_INTERVAL),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def poll_access_token(
|
|
79
|
+
client: httpx.AsyncClient,
|
|
80
|
+
device_code: str,
|
|
81
|
+
interval: int = DEFAULT_POLL_INTERVAL,
|
|
82
|
+
timeout: int = 900,
|
|
83
|
+
) -> AccessTokenResponse:
|
|
84
|
+
"""Poll GitHub for access token after user authorization.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
client: HTTP client
|
|
88
|
+
device_code: Device code from request_device_code
|
|
89
|
+
interval: Polling interval in seconds
|
|
90
|
+
timeout: Maximum time to wait in seconds
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Access token response
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
GitHubOAuthError: If authorization fails or times out
|
|
97
|
+
"""
|
|
98
|
+
start_time = time.time()
|
|
99
|
+
|
|
100
|
+
while time.time() - start_time < timeout:
|
|
101
|
+
response = await client.post(
|
|
102
|
+
GITHUB_ACCESS_TOKEN_URL,
|
|
103
|
+
json={
|
|
104
|
+
"client_id": GITHUB_CLIENT_ID,
|
|
105
|
+
"device_code": device_code,
|
|
106
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
107
|
+
},
|
|
108
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
109
|
+
)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
data = response.json()
|
|
112
|
+
|
|
113
|
+
if "access_token" in data:
|
|
114
|
+
return AccessTokenResponse(
|
|
115
|
+
access_token=data["access_token"],
|
|
116
|
+
token_type=data["token_type"],
|
|
117
|
+
scope=data["scope"],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
error = data.get("error")
|
|
121
|
+
if error == "authorization_pending":
|
|
122
|
+
# User hasn't authorized yet, keep polling
|
|
123
|
+
await _async_sleep(interval)
|
|
124
|
+
elif error == "slow_down":
|
|
125
|
+
# We're polling too fast, increase interval
|
|
126
|
+
interval += 5
|
|
127
|
+
await _async_sleep(interval)
|
|
128
|
+
elif error == "expired_token":
|
|
129
|
+
raise GitHubOAuthError("Device code expired. Please try again.")
|
|
130
|
+
elif error == "access_denied":
|
|
131
|
+
raise GitHubOAuthError("Authorization denied by user.")
|
|
132
|
+
else:
|
|
133
|
+
raise GitHubOAuthError(f"Unknown error: {error}")
|
|
134
|
+
|
|
135
|
+
raise GitHubOAuthError("Authorization timed out. Please try again.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def get_copilot_token(
|
|
139
|
+
client: httpx.AsyncClient,
|
|
140
|
+
github_token: str,
|
|
141
|
+
) -> CopilotTokenResponse:
|
|
142
|
+
"""Exchange GitHub token for Copilot token.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
client: HTTP client
|
|
146
|
+
github_token: GitHub access token
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Copilot token response
|
|
150
|
+
"""
|
|
151
|
+
# Headers matching copilot-api's githubHeaders
|
|
152
|
+
headers = {
|
|
153
|
+
"Authorization": f"token {github_token}",
|
|
154
|
+
"Accept": "application/json",
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
"Editor-Version": "vscode/1.104.3",
|
|
157
|
+
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
158
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
159
|
+
"X-GitHub-Api-Version": "2025-04-01",
|
|
160
|
+
"X-Vscode-User-Agent-Library-Version": "electron-fetch",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
response = await client.get(
|
|
164
|
+
COPILOT_TOKEN_URL,
|
|
165
|
+
headers=headers,
|
|
166
|
+
)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
data = response.json()
|
|
169
|
+
|
|
170
|
+
return CopilotTokenResponse(
|
|
171
|
+
token=data["token"],
|
|
172
|
+
expires_at=data["expires_at"],
|
|
173
|
+
refresh_in=data.get("refresh_in", 1800000), # Default 30 minutes in ms
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _async_sleep(seconds: float) -> None:
|
|
178
|
+
"""Async sleep helper."""
|
|
179
|
+
import asyncio
|
|
180
|
+
|
|
181
|
+
await asyncio.sleep(seconds)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Authentication manager for all providers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from router_maestro.auth.github_oauth import (
|
|
9
|
+
GitHubOAuthError,
|
|
10
|
+
get_copilot_token,
|
|
11
|
+
poll_access_token,
|
|
12
|
+
request_device_code,
|
|
13
|
+
)
|
|
14
|
+
from router_maestro.auth.storage import (
|
|
15
|
+
ApiKeyCredential,
|
|
16
|
+
AuthStorage,
|
|
17
|
+
OAuthCredential,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthManager:
|
|
24
|
+
"""Manager for authentication with various providers."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.storage = AuthStorage.load()
|
|
28
|
+
|
|
29
|
+
def save(self) -> None:
|
|
30
|
+
"""Save credentials to storage."""
|
|
31
|
+
self.storage.save()
|
|
32
|
+
|
|
33
|
+
def list_authenticated(self) -> list[str]:
|
|
34
|
+
"""List all authenticated providers."""
|
|
35
|
+
return self.storage.list_providers()
|
|
36
|
+
|
|
37
|
+
def is_authenticated(self, provider: str) -> bool:
|
|
38
|
+
"""Check if a provider is authenticated."""
|
|
39
|
+
return self.storage.get(provider) is not None
|
|
40
|
+
|
|
41
|
+
def get_credential(self, provider: str):
|
|
42
|
+
"""Get credential for a provider."""
|
|
43
|
+
return self.storage.get(provider)
|
|
44
|
+
|
|
45
|
+
def logout(self, provider: str) -> bool:
|
|
46
|
+
"""Log out from a provider."""
|
|
47
|
+
result = self.storage.remove(provider)
|
|
48
|
+
if result:
|
|
49
|
+
self.save()
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
async def login_copilot(self) -> bool:
|
|
53
|
+
"""Authenticate with GitHub Copilot using Device Flow.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if authentication was successful
|
|
57
|
+
"""
|
|
58
|
+
async with httpx.AsyncClient() as client:
|
|
59
|
+
# Step 1: Request device code
|
|
60
|
+
console.print("[yellow]Requesting device code from GitHub...[/yellow]")
|
|
61
|
+
try:
|
|
62
|
+
device_code = await request_device_code(client)
|
|
63
|
+
except httpx.HTTPError as e:
|
|
64
|
+
console.print(f"[red]Failed to get device code: {e}[/red]")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# Step 2: Show user code and verification URL
|
|
68
|
+
console.print()
|
|
69
|
+
console.print(
|
|
70
|
+
"[bold green]Please visit the following URL and enter the code:[/bold green]"
|
|
71
|
+
)
|
|
72
|
+
uri = device_code.verification_uri
|
|
73
|
+
console.print(f" URL: [link={uri}]{uri}[/link]")
|
|
74
|
+
console.print(f" Code: [bold cyan]{device_code.user_code}[/bold cyan]")
|
|
75
|
+
console.print()
|
|
76
|
+
console.print("[dim]Waiting for authorization...[/dim]")
|
|
77
|
+
|
|
78
|
+
# Step 3: Poll for access token
|
|
79
|
+
try:
|
|
80
|
+
access_token = await poll_access_token(
|
|
81
|
+
client,
|
|
82
|
+
device_code.device_code,
|
|
83
|
+
interval=device_code.interval,
|
|
84
|
+
)
|
|
85
|
+
except GitHubOAuthError as e:
|
|
86
|
+
console.print(f"[red]Authorization failed: {e}[/red]")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
console.print("[green]GitHub authorization successful![/green]")
|
|
90
|
+
|
|
91
|
+
# Step 4: Get Copilot token
|
|
92
|
+
console.print("[yellow]Getting Copilot token...[/yellow]")
|
|
93
|
+
try:
|
|
94
|
+
copilot_token = await get_copilot_token(client, access_token.access_token)
|
|
95
|
+
except httpx.HTTPError as e:
|
|
96
|
+
console.print(f"[red]Failed to get Copilot token: {e}[/red]")
|
|
97
|
+
console.print(
|
|
98
|
+
"[dim]Note: Make sure you have an active GitHub Copilot subscription.[/dim]"
|
|
99
|
+
)
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Step 5: Save credentials
|
|
103
|
+
self.storage.set(
|
|
104
|
+
"github-copilot",
|
|
105
|
+
OAuthCredential(
|
|
106
|
+
refresh=access_token.access_token, # GitHub token for refresh
|
|
107
|
+
access=copilot_token.token, # Copilot token for API calls
|
|
108
|
+
expires=copilot_token.expires_at,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
self.save()
|
|
112
|
+
|
|
113
|
+
console.print(
|
|
114
|
+
"[bold green]Successfully authenticated with GitHub Copilot![/bold green]"
|
|
115
|
+
)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def login_api_key(self, provider: str, api_key: str) -> bool:
|
|
119
|
+
"""Authenticate with an API key.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
provider: Provider name (e.g., "openai", "anthropic")
|
|
123
|
+
api_key: API key
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if authentication was successful
|
|
127
|
+
"""
|
|
128
|
+
self.storage.set(provider, ApiKeyCredential(key=api_key))
|
|
129
|
+
self.save()
|
|
130
|
+
console.print(f"[green]Successfully saved API key for {provider}[/green]")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_async(coro):
|
|
135
|
+
"""Run an async coroutine in sync context."""
|
|
136
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Auth storage for credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from router_maestro.config.paths import AUTH_FILE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthType(str, Enum):
|
|
13
|
+
"""Authentication type."""
|
|
14
|
+
|
|
15
|
+
OAUTH = "oauth"
|
|
16
|
+
API_KEY = "api"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OAuthCredential(BaseModel):
|
|
20
|
+
"""OAuth credential storage."""
|
|
21
|
+
|
|
22
|
+
type: AuthType = AuthType.OAUTH
|
|
23
|
+
refresh: str = Field(..., description="Refresh token")
|
|
24
|
+
access: str = Field(..., description="Access token")
|
|
25
|
+
expires: int = Field(default=0, description="Expiration timestamp (0 = never)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiKeyCredential(BaseModel):
|
|
29
|
+
"""API key credential storage."""
|
|
30
|
+
|
|
31
|
+
type: AuthType = AuthType.API_KEY
|
|
32
|
+
key: str = Field(..., description="API key")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Credential = OAuthCredential | ApiKeyCredential
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthStorage(BaseModel):
|
|
39
|
+
"""Root storage for all credentials."""
|
|
40
|
+
|
|
41
|
+
credentials: dict[str, Credential] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(cls, path: Path = AUTH_FILE) -> "AuthStorage":
|
|
45
|
+
"""Load credentials from file."""
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return cls()
|
|
48
|
+
|
|
49
|
+
with open(path, encoding="utf-8") as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
|
|
52
|
+
# Parse credentials based on type
|
|
53
|
+
credentials = {}
|
|
54
|
+
for name, cred_data in data.items():
|
|
55
|
+
if cred_data.get("type") == "oauth":
|
|
56
|
+
credentials[name] = OAuthCredential.model_validate(cred_data)
|
|
57
|
+
elif cred_data.get("type") == "api":
|
|
58
|
+
credentials[name] = ApiKeyCredential.model_validate(cred_data)
|
|
59
|
+
|
|
60
|
+
return cls(credentials=credentials)
|
|
61
|
+
|
|
62
|
+
def save(self, path: Path = AUTH_FILE) -> None:
|
|
63
|
+
"""Save credentials to file."""
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Convert to dict format matching the spec
|
|
67
|
+
data = {}
|
|
68
|
+
for name, cred in self.credentials.items():
|
|
69
|
+
data[name] = cred.model_dump(mode="json")
|
|
70
|
+
|
|
71
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
72
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
73
|
+
|
|
74
|
+
def get(self, provider: str) -> Credential | None:
|
|
75
|
+
"""Get credential for a provider."""
|
|
76
|
+
return self.credentials.get(provider)
|
|
77
|
+
|
|
78
|
+
def set(self, provider: str, credential: Credential) -> None:
|
|
79
|
+
"""Set credential for a provider."""
|
|
80
|
+
self.credentials[provider] = credential
|
|
81
|
+
|
|
82
|
+
def remove(self, provider: str) -> bool:
|
|
83
|
+
"""Remove credential for a provider. Returns True if removed."""
|
|
84
|
+
if provider in self.credentials:
|
|
85
|
+
del self.credentials[provider]
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def list_providers(self) -> list[str]:
|
|
90
|
+
"""List all authenticated providers."""
|
|
91
|
+
return list(self.credentials.keys())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for router-maestro."""
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Authentication management commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from router_maestro.cli.client import AdminClient, ServerNotRunningError, get_admin_client
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
PROVIDERS = {
|
|
16
|
+
"github-copilot": {"name": "GitHub Copilot", "auth_type": "oauth"},
|
|
17
|
+
"openai": {"name": "OpenAI", "auth_type": "api"},
|
|
18
|
+
"anthropic": {"name": "Anthropic", "auth_type": "api"},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _handle_server_error(e: Exception) -> None:
|
|
23
|
+
"""Handle server connection errors."""
|
|
24
|
+
if isinstance(e, ServerNotRunningError):
|
|
25
|
+
console.print(f"[red]{e}[/red]")
|
|
26
|
+
else:
|
|
27
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def login(
|
|
33
|
+
provider: str = typer.Argument(None, help="Provider to authenticate with"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Authenticate with a provider (interactive selection if not specified)."""
|
|
36
|
+
client = get_admin_client()
|
|
37
|
+
|
|
38
|
+
if provider is None:
|
|
39
|
+
# Interactive selection - get current status from server
|
|
40
|
+
try:
|
|
41
|
+
authenticated = asyncio.run(client.list_auth())
|
|
42
|
+
auth_providers = {p["provider"] for p in authenticated}
|
|
43
|
+
except Exception as e:
|
|
44
|
+
_handle_server_error(e)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
console.print("\n[bold]Available providers:[/bold]")
|
|
48
|
+
for i, (key, info) in enumerate(PROVIDERS.items(), 1):
|
|
49
|
+
status = "[green]✓[/green]" if key in auth_providers else "[dim]○[/dim]"
|
|
50
|
+
console.print(f" {i}. {status} {info['name']} ({key})")
|
|
51
|
+
|
|
52
|
+
console.print()
|
|
53
|
+
choice = Prompt.ask(
|
|
54
|
+
"Select provider",
|
|
55
|
+
choices=[str(i) for i in range(1, len(PROVIDERS) + 1)],
|
|
56
|
+
)
|
|
57
|
+
provider = list(PROVIDERS.keys())[int(choice) - 1]
|
|
58
|
+
|
|
59
|
+
if provider not in PROVIDERS:
|
|
60
|
+
console.print(f"[red]Unknown provider: {provider}[/red]")
|
|
61
|
+
console.print(f"[dim]Available: {', '.join(PROVIDERS.keys())}[/dim]")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
provider_info = PROVIDERS[provider]
|
|
65
|
+
console.print(f"\n[bold]Authenticating with {provider_info['name']}...[/bold]\n")
|
|
66
|
+
|
|
67
|
+
asyncio.run(_do_login(client, provider, provider_info))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _do_login(client: AdminClient, provider: str, provider_info: dict) -> None:
|
|
71
|
+
"""Handle authentication flow via HTTP API."""
|
|
72
|
+
try:
|
|
73
|
+
if provider_info["auth_type"] == "oauth":
|
|
74
|
+
# OAuth device flow
|
|
75
|
+
result = await client.login_oauth(provider)
|
|
76
|
+
|
|
77
|
+
console.print(
|
|
78
|
+
"[bold green]Please visit the following URL and enter the code:[/bold green]"
|
|
79
|
+
)
|
|
80
|
+
uri = result["verification_uri"]
|
|
81
|
+
console.print(f" URL: [link={uri}]{uri}[/link]")
|
|
82
|
+
console.print(f" Code: [bold cyan]{result['user_code']}[/bold cyan]")
|
|
83
|
+
console.print()
|
|
84
|
+
console.print("[dim]Waiting for authorization...[/dim]")
|
|
85
|
+
|
|
86
|
+
# Poll for completion
|
|
87
|
+
session_id = result["session_id"]
|
|
88
|
+
while True:
|
|
89
|
+
await asyncio.sleep(5)
|
|
90
|
+
status = await client.poll_oauth_status(session_id)
|
|
91
|
+
|
|
92
|
+
if status["status"] == "complete":
|
|
93
|
+
console.print("[bold green]Successfully authenticated![/bold green]")
|
|
94
|
+
break
|
|
95
|
+
elif status["status"] in ("error", "expired"):
|
|
96
|
+
error_msg = status.get("error", "Authentication failed")
|
|
97
|
+
console.print(f"[red]{error_msg}[/red]")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
# status == "pending" - continue polling
|
|
100
|
+
else:
|
|
101
|
+
# API key auth
|
|
102
|
+
api_key = Prompt.ask(f"Enter API key for {provider_info['name']}", password=True)
|
|
103
|
+
if not api_key:
|
|
104
|
+
console.print("[red]API key cannot be empty[/red]")
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
success = await client.login_api_key(provider, api_key)
|
|
108
|
+
if success:
|
|
109
|
+
console.print(f"[green]Successfully saved API key for {provider}[/green]")
|
|
110
|
+
else:
|
|
111
|
+
console.print(f"[red]Failed to save API key for {provider}[/red]")
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
|
|
114
|
+
except ServerNotRunningError as e:
|
|
115
|
+
console.print(f"[red]{e}[/red]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
except typer.Exit:
|
|
118
|
+
raise
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[red]Failed to authenticate: {e}[/red]")
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def logout(
|
|
126
|
+
provider: str = typer.Argument(..., help="Provider to log out from"),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Log out from a provider."""
|
|
129
|
+
client = get_admin_client()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
success = asyncio.run(client.logout(provider))
|
|
133
|
+
if success:
|
|
134
|
+
console.print(f"[green]Successfully logged out from {provider}[/green]")
|
|
135
|
+
else:
|
|
136
|
+
console.print(f"[yellow]Not authenticated with {provider}[/yellow]")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
_handle_server_error(e)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command(name="list")
|
|
142
|
+
def list_auth() -> None:
|
|
143
|
+
"""List all authenticated providers."""
|
|
144
|
+
client = get_admin_client()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
authenticated = asyncio.run(client.list_auth())
|
|
148
|
+
except Exception as e:
|
|
149
|
+
_handle_server_error(e)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if not authenticated:
|
|
153
|
+
console.print("[dim]No providers authenticated yet.[/dim]")
|
|
154
|
+
console.print("[dim]Use 'router-maestro auth login' to authenticate.[/dim]")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
table = Table(title="Authenticated Providers")
|
|
158
|
+
table.add_column("Provider", style="cyan")
|
|
159
|
+
table.add_column("Type", style="magenta")
|
|
160
|
+
table.add_column("Status", style="green")
|
|
161
|
+
|
|
162
|
+
for provider_info in authenticated:
|
|
163
|
+
auth_type = "OAuth" if provider_info["auth_type"] == "oauth" else "API Key"
|
|
164
|
+
status = "✓ Active" if provider_info["status"] == "active" else "⚠ Expired"
|
|
165
|
+
table.add_row(provider_info["provider"], auth_type, status)
|
|
166
|
+
|
|
167
|
+
console.print(table)
|