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.
Files changed (57) hide show
  1. router_maestro/__init__.py +3 -0
  2. router_maestro/__main__.py +6 -0
  3. router_maestro/auth/__init__.py +18 -0
  4. router_maestro/auth/github_oauth.py +181 -0
  5. router_maestro/auth/manager.py +136 -0
  6. router_maestro/auth/storage.py +91 -0
  7. router_maestro/cli/__init__.py +1 -0
  8. router_maestro/cli/auth.py +167 -0
  9. router_maestro/cli/client.py +322 -0
  10. router_maestro/cli/config.py +132 -0
  11. router_maestro/cli/context.py +146 -0
  12. router_maestro/cli/main.py +42 -0
  13. router_maestro/cli/model.py +288 -0
  14. router_maestro/cli/server.py +117 -0
  15. router_maestro/cli/stats.py +76 -0
  16. router_maestro/config/__init__.py +72 -0
  17. router_maestro/config/contexts.py +29 -0
  18. router_maestro/config/paths.py +50 -0
  19. router_maestro/config/priorities.py +93 -0
  20. router_maestro/config/providers.py +34 -0
  21. router_maestro/config/server.py +115 -0
  22. router_maestro/config/settings.py +76 -0
  23. router_maestro/providers/__init__.py +31 -0
  24. router_maestro/providers/anthropic.py +203 -0
  25. router_maestro/providers/base.py +123 -0
  26. router_maestro/providers/copilot.py +346 -0
  27. router_maestro/providers/openai.py +188 -0
  28. router_maestro/providers/openai_compat.py +175 -0
  29. router_maestro/routing/__init__.py +5 -0
  30. router_maestro/routing/router.py +526 -0
  31. router_maestro/server/__init__.py +5 -0
  32. router_maestro/server/app.py +87 -0
  33. router_maestro/server/middleware/__init__.py +11 -0
  34. router_maestro/server/middleware/auth.py +66 -0
  35. router_maestro/server/oauth_sessions.py +159 -0
  36. router_maestro/server/routes/__init__.py +8 -0
  37. router_maestro/server/routes/admin.py +358 -0
  38. router_maestro/server/routes/anthropic.py +228 -0
  39. router_maestro/server/routes/chat.py +142 -0
  40. router_maestro/server/routes/models.py +34 -0
  41. router_maestro/server/schemas/__init__.py +57 -0
  42. router_maestro/server/schemas/admin.py +87 -0
  43. router_maestro/server/schemas/anthropic.py +246 -0
  44. router_maestro/server/schemas/openai.py +107 -0
  45. router_maestro/server/translation.py +636 -0
  46. router_maestro/stats/__init__.py +14 -0
  47. router_maestro/stats/heatmap.py +154 -0
  48. router_maestro/stats/storage.py +228 -0
  49. router_maestro/stats/tracker.py +73 -0
  50. router_maestro/utils/__init__.py +16 -0
  51. router_maestro/utils/logging.py +81 -0
  52. router_maestro/utils/tokens.py +51 -0
  53. router_maestro-0.1.2.dist-info/METADATA +383 -0
  54. router_maestro-0.1.2.dist-info/RECORD +57 -0
  55. router_maestro-0.1.2.dist-info/WHEEL +4 -0
  56. router_maestro-0.1.2.dist-info/entry_points.txt +2 -0
  57. router_maestro-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Router-Maestro: Multi-model routing and load balancing system."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,6 @@
1
+ """CLI entry point for router-maestro."""
2
+
3
+ from router_maestro.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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)