router-maestro 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -1,3 +1,3 @@
1
1
  """Router-Maestro: Multi-model routing and load balancing system."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -241,38 +241,6 @@ class AdminClient:
241
241
  self._handle_connection_error(e)
242
242
  return False
243
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
244
  async def test_connection(self) -> dict:
277
245
  """Test connection to the server.
278
246
 
@@ -15,9 +15,17 @@ from rich.table import Table
15
15
  from router_maestro.cli.client import ServerNotRunningError, get_admin_client
16
16
  from router_maestro.config.server import get_current_context_api_key
17
17
 
18
- app = typer.Typer(no_args_is_help=True)
18
+ app = typer.Typer(invoke_without_command=True)
19
19
  console = Console()
20
20
 
21
+ # Available CLI tools for configuration
22
+ CLI_TOOLS = {
23
+ "claude-code": {
24
+ "name": "Claude Code",
25
+ "description": "Generate settings.json for Claude Code CLI",
26
+ },
27
+ }
28
+
21
29
 
22
30
  def get_claude_code_paths() -> dict[str, Path]:
23
31
  """Get Claude Code settings paths."""
@@ -27,6 +35,33 @@ def get_claude_code_paths() -> dict[str, Path]:
27
35
  }
28
36
 
29
37
 
38
+ @app.callback(invoke_without_command=True)
39
+ def config_callback(ctx: typer.Context) -> None:
40
+ """Generate configuration for CLI tools (interactive selection if not specified)."""
41
+ if ctx.invoked_subcommand is not None:
42
+ return
43
+
44
+ # Interactive selection
45
+ console.print("\n[bold]Available CLI tools:[/bold]")
46
+ tools = list(CLI_TOOLS.items())
47
+ for i, (key, info) in enumerate(tools, 1):
48
+ console.print(f" {i}. {info['name']} - {info['description']}")
49
+
50
+ console.print()
51
+ choice = Prompt.ask(
52
+ "Select tool to configure",
53
+ choices=[str(i) for i in range(1, len(tools) + 1)],
54
+ default="1",
55
+ )
56
+
57
+ idx = int(choice) - 1
58
+ tool_key = tools[idx][0]
59
+
60
+ # Dispatch to the appropriate command
61
+ if tool_key == "claude-code":
62
+ claude_code_config()
63
+
64
+
30
65
  @app.command(name="claude-code")
31
66
  def claude_code_config() -> None:
32
67
  """Generate Claude Code CLI settings.json for router-maestro."""
@@ -14,14 +14,13 @@ app = typer.Typer(
14
14
  console = Console()
15
15
 
16
16
  # Import and register sub-commands
17
- from router_maestro.cli import auth, config, context, model, server, stats # noqa: E402
17
+ from router_maestro.cli import auth, config, context, model, server # noqa: E402
18
18
 
19
19
  app.add_typer(server.app, name="server", help="Manage the API server")
20
20
  app.add_typer(auth.app, name="auth", help="Manage authentication for providers")
21
21
  app.add_typer(model.app, name="model", help="Manage models and priorities")
22
22
  app.add_typer(context.app, name="context", help="Manage deployment contexts")
23
23
  app.add_typer(config.app, name="config", help="Manage configuration")
24
- app.command(name="stats")(stats.stats)
25
24
 
26
25
 
27
26
  @app.callback(invoke_without_command=True)
@@ -98,7 +98,7 @@ def status() -> None:
98
98
 
99
99
  try:
100
100
  data = asyncio.run(client.test_connection())
101
- console.print(f"[green]Server is running[/green]")
101
+ console.print("[green]Server is running[/green]")
102
102
  console.print(f" Version: {data.get('version', 'unknown')}")
103
103
  console.print(f" Status: {data.get('status', 'unknown')}")
104
104
  except Exception as e:
@@ -8,7 +8,6 @@ from router_maestro.config.paths import (
8
8
  PRIORITIES_FILE,
9
9
  PROVIDERS_FILE,
10
10
  SERVER_CONFIG_FILE,
11
- STATS_DB_FILE,
12
11
  get_config_dir,
13
12
  get_data_dir,
14
13
  )
@@ -45,7 +44,6 @@ __all__ = [
45
44
  "PROVIDERS_FILE",
46
45
  "PRIORITIES_FILE",
47
46
  "CONTEXTS_FILE",
48
- "STATS_DB_FILE",
49
47
  "LOG_FILE",
50
48
  # Provider models
51
49
  "ModelConfig",
@@ -46,5 +46,4 @@ SERVER_CONFIG_FILE = get_data_dir() / "server.json"
46
46
  PROVIDERS_FILE = get_config_dir() / "providers.json"
47
47
  PRIORITIES_FILE = get_config_dir() / "priorities.json"
48
48
  CONTEXTS_FILE = get_config_dir() / "contexts.json"
49
- STATS_DB_FILE = get_data_dir() / "stats.db"
50
49
  LOG_FILE = get_data_dir() / "router-maestro.log"
@@ -382,7 +382,7 @@ class Router:
382
382
  try:
383
383
  await provider.ensure_token()
384
384
  if is_stream:
385
- stream = provider.chat_completion_stream(actual_request)
385
+ stream = await provider.chat_completion_stream(actual_request)
386
386
  logger.info("Stream request routed to %s", provider_name)
387
387
  return stream, provider_name
388
388
  else:
@@ -417,7 +417,7 @@ class Router:
417
417
  try:
418
418
  await other_provider.ensure_token()
419
419
  if is_stream:
420
- stream = other_provider.chat_completion_stream(fallback_request)
420
+ stream = await other_provider.chat_completion_stream(fallback_request)
421
421
  logger.info("Stream fallback succeeded via %s", other_name)
422
422
  return stream, other_name
423
423
  else:
@@ -1,9 +1,7 @@
1
1
  """Admin API routes for remote management."""
2
2
 
3
- from typing import Annotated
4
-
5
3
  import httpx
6
- from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
4
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
7
5
 
8
6
  from router_maestro.auth import AuthManager, AuthType
9
7
  from router_maestro.auth.github_oauth import (
@@ -29,9 +27,7 @@ from router_maestro.server.schemas.admin import (
29
27
  OAuthStatusResponse,
30
28
  PrioritiesResponse,
31
29
  PrioritiesUpdateRequest,
32
- StatsResponse,
33
30
  )
34
- from router_maestro.stats import StatsStorage
35
31
 
36
32
  router = APIRouter(prefix="/api/admin", tags=["admin"])
37
33
 
@@ -288,71 +284,3 @@ async def update_priorities(request: PrioritiesUpdateRequest) -> PrioritiesRespo
288
284
  priorities=config.priorities,
289
285
  fallback=config.fallback.model_dump(),
290
286
  )
291
-
292
-
293
- # ============================================================================
294
- # Stats endpoints
295
- # ============================================================================
296
-
297
-
298
- @router.get("/stats", response_model=StatsResponse)
299
- async def get_stats(
300
- days: Annotated[int, Query(ge=1, le=365)] = 7,
301
- provider: str | None = None,
302
- model: str | None = None,
303
- ) -> StatsResponse:
304
- """Get usage statistics."""
305
- storage = StatsStorage()
306
-
307
- # Get total stats
308
- total = storage.get_total_usage(days=days)
309
-
310
- # Get stats by model (which includes provider info)
311
- by_model_raw = storage.get_usage_by_model(days=days)
312
-
313
- # Aggregate by provider
314
- by_provider: dict[str, dict] = {}
315
- by_model: dict[str, dict] = {}
316
-
317
- for record in by_model_raw:
318
- provider_name = record["provider"]
319
- model_name = record["model"]
320
- model_key = f"{provider_name}/{model_name}"
321
-
322
- # Filter if requested
323
- if provider and provider_name != provider:
324
- continue
325
- if model and model_name != model:
326
- continue
327
-
328
- # Aggregate by provider
329
- if provider_name not in by_provider:
330
- by_provider[provider_name] = {
331
- "total_tokens": 0,
332
- "prompt_tokens": 0,
333
- "completion_tokens": 0,
334
- "request_count": 0,
335
- }
336
-
337
- by_provider[provider_name]["total_tokens"] += record.get("total_tokens", 0) or 0
338
- by_provider[provider_name]["prompt_tokens"] += record.get("prompt_tokens", 0) or 0
339
- by_provider[provider_name]["completion_tokens"] += record.get("completion_tokens", 0) or 0
340
- by_provider[provider_name]["request_count"] += record.get("request_count", 0) or 0
341
-
342
- # Store by model
343
- by_model[model_key] = {
344
- "total_tokens": record.get("total_tokens", 0) or 0,
345
- "prompt_tokens": record.get("prompt_tokens", 0) or 0,
346
- "completion_tokens": record.get("completion_tokens", 0) or 0,
347
- "request_count": record.get("request_count", 0) or 0,
348
- "avg_latency_ms": record.get("avg_latency_ms", 0) or 0,
349
- }
350
-
351
- return StatsResponse(
352
- total_requests=total.get("request_count", 0) or 0,
353
- total_tokens=total.get("total_tokens", 0) or 0,
354
- prompt_tokens=total.get("prompt_tokens", 0) or 0,
355
- completion_tokens=total.get("completion_tokens", 0) or 0,
356
- by_provider=by_provider,
357
- by_model=by_model,
358
- )
@@ -10,8 +10,6 @@ from router_maestro.server.schemas.admin import (
10
10
  OAuthStatusResponse,
11
11
  PrioritiesResponse,
12
12
  PrioritiesUpdateRequest,
13
- StatsQuery,
14
- StatsResponse,
15
13
  )
16
14
  from router_maestro.server.schemas.openai import (
17
15
  ChatCompletionChoice,
@@ -39,8 +37,6 @@ __all__ = [
39
37
  "OAuthStatusResponse",
40
38
  "PrioritiesResponse",
41
39
  "PrioritiesUpdateRequest",
42
- "StatsQuery",
43
- "StatsResponse",
44
40
  # OpenAI schemas
45
41
  "ChatCompletionChoice",
46
42
  "ChatCompletionChunk",
@@ -66,22 +66,3 @@ class PrioritiesUpdateRequest(BaseModel):
66
66
 
67
67
  priorities: list[str] = Field(..., description="New priority list")
68
68
  fallback: dict | None = Field(default=None, description="Optional fallback config update")
69
-
70
-
71
- class StatsQuery(BaseModel):
72
- """Query parameters for stats."""
73
-
74
- days: int = Field(default=7, ge=1, le=365, description="Number of days to query")
75
- provider: str | None = Field(default=None, description="Filter by provider")
76
- model: str | None = Field(default=None, description="Filter by model")
77
-
78
-
79
- class StatsResponse(BaseModel):
80
- """Response for usage statistics."""
81
-
82
- total_requests: int = Field(default=0)
83
- total_tokens: int = Field(default=0)
84
- prompt_tokens: int = Field(default=0)
85
- completion_tokens: int = Field(default=0)
86
- by_provider: dict[str, dict] = Field(default_factory=dict)
87
- by_model: dict[str, dict] = Field(default_factory=dict)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: router-maestro
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Multi-model routing and load balancing system with OpenAI-compatible API
5
5
  Author-email: Kanwen Li <likanwen@icloud.com>
6
6
  License-Expression: MIT
@@ -52,7 +52,6 @@ Router-Maestro acts as a proxy that gives you access to models from multiple pro
52
52
  - **Dual API compatibility**: Both OpenAI (`/v1/...`) and Anthropic (`/v1/messages`) API formats
53
53
  - **Cross-provider translation**: Seamlessly route OpenAI requests to Anthropic providers and vice versa
54
54
  - **Configuration hot-reload**: Auto-reload config files every 5 minutes without server restart
55
- - **Usage tracking**: Token usage statistics with heatmap visualization
56
55
  - **CLI management**: Full command-line interface for configuration and server control
57
56
  - **Docker ready**: Production-ready Docker images with Traefik integration
58
57
 
@@ -60,6 +59,11 @@ Router-Maestro acts as a proxy that gives you access to models from multiple pro
60
59
 
61
60
  - [Quick Start](#quick-start)
62
61
  - [Core Concepts](#core-concepts)
62
+ - [Model Identification](#model-identification)
63
+ - [Auto-Routing](#auto-routing)
64
+ - [Priority & Fallback](#priority--fallback)
65
+ - [Cross-Provider Translation](#cross-provider-translation)
66
+ - [Contexts](#contexts)
63
67
  - [CLI Reference](#cli-reference)
64
68
  - [API Reference](#api-reference)
65
69
  - [Configuration](#configuration)
@@ -70,20 +74,36 @@ Router-Maestro acts as a proxy that gives you access to models from multiple pro
70
74
 
71
75
  Get up and running in 4 steps:
72
76
 
73
- ### 1. Install
77
+ ### 1. Start the Server
78
+
79
+ #### Docker (recommended)
74
80
 
75
81
  ```bash
76
- pip install router-maestro
77
- # or
78
- uv pip install router-maestro
82
+ docker run -d -p 8080:8080 \
83
+ -v ~/.local/share/router-maestro:/home/maestro/.local/share/router-maestro \
84
+ -v ~/.config/router-maestro:/home/maestro/.config/router-maestro \
85
+ likanwen/router-maestro:latest
79
86
  ```
80
87
 
81
- ### 2. Start the Server
88
+ #### Install locally
82
89
 
83
90
  ```bash
91
+ pip install router-maestro
84
92
  router-maestro server start --port 8080
85
93
  ```
86
94
 
95
+ ### 2. Set Context (for Docker or Remote)
96
+
97
+ When running via Docker in remote VPS, set up a context to communicate with the containerized server:
98
+
99
+ ```bash
100
+ pip install router-maestro # Install CLI locally
101
+ router-maestro context add docker --endpoint http://localhost:8080
102
+ router-maestro context set docker
103
+ ```
104
+
105
+ > **What's a context?** A context is a named connection profile (endpoint + API key) that lets you manage local or remote Router-Maestro servers. See [Contexts](#contexts) for details.
106
+
87
107
  ### 3. Authenticate with GitHub Copilot
88
108
 
89
109
  ```bash
@@ -112,8 +132,8 @@ router-maestro config claude-code
112
132
 
113
133
  Models are identified using the format `{provider}/{model-id}`:
114
134
 
115
- | Example | Description |
116
- |---------|-------------|
135
+ | Example | Description |
136
+ | --------------------------------- | ----------------------------------- |
117
137
  | `github-copilot/gpt-4o` | GPT-4o via GitHub Copilot |
118
138
  | `github-copilot/claude-sonnet-4` | Claude Sonnet 4 via GitHub Copilot |
119
139
  | `openai/gpt-4-turbo` | GPT-4 Turbo via OpenAI |
@@ -144,8 +164,8 @@ router-maestro model priority list
144
164
 
145
165
  **Fallback** triggers when a request fails with a retryable error (429, 5xx):
146
166
 
147
- | Strategy | Behavior |
148
- |----------|----------|
167
+ | Strategy | Behavior |
168
+ | ------------ | ------------------------------------ |
149
169
  | `priority` | Try next model in priorities list |
150
170
  | `same-model` | Try same model on different provider |
151
171
  | `none` | Fail immediately |
@@ -171,29 +191,50 @@ POST /v1/messages {"model": "openai/gpt-4o", ...}
171
191
  POST /v1/chat/completions {"model": "anthropic/claude-3-5-sonnet", ...}
172
192
  ```
173
193
 
194
+ ### Contexts
195
+
196
+ A **context** is a named connection profile that stores an endpoint URL and API key. Contexts let you manage multiple Router-Maestro deployments from a single CLI.
197
+
198
+ | Context | Use Case |
199
+ | -------- | ------------------------------------------ |
200
+ | `local` | Default context for `router-maestro server start` |
201
+ | `docker` | Connect to a local Docker container |
202
+ | `my-vps` | Connect to a remote VPS deployment |
203
+
204
+ ```bash
205
+ # Add a context
206
+ router-maestro context add my-vps --endpoint https://api.example.com --api-key xxx
207
+
208
+ # Switch contexts
209
+ router-maestro context set my-vps
210
+
211
+ # All CLI commands now target the remote server
212
+ router-maestro model list
213
+ ```
214
+
174
215
  ## CLI Reference
175
216
 
176
217
  ### Server
177
218
 
178
- | Command | Description |
179
- |---------|-------------|
180
- | `server start --port 8080` | Start the server |
219
+ | Command | Description |
220
+ | -------------------------- | ------------------ |
221
+ | `server start --port 8080` | Start the server |
181
222
  | `server stop` | Stop the server |
182
223
  | `server info` | Show server status |
183
224
 
184
225
  ### Authentication
185
226
 
186
- | Command | Description |
187
- |---------|-------------|
188
- | `auth login [provider]` | Authenticate with a provider |
227
+ | Command | Description |
228
+ | ----------------------- | ------------------------------ |
229
+ | `auth login [provider]` | Authenticate with a provider |
189
230
  | `auth logout <provider>` | Remove authentication |
190
231
  | `auth list` | List authenticated providers |
191
232
 
192
233
  ### Models
193
234
 
194
- | Command | Description |
195
- |---------|-------------|
196
- | `model list` | List available models |
235
+ | Command | Description |
236
+ | ---------------------------------- | ---------------------- |
237
+ | `model list` | List available models |
197
238
  | `model refresh` | Refresh models cache |
198
239
  | `model priority list` | Show priorities |
199
240
  | `model priority <model> --position <n>` | Set priority |
@@ -201,9 +242,9 @@ POST /v1/chat/completions {"model": "anthropic/claude-3-5-sonnet", ...}
201
242
 
202
243
  ### Contexts (Remote Management)
203
244
 
204
- | Command | Description |
205
- |---------|-------------|
206
- | `context show` | Show current context |
245
+ | Command | Description |
246
+ | ---------------------------------------------------- | -------------------- |
247
+ | `context show` | Show current context |
207
248
  | `context list` | List all contexts |
208
249
  | `context set <name>` | Switch context |
209
250
  | `context add <name> --endpoint <url> --api-key <key>` | Add remote context |
@@ -211,11 +252,9 @@ POST /v1/chat/completions {"model": "anthropic/claude-3-5-sonnet", ...}
211
252
 
212
253
  ### Other
213
254
 
214
- | Command | Description |
215
- |---------|-------------|
255
+ | Command | Description |
256
+ | -------------------- | ----------------------------- |
216
257
  | `config claude-code` | Generate Claude Code settings |
217
- | `stats --days 7` | Show usage statistics |
218
- | `stats --days 30 --heatmap` | Show heatmap visualization |
219
258
 
220
259
  ## API Reference
221
260
 
@@ -262,8 +301,8 @@ POST /api/admin/models/refresh # Refresh model cache
262
301
 
263
302
  Following XDG Base Directory specification:
264
303
 
265
- | Type | Path | Contents |
266
- |------|------|----------|
304
+ | Type | Path | Contents |
305
+ | ---------- | ---------------------------------- | ---------------------------- |
267
306
  | **Config** | `~/.config/router-maestro/` | |
268
307
  | | `providers.json` | Custom provider definitions |
269
308
  | | `priorities.json` | Model priorities and fallback |
@@ -271,7 +310,6 @@ Following XDG Base Directory specification:
271
310
  | **Data** | `~/.local/share/router-maestro/` | |
272
311
  | | `auth.json` | OAuth tokens |
273
312
  | | `server.json` | Server state |
274
- | | `stats.db` | Usage statistics |
275
313
 
276
314
  ### Custom Providers
277
315
 
@@ -302,8 +340,8 @@ export OLLAMA_API_KEY="sk-..."
302
340
 
303
341
  Configuration files are automatically reloaded every 5 minutes:
304
342
 
305
- | File | Auto-Reload |
306
- |------|-------------|
343
+ | File | Auto-Reload |
344
+ | ------------------ | ---------------- |
307
345
  | `priorities.json` | ✓ (5 min) |
308
346
  | `providers.json` | ✓ (5 min) |
309
347
  | `auth.json` | Requires restart |
@@ -360,7 +398,6 @@ router-maestro context set my-vps
360
398
 
361
399
  # Now all commands target the VPS
362
400
  router-maestro model list
363
- router-maestro stats --days 7
364
401
  ```
365
402
 
366
403
  ### HTTPS with Traefik
@@ -368,6 +405,7 @@ router-maestro stats --days 7
368
405
  The Docker Compose setup includes Traefik for automatic HTTPS via Let's Encrypt with DNS challenge.
369
406
 
370
407
  For detailed configuration options including:
408
+
371
409
  - Other DNS providers (Route53, DigitalOcean, etc.)
372
410
  - HTTP challenge setup
373
411
  - Traefik dashboard configuration
@@ -1,4 +1,4 @@
1
- router_maestro/__init__.py,sha256=DjSxTt-117MUK3rqehh5MME5nt25J3uPF88_sJGhvBk,92
1
+ router_maestro/__init__.py,sha256=dQ7VGwTEZyoTDYi_HcMlVOwL7DKJ75prK_l_Qa9eN_4,92
2
2
  router_maestro/__main__.py,sha256=cUHr8B7JBiv5HhnN6l2iayDkGSBpI5Kf4I3jv9I_I3o,121
3
3
  router_maestro/auth/__init__.py,sha256=0JgD1w2gtGSkj809kgSKQanYYkncg6eF-hHoz-jQPgo,353
4
4
  router_maestro/auth/github_oauth.py,sha256=acQlAA2Zh6c8KQYdzXbC4ww0EJ41AgvbI5ixpFuNoRg,5060
@@ -6,16 +6,15 @@ router_maestro/auth/manager.py,sha256=OQjdofmjVndTQQIrAU7O-hJAL-w_ijYRU09fZNU8E3
6
6
  router_maestro/auth/storage.py,sha256=TCLxgQ1lWcWD4xJXJzx5OMpvuAun_LSRItK0zhR6H0Y,2755
7
7
  router_maestro/cli/__init__.py,sha256=yIAshaHpLL0WrDFmRpoMRM2EUe75x0wmM5NlGW3C89s,37
8
8
  router_maestro/cli/auth.py,sha256=eq5LBUohbMnHS4dZeyvq4OQAjzdrJ-StP2FGuUhkKa0,5940
9
- router_maestro/cli/client.py,sha256=f1Q4vuS0N7Vfmy1LVavVcTpsKEprjonX4EPVH4SG-zo,10448
10
- router_maestro/cli/config.py,sha256=lVmMlUASUynbqOQawuQQhi8C3h2OvGScZvaeIArZ2ns,4662
9
+ router_maestro/cli/client.py,sha256=mRzpsA_Dxn-Xq7W1_t6EiyddMI0a3cvuTL6-2JuV4mE,9383
10
+ router_maestro/cli/config.py,sha256=9e_O0J-cxX2FCqHl8bKFKh897PK9mErTp31SXYmmU5c,5667
11
11
  router_maestro/cli/context.py,sha256=EPbT7fReIW17veU76CSAcv8QjzMsCIPm1QDBlGsV8fQ,4549
12
- router_maestro/cli/main.py,sha256=L08B23nM-DCC_wgUAk7Dt6TZmYY03t2pbo1Rh2Bzugc,1360
12
+ router_maestro/cli/main.py,sha256=5yiK4Q149goSB2KKzgMuF5EpcC8FBzOUCkEt8wY5NAU,1314
13
13
  router_maestro/cli/model.py,sha256=2IG3IpQWh8Ejdv5Htcgr90O2v2UAa80TU15oOniPdvk,9054
14
- router_maestro/cli/server.py,sha256=iOEOuug8MvPtY5vCL8KubOkHNt35H6R1nI73dStI7Wc,3697
15
- router_maestro/cli/stats.py,sha256=MPRMNbGbXDnb1tIn_jyyJxoHEHLEN1vANLMj1cImkeg,2746
16
- router_maestro/config/__init__.py,sha256=rATq3PIA8vKLpunsCLeCA7GbKjqyzBvlBgI7OwM-6FI,1664
14
+ router_maestro/cli/server.py,sha256=k2OOBBDob55jsQ7XmJ1Tk4_aJgrmFCu4rZPbZuMfmPQ,3696
15
+ router_maestro/config/__init__.py,sha256=-eiXX97Kbd5eIJvbpLbtD6Xrh7XmyFGM_485h6uBokc,1624
17
16
  router_maestro/config/contexts.py,sha256=ujaH5I3xa3Eg_KlRaF5KlmsACuUqoKqsp1-i1J2kQz4,923
18
- router_maestro/config/paths.py,sha256=xaAi9LuTFSag7yGJp9oCnrcTpnXcaPTMKU5yAIYkCT8,1617
17
+ router_maestro/config/paths.py,sha256=F-Q37bGOnQq32MNNbUbvm_ZfZp6-Ik9nVmroEnNNVC8,1573
19
18
  router_maestro/config/priorities.py,sha256=wEBs6CmLQiw0ehuA-kh7topyac6APopxaR83CpnHhDQ,2669
20
19
  router_maestro/config/providers.py,sha256=yMU_DFh4_eQXMT8jGImqwzAPHp0L3fR34gG2r-4Zhnw,1158
21
20
  router_maestro/config/server.py,sha256=Mr7Axbo8b1z3OFEe_G8VuO7wvx_pA32-ZBbT4nSRdX0,3049
@@ -27,7 +26,7 @@ router_maestro/providers/copilot.py,sha256=NieOXBecwymSnB1Nm_JKVvzeZyZnO8g6Y1kNa
27
26
  router_maestro/providers/openai.py,sha256=Bsq5mzAVf4CawH2Tn80y3-MyLLVeZ3VsPxGDNH1t_Nk,7647
28
27
  router_maestro/providers/openai_compat.py,sha256=ef4RttKVZUTBiRed4BEuC2Jg8vr5GM7YqPUtFYeAhZo,6383
29
28
  router_maestro/routing/__init__.py,sha256=eCEQVbg1LAfcSVLQZpZtYf8ImbOhFIaR7POUb1pCbXM,169
30
- router_maestro/routing/router.py,sha256=4T-yBI9fCRKXeyjeVT6aSfALj2OMaNwsn11qXntno-o,20390
29
+ router_maestro/routing/router.py,sha256=uPk_sSyxdcmq-b-LJgPwtQOLNXDAvjDjs2A8po7MqQE,20402
31
30
  router_maestro/server/__init__.py,sha256=YzExJfP0jw6hXx84lo0yPVU0wG17B16SfdyEpjstpxk,128
32
31
  router_maestro/server/app.py,sha256=rI2TuCS_STzmzdds5Tq2vzoP9y9bpRFJJwNs5e8uTbU,2707
33
32
  router_maestro/server/oauth_sessions.py,sha256=r_VM6vAtbo5HAmXjYt6XoECBcWGxqABKGbVmW8HoCz0,4625
@@ -35,23 +34,19 @@ router_maestro/server/translation.py,sha256=TrYfIb5M5MAUC4TD5By8X3RONSZ6cV1YS_Jw
35
34
  router_maestro/server/middleware/__init__.py,sha256=PhtP2E04wApnOUBLE76mrOa0sSHp7reHmIl7PaCAylY,187
36
35
  router_maestro/server/middleware/auth.py,sha256=Ak3k5cC8m4qPGUIheuOB--QiFvs6GIAcTRJqtCGCjAA,2018
37
36
  router_maestro/server/routes/__init__.py,sha256=eGEpNCnSRVQC1pFL7_evDmZfkMrviuI-n1okAS-YnhM,397
38
- router_maestro/server/routes/admin.py,sha256=5xr0D-9SKuz0T48b7pRs9hueHPHTN-WlsBq5r_VCb2s,11500
37
+ router_maestro/server/routes/admin.py,sha256=oub4hDrYaytuorXkJzmz0YZ4Z2rcyNuwKcK_4IGvcDY,8942
39
38
  router_maestro/server/routes/anthropic.py,sha256=T5-rHBPDyPxP4Cs0yzm7Kvvn-zgV6jspnZdoSVDeH2w,8041
40
39
  router_maestro/server/routes/chat.py,sha256=vyYX1ILhgAb9HYD87h1U3c5btpplqkTaejA81pWg4Oo,4752
41
40
  router_maestro/server/routes/models.py,sha256=PTSXojNFN9j90Bke74ZO6sEsfIc8u_4A69eW1QzFIbc,716
42
- router_maestro/server/schemas/__init__.py,sha256=rNGneAw3A3MKdCwdbXURjZcWAjQotgwMvJNRXXp0Lwo,1250
43
- router_maestro/server/schemas/admin.py,sha256=ITmXy9xWhVnZk0T3TSOOMQKgcHzou9fsc8DS30fo2rU,3052
41
+ router_maestro/server/schemas/__init__.py,sha256=VmJZoTMLb-bF33m79urhbejVdLfjDGMqCJP5QvWbHsU,1176
42
+ router_maestro/server/schemas/admin.py,sha256=DuUojkCcq9n8pDhWG6L0SpzQooh91lmHjCRzgZ4AMwk,2369
44
43
  router_maestro/server/schemas/anthropic.py,sha256=hNl6rZ7AX-HdLxtsd0cWpZjpIyK1AkEBcuiQpZQqPYc,6136
45
44
  router_maestro/server/schemas/openai.py,sha256=s2487RYIn1h-CIaUpLue9BScDaTsafbVg5yc-kKhfME,2141
46
- router_maestro/stats/__init__.py,sha256=igFtk7xFgvxk_AQsbQlzz8KBubvrmgnNfe-BS9ufuGM,403
47
- router_maestro/stats/heatmap.py,sha256=G_yerQcLOdP6cND-KTlhXBBNaRdcOBxy6HzGyUlDj6A,5255
48
- router_maestro/stats/storage.py,sha256=WsFrhmgs9eWKcAPRGDrhT4Ft1-Uzuiz3Q0fYNfgVvQs,7676
49
- router_maestro/stats/tracker.py,sha256=iutAfdm6JL2rUTBLUnLbUiSaBEAxkzXmi8d5iEllCcI,1984
50
45
  router_maestro/utils/__init__.py,sha256=oSQyV--FueMPggRfjWWVnAKtjkcZWFOm9hCTymu0oZU,409
51
46
  router_maestro/utils/logging.py,sha256=gJWoRYibAxCWn4VmTmnrwpBRzQ7Uu5YIEk5zDiF9X_k,2393
52
47
  router_maestro/utils/tokens.py,sha256=t2E5BrrE5X3VCgw-rYFMkic7heJ0huj9rrOXAIlKq8o,1330
53
- router_maestro-0.1.2.dist-info/METADATA,sha256=1qwn774LuxcaBm_iiszi5ZcPTS_F7-mRQWi2VgD-5m0,10033
54
- router_maestro-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
55
- router_maestro-0.1.2.dist-info/entry_points.txt,sha256=zoFUxxvNcFe0nTgpRbIdygIDEOla3KbvW6HbOCOlgv4,63
56
- router_maestro-0.1.2.dist-info/licenses/LICENSE,sha256=Ea86BSGu7_tpLAuzif_JmM9zjMoKQEf95VVF9sZw3Jo,1084
57
- router_maestro-0.1.2.dist-info/RECORD,,
48
+ router_maestro-0.1.4.dist-info/METADATA,sha256=0f158gqfAjoReykKlaeBee6eraI8fwKqmAW9WifpKNo,12161
49
+ router_maestro-0.1.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
+ router_maestro-0.1.4.dist-info/entry_points.txt,sha256=zoFUxxvNcFe0nTgpRbIdygIDEOla3KbvW6HbOCOlgv4,63
51
+ router_maestro-0.1.4.dist-info/licenses/LICENSE,sha256=Ea86BSGu7_tpLAuzif_JmM9zjMoKQEf95VVF9sZw3Jo,1084
52
+ router_maestro-0.1.4.dist-info/RECORD,,
@@ -1,76 +0,0 @@
1
- """Token usage statistics command."""
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
-
11
- console = Console()
12
-
13
-
14
- def stats(
15
- days: int = typer.Option(7, "--days", "-d", help="Number of days to show"),
16
- provider: str = typer.Option(None, "--provider", "-p", help="Filter by provider"),
17
- model: str = typer.Option(None, "--model", "-m", help="Filter by model"),
18
- ) -> None:
19
- """Show token usage statistics."""
20
- client = get_admin_client()
21
-
22
- try:
23
- data = asyncio.run(client.get_stats(days=days, provider=provider, model=model))
24
- except AdminClientError as e:
25
- console.print(f"[red]{e}[/red]")
26
- raise typer.Exit(1)
27
- except Exception as e:
28
- console.print(f"[red]Failed to get stats: {e}[/red]")
29
- raise typer.Exit(1)
30
-
31
- if data.get("total_requests", 0) == 0:
32
- console.print("[dim]No usage data available.[/dim]")
33
- return
34
-
35
- # Summary table
36
- console.print(f"\n[bold]Token Usage Summary (Last {days} Days)[/bold]\n")
37
-
38
- summary_table = Table(show_header=False, box=None)
39
- summary_table.add_column("Metric", style="cyan")
40
- summary_table.add_column("Value", style="green", justify="right")
41
-
42
- summary_table.add_row("Total Requests", f"{data.get('total_requests', 0):,}")
43
- summary_table.add_row("Total Tokens", f"{data.get('total_tokens', 0):,}")
44
- summary_table.add_row(" Prompt", f"{data.get('prompt_tokens', 0):,}")
45
- summary_table.add_row(" Completion", f"{data.get('completion_tokens', 0):,}")
46
-
47
- console.print(summary_table)
48
-
49
- # By model table
50
- by_model = data.get("by_model", {})
51
- if by_model:
52
- console.print("\n[bold]Usage by Model[/bold]\n")
53
-
54
- model_table = Table()
55
- model_table.add_column("Model", style="cyan")
56
- model_table.add_column("Provider", style="magenta")
57
- model_table.add_column("Requests", justify="right")
58
- model_table.add_column("Total Tokens", justify="right", style="green")
59
- model_table.add_column("Avg Latency", justify="right")
60
-
61
- for model_key, record in by_model.items():
62
- parts = model_key.split("/", 1)
63
- provider_name = parts[0] if len(parts) > 1 else "-"
64
- model_name = parts[1] if len(parts) > 1 else model_key
65
-
66
- avg_latency = record.get("avg_latency_ms")
67
- latency = f"{avg_latency:.0f} ms" if avg_latency else "-"
68
- model_table.add_row(
69
- model_name,
70
- provider_name,
71
- f"{record.get('request_count', 0):,}",
72
- f"{record.get('total_tokens', 0):,}",
73
- latency,
74
- )
75
-
76
- console.print(model_table)
@@ -1,14 +0,0 @@
1
- """Stats module for router-maestro."""
2
-
3
- from router_maestro.stats.heatmap import display_stats_summary, generate_heatmap
4
- from router_maestro.stats.storage import StatsStorage, UsageRecord
5
- from router_maestro.stats.tracker import RequestTimer, UsageTracker
6
-
7
- __all__ = [
8
- "StatsStorage",
9
- "UsageRecord",
10
- "UsageTracker",
11
- "RequestTimer",
12
- "generate_heatmap",
13
- "display_stats_summary",
14
- ]
@@ -1,154 +0,0 @@
1
- """Terminal heatmap generation for token usage."""
2
-
3
- from datetime import datetime, timedelta
4
-
5
- import plotext as plt
6
- from rich.console import Console
7
- from rich.table import Table
8
-
9
- from router_maestro.stats.storage import StatsStorage
10
-
11
- console = Console()
12
-
13
-
14
- def generate_heatmap(days: int = 7, provider: str | None = None, model: str | None = None) -> None:
15
- """Generate and display a terminal heatmap of token usage.
16
-
17
- Args:
18
- days: Number of days to show
19
- provider: Filter by provider (optional)
20
- model: Filter by model (optional)
21
- """
22
- storage = StatsStorage()
23
- hourly_data = storage.get_usage_by_hour(days=days, provider=provider, model=model)
24
-
25
- if not hourly_data:
26
- console.print("[dim]No usage data available for the specified period.[/dim]")
27
- return
28
-
29
- # Build a matrix for the heatmap: rows = days, columns = hours
30
- # Initialize with zeros
31
- today = datetime.now().date()
32
- dates = [(today - timedelta(days=i)) for i in range(days - 1, -1, -1)]
33
-
34
- # Create a 2D matrix (days x 24 hours)
35
- matrix = [[0 for _ in range(24)] for _ in range(len(dates))]
36
- date_to_idx = {d: i for i, d in enumerate(dates)}
37
-
38
- for record in hourly_data:
39
- date = datetime.fromisoformat(record["date"]).date()
40
- hour = record["hour"]
41
- tokens = record["total_tokens"]
42
-
43
- if date in date_to_idx:
44
- matrix[date_to_idx[date]][hour] = tokens
45
-
46
- # Display using plotext
47
- plt.clear_figure()
48
- plt.title(f"Token Usage Heatmap (Last {days} Days)")
49
-
50
- # Create a simple bar chart by day since plotext doesn't have heatmap
51
- daily_data = storage.get_usage_by_day(days=days, provider=provider, model=model)
52
-
53
- if daily_data:
54
- dates_str = [record["date"] for record in daily_data]
55
- tokens = [record["total_tokens"] for record in daily_data]
56
-
57
- plt.bar(dates_str, tokens)
58
- plt.xlabel("Date")
59
- plt.ylabel("Total Tokens")
60
- plt.show()
61
-
62
- # Also show a text-based heatmap using Rich
63
- _display_text_heatmap(dates, matrix)
64
-
65
-
66
- def _display_text_heatmap(dates: list, matrix: list[list[int]]) -> None:
67
- """Display a text-based heatmap using Rich.
68
-
69
- Args:
70
- dates: List of dates
71
- matrix: 2D matrix of token counts (days x hours)
72
- """
73
- console.print("\n[bold]Hourly Activity Heatmap:[/bold]")
74
-
75
- # Find max value for scaling
76
- max_val = max(max(row) for row in matrix) if matrix else 1
77
- if max_val == 0:
78
- max_val = 1
79
-
80
- # Create intensity characters
81
- intensity_chars = " ░▒▓█"
82
-
83
- # Build the heatmap
84
- hour_labels = " " + "".join(f"{h:2d}" for h in range(0, 24, 2))
85
- console.print(f"[dim]{hour_labels}[/dim]")
86
-
87
- for i, date in enumerate(dates):
88
- row_str = f"{date.strftime('%m/%d')} "
89
- for h in range(24):
90
- value = matrix[i][h]
91
- intensity = int((value / max_val) * (len(intensity_chars) - 1))
92
- char = intensity_chars[intensity]
93
- row_str += char
94
- console.print(row_str)
95
-
96
- # Legend
97
- console.print(f"\n[dim]Legend: {' '.join(intensity_chars)} (low to high)[/dim]")
98
-
99
-
100
- def display_stats_summary(days: int = 7) -> None:
101
- """Display a summary of token usage statistics.
102
-
103
- Args:
104
- days: Number of days to summarize
105
- """
106
- storage = StatsStorage()
107
- total = storage.get_total_usage(days=days)
108
- by_model = storage.get_usage_by_model(days=days)
109
-
110
- if not total or total.get("total_tokens") is None:
111
- console.print("[dim]No usage data available.[/dim]")
112
- return
113
-
114
- # Summary table
115
- console.print(f"\n[bold]Token Usage Summary (Last {days} Days)[/bold]\n")
116
-
117
- summary_table = Table(show_header=False, box=None)
118
- summary_table.add_column("Metric", style="cyan")
119
- summary_table.add_column("Value", style="green", justify="right")
120
-
121
- summary_table.add_row("Total Requests", f"{total.get('request_count', 0):,}")
122
- summary_table.add_row("Successful", f"{total.get('success_count', 0):,}")
123
- summary_table.add_row("Total Tokens", f"{total.get('total_tokens', 0):,}")
124
- summary_table.add_row(" Prompt", f"{total.get('prompt_tokens', 0):,}")
125
- summary_table.add_row(" Completion", f"{total.get('completion_tokens', 0):,}")
126
-
127
- if total.get("avg_latency_ms"):
128
- summary_table.add_row("Avg Latency", f"{total.get('avg_latency_ms', 0):.0f} ms")
129
-
130
- console.print(summary_table)
131
-
132
- # By model table
133
- if by_model:
134
- console.print("\n[bold]Usage by Model[/bold]\n")
135
-
136
- model_table = Table()
137
- model_table.add_column("Model", style="cyan")
138
- model_table.add_column("Provider", style="magenta")
139
- model_table.add_column("Requests", justify="right")
140
- model_table.add_column("Total Tokens", justify="right", style="green")
141
- model_table.add_column("Avg Latency", justify="right")
142
-
143
- for record in by_model:
144
- avg_latency = record.get("avg_latency_ms")
145
- latency = f"{avg_latency:.0f} ms" if avg_latency else "-"
146
- model_table.add_row(
147
- record["model"],
148
- record["provider"],
149
- f"{record['request_count']:,}",
150
- f"{record['total_tokens']:,}",
151
- latency,
152
- )
153
-
154
- console.print(model_table)
@@ -1,228 +0,0 @@
1
- """SQLite storage for token usage statistics."""
2
-
3
- import sqlite3
4
- from collections.abc import Iterator
5
- from contextlib import contextmanager
6
- from datetime import datetime
7
- from pathlib import Path
8
-
9
- from pydantic import BaseModel
10
-
11
- from router_maestro.config import STATS_DB_FILE
12
-
13
-
14
- class UsageRecord(BaseModel):
15
- """A single token usage record."""
16
-
17
- id: int | None = None
18
- timestamp: datetime
19
- provider: str
20
- model: str
21
- prompt_tokens: int
22
- completion_tokens: int
23
- total_tokens: int
24
- success: bool
25
- latency_ms: int | None = None
26
-
27
-
28
- class StatsStorage:
29
- """SQLite storage for token usage statistics."""
30
-
31
- def __init__(self, db_path: Path = STATS_DB_FILE) -> None:
32
- self.db_path = db_path
33
- self._init_db()
34
-
35
- def _init_db(self) -> None:
36
- """Initialize the database schema."""
37
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
38
-
39
- with self._get_connection() as conn:
40
- conn.execute("""
41
- CREATE TABLE IF NOT EXISTS usage (
42
- id INTEGER PRIMARY KEY AUTOINCREMENT,
43
- timestamp TEXT NOT NULL,
44
- provider TEXT NOT NULL,
45
- model TEXT NOT NULL,
46
- prompt_tokens INTEGER NOT NULL,
47
- completion_tokens INTEGER NOT NULL,
48
- total_tokens INTEGER NOT NULL,
49
- success INTEGER NOT NULL,
50
- latency_ms INTEGER
51
- )
52
- """)
53
- conn.execute("""
54
- CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage(timestamp)
55
- """)
56
- conn.execute("""
57
- CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider)
58
- """)
59
- conn.execute("""
60
- CREATE INDEX IF NOT EXISTS idx_usage_model ON usage(model)
61
- """)
62
- conn.commit()
63
-
64
- @contextmanager
65
- def _get_connection(self) -> Iterator[sqlite3.Connection]:
66
- """Get a database connection."""
67
- conn = sqlite3.connect(self.db_path)
68
- conn.row_factory = sqlite3.Row
69
- try:
70
- yield conn
71
- finally:
72
- conn.close()
73
-
74
- def record(self, record: UsageRecord) -> None:
75
- """Record a usage event."""
76
- with self._get_connection() as conn:
77
- conn.execute(
78
- """
79
- INSERT INTO usage (
80
- timestamp, provider, model,
81
- prompt_tokens, completion_tokens, total_tokens,
82
- success, latency_ms
83
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
84
- """,
85
- (
86
- record.timestamp.isoformat(),
87
- record.provider,
88
- record.model,
89
- record.prompt_tokens,
90
- record.completion_tokens,
91
- record.total_tokens,
92
- 1 if record.success else 0,
93
- record.latency_ms,
94
- ),
95
- )
96
- conn.commit()
97
-
98
- def get_usage_by_day(
99
- self, days: int = 7, provider: str | None = None, model: str | None = None
100
- ) -> list[dict]:
101
- """Get usage aggregated by day.
102
-
103
- Args:
104
- days: Number of days to look back
105
- provider: Filter by provider (optional)
106
- model: Filter by model (optional)
107
-
108
- Returns:
109
- List of dicts with date, total_tokens, request_count
110
- """
111
- with self._get_connection() as conn:
112
- query = """
113
- SELECT
114
- DATE(timestamp) as date,
115
- SUM(total_tokens) as total_tokens,
116
- SUM(prompt_tokens) as prompt_tokens,
117
- SUM(completion_tokens) as completion_tokens,
118
- COUNT(*) as request_count,
119
- SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count
120
- FROM usage
121
- WHERE timestamp >= datetime('now', ?)
122
- """
123
- params: list = [f"-{days} days"]
124
-
125
- if provider:
126
- query += " AND provider = ?"
127
- params.append(provider)
128
- if model:
129
- query += " AND model = ?"
130
- params.append(model)
131
-
132
- query += " GROUP BY DATE(timestamp) ORDER BY date"
133
-
134
- cursor = conn.execute(query, params)
135
- return [dict(row) for row in cursor.fetchall()]
136
-
137
- def get_usage_by_hour(
138
- self, days: int = 7, provider: str | None = None, model: str | None = None
139
- ) -> list[dict]:
140
- """Get usage aggregated by hour.
141
-
142
- Args:
143
- days: Number of days to look back
144
- provider: Filter by provider (optional)
145
- model: Filter by model (optional)
146
-
147
- Returns:
148
- List of dicts with date, hour, total_tokens, request_count
149
- """
150
- with self._get_connection() as conn:
151
- query = """
152
- SELECT
153
- DATE(timestamp) as date,
154
- CAST(strftime('%H', timestamp) AS INTEGER) as hour,
155
- SUM(total_tokens) as total_tokens,
156
- COUNT(*) as request_count
157
- FROM usage
158
- WHERE timestamp >= datetime('now', ?)
159
- """
160
- params: list = [f"-{days} days"]
161
-
162
- if provider:
163
- query += " AND provider = ?"
164
- params.append(provider)
165
- if model:
166
- query += " AND model = ?"
167
- params.append(model)
168
-
169
- query += " GROUP BY DATE(timestamp), hour ORDER BY date, hour"
170
-
171
- cursor = conn.execute(query, params)
172
- return [dict(row) for row in cursor.fetchall()]
173
-
174
- def get_usage_by_model(self, days: int = 7) -> list[dict]:
175
- """Get usage aggregated by model.
176
-
177
- Args:
178
- days: Number of days to look back
179
-
180
- Returns:
181
- List of dicts with model, provider, total_tokens, request_count
182
- """
183
- with self._get_connection() as conn:
184
- cursor = conn.execute(
185
- """
186
- SELECT
187
- model,
188
- provider,
189
- SUM(total_tokens) as total_tokens,
190
- SUM(prompt_tokens) as prompt_tokens,
191
- SUM(completion_tokens) as completion_tokens,
192
- COUNT(*) as request_count,
193
- AVG(latency_ms) as avg_latency_ms
194
- FROM usage
195
- WHERE timestamp >= datetime('now', ?)
196
- GROUP BY model, provider
197
- ORDER BY total_tokens DESC
198
- """,
199
- (f"-{days} days",),
200
- )
201
- return [dict(row) for row in cursor.fetchall()]
202
-
203
- def get_total_usage(self, days: int = 7) -> dict:
204
- """Get total usage statistics.
205
-
206
- Args:
207
- days: Number of days to look back
208
-
209
- Returns:
210
- Dict with total statistics
211
- """
212
- with self._get_connection() as conn:
213
- cursor = conn.execute(
214
- """
215
- SELECT
216
- SUM(total_tokens) as total_tokens,
217
- SUM(prompt_tokens) as prompt_tokens,
218
- SUM(completion_tokens) as completion_tokens,
219
- COUNT(*) as request_count,
220
- SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
221
- AVG(latency_ms) as avg_latency_ms
222
- FROM usage
223
- WHERE timestamp >= datetime('now', ?)
224
- """,
225
- (f"-{days} days",),
226
- )
227
- row = cursor.fetchone()
228
- return dict(row) if row else {}
@@ -1,73 +0,0 @@
1
- """Token usage tracker."""
2
-
3
- import time
4
- from datetime import datetime
5
-
6
- from router_maestro.stats.storage import StatsStorage, UsageRecord
7
-
8
-
9
- class UsageTracker:
10
- """Tracks token usage for requests."""
11
-
12
- _instance: "UsageTracker | None" = None
13
-
14
- def __init__(self) -> None:
15
- self.storage = StatsStorage()
16
-
17
- @classmethod
18
- def get_instance(cls) -> "UsageTracker":
19
- """Get the singleton instance."""
20
- if cls._instance is None:
21
- cls._instance = cls()
22
- return cls._instance
23
-
24
- def record(
25
- self,
26
- provider: str,
27
- model: str,
28
- prompt_tokens: int,
29
- completion_tokens: int,
30
- success: bool = True,
31
- latency_ms: int | None = None,
32
- ) -> None:
33
- """Record a usage event.
34
-
35
- Args:
36
- provider: Provider name
37
- model: Model name
38
- prompt_tokens: Number of prompt tokens
39
- completion_tokens: Number of completion tokens
40
- success: Whether the request was successful
41
- latency_ms: Latency in milliseconds
42
- """
43
- record = UsageRecord(
44
- timestamp=datetime.now(),
45
- provider=provider,
46
- model=model,
47
- prompt_tokens=prompt_tokens,
48
- completion_tokens=completion_tokens,
49
- total_tokens=prompt_tokens + completion_tokens,
50
- success=success,
51
- latency_ms=latency_ms,
52
- )
53
- self.storage.record(record)
54
-
55
-
56
- class RequestTimer:
57
- """Context manager for timing requests."""
58
-
59
- def __init__(self) -> None:
60
- self.start_time: float = 0
61
- self.end_time: float = 0
62
-
63
- def __enter__(self) -> "RequestTimer":
64
- self.start_time = time.perf_counter()
65
- return self
66
-
67
- def __exit__(self, *args) -> None:
68
- self.end_time = time.perf_counter()
69
-
70
- @property
71
- def elapsed_ms(self) -> int:
72
- """Get elapsed time in milliseconds."""
73
- return int((self.end_time - self.start_time) * 1000)