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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .google import Client as GoogleClient
2
+ from .openai import OpenAI
3
+ from .anthropic import Anthropic
4
+
5
+ __all__ = ["GoogleClient", "OpenAI", "Anthropic"]
@@ -0,0 +1,3 @@
1
+ from .client import Anthropic, AsyncAnthropic
2
+
3
+ __all__ = ["Anthropic", "AsyncAnthropic"]
@@ -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,3 @@
1
+ from .client import Client
2
+
3
+ __all__ = ["Client"]
@@ -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,3 @@
1
+ from .client import OpenAI, AsyncOpenAI
2
+
3
+ __all__ = ["OpenAI", "AsyncOpenAI"]
@@ -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,3 @@
1
+ anthropic>=0.84.0
2
+ google-genai>=0.1.0
3
+ openai>=2.26.0
@@ -0,0 +1 @@
1
+ multiroute