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,322 @@
|
|
|
1
|
+
"""Admin client for CLI operations.
|
|
2
|
+
|
|
3
|
+
All CLI commands use HTTP API to communicate with the server.
|
|
4
|
+
This ensures consistent behavior between local and remote contexts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from router_maestro.config import load_contexts_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdminClientError(Exception):
|
|
13
|
+
"""Error from admin client operations."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerNotRunningError(AdminClientError):
|
|
19
|
+
"""Server is not running."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, endpoint: str) -> None:
|
|
22
|
+
self.endpoint = endpoint
|
|
23
|
+
super().__init__(
|
|
24
|
+
f"Server is not running at {endpoint}. Start it with: router-maestro server start"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AdminClient:
|
|
29
|
+
"""HTTP client for server admin operations.
|
|
30
|
+
|
|
31
|
+
All operations go through the HTTP API, ensuring consistent behavior
|
|
32
|
+
whether connecting to a local or remote server.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, endpoint: str, api_key: str | None) -> None:
|
|
36
|
+
self.endpoint = endpoint.rstrip("/")
|
|
37
|
+
self.api_key = api_key
|
|
38
|
+
|
|
39
|
+
def _get_headers(self) -> dict[str, str]:
|
|
40
|
+
"""Get headers for API requests."""
|
|
41
|
+
headers = {"Content-Type": "application/json"}
|
|
42
|
+
if self.api_key:
|
|
43
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
44
|
+
return headers
|
|
45
|
+
|
|
46
|
+
def _handle_connection_error(self, e: Exception) -> None:
|
|
47
|
+
"""Handle connection errors with helpful messages."""
|
|
48
|
+
if isinstance(e, httpx.ConnectError):
|
|
49
|
+
raise ServerNotRunningError(self.endpoint) from e
|
|
50
|
+
raise AdminClientError(f"Request failed: {e}") from e
|
|
51
|
+
|
|
52
|
+
async def list_auth(self) -> list[dict]:
|
|
53
|
+
"""List authenticated providers.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of dicts with provider, auth_type, status
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
async with httpx.AsyncClient() as client:
|
|
60
|
+
response = await client.get(
|
|
61
|
+
f"{self.endpoint}/api/admin/auth",
|
|
62
|
+
headers=self._get_headers(),
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
data = response.json()
|
|
66
|
+
return data.get("providers", [])
|
|
67
|
+
except httpx.HTTPError as e:
|
|
68
|
+
self._handle_connection_error(e)
|
|
69
|
+
return [] # unreachable, for type checker
|
|
70
|
+
|
|
71
|
+
async def login_oauth(self, provider: str) -> dict:
|
|
72
|
+
"""Initiate OAuth login.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
provider: Provider name (e.g., 'github-copilot')
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict with session_id, user_code, verification_uri, expires_in
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
async with httpx.AsyncClient() as client:
|
|
82
|
+
response = await client.post(
|
|
83
|
+
f"{self.endpoint}/api/admin/auth/login",
|
|
84
|
+
headers=self._get_headers(),
|
|
85
|
+
json={"provider": provider},
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response.json()
|
|
89
|
+
except httpx.HTTPError as e:
|
|
90
|
+
self._handle_connection_error(e)
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
async def login_api_key(self, provider: str, api_key: str) -> bool:
|
|
94
|
+
"""Login with API key.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
provider: Provider name
|
|
98
|
+
api_key: API key
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True if successful
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
async with httpx.AsyncClient() as client:
|
|
105
|
+
response = await client.post(
|
|
106
|
+
f"{self.endpoint}/api/admin/auth/login",
|
|
107
|
+
headers=self._get_headers(),
|
|
108
|
+
json={"provider": provider, "api_key": api_key},
|
|
109
|
+
)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
data = response.json()
|
|
112
|
+
return data.get("success", False)
|
|
113
|
+
except httpx.HTTPError as e:
|
|
114
|
+
self._handle_connection_error(e)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
async def logout(self, provider: str) -> bool:
|
|
118
|
+
"""Logout from a provider.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
provider: Provider name
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if successful
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
async with httpx.AsyncClient() as client:
|
|
128
|
+
response = await client.delete(
|
|
129
|
+
f"{self.endpoint}/api/admin/auth/{provider}",
|
|
130
|
+
headers=self._get_headers(),
|
|
131
|
+
)
|
|
132
|
+
if response.status_code == 404:
|
|
133
|
+
return False
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
data = response.json()
|
|
136
|
+
return data.get("success", False)
|
|
137
|
+
except httpx.HTTPError as e:
|
|
138
|
+
self._handle_connection_error(e)
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
async def poll_oauth_status(self, session_id: str) -> dict:
|
|
142
|
+
"""Poll OAuth session status.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
session_id: Session ID from login_oauth
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dict with status ('pending', 'complete', 'expired', 'error') and optional error
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
async with httpx.AsyncClient() as client:
|
|
152
|
+
response = await client.get(
|
|
153
|
+
f"{self.endpoint}/api/admin/auth/oauth/status/{session_id}",
|
|
154
|
+
headers=self._get_headers(),
|
|
155
|
+
)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
return response.json()
|
|
158
|
+
except httpx.HTTPError as e:
|
|
159
|
+
self._handle_connection_error(e)
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
async def list_models(self) -> list[dict]:
|
|
163
|
+
"""List available models.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of dicts with provider, id, name
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
async with httpx.AsyncClient() as client:
|
|
170
|
+
response = await client.get(
|
|
171
|
+
f"{self.endpoint}/api/admin/models",
|
|
172
|
+
headers=self._get_headers(),
|
|
173
|
+
)
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
data = response.json()
|
|
176
|
+
return data.get("models", [])
|
|
177
|
+
except httpx.HTTPError as e:
|
|
178
|
+
self._handle_connection_error(e)
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
async def refresh_models(self) -> bool:
|
|
182
|
+
"""Refresh the models cache on the server.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if successful
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
async with httpx.AsyncClient() as client:
|
|
189
|
+
response = await client.post(
|
|
190
|
+
f"{self.endpoint}/api/admin/models/refresh",
|
|
191
|
+
headers=self._get_headers(),
|
|
192
|
+
)
|
|
193
|
+
response.raise_for_status()
|
|
194
|
+
return True
|
|
195
|
+
except httpx.HTTPError as e:
|
|
196
|
+
self._handle_connection_error(e)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
async def get_priorities(self) -> dict:
|
|
200
|
+
"""Get priority configuration.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Dict with priorities list and fallback config
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
async with httpx.AsyncClient() as client:
|
|
207
|
+
response = await client.get(
|
|
208
|
+
f"{self.endpoint}/api/admin/priorities",
|
|
209
|
+
headers=self._get_headers(),
|
|
210
|
+
)
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
return response.json()
|
|
213
|
+
except httpx.HTTPError as e:
|
|
214
|
+
self._handle_connection_error(e)
|
|
215
|
+
return {}
|
|
216
|
+
|
|
217
|
+
async def set_priorities(self, priorities: list[str], fallback: dict | None = None) -> bool:
|
|
218
|
+
"""Set priority configuration.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
priorities: List of model keys (provider/model)
|
|
222
|
+
fallback: Optional fallback configuration
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if successful
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
async with httpx.AsyncClient() as client:
|
|
229
|
+
payload: dict = {"priorities": priorities}
|
|
230
|
+
if fallback is not None:
|
|
231
|
+
payload["fallback"] = fallback
|
|
232
|
+
|
|
233
|
+
response = await client.put(
|
|
234
|
+
f"{self.endpoint}/api/admin/priorities",
|
|
235
|
+
headers=self._get_headers(),
|
|
236
|
+
json=payload,
|
|
237
|
+
)
|
|
238
|
+
response.raise_for_status()
|
|
239
|
+
return True
|
|
240
|
+
except httpx.HTTPError as e:
|
|
241
|
+
self._handle_connection_error(e)
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
async def get_stats(
|
|
245
|
+
self, days: int = 7, provider: str | None = None, model: str | None = None
|
|
246
|
+
) -> dict:
|
|
247
|
+
"""Get usage statistics.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
days: Number of days to query
|
|
251
|
+
provider: Optional provider filter
|
|
252
|
+
model: Optional model filter
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Stats dict with total_requests, total_tokens, by_provider, by_model
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
async with httpx.AsyncClient() as client:
|
|
259
|
+
params: dict = {"days": days}
|
|
260
|
+
if provider:
|
|
261
|
+
params["provider"] = provider
|
|
262
|
+
if model:
|
|
263
|
+
params["model"] = model
|
|
264
|
+
|
|
265
|
+
response = await client.get(
|
|
266
|
+
f"{self.endpoint}/api/admin/stats",
|
|
267
|
+
headers=self._get_headers(),
|
|
268
|
+
params=params,
|
|
269
|
+
)
|
|
270
|
+
response.raise_for_status()
|
|
271
|
+
return response.json()
|
|
272
|
+
except httpx.HTTPError as e:
|
|
273
|
+
self._handle_connection_error(e)
|
|
274
|
+
return {}
|
|
275
|
+
|
|
276
|
+
async def test_connection(self) -> dict:
|
|
277
|
+
"""Test connection to the server.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dict with server info (name, version, status)
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ServerNotRunningError: If server is not running
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
287
|
+
response = await client.get(
|
|
288
|
+
f"{self.endpoint}/",
|
|
289
|
+
headers=self._get_headers(),
|
|
290
|
+
)
|
|
291
|
+
response.raise_for_status()
|
|
292
|
+
return response.json()
|
|
293
|
+
except httpx.HTTPError as e:
|
|
294
|
+
self._handle_connection_error(e)
|
|
295
|
+
return {}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def get_admin_client() -> AdminClient:
|
|
299
|
+
"""Get admin client for current context.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
AdminClient configured with current context's endpoint and API key
|
|
303
|
+
"""
|
|
304
|
+
config = load_contexts_config()
|
|
305
|
+
ctx = config.contexts.get(config.current)
|
|
306
|
+
|
|
307
|
+
if not ctx:
|
|
308
|
+
# Fallback to default local endpoint
|
|
309
|
+
return AdminClient("http://localhost:8080", None)
|
|
310
|
+
|
|
311
|
+
return AdminClient(ctx.endpoint, ctx.api_key)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_current_endpoint() -> str:
|
|
315
|
+
"""Get the endpoint for the current context.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The endpoint URL
|
|
319
|
+
"""
|
|
320
|
+
config = load_contexts_config()
|
|
321
|
+
ctx = config.contexts.get(config.current)
|
|
322
|
+
return ctx.endpoint if ctx else "http://localhost:8080"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Configuration management commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Confirm, Prompt
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from router_maestro.cli.client import ServerNotRunningError, get_admin_client
|
|
16
|
+
from router_maestro.config.server import get_current_context_api_key
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_claude_code_paths() -> dict[str, Path]:
|
|
23
|
+
"""Get Claude Code settings paths."""
|
|
24
|
+
return {
|
|
25
|
+
"user": Path.home() / ".claude" / "settings.json",
|
|
26
|
+
"project": Path.cwd() / ".claude" / "settings.json",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command(name="claude-code")
|
|
31
|
+
def claude_code_config() -> None:
|
|
32
|
+
"""Generate Claude Code CLI settings.json for router-maestro."""
|
|
33
|
+
# Step 1: Select level
|
|
34
|
+
console.print("\n[bold]Step 1: Select configuration level[/bold]")
|
|
35
|
+
console.print(" 1. User-level (~/.claude/settings.json)")
|
|
36
|
+
console.print(" 2. Project-level (./.claude/settings.json)")
|
|
37
|
+
choice = Prompt.ask("Select", choices=["1", "2"], default="1")
|
|
38
|
+
|
|
39
|
+
paths = get_claude_code_paths()
|
|
40
|
+
level = "user" if choice == "1" else "project"
|
|
41
|
+
settings_path = paths[level]
|
|
42
|
+
|
|
43
|
+
# Step 2: Backup if exists
|
|
44
|
+
if settings_path.exists():
|
|
45
|
+
console.print(f"\n[yellow]settings.json already exists at {settings_path}[/yellow]")
|
|
46
|
+
if Confirm.ask("Backup existing file?", default=True):
|
|
47
|
+
backup_path = settings_path.with_suffix(
|
|
48
|
+
f".json.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
49
|
+
)
|
|
50
|
+
shutil.copy(settings_path, backup_path)
|
|
51
|
+
console.print(f"[green]Backed up to {backup_path}[/green]")
|
|
52
|
+
|
|
53
|
+
# Step 3 & 4: Select models from server
|
|
54
|
+
try:
|
|
55
|
+
client = get_admin_client()
|
|
56
|
+
models = asyncio.run(client.list_models())
|
|
57
|
+
except ServerNotRunningError as e:
|
|
58
|
+
console.print(f"[red]{e}[/red]")
|
|
59
|
+
console.print("[dim]Tip: Start router-maestro server first.[/dim]")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
if not models:
|
|
66
|
+
console.print("[red]No models available. Please authenticate first.[/red]")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
# Display models
|
|
70
|
+
console.print("\n[bold]Available models:[/bold]")
|
|
71
|
+
table = Table()
|
|
72
|
+
table.add_column("#", style="dim")
|
|
73
|
+
table.add_column("Model Key", style="green")
|
|
74
|
+
table.add_column("Name", style="white")
|
|
75
|
+
for i, model in enumerate(models, 1):
|
|
76
|
+
table.add_row(str(i), f"{model['provider']}/{model['id']}", model["name"])
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
# Select main model
|
|
80
|
+
console.print("\n[bold]Step 3: Select main model[/bold]")
|
|
81
|
+
main_choice = Prompt.ask("Enter number (or 0 for auto-routing)", default="0")
|
|
82
|
+
main_model = "router-maestro"
|
|
83
|
+
if main_choice != "0" and main_choice.isdigit():
|
|
84
|
+
idx = int(main_choice) - 1
|
|
85
|
+
if 0 <= idx < len(models):
|
|
86
|
+
m = models[idx]
|
|
87
|
+
main_model = f"{m['provider']}/{m['id']}"
|
|
88
|
+
|
|
89
|
+
# Select fast model
|
|
90
|
+
console.print("\n[bold]Step 4: Select small/fast model[/bold]")
|
|
91
|
+
fast_choice = Prompt.ask("Enter number", default="1")
|
|
92
|
+
fast_model = "router-maestro"
|
|
93
|
+
if fast_choice.isdigit():
|
|
94
|
+
idx = int(fast_choice) - 1
|
|
95
|
+
if 0 <= idx < len(models):
|
|
96
|
+
m = models[idx]
|
|
97
|
+
fast_model = f"{m['provider']}/{m['id']}"
|
|
98
|
+
|
|
99
|
+
# Step 5: Generate config
|
|
100
|
+
auth_token = get_current_context_api_key() or "router-maestro"
|
|
101
|
+
client = get_admin_client()
|
|
102
|
+
base_url = (
|
|
103
|
+
client.endpoint.rstrip("/") if hasattr(client, "endpoint") else "http://localhost:8080"
|
|
104
|
+
)
|
|
105
|
+
anthropic_url = f"{base_url}/api/anthropic"
|
|
106
|
+
|
|
107
|
+
config = {
|
|
108
|
+
"env": {
|
|
109
|
+
"ANTHROPIC_BASE_URL": anthropic_url,
|
|
110
|
+
"ANTHROPIC_AUTH_TOKEN": auth_token,
|
|
111
|
+
"ANTHROPIC_MODEL": main_model,
|
|
112
|
+
"ANTHROPIC_SMALL_FAST_MODEL": fast_model,
|
|
113
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
119
|
+
json.dump(config, f, indent=2)
|
|
120
|
+
|
|
121
|
+
console.print(
|
|
122
|
+
Panel(
|
|
123
|
+
f"[green]Created {settings_path}[/green]\n\n"
|
|
124
|
+
f"Main model: {main_model}\n"
|
|
125
|
+
f"Fast model: {fast_model}\n\n"
|
|
126
|
+
f"Endpoint: {anthropic_url}\n\n"
|
|
127
|
+
"[dim]Start router-maestro server before using Claude Code:[/dim]\n"
|
|
128
|
+
" router-maestro server start",
|
|
129
|
+
title="Success",
|
|
130
|
+
border_style="green",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Context management commands for remote deployments."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from router_maestro.cli.client import AdminClientError, get_admin_client
|
|
10
|
+
from router_maestro.config import (
|
|
11
|
+
ContextConfig,
|
|
12
|
+
load_contexts_config,
|
|
13
|
+
save_contexts_config,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(name="list")
|
|
21
|
+
def list_contexts() -> None:
|
|
22
|
+
"""List all configured contexts."""
|
|
23
|
+
config = load_contexts_config()
|
|
24
|
+
|
|
25
|
+
if not config.contexts:
|
|
26
|
+
console.print("[dim]No contexts configured.[/dim]")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
table = Table(title="Deployment Contexts")
|
|
30
|
+
table.add_column("Name", style="cyan")
|
|
31
|
+
table.add_column("Endpoint", style="green")
|
|
32
|
+
table.add_column("API Key", style="yellow")
|
|
33
|
+
table.add_column("Current", style="magenta")
|
|
34
|
+
|
|
35
|
+
for name, ctx in config.contexts.items():
|
|
36
|
+
is_current = "✓" if name == config.current else ""
|
|
37
|
+
api_key_display = "****" if ctx.api_key else "-"
|
|
38
|
+
table.add_row(name, ctx.endpoint, api_key_display, is_current)
|
|
39
|
+
|
|
40
|
+
console.print(table)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command(name="set")
|
|
44
|
+
def set_context(
|
|
45
|
+
name: str = typer.Argument(..., help="Context name to switch to"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Switch to a different context."""
|
|
48
|
+
config = load_contexts_config()
|
|
49
|
+
|
|
50
|
+
if name not in config.contexts:
|
|
51
|
+
console.print(f"[red]Context '{name}' not found[/red]")
|
|
52
|
+
console.print(f"[dim]Available: {', '.join(config.contexts.keys())}[/dim]")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
config.current = name
|
|
56
|
+
save_contexts_config(config)
|
|
57
|
+
console.print(f"[green]Switched to context: {name}[/green]")
|
|
58
|
+
console.print(f" Endpoint: {config.contexts[name].endpoint}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def add(
|
|
63
|
+
name: str = typer.Argument(..., help="Name for the new context"),
|
|
64
|
+
endpoint: str = typer.Option(..., "--endpoint", "-e", help="API endpoint URL"),
|
|
65
|
+
api_key: str = typer.Option(None, "--api-key", "-k", help="API key (optional)"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Add a new context."""
|
|
68
|
+
config = load_contexts_config()
|
|
69
|
+
|
|
70
|
+
if name in config.contexts:
|
|
71
|
+
console.print(f"[yellow]Context '{name}' already exists. Overwriting...[/yellow]")
|
|
72
|
+
|
|
73
|
+
config.contexts[name] = ContextConfig(endpoint=endpoint, api_key=api_key)
|
|
74
|
+
save_contexts_config(config)
|
|
75
|
+
|
|
76
|
+
console.print(f"[green]Added context: {name}[/green]")
|
|
77
|
+
console.print(f" Endpoint: {endpoint}")
|
|
78
|
+
if api_key:
|
|
79
|
+
console.print(" API Key: ****")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def remove(
|
|
84
|
+
name: str = typer.Argument(..., help="Context name to remove"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Remove a context."""
|
|
87
|
+
config = load_contexts_config()
|
|
88
|
+
|
|
89
|
+
if name not in config.contexts:
|
|
90
|
+
console.print(f"[red]Context '{name}' not found[/red]")
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
if name == "local":
|
|
94
|
+
console.print("[red]Cannot remove the 'local' context[/red]")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
if name == config.current:
|
|
98
|
+
console.print("[yellow]Switching to 'local' context first...[/yellow]")
|
|
99
|
+
config.current = "local"
|
|
100
|
+
|
|
101
|
+
del config.contexts[name]
|
|
102
|
+
save_contexts_config(config)
|
|
103
|
+
console.print(f"[green]Removed context: {name}[/green]")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def current() -> None:
|
|
108
|
+
"""Show the current context."""
|
|
109
|
+
config = load_contexts_config()
|
|
110
|
+
ctx = config.contexts.get(config.current)
|
|
111
|
+
|
|
112
|
+
if ctx:
|
|
113
|
+
console.print(f"[bold]Current context:[/bold] {config.current}")
|
|
114
|
+
console.print(f" Endpoint: {ctx.endpoint}")
|
|
115
|
+
if ctx.api_key:
|
|
116
|
+
console.print(" API Key: ****")
|
|
117
|
+
else:
|
|
118
|
+
console.print("[yellow]No context selected[/yellow]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def test() -> None:
|
|
123
|
+
"""Test connection to the current context's server."""
|
|
124
|
+
config = load_contexts_config()
|
|
125
|
+
ctx = config.contexts.get(config.current)
|
|
126
|
+
|
|
127
|
+
if not ctx:
|
|
128
|
+
console.print("[red]No context selected[/red]")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
console.print(f"[bold]Testing connection to:[/bold] {config.current}")
|
|
132
|
+
console.print(f" Endpoint: {ctx.endpoint}")
|
|
133
|
+
|
|
134
|
+
client = get_admin_client()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
result = asyncio.run(client.test_connection())
|
|
138
|
+
console.print("\n[green]✓ Connection successful![/green]")
|
|
139
|
+
console.print(f" Server: {result.get('name', 'Unknown')}")
|
|
140
|
+
console.print(f" Version: {result.get('version', 'Unknown')}")
|
|
141
|
+
except AdminClientError as e:
|
|
142
|
+
console.print(f"\n[red]✗ {e}[/red]")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
console.print(f"\n[red]✗ Connection failed: {e}[/red]")
|
|
146
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Router-Maestro CLI application."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from router_maestro import __version__
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="router-maestro",
|
|
10
|
+
help="Multi-model routing and load balancing system with OpenAI-compatible API",
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
# Import and register sub-commands
|
|
17
|
+
from router_maestro.cli import auth, config, context, model, server, stats # noqa: E402
|
|
18
|
+
|
|
19
|
+
app.add_typer(server.app, name="server", help="Manage the API server")
|
|
20
|
+
app.add_typer(auth.app, name="auth", help="Manage authentication for providers")
|
|
21
|
+
app.add_typer(model.app, name="model", help="Manage models and priorities")
|
|
22
|
+
app.add_typer(context.app, name="context", help="Manage deployment contexts")
|
|
23
|
+
app.add_typer(config.app, name="config", help="Manage configuration")
|
|
24
|
+
app.command(name="stats")(stats.stats)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def main(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version and exit"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Router-Maestro: Multi-model routing and load balancing system."""
|
|
33
|
+
if version:
|
|
34
|
+
console.print(f"router-maestro version {__version__}")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
if ctx.invoked_subcommand is None:
|
|
38
|
+
console.print("[yellow]Use --help for usage information[/yellow]")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
app()
|