multiroute 0.1.0__tar.gz
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.
- multiroute-0.1.0/LICENSE +21 -0
- multiroute-0.1.0/PKG-INFO +11 -0
- multiroute-0.1.0/README.md +0 -0
- multiroute-0.1.0/pyproject.toml +34 -0
- multiroute-0.1.0/setup.cfg +4 -0
- multiroute-0.1.0/src/multiroute/__init__.py +5 -0
- multiroute-0.1.0/src/multiroute/anthropic/__init__.py +3 -0
- multiroute-0.1.0/src/multiroute/anthropic/client.py +374 -0
- multiroute-0.1.0/src/multiroute/google/__init__.py +3 -0
- multiroute-0.1.0/src/multiroute/google/client.py +487 -0
- multiroute-0.1.0/src/multiroute/openai/__init__.py +3 -0
- multiroute-0.1.0/src/multiroute/openai/client.py +113 -0
- multiroute-0.1.0/src/multiroute.egg-info/PKG-INFO +11 -0
- multiroute-0.1.0/src/multiroute.egg-info/SOURCES.txt +15 -0
- multiroute-0.1.0/src/multiroute.egg-info/dependency_links.txt +1 -0
- multiroute-0.1.0/src/multiroute.egg-info/requires.txt +3 -0
- multiroute-0.1.0/src/multiroute.egg-info/top_level.txt +1 -0
multiroute-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 multiroute
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multiroute
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: anthropic>=0.84.0
|
|
9
|
+
Requires-Dist: google-genai>=0.1.0
|
|
10
|
+
Requires-Dist: openai>=2.26.0
|
|
11
|
+
Dynamic: license-file
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "multiroute"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"anthropic>=0.84.0",
|
|
9
|
+
"google-genai>=0.1.0",
|
|
10
|
+
"openai>=2.26.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv]
|
|
14
|
+
dev-dependencies = [
|
|
15
|
+
"multiroute",
|
|
16
|
+
"httpx>=0.28.1",
|
|
17
|
+
"pytest>=9.0.2",
|
|
18
|
+
"pytest-asyncio>=1.3.0",
|
|
19
|
+
"respx>=0.22.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.workspace]
|
|
23
|
+
members = [
|
|
24
|
+
".",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
multiroute = { workspace = true }
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = [
|
|
32
|
+
"src"
|
|
33
|
+
]
|
|
34
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
import anthropic
|
|
6
|
+
import httpx
|
|
7
|
+
import openai
|
|
8
|
+
from anthropic.resources.messages import AsyncMessages, Messages
|
|
9
|
+
from anthropic.types import Message
|
|
10
|
+
|
|
11
|
+
MULTIROUTE_BASE_URL = "https://api.multiroute.ai/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_multiroute_error(e: Exception) -> bool:
|
|
15
|
+
if isinstance(e, (anthropic.APIConnectionError, openai.APIConnectionError)):
|
|
16
|
+
return True
|
|
17
|
+
if isinstance(
|
|
18
|
+
e, (anthropic.InternalServerError, openai.InternalServerError)
|
|
19
|
+
): # 5xx errors
|
|
20
|
+
return True
|
|
21
|
+
if isinstance(e, (anthropic.APITimeoutError, openai.APITimeoutError)):
|
|
22
|
+
return True
|
|
23
|
+
if isinstance(e, (anthropic.NotFoundError, openai.NotFoundError)): # 404
|
|
24
|
+
return True
|
|
25
|
+
# Catch httpx generic errors that might happen when we bypass the client
|
|
26
|
+
if isinstance(e, httpx.RequestError):
|
|
27
|
+
return True
|
|
28
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
29
|
+
if e.response.status_code >= 500 or e.response.status_code == 404:
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_shared_openai_client = None
|
|
35
|
+
_shared_async_openai_client = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_shared_openai_client() -> openai.OpenAI:
|
|
39
|
+
global _shared_openai_client
|
|
40
|
+
if _shared_openai_client is None:
|
|
41
|
+
_shared_openai_client = openai.OpenAI(
|
|
42
|
+
base_url=MULTIROUTE_BASE_URL,
|
|
43
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY") or "dummy",
|
|
44
|
+
max_retries=0,
|
|
45
|
+
)
|
|
46
|
+
return _shared_openai_client
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_shared_async_openai_client() -> openai.AsyncOpenAI:
|
|
50
|
+
global _shared_async_openai_client
|
|
51
|
+
if _shared_async_openai_client is None:
|
|
52
|
+
_shared_async_openai_client = openai.AsyncOpenAI(
|
|
53
|
+
base_url=MULTIROUTE_BASE_URL,
|
|
54
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY") or "dummy",
|
|
55
|
+
max_retries=0,
|
|
56
|
+
)
|
|
57
|
+
return _shared_async_openai_client
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _anthropic_to_openai_request(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
61
|
+
"""Convert Anthropic messages request parameters to OpenAI chat/completions format."""
|
|
62
|
+
openai_req = {}
|
|
63
|
+
model = kwargs["model"]
|
|
64
|
+
|
|
65
|
+
# Model
|
|
66
|
+
if "model" in kwargs:
|
|
67
|
+
openai_req["model"] = model
|
|
68
|
+
|
|
69
|
+
# System prompt -> role: system
|
|
70
|
+
messages = []
|
|
71
|
+
if "system" in kwargs and kwargs["system"]:
|
|
72
|
+
# system could be string or list of text blocks
|
|
73
|
+
sys_content = kwargs["system"]
|
|
74
|
+
if isinstance(sys_content, list):
|
|
75
|
+
# simplify list of text blocks to a single string for openai
|
|
76
|
+
sys_text = "".join(b["text"] for b in sys_content if b["type"] == "text")
|
|
77
|
+
messages.append({"role": "system", "content": sys_text})
|
|
78
|
+
else:
|
|
79
|
+
messages.append({"role": "system", "content": sys_content})
|
|
80
|
+
|
|
81
|
+
# Messages
|
|
82
|
+
if "messages" in kwargs:
|
|
83
|
+
for msg in kwargs["messages"]:
|
|
84
|
+
# Basic mapping, complex content blocks might need deeper extraction
|
|
85
|
+
# Anthropic allows content to be string or list of blocks
|
|
86
|
+
content = msg["content"]
|
|
87
|
+
if isinstance(content, list):
|
|
88
|
+
# Convert anthropic text blocks to openai text content
|
|
89
|
+
openai_content = []
|
|
90
|
+
tool_calls = []
|
|
91
|
+
for block in content:
|
|
92
|
+
# Handle both dictionaries and Anthropic Pydantic models
|
|
93
|
+
b_type = (
|
|
94
|
+
block.get("type")
|
|
95
|
+
if isinstance(block, dict)
|
|
96
|
+
else getattr(block, "type", None)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if b_type == "text":
|
|
100
|
+
b_text = (
|
|
101
|
+
block.get("text")
|
|
102
|
+
if isinstance(block, dict)
|
|
103
|
+
else getattr(block, "text", "")
|
|
104
|
+
)
|
|
105
|
+
openai_content.append({"type": "text", "text": b_text})
|
|
106
|
+
elif b_type == "image":
|
|
107
|
+
# Convert image block
|
|
108
|
+
b_source = (
|
|
109
|
+
block.get("source")
|
|
110
|
+
if isinstance(block, dict)
|
|
111
|
+
else getattr(block, "source", {})
|
|
112
|
+
)
|
|
113
|
+
b_mime = (
|
|
114
|
+
b_source.get("media_type")
|
|
115
|
+
if isinstance(b_source, dict)
|
|
116
|
+
else getattr(b_source, "media_type", "")
|
|
117
|
+
)
|
|
118
|
+
b_data = (
|
|
119
|
+
b_source.get("data")
|
|
120
|
+
if isinstance(b_source, dict)
|
|
121
|
+
else getattr(b_source, "data", "")
|
|
122
|
+
)
|
|
123
|
+
openai_content.append(
|
|
124
|
+
{
|
|
125
|
+
"type": "image_url",
|
|
126
|
+
"image_url": {"url": f"data:{b_mime};base64,{b_data}"},
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
elif b_type == "tool_use":
|
|
130
|
+
# Assistant turn with tool call
|
|
131
|
+
b_id = (
|
|
132
|
+
block.get("id")
|
|
133
|
+
if isinstance(block, dict)
|
|
134
|
+
else getattr(block, "id", "")
|
|
135
|
+
)
|
|
136
|
+
b_name = (
|
|
137
|
+
block.get("name")
|
|
138
|
+
if isinstance(block, dict)
|
|
139
|
+
else getattr(block, "name", "")
|
|
140
|
+
)
|
|
141
|
+
b_input = (
|
|
142
|
+
block.get("input")
|
|
143
|
+
if isinstance(block, dict)
|
|
144
|
+
else getattr(block, "input", {})
|
|
145
|
+
)
|
|
146
|
+
tool_calls.append(
|
|
147
|
+
{
|
|
148
|
+
"id": b_id,
|
|
149
|
+
"type": "function",
|
|
150
|
+
"function": {
|
|
151
|
+
"name": b_name,
|
|
152
|
+
"arguments": json.dumps(b_input),
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
elif b_type == "tool_result":
|
|
157
|
+
# Tool result back to model
|
|
158
|
+
b_id = (
|
|
159
|
+
block.get("tool_use_id")
|
|
160
|
+
if isinstance(block, dict)
|
|
161
|
+
else getattr(block, "tool_use_id", "")
|
|
162
|
+
)
|
|
163
|
+
b_content = (
|
|
164
|
+
block.get("content", "")
|
|
165
|
+
if isinstance(block, dict)
|
|
166
|
+
else getattr(block, "content", "")
|
|
167
|
+
)
|
|
168
|
+
messages.append(
|
|
169
|
+
{
|
|
170
|
+
"role": "tool",
|
|
171
|
+
"tool_call_id": b_id,
|
|
172
|
+
"content": str(b_content),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
continue # Skip appending to current role's content
|
|
176
|
+
|
|
177
|
+
if tool_calls:
|
|
178
|
+
# OpenAI assistant message with tool_calls
|
|
179
|
+
msg_obj = {"role": "assistant", "tool_calls": tool_calls}
|
|
180
|
+
# If there's text content, OpenAI allows it alongside tool_calls
|
|
181
|
+
# but it must be a string or a list of content parts.
|
|
182
|
+
if openai_content:
|
|
183
|
+
# Anthropic text blocks were already converted to OpenAI parts format
|
|
184
|
+
msg_obj["content"] = (
|
|
185
|
+
openai_content[0]["text"]
|
|
186
|
+
if len(openai_content) == 1
|
|
187
|
+
else openai_content
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
# OpenAI requires 'content' to be present (can be null) even if tool_calls are present
|
|
191
|
+
msg_obj["content"] = None
|
|
192
|
+
messages.append(msg_obj)
|
|
193
|
+
elif openai_content:
|
|
194
|
+
# If this message ONLY contained tool_result blocks (which were already handled above)
|
|
195
|
+
# and no text/image blocks, we don't want to append an empty user message.
|
|
196
|
+
# But if there are content parts, append them.
|
|
197
|
+
messages.append({"role": msg["role"], "content": openai_content})
|
|
198
|
+
elif (
|
|
199
|
+
not tool_calls
|
|
200
|
+
and not openai_content
|
|
201
|
+
and any(m["role"] == "tool" for m in messages)
|
|
202
|
+
):
|
|
203
|
+
# Special case: If this turn ONLY contained tool_result blocks,
|
|
204
|
+
# those have already been appended to 'messages' as role: 'tool'.
|
|
205
|
+
# We do NOT append anything else for this turn.
|
|
206
|
+
pass
|
|
207
|
+
elif not tool_calls and not openai_content:
|
|
208
|
+
# No content blocks at all (shouldn't happen in valid Anthropic requests)
|
|
209
|
+
pass
|
|
210
|
+
else:
|
|
211
|
+
messages.append({"role": msg["role"], "content": content})
|
|
212
|
+
|
|
213
|
+
openai_req["messages"] = messages
|
|
214
|
+
|
|
215
|
+
if "tools" in kwargs:
|
|
216
|
+
openai_tools = []
|
|
217
|
+
for tool in kwargs["tools"]:
|
|
218
|
+
openai_tools.append(
|
|
219
|
+
{
|
|
220
|
+
"type": "function",
|
|
221
|
+
"function": {
|
|
222
|
+
"name": tool["name"],
|
|
223
|
+
"description": tool.get("description", ""),
|
|
224
|
+
"parameters": tool.get("input_schema", {}),
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
openai_req["tools"] = openai_tools
|
|
229
|
+
|
|
230
|
+
if "tool_choice" in kwargs:
|
|
231
|
+
tc = kwargs["tool_choice"]
|
|
232
|
+
if tc.get("type") == "auto":
|
|
233
|
+
openai_req["tool_choice"] = "auto"
|
|
234
|
+
elif tc.get("type") == "any":
|
|
235
|
+
openai_req["tool_choice"] = "required"
|
|
236
|
+
elif tc.get("type") == "tool":
|
|
237
|
+
openai_req["tool_choice"] = {
|
|
238
|
+
"type": "function",
|
|
239
|
+
"function": {"name": tc["name"]},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if "max_tokens" in kwargs:
|
|
243
|
+
openai_req["max_tokens"] = kwargs["max_tokens"]
|
|
244
|
+
if "temperature" in kwargs:
|
|
245
|
+
openai_req["temperature"] = kwargs["temperature"]
|
|
246
|
+
if "top_p" in kwargs:
|
|
247
|
+
openai_req["top_p"] = kwargs["top_p"]
|
|
248
|
+
if "stop_sequences" in kwargs:
|
|
249
|
+
openai_req["stop"] = kwargs["stop_sequences"]
|
|
250
|
+
|
|
251
|
+
return openai_req
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _openai_to_anthropic_response(openai_resp: Dict[str, Any]) -> Message:
|
|
255
|
+
"""Convert OpenAI chat/completions response to Anthropic Message type."""
|
|
256
|
+
choice = openai_resp.get("choices", [{}])[0]
|
|
257
|
+
message_data = choice.get("message", {})
|
|
258
|
+
|
|
259
|
+
# Map finish reason
|
|
260
|
+
finish_reason = choice.get("finish_reason")
|
|
261
|
+
stop_reason = "end_turn"
|
|
262
|
+
if finish_reason == "length":
|
|
263
|
+
stop_reason = "max_tokens"
|
|
264
|
+
elif finish_reason == "tool_calls":
|
|
265
|
+
stop_reason = "tool_use"
|
|
266
|
+
elif finish_reason == "stop":
|
|
267
|
+
stop_reason = "end_turn"
|
|
268
|
+
|
|
269
|
+
# Content
|
|
270
|
+
content = message_data.get("content", "")
|
|
271
|
+
content_blocks = [{"type": "text", "text": content}] if content else []
|
|
272
|
+
|
|
273
|
+
# Tool calls
|
|
274
|
+
tool_calls = message_data.get("tool_calls", [])
|
|
275
|
+
if tool_calls:
|
|
276
|
+
for tc in tool_calls:
|
|
277
|
+
# Safely parse arguments, they might be string or dict
|
|
278
|
+
try:
|
|
279
|
+
args = tc["function"]["arguments"]
|
|
280
|
+
if isinstance(args, str):
|
|
281
|
+
args = json.loads(args)
|
|
282
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
283
|
+
args = {}
|
|
284
|
+
|
|
285
|
+
content_blocks.append(
|
|
286
|
+
{
|
|
287
|
+
"type": "tool_use",
|
|
288
|
+
"id": tc.get("id", ""),
|
|
289
|
+
"name": tc["function"]["name"],
|
|
290
|
+
"input": args,
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Usage
|
|
295
|
+
usage_data = openai_resp.get("usage", {})
|
|
296
|
+
|
|
297
|
+
# Create the dictionary structure expected by Anthropic models
|
|
298
|
+
msg_dict = {
|
|
299
|
+
"id": openai_resp.get("id", ""),
|
|
300
|
+
"type": "message",
|
|
301
|
+
"role": "assistant",
|
|
302
|
+
"content": content_blocks,
|
|
303
|
+
"model": openai_resp.get("model", ""),
|
|
304
|
+
"stop_reason": stop_reason,
|
|
305
|
+
"stop_sequence": None,
|
|
306
|
+
"usage": {
|
|
307
|
+
"input_tokens": usage_data.get("prompt_tokens", 0),
|
|
308
|
+
"output_tokens": usage_data.get("completion_tokens", 0),
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return Message.construct(**msg_dict)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class MultirouteMessages(Messages):
|
|
316
|
+
def create(self, **kwargs) -> Message:
|
|
317
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
318
|
+
return super().create(**kwargs)
|
|
319
|
+
|
|
320
|
+
# Save original API URL and client behavior
|
|
321
|
+
original_base_url = self._client.base_url
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
openai_req = _anthropic_to_openai_request(kwargs)
|
|
325
|
+
|
|
326
|
+
client = _get_shared_openai_client().with_options(
|
|
327
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY"),
|
|
328
|
+
timeout=self._client.timeout,
|
|
329
|
+
)
|
|
330
|
+
openai_resp_obj = client.chat.completions.create(**openai_req)
|
|
331
|
+
|
|
332
|
+
openai_resp = openai_resp_obj.model_dump()
|
|
333
|
+
return _openai_to_anthropic_response(openai_resp)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
if _is_multiroute_error(e):
|
|
336
|
+
# Fallback to the real anthropic create using original parameters
|
|
337
|
+
return super().create(**kwargs)
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class AsyncMultirouteMessages(AsyncMessages):
|
|
342
|
+
async def create(self, **kwargs) -> Message:
|
|
343
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
344
|
+
return await super().create(**kwargs)
|
|
345
|
+
|
|
346
|
+
original_base_url = self._client.base_url
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
openai_req = _anthropic_to_openai_request(kwargs)
|
|
350
|
+
|
|
351
|
+
client = _get_shared_async_openai_client().with_options(
|
|
352
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY"),
|
|
353
|
+
timeout=self._client.timeout,
|
|
354
|
+
)
|
|
355
|
+
openai_resp_obj = await client.chat.completions.create(**openai_req)
|
|
356
|
+
|
|
357
|
+
openai_resp = openai_resp_obj.model_dump()
|
|
358
|
+
return _openai_to_anthropic_response(openai_resp)
|
|
359
|
+
except Exception as e:
|
|
360
|
+
if _is_multiroute_error(e):
|
|
361
|
+
return await super().create(**kwargs)
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class Anthropic(anthropic.Anthropic):
|
|
366
|
+
def __init__(self, *args, **kwargs):
|
|
367
|
+
super().__init__(*args, **kwargs)
|
|
368
|
+
self.messages = MultirouteMessages(self)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class AsyncAnthropic(anthropic.AsyncAnthropic):
|
|
372
|
+
def __init__(self, *args, **kwargs):
|
|
373
|
+
super().__init__(*args, **kwargs)
|
|
374
|
+
self.messages = AsyncMultirouteMessages(self)
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
import google.genai as genai
|
|
6
|
+
import httpx
|
|
7
|
+
import openai
|
|
8
|
+
from google.genai import types
|
|
9
|
+
from google.genai._transformers import t_tools
|
|
10
|
+
from google.genai.types import FinishReason, GenerateContentResponseUsageMetadata
|
|
11
|
+
|
|
12
|
+
MULTIROUTE_BASE_URL = "https://api.multiroute.ai/v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_multiroute_error(e: Exception) -> bool:
|
|
16
|
+
if isinstance(
|
|
17
|
+
e,
|
|
18
|
+
(
|
|
19
|
+
openai.APIConnectionError,
|
|
20
|
+
openai.InternalServerError,
|
|
21
|
+
openai.APITimeoutError,
|
|
22
|
+
openai.NotFoundError,
|
|
23
|
+
),
|
|
24
|
+
):
|
|
25
|
+
return True
|
|
26
|
+
if isinstance(e, httpx.RequestError):
|
|
27
|
+
return True
|
|
28
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
29
|
+
if e.response.status_code >= 500 or e.response.status_code == 404:
|
|
30
|
+
return True
|
|
31
|
+
if "google.genai.errors.APIError" in str(type(e)):
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_shared_openai_client = None
|
|
37
|
+
_shared_async_openai_client = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_shared_openai_client() -> openai.OpenAI:
|
|
41
|
+
global _shared_openai_client
|
|
42
|
+
if _shared_openai_client is None:
|
|
43
|
+
_shared_openai_client = openai.OpenAI(
|
|
44
|
+
base_url=MULTIROUTE_BASE_URL,
|
|
45
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY") or "dummy",
|
|
46
|
+
max_retries=0,
|
|
47
|
+
)
|
|
48
|
+
return _shared_openai_client
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_shared_async_openai_client() -> openai.AsyncOpenAI:
|
|
52
|
+
global _shared_async_openai_client
|
|
53
|
+
if _shared_async_openai_client is None:
|
|
54
|
+
_shared_async_openai_client = openai.AsyncOpenAI(
|
|
55
|
+
base_url=MULTIROUTE_BASE_URL,
|
|
56
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY") or "dummy",
|
|
57
|
+
max_retries=0,
|
|
58
|
+
)
|
|
59
|
+
return _shared_async_openai_client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lower_dict_types(d: Any) -> Any:
|
|
63
|
+
"""Helper to convert Google's UPPERCASE types to OpenAI's lowercase types."""
|
|
64
|
+
if not isinstance(d, dict):
|
|
65
|
+
return d
|
|
66
|
+
res = {}
|
|
67
|
+
for k, v in d.items():
|
|
68
|
+
if k == "type" and isinstance(v, str):
|
|
69
|
+
res[k] = v.lower()
|
|
70
|
+
elif isinstance(v, dict):
|
|
71
|
+
res[k] = _lower_dict_types(v)
|
|
72
|
+
elif isinstance(v, list):
|
|
73
|
+
res[k] = [
|
|
74
|
+
_lower_dict_types(item) if isinstance(item, dict) else item
|
|
75
|
+
for item in v
|
|
76
|
+
]
|
|
77
|
+
else:
|
|
78
|
+
res[k] = v
|
|
79
|
+
return res
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _schema_to_dict(schema: Any) -> Dict[str, Any]:
|
|
83
|
+
if not schema:
|
|
84
|
+
return {}
|
|
85
|
+
d = {}
|
|
86
|
+
if hasattr(schema, "type"):
|
|
87
|
+
d["type"] = schema.type.value if hasattr(schema.type, "value") else schema.type
|
|
88
|
+
if hasattr(schema, "description") and schema.description:
|
|
89
|
+
d["description"] = schema.description
|
|
90
|
+
if hasattr(schema, "properties") and schema.properties:
|
|
91
|
+
d["properties"] = {k: _schema_to_dict(v) for k, v in schema.properties.items()}
|
|
92
|
+
if hasattr(schema, "required") and schema.required:
|
|
93
|
+
d["required"] = schema.required
|
|
94
|
+
if hasattr(schema, "items") and schema.items:
|
|
95
|
+
d["items"] = _schema_to_dict(schema.items)
|
|
96
|
+
return d
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _google_to_openai_request(
|
|
100
|
+
model: str, contents: Any, config: Any = None, client: Any = None
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
messages = []
|
|
103
|
+
|
|
104
|
+
if isinstance(contents, str):
|
|
105
|
+
messages.append({"role": "user", "content": contents})
|
|
106
|
+
elif isinstance(contents, list):
|
|
107
|
+
for item in contents:
|
|
108
|
+
if isinstance(item, str):
|
|
109
|
+
messages.append({"role": "user", "content": item})
|
|
110
|
+
elif hasattr(item, "role") and hasattr(item, "parts"):
|
|
111
|
+
# types.Content object
|
|
112
|
+
role = "user" if item.role == "user" else "assistant"
|
|
113
|
+
content_text = ""
|
|
114
|
+
has_function_call = False
|
|
115
|
+
has_function_response = False
|
|
116
|
+
for part in item.parts:
|
|
117
|
+
if hasattr(part, "function_call") and part.function_call:
|
|
118
|
+
fc = part.function_call
|
|
119
|
+
name = getattr(fc, "name", "")
|
|
120
|
+
args = getattr(fc, "args", {})
|
|
121
|
+
messages.append(
|
|
122
|
+
{
|
|
123
|
+
"role": "assistant",
|
|
124
|
+
"tool_calls": [
|
|
125
|
+
{
|
|
126
|
+
"id": name,
|
|
127
|
+
"type": "function",
|
|
128
|
+
"function": {
|
|
129
|
+
"name": name,
|
|
130
|
+
"arguments": json.dumps(args),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
has_function_call = True
|
|
137
|
+
elif hasattr(part, "function_response") and part.function_response:
|
|
138
|
+
fr = part.function_response
|
|
139
|
+
name = getattr(fr, "name", "")
|
|
140
|
+
resp = getattr(fr, "response", {})
|
|
141
|
+
messages.append(
|
|
142
|
+
{
|
|
143
|
+
"role": "tool",
|
|
144
|
+
"tool_call_id": name,
|
|
145
|
+
"content": json.dumps(resp),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
has_function_response = True
|
|
149
|
+
elif hasattr(part, "text") and part.text:
|
|
150
|
+
content_text += part.text
|
|
151
|
+
elif isinstance(part, dict) and "text" in part:
|
|
152
|
+
content_text += part["text"]
|
|
153
|
+
|
|
154
|
+
if not has_function_call and not has_function_response and content_text:
|
|
155
|
+
messages.append({"role": role, "content": content_text})
|
|
156
|
+
elif content_text and has_function_call:
|
|
157
|
+
messages[-1]["content"] = content_text
|
|
158
|
+
elif hasattr(item, "function_call") and item.function_call:
|
|
159
|
+
# Bare Part with function_call
|
|
160
|
+
fc = item.function_call
|
|
161
|
+
name = getattr(fc, "name", "")
|
|
162
|
+
args = getattr(fc, "args", {})
|
|
163
|
+
messages.append(
|
|
164
|
+
{
|
|
165
|
+
"role": "assistant",
|
|
166
|
+
"tool_calls": [
|
|
167
|
+
{
|
|
168
|
+
"id": name,
|
|
169
|
+
"type": "function",
|
|
170
|
+
"function": {
|
|
171
|
+
"name": name,
|
|
172
|
+
"arguments": json.dumps(args),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
elif hasattr(item, "function_response") and item.function_response:
|
|
179
|
+
# Bare Part with function_response
|
|
180
|
+
fr = item.function_response
|
|
181
|
+
name = getattr(fr, "name", "")
|
|
182
|
+
resp = getattr(fr, "response", {})
|
|
183
|
+
messages.append(
|
|
184
|
+
{"role": "tool", "tool_call_id": name, "content": json.dumps(resp)}
|
|
185
|
+
)
|
|
186
|
+
elif hasattr(item, "text") and item.text:
|
|
187
|
+
messages.append({"role": "user", "content": item.text})
|
|
188
|
+
elif isinstance(item, dict):
|
|
189
|
+
role = item.get("role", "user")
|
|
190
|
+
role = "user" if role == "user" else "assistant"
|
|
191
|
+
parts = item.get("parts", [])
|
|
192
|
+
content_text = ""
|
|
193
|
+
has_function_response = False
|
|
194
|
+
has_function_call = False
|
|
195
|
+
for p in parts:
|
|
196
|
+
if isinstance(p, dict):
|
|
197
|
+
if "functionCall" in p or "function_call" in p:
|
|
198
|
+
fc = p.get("functionCall") or p.get("function_call")
|
|
199
|
+
name = (
|
|
200
|
+
fc.get("name")
|
|
201
|
+
if isinstance(fc, dict)
|
|
202
|
+
else getattr(fc, "name", "")
|
|
203
|
+
)
|
|
204
|
+
args = (
|
|
205
|
+
fc.get("args")
|
|
206
|
+
if isinstance(fc, dict)
|
|
207
|
+
else getattr(fc, "args", {})
|
|
208
|
+
)
|
|
209
|
+
messages.append(
|
|
210
|
+
{
|
|
211
|
+
"role": "assistant",
|
|
212
|
+
"tool_calls": [
|
|
213
|
+
{
|
|
214
|
+
"id": name,
|
|
215
|
+
"type": "function",
|
|
216
|
+
"function": {
|
|
217
|
+
"name": name,
|
|
218
|
+
"arguments": json.dumps(args),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
has_function_call = True
|
|
225
|
+
elif "functionResponse" in p or "function_response" in p:
|
|
226
|
+
fr = p.get("functionResponse") or p.get("function_response")
|
|
227
|
+
name = (
|
|
228
|
+
fr.get("name")
|
|
229
|
+
if isinstance(fr, dict)
|
|
230
|
+
else getattr(fr, "name", "")
|
|
231
|
+
)
|
|
232
|
+
resp = (
|
|
233
|
+
fr.get("response")
|
|
234
|
+
if isinstance(fr, dict)
|
|
235
|
+
else getattr(fr, "response", {})
|
|
236
|
+
)
|
|
237
|
+
messages.append(
|
|
238
|
+
{
|
|
239
|
+
"role": "tool",
|
|
240
|
+
"tool_call_id": name, # OpenAI requires an ID, Google usually uses name
|
|
241
|
+
"content": json.dumps(resp),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
has_function_response = True
|
|
245
|
+
elif "text" in p:
|
|
246
|
+
content_text += p["text"]
|
|
247
|
+
elif hasattr(p, "function_call") and p.function_call:
|
|
248
|
+
fc = p.function_call
|
|
249
|
+
name = getattr(fc, "name", "")
|
|
250
|
+
args = getattr(fc, "args", {})
|
|
251
|
+
messages.append(
|
|
252
|
+
{
|
|
253
|
+
"role": "assistant",
|
|
254
|
+
"tool_calls": [
|
|
255
|
+
{
|
|
256
|
+
"id": name,
|
|
257
|
+
"type": "function",
|
|
258
|
+
"function": {
|
|
259
|
+
"name": name,
|
|
260
|
+
"arguments": json.dumps(args),
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
has_function_call = True
|
|
267
|
+
elif hasattr(p, "function_response") and p.function_response:
|
|
268
|
+
fr = p.function_response
|
|
269
|
+
name = getattr(fr, "name", "")
|
|
270
|
+
resp = getattr(fr, "response", {})
|
|
271
|
+
messages.append(
|
|
272
|
+
{
|
|
273
|
+
"role": "tool",
|
|
274
|
+
"tool_call_id": name,
|
|
275
|
+
"content": json.dumps(resp),
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
has_function_response = True
|
|
279
|
+
elif hasattr(p, "text") and p.text:
|
|
280
|
+
content_text += p.text
|
|
281
|
+
|
|
282
|
+
if not has_function_response and not has_function_call and content_text:
|
|
283
|
+
messages.append({"role": role, "content": content_text})
|
|
284
|
+
elif content_text and has_function_call:
|
|
285
|
+
# Assistant sent text AND tool call
|
|
286
|
+
messages[-1]["content"] = content_text
|
|
287
|
+
|
|
288
|
+
openai_req = {"model": model, "messages": messages}
|
|
289
|
+
|
|
290
|
+
if config:
|
|
291
|
+
if isinstance(config, dict):
|
|
292
|
+
if "temperature" in config:
|
|
293
|
+
openai_req["temperature"] = config["temperature"]
|
|
294
|
+
if "top_p" in config:
|
|
295
|
+
openai_req["top_p"] = config["top_p"]
|
|
296
|
+
if "max_output_tokens" in config:
|
|
297
|
+
openai_req["max_tokens"] = config["max_output_tokens"]
|
|
298
|
+
if "stop_sequences" in config:
|
|
299
|
+
openai_req["stop"] = config["stop_sequences"]
|
|
300
|
+
else:
|
|
301
|
+
if hasattr(config, "temperature") and config.temperature is not None:
|
|
302
|
+
openai_req["temperature"] = config.temperature
|
|
303
|
+
if hasattr(config, "top_p") and config.top_p is not None:
|
|
304
|
+
openai_req["top_p"] = config.top_p
|
|
305
|
+
if (
|
|
306
|
+
hasattr(config, "max_output_tokens")
|
|
307
|
+
and config.max_output_tokens is not None
|
|
308
|
+
):
|
|
309
|
+
openai_req["max_tokens"] = config.max_output_tokens
|
|
310
|
+
if hasattr(config, "stop_sequences") and config.stop_sequences is not None:
|
|
311
|
+
openai_req["stop"] = config.stop_sequences
|
|
312
|
+
|
|
313
|
+
if hasattr(config, "tools") and config.tools:
|
|
314
|
+
tools = config.tools
|
|
315
|
+
if client and hasattr(client, "_api_client"):
|
|
316
|
+
# Use Google's internal transformer if we have a client instance
|
|
317
|
+
try:
|
|
318
|
+
g_tools = t_tools(client._api_client, tools)
|
|
319
|
+
openai_tools = []
|
|
320
|
+
for t in g_tools:
|
|
321
|
+
if (
|
|
322
|
+
hasattr(t, "function_declarations")
|
|
323
|
+
and t.function_declarations
|
|
324
|
+
):
|
|
325
|
+
for fd in t.function_declarations:
|
|
326
|
+
# Convert schema to dict and lowercase the types
|
|
327
|
+
params_dict = _schema_to_dict(fd.parameters)
|
|
328
|
+
params_dict = _lower_dict_types(params_dict)
|
|
329
|
+
|
|
330
|
+
openai_tools.append(
|
|
331
|
+
{
|
|
332
|
+
"type": "function",
|
|
333
|
+
"function": {
|
|
334
|
+
"name": fd.name,
|
|
335
|
+
"description": fd.description or "",
|
|
336
|
+
"parameters": params_dict,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
if openai_tools:
|
|
341
|
+
openai_req["tools"] = openai_tools
|
|
342
|
+
except Exception:
|
|
343
|
+
pass # Silently drop tools if conversion fails, let backend handle if possible
|
|
344
|
+
|
|
345
|
+
return openai_req
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _openai_to_google_response(
|
|
349
|
+
openai_resp: Dict[str, Any], model: str
|
|
350
|
+
) -> types.GenerateContentResponse:
|
|
351
|
+
choice = openai_resp.get("choices", [{}])[0]
|
|
352
|
+
message_data = choice.get("message", {})
|
|
353
|
+
content = message_data.get("content", "")
|
|
354
|
+
|
|
355
|
+
usage_data = openai_resp.get("usage", {})
|
|
356
|
+
|
|
357
|
+
parts = []
|
|
358
|
+
if content:
|
|
359
|
+
parts.append(types.Part(text=content))
|
|
360
|
+
|
|
361
|
+
tool_calls = message_data.get("tool_calls", [])
|
|
362
|
+
if tool_calls:
|
|
363
|
+
for tc in tool_calls:
|
|
364
|
+
try:
|
|
365
|
+
args = tc["function"]["arguments"]
|
|
366
|
+
if isinstance(args, str):
|
|
367
|
+
args = json.loads(args)
|
|
368
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
369
|
+
args = {}
|
|
370
|
+
|
|
371
|
+
parts.append(
|
|
372
|
+
types.Part(
|
|
373
|
+
function_call=types.FunctionCall(
|
|
374
|
+
name=tc["function"]["name"], args=args
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
finish_reason_str = choice.get("finish_reason")
|
|
380
|
+
if finish_reason_str == "stop":
|
|
381
|
+
finish_reason = FinishReason.STOP
|
|
382
|
+
elif finish_reason_str == "length":
|
|
383
|
+
finish_reason = FinishReason.MAX_TOKENS
|
|
384
|
+
elif finish_reason_str == "tool_calls":
|
|
385
|
+
finish_reason = FinishReason.STOP
|
|
386
|
+
else:
|
|
387
|
+
finish_reason = FinishReason.OTHER
|
|
388
|
+
|
|
389
|
+
candidate = types.Candidate(
|
|
390
|
+
content=types.Content(role="model", parts=parts),
|
|
391
|
+
finish_reason=finish_reason,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
response = types.GenerateContentResponse(
|
|
395
|
+
candidates=[candidate],
|
|
396
|
+
usage_metadata=GenerateContentResponseUsageMetadata(
|
|
397
|
+
prompt_token_count=usage_data.get("prompt_tokens", 0),
|
|
398
|
+
candidates_token_count=usage_data.get("completion_tokens", 0),
|
|
399
|
+
total_token_count=usage_data.get("total_tokens", 0),
|
|
400
|
+
),
|
|
401
|
+
model_version=model,
|
|
402
|
+
)
|
|
403
|
+
return response
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class MultirouteModels:
|
|
407
|
+
def __init__(self, client: genai.Client, original_method):
|
|
408
|
+
self._client = client
|
|
409
|
+
self._original_generate_content = original_method
|
|
410
|
+
|
|
411
|
+
def generate_content(
|
|
412
|
+
self, model: str, contents: Any, config: Any = None, **kwargs
|
|
413
|
+
) -> types.GenerateContentResponse:
|
|
414
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
415
|
+
return self._original_generate_content(
|
|
416
|
+
model=model, contents=contents, config=config, **kwargs
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
openai_req = _google_to_openai_request(
|
|
421
|
+
model, contents, config, self._client
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
client = _get_shared_openai_client().with_options(
|
|
425
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY"),
|
|
426
|
+
timeout=kwargs.get("timeout", 60),
|
|
427
|
+
)
|
|
428
|
+
openai_resp_obj = client.chat.completions.create(**openai_req)
|
|
429
|
+
|
|
430
|
+
openai_resp = openai_resp_obj.model_dump()
|
|
431
|
+
return _openai_to_google_response(openai_resp, model)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
if _is_multiroute_error(e):
|
|
434
|
+
return self._original_generate_content(
|
|
435
|
+
model=model, contents=contents, config=config, **kwargs
|
|
436
|
+
)
|
|
437
|
+
raise
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class AsyncMultirouteModels:
|
|
441
|
+
def __init__(self, client: genai.Client, original_method):
|
|
442
|
+
self._client = client
|
|
443
|
+
self._original_generate_content = original_method
|
|
444
|
+
|
|
445
|
+
async def generate_content(
|
|
446
|
+
self, model: str, contents: Any, config: Any = None, **kwargs
|
|
447
|
+
) -> types.GenerateContentResponse:
|
|
448
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
449
|
+
return await self._original_generate_content(
|
|
450
|
+
model=model, contents=contents, config=config, **kwargs
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
openai_req = _google_to_openai_request(
|
|
455
|
+
model, contents, config, self._client
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
client = _get_shared_async_openai_client().with_options(
|
|
459
|
+
api_key=os.environ.get("MULTIROUTE_API_KEY"),
|
|
460
|
+
timeout=kwargs.get("timeout", 60),
|
|
461
|
+
)
|
|
462
|
+
openai_resp_obj = await client.chat.completions.create(**openai_req)
|
|
463
|
+
|
|
464
|
+
openai_resp = openai_resp_obj.model_dump()
|
|
465
|
+
return _openai_to_google_response(openai_resp, model)
|
|
466
|
+
except Exception as e:
|
|
467
|
+
if _is_multiroute_error(e):
|
|
468
|
+
return await self._original_generate_content(
|
|
469
|
+
model=model, contents=contents, config=config, **kwargs
|
|
470
|
+
)
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class Client(genai.Client):
|
|
475
|
+
def __init__(self, *args, **kwargs):
|
|
476
|
+
super().__init__(*args, **kwargs)
|
|
477
|
+
# Save original methods and override
|
|
478
|
+
self._multiroute_models = MultirouteModels(self, self.models.generate_content)
|
|
479
|
+
self.models.generate_content = self._multiroute_models.generate_content
|
|
480
|
+
|
|
481
|
+
if hasattr(self, "aio") and hasattr(self.aio, "models"):
|
|
482
|
+
self._async_multiroute_models = AsyncMultirouteModels(
|
|
483
|
+
self, self.aio.models.generate_content
|
|
484
|
+
)
|
|
485
|
+
self.aio.models.generate_content = (
|
|
486
|
+
self._async_multiroute_models.generate_content
|
|
487
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import openai
|
|
5
|
+
from openai.resources.chat.completions import (
|
|
6
|
+
AsyncCompletions as AsyncChatCompletions,
|
|
7
|
+
)
|
|
8
|
+
from openai.resources.chat.completions import (
|
|
9
|
+
Completions as ChatCompletions,
|
|
10
|
+
)
|
|
11
|
+
from openai.resources.responses import AsyncResponses, Responses
|
|
12
|
+
|
|
13
|
+
MULTIROUTE_BASE_URL = "https://api.multiroute.ai/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_multiroute_error(e: Exception) -> bool:
|
|
17
|
+
if isinstance(e, openai.APIConnectionError):
|
|
18
|
+
return True
|
|
19
|
+
if isinstance(e, openai.InternalServerError): # 5xx errors
|
|
20
|
+
return True
|
|
21
|
+
if isinstance(e, openai.APITimeoutError):
|
|
22
|
+
return True
|
|
23
|
+
if isinstance(
|
|
24
|
+
e, openai.NotFoundError
|
|
25
|
+
): # 404 - useful if endpoint or model is missing on proxy
|
|
26
|
+
return True
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MultirouteChatCompletions(ChatCompletions):
|
|
31
|
+
def create(self, **kwargs) -> Any:
|
|
32
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
33
|
+
return super().create(**kwargs)
|
|
34
|
+
|
|
35
|
+
# Safely create a temporary client sharing the connection pool
|
|
36
|
+
temp_client = self._client.with_options(
|
|
37
|
+
base_url=MULTIROUTE_BASE_URL, api_key=os.environ.get("MULTIROUTE_API_KEY")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
# Bypass the overridden create and call the original ChatCompletions directly
|
|
42
|
+
return ChatCompletions(temp_client).create(**kwargs)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
if _is_multiroute_error(e):
|
|
45
|
+
# Fallback to original
|
|
46
|
+
return super().create(**kwargs)
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncMultirouteChatCompletions(AsyncChatCompletions):
|
|
51
|
+
async def create(self, **kwargs) -> Any:
|
|
52
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
53
|
+
return await super().create(**kwargs)
|
|
54
|
+
|
|
55
|
+
temp_client = self._client.with_options(
|
|
56
|
+
base_url=MULTIROUTE_BASE_URL, api_key=os.environ.get("MULTIROUTE_API_KEY")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return await AsyncChatCompletions(temp_client).create(**kwargs)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
if _is_multiroute_error(e):
|
|
63
|
+
return await super().create(**kwargs)
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MultirouteResponses(Responses):
|
|
68
|
+
def create(self, **kwargs) -> Any:
|
|
69
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
70
|
+
return super().create(**kwargs)
|
|
71
|
+
|
|
72
|
+
temp_client = self._client.with_options(
|
|
73
|
+
base_url=MULTIROUTE_BASE_URL, api_key=os.environ.get("MULTIROUTE_API_KEY")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return Responses(temp_client).create(**kwargs)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
if _is_multiroute_error(e):
|
|
80
|
+
return super().create(**kwargs)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AsyncMultirouteResponses(AsyncResponses):
|
|
85
|
+
async def create(self, **kwargs) -> Any:
|
|
86
|
+
if not os.environ.get("MULTIROUTE_API_KEY"):
|
|
87
|
+
return await super().create(**kwargs)
|
|
88
|
+
|
|
89
|
+
temp_client = self._client.with_options(
|
|
90
|
+
base_url=MULTIROUTE_BASE_URL, api_key=os.environ.get("MULTIROUTE_API_KEY")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return await AsyncResponses(temp_client).create(**kwargs)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
if _is_multiroute_error(e):
|
|
97
|
+
return await super().create(**kwargs)
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OpenAI(openai.OpenAI):
|
|
102
|
+
def __init__(self, *args, **kwargs):
|
|
103
|
+
super().__init__(*args, **kwargs)
|
|
104
|
+
# Override the chat completions and responses resources with our wrappers
|
|
105
|
+
self.chat.completions = MultirouteChatCompletions(self)
|
|
106
|
+
self.responses = MultirouteResponses(self)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AsyncOpenAI(openai.AsyncOpenAI):
|
|
110
|
+
def __init__(self, *args, **kwargs):
|
|
111
|
+
super().__init__(*args, **kwargs)
|
|
112
|
+
self.chat.completions = AsyncMultirouteChatCompletions(self)
|
|
113
|
+
self.responses = AsyncMultirouteResponses(self)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multiroute
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: anthropic>=0.84.0
|
|
9
|
+
Requires-Dist: google-genai>=0.1.0
|
|
10
|
+
Requires-Dist: openai>=2.26.0
|
|
11
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/multiroute/__init__.py
|
|
5
|
+
src/multiroute.egg-info/PKG-INFO
|
|
6
|
+
src/multiroute.egg-info/SOURCES.txt
|
|
7
|
+
src/multiroute.egg-info/dependency_links.txt
|
|
8
|
+
src/multiroute.egg-info/requires.txt
|
|
9
|
+
src/multiroute.egg-info/top_level.txt
|
|
10
|
+
src/multiroute/anthropic/__init__.py
|
|
11
|
+
src/multiroute/anthropic/client.py
|
|
12
|
+
src/multiroute/google/__init__.py
|
|
13
|
+
src/multiroute/google/client.py
|
|
14
|
+
src/multiroute/openai/__init__.py
|
|
15
|
+
src/multiroute/openai/client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
multiroute
|