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,636 @@
1
+ """Translation between Anthropic and OpenAI API formats."""
2
+
3
+ from router_maestro.providers import ChatRequest, Message
4
+ from router_maestro.server.schemas.anthropic import (
5
+ AnthropicAssistantContentBlock,
6
+ AnthropicAssistantMessage,
7
+ AnthropicImageBlock,
8
+ AnthropicMessagesRequest,
9
+ AnthropicMessagesResponse,
10
+ AnthropicStreamState,
11
+ AnthropicTextBlock,
12
+ AnthropicThinkingBlock,
13
+ AnthropicToolUseBlock,
14
+ AnthropicUsage,
15
+ AnthropicUserMessage,
16
+ )
17
+ from router_maestro.utils import get_logger, map_openai_stop_reason_to_anthropic
18
+
19
+ logger = get_logger("server.translation")
20
+
21
+
22
+ def translate_anthropic_to_openai(request: AnthropicMessagesRequest) -> ChatRequest:
23
+ """Translate Anthropic Messages request to OpenAI ChatCompletion request."""
24
+ messages = _translate_messages(request.messages, request.system)
25
+ tools = _translate_tools(request.tools) if request.tools else None
26
+ tool_choice = _translate_tool_choice(request.tool_choice) if request.tool_choice else None
27
+
28
+ logger.debug(
29
+ "Translating Anthropic request: model=%s -> %s, messages=%d",
30
+ request.model,
31
+ _translate_model_name(request.model),
32
+ len(messages),
33
+ )
34
+
35
+ return ChatRequest(
36
+ model=_translate_model_name(request.model),
37
+ messages=messages,
38
+ max_tokens=request.max_tokens,
39
+ temperature=request.temperature,
40
+ stream=request.stream,
41
+ tools=tools,
42
+ tool_choice=tool_choice,
43
+ )
44
+
45
+
46
+ def _translate_model_name(model: str) -> str:
47
+ """Translate model name for compatibility.
48
+
49
+ Claude Code uses model names like 'claude-sonnet-4-20250514' or 'claude-sonnet-4.5'.
50
+ The Copilot API uses names like 'claude-sonnet-4' or may accept the full version.
51
+ """
52
+ # Handle Claude model version suffixes
53
+ # e.g., claude-sonnet-4-20250514 -> claude-sonnet-4
54
+ # e.g., claude-opus-4.5 -> claude-opus-4.5 (keep as-is, it's a valid model)
55
+ # e.g., claude-haiku-4-5-20251001 -> claude-haiku-4.5 (hyphenated version to dot)
56
+ import re
57
+
58
+ # Pattern: claude-{tier}-{major}[-{date_suffix}]
59
+ # We want to strip date suffixes like -20250514 but keep version numbers like .5
60
+ match = re.match(r"^(claude-(?:sonnet|opus|haiku)-\d+(?:\.\d+)?)-\d{8}$", model)
61
+ if match:
62
+ return match.group(1)
63
+
64
+ # Handle hyphenated version numbers (e.g., claude-haiku-4-5-20251001 -> claude-haiku-4.5)
65
+ # Claude Code may send versions like "4-5" instead of "4.5"
66
+ match = re.match(r"^(claude-(?:sonnet|opus|haiku))-(\d+)-(\d+)-(\d{8})$", model)
67
+ if match:
68
+ tier = match.group(1)
69
+ major = match.group(2)
70
+ minor = match.group(3)
71
+ return f"{tier}-{major}.{minor}"
72
+
73
+ return model
74
+
75
+
76
+ def _translate_tools(tools: list) -> list[dict]:
77
+ """Translate Anthropic tools to OpenAI format.
78
+
79
+ Anthropic format:
80
+ {
81
+ "name": "tool_name",
82
+ "description": "description",
83
+ "input_schema": {...} # JSON Schema
84
+ }
85
+
86
+ OpenAI format:
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "tool_name",
91
+ "description": "description",
92
+ "parameters": {...} # JSON Schema
93
+ }
94
+ }
95
+ """
96
+ result = []
97
+ for tool in tools:
98
+ if isinstance(tool, dict):
99
+ name = tool.get("name", "")
100
+ description = tool.get("description", "")
101
+ input_schema = tool.get("input_schema", {})
102
+ else:
103
+ name = getattr(tool, "name", "")
104
+ description = getattr(tool, "description", "")
105
+ input_schema = getattr(tool, "input_schema", {})
106
+
107
+ result.append(
108
+ {
109
+ "type": "function",
110
+ "function": {
111
+ "name": name,
112
+ "description": description,
113
+ "parameters": input_schema,
114
+ },
115
+ }
116
+ )
117
+ return result
118
+
119
+
120
+ def _translate_tool_choice(tool_choice) -> str | dict | None:
121
+ """Translate Anthropic tool_choice to OpenAI format.
122
+
123
+ Anthropic format:
124
+ - {"type": "auto"} -> "auto"
125
+ - {"type": "any"} -> "required"
126
+ - {"type": "tool", "name": "tool_name"} ->
127
+ {"type": "function", "function": {"name": "tool_name"}}
128
+
129
+ OpenAI format:
130
+ - "auto" - model decides
131
+ - "none" - no tools
132
+ - "required" - must use a tool
133
+ - {"type": "function", "function": {"name": "..."}} - specific tool
134
+ """
135
+ if isinstance(tool_choice, dict):
136
+ choice_type = tool_choice.get("type")
137
+ if choice_type == "auto":
138
+ return "auto"
139
+ elif choice_type == "any":
140
+ return "required"
141
+ elif choice_type == "tool":
142
+ tool_name = tool_choice.get("name", "")
143
+ return {"type": "function", "function": {"name": tool_name}}
144
+ return None
145
+
146
+
147
+ def _sanitize_system_prompt(system: str) -> str:
148
+ """Remove reserved keywords from system prompt that Copilot rejects."""
149
+ import re
150
+
151
+ # Remove x-anthropic-billing-header line (Claude Code adds this)
152
+ # Pattern matches the header line and any following newlines
153
+ system = re.sub(r"x-anthropic-billing-header:[^\n]*\n*", "", system)
154
+ return system.strip()
155
+
156
+
157
+ def _translate_messages(
158
+ messages: list, system: str | list[AnthropicTextBlock] | None
159
+ ) -> list[Message]:
160
+ """Translate Anthropic messages to OpenAI format."""
161
+ result: list[Message] = []
162
+
163
+ # Handle system prompt
164
+ if system:
165
+ if isinstance(system, str):
166
+ system_text = _sanitize_system_prompt(system)
167
+ result.append(Message(role="system", content=system_text))
168
+ else:
169
+ system_text = "\n\n".join(block.text for block in system)
170
+ system_text = _sanitize_system_prompt(system_text)
171
+ result.append(Message(role="system", content=system_text))
172
+
173
+ # Handle conversation messages
174
+ for msg in messages:
175
+ is_user = isinstance(msg, AnthropicUserMessage) or (
176
+ isinstance(msg, dict) and msg.get("role") == "user"
177
+ )
178
+ is_assistant = isinstance(msg, AnthropicAssistantMessage) or (
179
+ isinstance(msg, dict) and msg.get("role") == "assistant"
180
+ )
181
+ if is_user:
182
+ result.extend(_handle_user_message(msg))
183
+ elif is_assistant:
184
+ result.extend(_handle_assistant_message(msg))
185
+
186
+ return result
187
+
188
+
189
+ def _handle_user_message(message: AnthropicUserMessage | dict) -> list[Message]:
190
+ """Handle user message translation."""
191
+ if isinstance(message, AnthropicUserMessage):
192
+ content = message.content
193
+ else:
194
+ content = message.get("content", "")
195
+
196
+ if isinstance(content, str):
197
+ return [Message(role="user", content=content)]
198
+
199
+ # Handle content blocks
200
+ tool_results = []
201
+ other_blocks = []
202
+
203
+ for block in content:
204
+ if isinstance(block, dict):
205
+ block_type = block.get("type")
206
+ else:
207
+ block_type = getattr(block, "type", None)
208
+
209
+ if block_type == "tool_result":
210
+ tool_results.append(block)
211
+ else:
212
+ other_blocks.append(block)
213
+
214
+ result: list[Message] = []
215
+
216
+ # Tool results become tool role messages in OpenAI format
217
+ for block in tool_results:
218
+ if isinstance(block, dict):
219
+ tool_content = block.get("content", "")
220
+ tool_use_id = block.get("tool_use_id", "")
221
+ else:
222
+ tool_content = block.content
223
+ tool_use_id = block.tool_use_id
224
+
225
+ # Handle content as array of content blocks
226
+ if isinstance(tool_content, list):
227
+ text_parts = []
228
+ for item in tool_content:
229
+ if isinstance(item, dict) and item.get("type") == "text":
230
+ text_parts.append(item.get("text", ""))
231
+ elif hasattr(item, "type") and item.type == "text":
232
+ text_parts.append(getattr(item, "text", ""))
233
+ tool_content = "\n".join(text_parts)
234
+
235
+ result.append(
236
+ Message(
237
+ role="tool",
238
+ content=str(tool_content),
239
+ tool_call_id=tool_use_id,
240
+ )
241
+ )
242
+
243
+ # Other content becomes user message - handle both text and images
244
+ if other_blocks:
245
+ multimodal_content = _extract_multimodal_content(other_blocks)
246
+ if multimodal_content:
247
+ result.append(Message(role="user", content=multimodal_content))
248
+
249
+ return result if result else [Message(role="user", content="")]
250
+
251
+
252
+ def _handle_assistant_message(message: AnthropicAssistantMessage | dict) -> list[Message]:
253
+ """Handle assistant message translation."""
254
+ if isinstance(message, AnthropicAssistantMessage):
255
+ content = message.content
256
+ else:
257
+ content = message.get("content", "")
258
+
259
+ if isinstance(content, str):
260
+ return [Message(role="assistant", content=content)]
261
+
262
+ # Extract text content and tool_use blocks
263
+ text_content = _extract_text_content(content)
264
+ tool_calls = _extract_tool_calls(content)
265
+
266
+ return [Message(role="assistant", content=text_content or "", tool_calls=tool_calls)]
267
+
268
+
269
+ def _extract_tool_calls(blocks: list) -> list[dict] | None:
270
+ """Extract tool_use blocks and convert to OpenAI tool_calls format."""
271
+ tool_calls = []
272
+ for block in blocks:
273
+ if isinstance(block, dict):
274
+ if block.get("type") == "tool_use":
275
+ tool_call = {
276
+ "id": block.get("id", ""),
277
+ "type": "function",
278
+ "function": {
279
+ "name": block.get("name", ""),
280
+ "arguments": block.get("input", {}),
281
+ },
282
+ }
283
+ # Convert input to JSON string if it's a dict
284
+ if isinstance(tool_call["function"]["arguments"], dict):
285
+ import json
286
+
287
+ tool_call["function"]["arguments"] = json.dumps(
288
+ tool_call["function"]["arguments"]
289
+ )
290
+ tool_calls.append(tool_call)
291
+ elif isinstance(block, AnthropicToolUseBlock):
292
+ import json
293
+
294
+ tool_call = {
295
+ "id": block.id,
296
+ "type": "function",
297
+ "function": {
298
+ "name": block.name,
299
+ "arguments": json.dumps(block.input)
300
+ if isinstance(block.input, dict)
301
+ else str(block.input),
302
+ },
303
+ }
304
+ tool_calls.append(tool_call)
305
+ return tool_calls if tool_calls else None
306
+
307
+
308
+ def _extract_text_content(blocks: list) -> str:
309
+ """Extract text content from content blocks."""
310
+ texts = []
311
+ for block in blocks:
312
+ if isinstance(block, dict):
313
+ block_type = block.get("type")
314
+ if block_type == "text":
315
+ texts.append(block.get("text", ""))
316
+ elif block_type == "thinking":
317
+ texts.append(block.get("thinking", ""))
318
+ elif isinstance(block, AnthropicTextBlock):
319
+ texts.append(block.text)
320
+ elif isinstance(block, AnthropicThinkingBlock):
321
+ texts.append(block.thinking)
322
+ return "\n\n".join(texts)
323
+
324
+
325
+ def _extract_multimodal_content(blocks: list) -> str | list:
326
+ """Extract content from blocks, handling both text and images.
327
+
328
+ Returns a string if only text is present, or a list of content parts
329
+ for multimodal content (OpenAI format).
330
+ """
331
+ text_parts = []
332
+ image_parts = []
333
+
334
+ for block in blocks:
335
+ if isinstance(block, dict):
336
+ block_type = block.get("type")
337
+ if block_type == "text":
338
+ text_parts.append(block.get("text", ""))
339
+ elif block_type == "thinking":
340
+ text_parts.append(block.get("thinking", ""))
341
+ elif block_type == "image":
342
+ # Convert Anthropic image format to OpenAI format
343
+ source = block.get("source", {})
344
+ if source.get("type") == "base64":
345
+ media_type = source.get("media_type", "image/png")
346
+ data = source.get("data", "")
347
+ image_parts.append(
348
+ {
349
+ "type": "image_url",
350
+ "image_url": {"url": f"data:{media_type};base64,{data}"},
351
+ }
352
+ )
353
+ elif isinstance(block, AnthropicTextBlock):
354
+ text_parts.append(block.text)
355
+ elif isinstance(block, AnthropicThinkingBlock):
356
+ text_parts.append(block.thinking)
357
+ elif isinstance(block, AnthropicImageBlock):
358
+ # Convert Anthropic image to OpenAI format
359
+ media_type = block.source.media_type
360
+ data = block.source.data
361
+ image_parts.append(
362
+ {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}}
363
+ )
364
+
365
+ # If no images, return simple text string
366
+ if not image_parts:
367
+ return "\n\n".join(text_parts)
368
+
369
+ # Build multimodal content list (OpenAI format)
370
+ content_parts = []
371
+
372
+ # Add text parts first
373
+ if text_parts:
374
+ content_parts.append({"type": "text", "text": "\n\n".join(text_parts)})
375
+
376
+ # Add image parts
377
+ content_parts.extend(image_parts)
378
+
379
+ return content_parts
380
+
381
+
382
+ def translate_openai_to_anthropic(
383
+ openai_response: dict, model: str, request_id: str
384
+ ) -> AnthropicMessagesResponse:
385
+ """Translate OpenAI ChatCompletion response to Anthropic Messages response."""
386
+ content: list[AnthropicAssistantContentBlock] = []
387
+
388
+ # Extract content from choices
389
+ if "choices" in openai_response:
390
+ for choice in openai_response["choices"]:
391
+ message = choice.get("message", {})
392
+ msg_content = message.get("content")
393
+
394
+ if msg_content:
395
+ content.append(AnthropicTextBlock(type="text", text=msg_content))
396
+
397
+ # Handle tool calls if present
398
+ tool_calls = message.get("tool_calls", [])
399
+ for tool_call in tool_calls:
400
+ content.append(
401
+ AnthropicToolUseBlock(
402
+ type="tool_use",
403
+ id=tool_call.get("id", ""),
404
+ name=tool_call.get("function", {}).get("name", ""),
405
+ input=tool_call.get("function", {}).get("arguments", {}),
406
+ )
407
+ )
408
+
409
+ # Map finish reason
410
+ finish_reason = None
411
+ if openai_response.get("choices"):
412
+ openai_reason = openai_response["choices"][0].get("finish_reason")
413
+ finish_reason = _map_stop_reason(openai_reason)
414
+
415
+ # Extract usage
416
+ openai_usage = openai_response.get("usage", {})
417
+ usage = AnthropicUsage(
418
+ input_tokens=openai_usage.get("prompt_tokens", 0),
419
+ output_tokens=openai_usage.get("completion_tokens", 0),
420
+ )
421
+
422
+ return AnthropicMessagesResponse(
423
+ id=request_id,
424
+ type="message",
425
+ role="assistant",
426
+ content=content,
427
+ model=model,
428
+ stop_reason=finish_reason,
429
+ stop_sequence=None,
430
+ usage=usage,
431
+ )
432
+
433
+
434
+ def _map_stop_reason(
435
+ openai_reason: str | None,
436
+ ) -> str | None:
437
+ """Map OpenAI finish reason to Anthropic stop reason."""
438
+ return map_openai_stop_reason_to_anthropic(openai_reason)
439
+
440
+
441
+ def translate_openai_chunk_to_anthropic_events(
442
+ chunk: dict, state: AnthropicStreamState, model: str
443
+ ) -> list[dict]:
444
+ """Translate OpenAI streaming chunk to Anthropic SSE events."""
445
+ events: list[dict] = []
446
+
447
+ # Don't process any more chunks after message is complete
448
+ if state.message_complete:
449
+ return events
450
+
451
+ # Track latest usage info from any chunk that contains it
452
+ if chunk.get("usage"):
453
+ state.last_usage = chunk["usage"]
454
+
455
+ if not chunk.get("choices"):
456
+ return events
457
+
458
+ choice = chunk["choices"][0]
459
+ delta = choice.get("delta", {})
460
+
461
+ # Send message_start if not sent yet
462
+ if not state.message_start_sent:
463
+ # Determine input tokens: prefer actual usage, fall back to estimate
464
+ input_tokens = 0
465
+ if state.last_usage:
466
+ input_tokens = state.last_usage.get("prompt_tokens", 0)
467
+ elif state.estimated_input_tokens:
468
+ input_tokens = state.estimated_input_tokens
469
+
470
+ events.append(
471
+ {
472
+ "type": "message_start",
473
+ "message": {
474
+ "id": chunk.get("id", ""),
475
+ "type": "message",
476
+ "role": "assistant",
477
+ "content": [],
478
+ "model": model,
479
+ "stop_reason": None,
480
+ "stop_sequence": None,
481
+ "usage": {
482
+ "input_tokens": input_tokens,
483
+ "output_tokens": 1,
484
+ "cache_creation_input_tokens": None,
485
+ "cache_read_input_tokens": None,
486
+ "server_tool_use": None,
487
+ "service_tier": "standard",
488
+ },
489
+ },
490
+ }
491
+ )
492
+ state.message_start_sent = True
493
+
494
+ # Handle text content
495
+ if delta.get("content"):
496
+ # Close tool block if open
497
+ if _is_tool_block_open(state):
498
+ events.append(
499
+ {
500
+ "type": "content_block_stop",
501
+ "index": state.content_block_index,
502
+ }
503
+ )
504
+ state.content_block_index += 1
505
+ state.content_block_open = False
506
+
507
+ # Start text block if not open
508
+ if not state.content_block_open:
509
+ events.append(
510
+ {
511
+ "type": "content_block_start",
512
+ "index": state.content_block_index,
513
+ "content_block": {
514
+ "type": "text",
515
+ "text": "",
516
+ },
517
+ }
518
+ )
519
+ state.content_block_open = True
520
+
521
+ # Send text delta
522
+ events.append(
523
+ {
524
+ "type": "content_block_delta",
525
+ "index": state.content_block_index,
526
+ "delta": {
527
+ "type": "text_delta",
528
+ "text": delta["content"],
529
+ },
530
+ }
531
+ )
532
+
533
+ # Handle tool calls
534
+ if delta.get("tool_calls"):
535
+ for tool_call in delta["tool_calls"]:
536
+ tool_index = tool_call.get("index", 0)
537
+
538
+ if tool_call.get("id") and tool_call.get("function", {}).get("name"):
539
+ # New tool call starting
540
+ if state.content_block_open:
541
+ events.append(
542
+ {
543
+ "type": "content_block_stop",
544
+ "index": state.content_block_index,
545
+ }
546
+ )
547
+ state.content_block_index += 1
548
+ state.content_block_open = False
549
+
550
+ anthropic_block_index = state.content_block_index
551
+ state.tool_calls[tool_index] = {
552
+ "id": tool_call["id"],
553
+ "name": tool_call["function"]["name"],
554
+ "anthropic_block_index": anthropic_block_index,
555
+ }
556
+
557
+ events.append(
558
+ {
559
+ "type": "content_block_start",
560
+ "index": anthropic_block_index,
561
+ "content_block": {
562
+ "type": "tool_use",
563
+ "id": tool_call["id"],
564
+ "name": tool_call["function"]["name"],
565
+ "input": {},
566
+ },
567
+ }
568
+ )
569
+ state.content_block_open = True
570
+
571
+ if tool_call.get("function", {}).get("arguments"):
572
+ tool_info = state.tool_calls.get(tool_index)
573
+ if tool_info:
574
+ events.append(
575
+ {
576
+ "type": "content_block_delta",
577
+ "index": tool_info["anthropic_block_index"],
578
+ "delta": {
579
+ "type": "input_json_delta",
580
+ "partial_json": tool_call["function"]["arguments"],
581
+ },
582
+ }
583
+ )
584
+
585
+ # Handle finish
586
+ finish_reason = choice.get("finish_reason")
587
+ if finish_reason:
588
+ if state.content_block_open:
589
+ events.append(
590
+ {
591
+ "type": "content_block_stop",
592
+ "index": state.content_block_index,
593
+ }
594
+ )
595
+ state.content_block_open = False
596
+
597
+ # Get usage from chunk or from tracked last_usage
598
+ usage = chunk.get("usage") or state.last_usage or {}
599
+ prompt_tokens = usage.get("prompt_tokens", 0)
600
+ completion_tokens = usage.get("completion_tokens", 0)
601
+
602
+ # Use estimated_input_tokens for context display since Copilot may truncate input
603
+ # This gives Claude Code accurate context percentage based on actual conversation size
604
+ input_tokens_for_display = (
605
+ state.estimated_input_tokens if state.estimated_input_tokens > 0 else prompt_tokens
606
+ )
607
+
608
+ events.append(
609
+ {
610
+ "type": "message_delta",
611
+ "delta": {
612
+ "stop_reason": _map_stop_reason(finish_reason),
613
+ "stop_sequence": None,
614
+ },
615
+ "usage": {
616
+ "input_tokens": input_tokens_for_display,
617
+ "output_tokens": completion_tokens,
618
+ "cache_creation_input_tokens": 0,
619
+ "cache_read_input_tokens": 0,
620
+ "server_tool_use": None,
621
+ },
622
+ }
623
+ )
624
+ events.append({"type": "message_stop"})
625
+ state.message_complete = True
626
+
627
+ return events
628
+
629
+
630
+ def _is_tool_block_open(state: AnthropicStreamState) -> bool:
631
+ """Check if a tool block is currently open."""
632
+ if not state.content_block_open:
633
+ return False
634
+ return any(
635
+ tc["anthropic_block_index"] == state.content_block_index for tc in state.tool_calls.values()
636
+ )
@@ -0,0 +1,14 @@
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
+ ]