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,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
|
+
]
|