omniai-kit 1.0.0__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.
- omniai/__init__.py +117 -0
- omniai/_base.py +386 -0
- omniai/_constants.py +39 -0
- omniai/_version.py +4 -0
- omniai/client.py +316 -0
- omniai/exceptions.py +113 -0
- omniai/py.typed +1 -0
- omniai/resources/__init__.py +26 -0
- omniai/resources/chat.py +403 -0
- omniai/resources/livekit.py +71 -0
- omniai/resources/models.py +63 -0
- omniai/resources/router.py +1040 -0
- omniai/resources/tasks.py +171 -0
- omniai/types/__init__.py +27 -0
- omniai/types/chat.py +48 -0
- omniai/types/livekit.py +17 -0
- omniai/types/models.py +30 -0
- omniai/types/tasks.py +208 -0
- omniai_kit-1.0.0.dist-info/METADATA +270 -0
- omniai_kit-1.0.0.dist-info/RECORD +23 -0
- omniai_kit-1.0.0.dist-info/WHEEL +5 -0
- omniai_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
- omniai_kit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Set, Tuple, Union
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from omniai.exceptions import OmniaiUnsupportedProviderError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("omniai.router")
|
|
14
|
+
|
|
15
|
+
SUPPORTED_PROVIDERS: Tuple[str, ...] = ("openai", "gemini", "anthropic", "vllm", "openrouter", "ollama", "custom", "local")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_sdk_base_url(url: Optional[str]) -> None:
|
|
19
|
+
"""
|
|
20
|
+
SEC-08: Validates base_url in SDK RouterEngine.
|
|
21
|
+
Enforces https:// scheme for external hosts; allows http:// only for localhost/127.0.0.1/private testnets.
|
|
22
|
+
"""
|
|
23
|
+
if not url:
|
|
24
|
+
return
|
|
25
|
+
parsed = urlparse(url)
|
|
26
|
+
if parsed.scheme not in ("http", "https"):
|
|
27
|
+
raise ValueError(f"Invalid base_url scheme '{parsed.scheme}'. Only http and https are permitted.")
|
|
28
|
+
hostname = (parsed.hostname or "").lower()
|
|
29
|
+
if not hostname:
|
|
30
|
+
raise ValueError("Invalid base_url: missing hostname.")
|
|
31
|
+
if parsed.scheme == "http" and hostname not in ("localhost", "127.0.0.1", "::1", "host.docker.internal"):
|
|
32
|
+
# For local container networking or internal vLLM/Ollama, allow internal hosts
|
|
33
|
+
if not (
|
|
34
|
+
hostname.endswith(".local")
|
|
35
|
+
or hostname.startswith("vllm")
|
|
36
|
+
or hostname.startswith("ollama")
|
|
37
|
+
or hostname.startswith("10.")
|
|
38
|
+
or hostname.startswith("192.168.")
|
|
39
|
+
or hostname.startswith("172.")
|
|
40
|
+
):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Insecure plaintext HTTP endpoint '{url}' is prohibited for remote hosts. Use HTTPS."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_langsmith_tracer():
|
|
47
|
+
"""Returns LangSmith traceable decorator if enabled, otherwise a pass-through decorator."""
|
|
48
|
+
if os.getenv("LANGSMITH_TRACING", "").lower() == "true" and os.getenv("LANGSMITH_API_KEY"):
|
|
49
|
+
try:
|
|
50
|
+
from langsmith import traceable
|
|
51
|
+
return traceable
|
|
52
|
+
except ImportError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def dummy_decorator(*args, **kwargs):
|
|
56
|
+
def inner(func):
|
|
57
|
+
return func
|
|
58
|
+
if len(args) == 1 and callable(args[0]):
|
|
59
|
+
return args[0]
|
|
60
|
+
return inner
|
|
61
|
+
|
|
62
|
+
return dummy_decorator
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
traceable = _get_langsmith_tracer()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RouterEngine:
|
|
69
|
+
"""
|
|
70
|
+
Production-grade multi-provider LLM inference engine.
|
|
71
|
+
Strictly compatible with 4 providers:
|
|
72
|
+
1. OpenAI ('openai:model-name')
|
|
73
|
+
2. Google Gemini ('gemini:model-name')
|
|
74
|
+
3. Anthropic Claude ('anthropic:model-name')
|
|
75
|
+
4. vLLM Self-Hosted Inference ('vllm:model-name')
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
SUPPORTED_PROVIDERS: Tuple[str, ...] = SUPPORTED_PROVIDERS
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def parse_provider(cls, model_str: str) -> tuple[str, str]:
|
|
82
|
+
"""
|
|
83
|
+
Splits 'provider:model-name' into (provider, model_name).
|
|
84
|
+
Enforces strict whitelisting for: openai, gemini, anthropic, vllm.
|
|
85
|
+
"""
|
|
86
|
+
if ":" in model_str:
|
|
87
|
+
provider, _, model_name = model_str.partition(":")
|
|
88
|
+
provider_clean = provider.strip().lower()
|
|
89
|
+
if provider_clean not in cls.SUPPORTED_PROVIDERS:
|
|
90
|
+
raise OmniaiUnsupportedProviderError(
|
|
91
|
+
f"Unsupported provider '{provider_clean}'. The OmniAI SDK strictly supports: "
|
|
92
|
+
f"{', '.join(cls.SUPPORTED_PROVIDERS)}."
|
|
93
|
+
)
|
|
94
|
+
return (provider_clean, model_name.strip())
|
|
95
|
+
|
|
96
|
+
# Implicit provider detection
|
|
97
|
+
lower = model_str.lower()
|
|
98
|
+
if lower.startswith("gemini"):
|
|
99
|
+
return ("gemini", model_str)
|
|
100
|
+
elif lower.startswith("claude"):
|
|
101
|
+
return ("anthropic", model_str)
|
|
102
|
+
elif lower.startswith("openrouter/"):
|
|
103
|
+
return ("openrouter", model_str[11:])
|
|
104
|
+
elif lower.startswith("vllm/"):
|
|
105
|
+
return ("vllm", model_str[5:])
|
|
106
|
+
elif lower.startswith("ollama/"):
|
|
107
|
+
return ("ollama", model_str[7:])
|
|
108
|
+
elif lower.startswith("custom/"):
|
|
109
|
+
return ("custom", model_str[7:])
|
|
110
|
+
elif lower.startswith("local/"):
|
|
111
|
+
return ("local", model_str[6:])
|
|
112
|
+
else:
|
|
113
|
+
return ("openai", model_str)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def resolve_output_schema(
|
|
117
|
+
tools: Optional[Union[List[Any], Dict[str, Any]]] = None,
|
|
118
|
+
output_format: Optional[Union[str, Dict[str, Any]]] = None,
|
|
119
|
+
) -> Optional[Union[str, Dict[str, Any]]]:
|
|
120
|
+
"""Resolves `tools` and `output_format` into a single schema dictionary or 'json' mode."""
|
|
121
|
+
if tools:
|
|
122
|
+
if isinstance(tools, list):
|
|
123
|
+
schemas: List[Dict[str, Any]] = []
|
|
124
|
+
for item in tools:
|
|
125
|
+
if isinstance(item, type) and issubclass(item, BaseModel):
|
|
126
|
+
schemas.append(item.model_json_schema())
|
|
127
|
+
elif isinstance(item, dict):
|
|
128
|
+
if "function" in item and isinstance(item["function"], dict) and "parameters" in item["function"]:
|
|
129
|
+
schemas.append(item["function"]["parameters"])
|
|
130
|
+
else:
|
|
131
|
+
schemas.append(item)
|
|
132
|
+
elif isinstance(item, str):
|
|
133
|
+
schemas.append({"title": item, "type": "object"})
|
|
134
|
+
|
|
135
|
+
if len(schemas) == 1:
|
|
136
|
+
return schemas[0]
|
|
137
|
+
elif len(schemas) > 1:
|
|
138
|
+
return {"anyOf": schemas}
|
|
139
|
+
|
|
140
|
+
elif isinstance(payload_tools := tools, dict):
|
|
141
|
+
if "function" in payload_tools and isinstance(payload_tools["function"], dict) and "parameters" in payload_tools["function"]:
|
|
142
|
+
return payload_tools["function"]["parameters"]
|
|
143
|
+
return payload_tools
|
|
144
|
+
|
|
145
|
+
return output_format
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _normalize_message(msg: Union[Dict[str, Any], Any]) -> Dict[str, Any]:
|
|
149
|
+
"""Normalizes dict, Pydantic, or object message format to standard dict."""
|
|
150
|
+
if isinstance(msg, dict):
|
|
151
|
+
return msg
|
|
152
|
+
return {
|
|
153
|
+
"role": getattr(msg, "role", "user"),
|
|
154
|
+
"content": getattr(msg, "content", ""),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def build_openai_messages(
|
|
159
|
+
cls,
|
|
160
|
+
prompt: Optional[str] = None,
|
|
161
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
162
|
+
system_instruction: Optional[str] = None,
|
|
163
|
+
) -> List[Dict[str, Any]]:
|
|
164
|
+
"""Constructs standardized OpenAI-compatible messages list."""
|
|
165
|
+
built: List[Dict[str, Any]] = []
|
|
166
|
+
if system_instruction:
|
|
167
|
+
built.append({"role": "system", "content": system_instruction})
|
|
168
|
+
|
|
169
|
+
if messages:
|
|
170
|
+
norm_messages = [cls._normalize_message(m) for m in messages]
|
|
171
|
+
for msg in norm_messages:
|
|
172
|
+
role = "assistant" if msg.get("role") in ("assistant", "ai") else msg.get("role", "user")
|
|
173
|
+
if role == "system" and system_instruction:
|
|
174
|
+
continue
|
|
175
|
+
built.append({"role": role, "content": msg.get("content", "")})
|
|
176
|
+
elif prompt:
|
|
177
|
+
built.append({"role": "user", "content": prompt})
|
|
178
|
+
|
|
179
|
+
return built
|
|
180
|
+
|
|
181
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
182
|
+
# 1. Google Gemini Provider
|
|
183
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _execute_gemini(
|
|
187
|
+
cls,
|
|
188
|
+
model_name: str,
|
|
189
|
+
prompt: Optional[str],
|
|
190
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
191
|
+
system_instruction: Optional[str],
|
|
192
|
+
temperature: float,
|
|
193
|
+
thinking: Optional[int],
|
|
194
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
195
|
+
api_key: str,
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Executes Google Gemini inference via google-genai SDK or OpenAI-compatible endpoint."""
|
|
198
|
+
try:
|
|
199
|
+
from google import genai
|
|
200
|
+
from google.genai import types
|
|
201
|
+
|
|
202
|
+
client = genai.Client(api_key=api_key)
|
|
203
|
+
config_args: Dict[str, Any] = {"temperature": temperature}
|
|
204
|
+
if system_instruction:
|
|
205
|
+
config_args["system_instruction"] = system_instruction
|
|
206
|
+
if thinking is not None and thinking >= 0:
|
|
207
|
+
config_args["thinking_config"] = types.ThinkingConfig(thinking_budget=thinking)
|
|
208
|
+
if schema:
|
|
209
|
+
config_args["response_mime_type"] = "application/json"
|
|
210
|
+
if isinstance(schema, dict):
|
|
211
|
+
config_args["response_schema"] = schema
|
|
212
|
+
|
|
213
|
+
config = types.GenerateContentConfig(**config_args)
|
|
214
|
+
|
|
215
|
+
if messages:
|
|
216
|
+
contents = []
|
|
217
|
+
for msg in messages:
|
|
218
|
+
role = "model" if msg.get("role") in ("assistant", "ai") else "user"
|
|
219
|
+
contents.append(types.Content(role=role, parts=[types.Part.from_text(text=msg.get("content", ""))]))
|
|
220
|
+
response = client.models.generate_content(model=model_name, contents=contents, config=config)
|
|
221
|
+
else:
|
|
222
|
+
response = client.models.generate_content(model=model_name, contents=prompt or "", config=config)
|
|
223
|
+
|
|
224
|
+
text = response.text or ""
|
|
225
|
+
if schema and isinstance(schema, dict):
|
|
226
|
+
try:
|
|
227
|
+
return json.loads(text)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
elif schema and str(schema).lower() == "json":
|
|
231
|
+
try:
|
|
232
|
+
return json.loads(text)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
return text
|
|
236
|
+
|
|
237
|
+
except Exception:
|
|
238
|
+
# Fallback to Google Generative Language REST
|
|
239
|
+
return cls._execute_openai(
|
|
240
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
241
|
+
api_key=api_key,
|
|
242
|
+
model_name=model_name,
|
|
243
|
+
prompt=prompt,
|
|
244
|
+
messages=messages,
|
|
245
|
+
system_instruction=system_instruction,
|
|
246
|
+
temperature=temperature,
|
|
247
|
+
thinking=thinking,
|
|
248
|
+
schema=schema,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
async def _execute_gemini_stream(
|
|
253
|
+
cls,
|
|
254
|
+
model_name: str,
|
|
255
|
+
prompt: Optional[str],
|
|
256
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
257
|
+
system_instruction: Optional[str],
|
|
258
|
+
temperature: float,
|
|
259
|
+
thinking: Optional[int],
|
|
260
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
261
|
+
api_key: str,
|
|
262
|
+
) -> AsyncGenerator[str, None]:
|
|
263
|
+
"""Streams Google Gemini inference chunks via google-genai SDK."""
|
|
264
|
+
from google import genai
|
|
265
|
+
from google.genai import types
|
|
266
|
+
|
|
267
|
+
client = genai.Client(api_key=api_key)
|
|
268
|
+
config_args: Dict[str, Any] = {"temperature": temperature}
|
|
269
|
+
if system_instruction:
|
|
270
|
+
config_args["system_instruction"] = system_instruction
|
|
271
|
+
if thinking is not None and thinking >= 0:
|
|
272
|
+
config_args["thinking_config"] = types.ThinkingConfig(thinking_budget=thinking)
|
|
273
|
+
if schema:
|
|
274
|
+
config_args["response_mime_type"] = "application/json"
|
|
275
|
+
if isinstance(schema, dict):
|
|
276
|
+
config_args["response_schema"] = schema
|
|
277
|
+
|
|
278
|
+
config = types.GenerateContentConfig(**config_args)
|
|
279
|
+
|
|
280
|
+
if messages:
|
|
281
|
+
contents = []
|
|
282
|
+
for msg in messages:
|
|
283
|
+
role = "model" if msg.get("role") in ("assistant", "ai") else "user"
|
|
284
|
+
contents.append(types.Content(role=role, parts=[types.Part.from_text(text=msg.get("content", ""))]))
|
|
285
|
+
stream = client.models.generate_content_stream(model=model_name, contents=contents, config=config)
|
|
286
|
+
else:
|
|
287
|
+
stream = client.models.generate_content_stream(model=model_name, contents=prompt or "", config=config)
|
|
288
|
+
|
|
289
|
+
for chunk in stream:
|
|
290
|
+
if chunk.text:
|
|
291
|
+
yield chunk.text
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
295
|
+
# 2. Anthropic Claude Provider
|
|
296
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _sanitize_anthropic_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
300
|
+
"""
|
|
301
|
+
Strips `thinking` content blocks from assistant messages.
|
|
302
|
+
Anthropic rejects requests where an assistant turn contains only a thinking
|
|
303
|
+
block and no text block — this causes a 400 "model output must contain
|
|
304
|
+
either output text or tool calls" error in multi-turn thinking sessions.
|
|
305
|
+
"""
|
|
306
|
+
sanitized = []
|
|
307
|
+
for msg in messages:
|
|
308
|
+
content = msg.get("content")
|
|
309
|
+
if msg.get("role") in ("assistant", "ai") and isinstance(content, list):
|
|
310
|
+
# Keep only text/tool_use blocks — discard thinking blocks
|
|
311
|
+
filtered = [b for b in content if isinstance(b, dict) and b.get("type") != "thinking"]
|
|
312
|
+
if not filtered:
|
|
313
|
+
# All content was thinking — convert to a minimal text placeholder
|
|
314
|
+
# so the conversation turn is still valid
|
|
315
|
+
text_parts = [
|
|
316
|
+
b.get("thinking", "") or b.get("text", "")
|
|
317
|
+
for b in content
|
|
318
|
+
if isinstance(b, dict)
|
|
319
|
+
]
|
|
320
|
+
combined = " ".join(t for t in text_parts if t).strip()
|
|
321
|
+
sanitized.append({"role": "assistant", "content": combined or "..."})
|
|
322
|
+
else:
|
|
323
|
+
# Collapse single-text list to a plain string for cleanliness
|
|
324
|
+
if len(filtered) == 1 and filtered[0].get("type") == "text":
|
|
325
|
+
sanitized.append({"role": msg["role"], "content": filtered[0].get("text", "")})
|
|
326
|
+
else:
|
|
327
|
+
sanitized.append({**msg, "content": filtered})
|
|
328
|
+
else:
|
|
329
|
+
sanitized.append(msg)
|
|
330
|
+
return sanitized
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def _execute_anthropic(
|
|
334
|
+
cls,
|
|
335
|
+
model_name: str,
|
|
336
|
+
prompt: Optional[str],
|
|
337
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
338
|
+
system_instruction: Optional[str],
|
|
339
|
+
temperature: float,
|
|
340
|
+
thinking: Optional[int],
|
|
341
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
342
|
+
api_key: str,
|
|
343
|
+
) -> Any:
|
|
344
|
+
"""Executes Anthropic Claude inference via direct HTTP REST API."""
|
|
345
|
+
headers = {
|
|
346
|
+
"x-api-key": api_key,
|
|
347
|
+
"anthropic-version": "2023-06-01",
|
|
348
|
+
"content-type": "application/json",
|
|
349
|
+
}
|
|
350
|
+
anth_messages: List[Dict[str, Any]] = []
|
|
351
|
+
if messages:
|
|
352
|
+
for msg in messages:
|
|
353
|
+
role = "assistant" if msg.get("role") in ("assistant", "ai") else "user"
|
|
354
|
+
if msg.get("role") == "system" and not system_instruction:
|
|
355
|
+
system_instruction = msg.get("content")
|
|
356
|
+
continue
|
|
357
|
+
anth_messages.append({"role": role, "content": msg.get("content", "")})
|
|
358
|
+
else:
|
|
359
|
+
anth_messages.append({"role": "user", "content": prompt or ""})
|
|
360
|
+
|
|
361
|
+
# Strip thinking blocks from assistant history to avoid Anthropic 400
|
|
362
|
+
anth_messages = cls._sanitize_anthropic_messages(anth_messages)
|
|
363
|
+
|
|
364
|
+
payload: Dict[str, Any] = {
|
|
365
|
+
"model": model_name,
|
|
366
|
+
"messages": anth_messages,
|
|
367
|
+
"max_tokens": 4096,
|
|
368
|
+
"temperature": temperature,
|
|
369
|
+
}
|
|
370
|
+
if system_instruction:
|
|
371
|
+
payload["system"] = system_instruction
|
|
372
|
+
|
|
373
|
+
if thinking and thinking > 0:
|
|
374
|
+
payload["thinking"] = {"type": "enabled", "budget_tokens": thinking}
|
|
375
|
+
payload["max_tokens"] = max(4096, thinking + 2048)
|
|
376
|
+
payload["temperature"] = 1.0
|
|
377
|
+
# Required beta header for extended thinking on older Anthropic API versions
|
|
378
|
+
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
|
|
379
|
+
|
|
380
|
+
with httpx.Client(timeout=90.0) as client:
|
|
381
|
+
resp = client.post("https://api.anthropic.com/v1/messages", headers=headers, json=payload)
|
|
382
|
+
if resp.status_code != 200:
|
|
383
|
+
raise RuntimeError(f"Anthropic API error ({resp.status_code}): {resp.text}")
|
|
384
|
+
data = resp.json()
|
|
385
|
+
content_blocks = data.get("content", [])
|
|
386
|
+
text = "".join(b.get("text", "") for b in content_blocks if b.get("type") == "text")
|
|
387
|
+
|
|
388
|
+
# Fallback: if model returned only thinking blocks (no text), extract thinking content
|
|
389
|
+
if not text and content_blocks:
|
|
390
|
+
text = "".join(
|
|
391
|
+
b.get("thinking", "") or b.get("text", "")
|
|
392
|
+
for b in content_blocks
|
|
393
|
+
if b.get("type") in ("thinking", "text")
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if schema and (isinstance(schema, dict) or str(schema).lower() == "json"):
|
|
397
|
+
try:
|
|
398
|
+
return json.loads(text)
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
return text
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
async def _execute_anthropic_stream(
|
|
405
|
+
cls,
|
|
406
|
+
model_name: str,
|
|
407
|
+
prompt: Optional[str],
|
|
408
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
409
|
+
system_instruction: Optional[str],
|
|
410
|
+
temperature: float,
|
|
411
|
+
thinking: Optional[int],
|
|
412
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
413
|
+
api_key: str,
|
|
414
|
+
) -> AsyncGenerator[str, None]:
|
|
415
|
+
"""Streams Anthropic Claude inference chunks via SSE."""
|
|
416
|
+
headers = {
|
|
417
|
+
"x-api-key": api_key,
|
|
418
|
+
"anthropic-version": "2023-06-01",
|
|
419
|
+
"content-type": "application/json",
|
|
420
|
+
}
|
|
421
|
+
anth_messages: List[Dict[str, Any]] = []
|
|
422
|
+
if messages:
|
|
423
|
+
for msg in messages:
|
|
424
|
+
role = "assistant" if msg.get("role") in ("assistant", "ai") else "user"
|
|
425
|
+
if msg.get("role") == "system" and not system_instruction:
|
|
426
|
+
system_instruction = msg.get("content")
|
|
427
|
+
continue
|
|
428
|
+
anth_messages.append({"role": role, "content": msg.get("content", "")})
|
|
429
|
+
else:
|
|
430
|
+
anth_messages.append({"role": "user", "content": prompt or ""})
|
|
431
|
+
|
|
432
|
+
# Strip thinking blocks from assistant history to avoid Anthropic 400
|
|
433
|
+
anth_messages = cls._sanitize_anthropic_messages(anth_messages)
|
|
434
|
+
|
|
435
|
+
payload: Dict[str, Any] = {
|
|
436
|
+
"model": model_name,
|
|
437
|
+
"messages": anth_messages,
|
|
438
|
+
"max_tokens": 4096,
|
|
439
|
+
"temperature": temperature,
|
|
440
|
+
"stream": True,
|
|
441
|
+
}
|
|
442
|
+
if system_instruction:
|
|
443
|
+
payload["system"] = system_instruction
|
|
444
|
+
|
|
445
|
+
if thinking and thinking > 0:
|
|
446
|
+
payload["thinking"] = {"type": "enabled", "budget_tokens": thinking}
|
|
447
|
+
payload["max_tokens"] = max(4096, thinking + 2048)
|
|
448
|
+
payload["temperature"] = 1.0
|
|
449
|
+
# Required beta header for extended thinking on older Anthropic API versions
|
|
450
|
+
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
|
|
451
|
+
|
|
452
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
453
|
+
async with client.stream("POST", "https://api.anthropic.com/v1/messages", headers=headers, json=payload) as resp:
|
|
454
|
+
if resp.status_code != 200:
|
|
455
|
+
err = await resp.aread()
|
|
456
|
+
raise RuntimeError(f"Anthropic streaming error ({resp.status_code}): {err.decode('utf-8')}")
|
|
457
|
+
async for line in resp.aiter_lines():
|
|
458
|
+
if line.startswith("data: "):
|
|
459
|
+
raw_data = line[6:].strip()
|
|
460
|
+
if raw_data == "[DONE]":
|
|
461
|
+
break
|
|
462
|
+
try:
|
|
463
|
+
evt = json.loads(raw_data)
|
|
464
|
+
if evt.get("type") == "content_block_delta":
|
|
465
|
+
delta = evt.get("delta", {})
|
|
466
|
+
if delta.get("type") == "text_delta":
|
|
467
|
+
yield delta.get("text", "")
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
472
|
+
# 3. OpenAI-Compatible Core Execution (OpenAI, OpenRouter, vLLM)
|
|
473
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
@classmethod
|
|
476
|
+
def _execute_openai_compatible(
|
|
477
|
+
cls,
|
|
478
|
+
base_url: Optional[str],
|
|
479
|
+
api_key: str,
|
|
480
|
+
model_name: str,
|
|
481
|
+
prompt: Optional[str],
|
|
482
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
483
|
+
system_instruction: Optional[str],
|
|
484
|
+
temperature: float,
|
|
485
|
+
thinking: Optional[int],
|
|
486
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
487
|
+
) -> Any:
|
|
488
|
+
"""Executes OpenAI / OpenRouter / vLLM inference via OpenAI client with robust parameter handling."""
|
|
489
|
+
validate_sdk_base_url(base_url)
|
|
490
|
+
from openai import OpenAI
|
|
491
|
+
|
|
492
|
+
client = OpenAI(
|
|
493
|
+
api_key=api_key or "EMPTY",
|
|
494
|
+
base_url=base_url,
|
|
495
|
+
)
|
|
496
|
+
built_messages = cls.build_openai_messages(prompt, messages, system_instruction)
|
|
497
|
+
|
|
498
|
+
# OpenAI reasoning models (o1, o3, etc.) and newer official API endpoints require max_completion_tokens
|
|
499
|
+
is_official_openai = base_url is None or "api.openai.com" in (base_url or "")
|
|
500
|
+
is_reasoning_model = any(model_name.lower().startswith(p) for p in ("o1", "o3", "gpt-5"))
|
|
501
|
+
|
|
502
|
+
kwargs: Dict[str, Any] = {
|
|
503
|
+
"model": model_name,
|
|
504
|
+
"messages": built_messages,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Reasoning models reject custom temperature
|
|
508
|
+
if not is_reasoning_model:
|
|
509
|
+
kwargs["temperature"] = temperature
|
|
510
|
+
|
|
511
|
+
if is_official_openai or is_reasoning_model:
|
|
512
|
+
kwargs["max_completion_tokens"] = 4096
|
|
513
|
+
else:
|
|
514
|
+
kwargs["max_tokens"] = 4096
|
|
515
|
+
|
|
516
|
+
if thinking and thinking > 0:
|
|
517
|
+
effort = "low" if thinking <= 2000 else "medium" if thinking <= 8000 else "high"
|
|
518
|
+
kwargs["reasoning_effort"] = effort
|
|
519
|
+
kwargs.pop("temperature", None)
|
|
520
|
+
|
|
521
|
+
if schema:
|
|
522
|
+
if isinstance(schema, dict):
|
|
523
|
+
kwargs["response_format"] = {
|
|
524
|
+
"type": "json_schema",
|
|
525
|
+
"json_schema": {"name": "output_schema", "schema": schema, "strict": True},
|
|
526
|
+
}
|
|
527
|
+
elif str(schema).lower() == "json":
|
|
528
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
response = client.chat.completions.create(**kwargs)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
err_msg = str(e).lower()
|
|
534
|
+
if "max_tokens" in err_msg and "max_completion_tokens" in err_msg:
|
|
535
|
+
if "max_tokens" in kwargs:
|
|
536
|
+
kwargs.pop("max_tokens", None)
|
|
537
|
+
kwargs["max_completion_tokens"] = 4096
|
|
538
|
+
else:
|
|
539
|
+
kwargs.pop("max_completion_tokens", None)
|
|
540
|
+
kwargs["max_tokens"] = 4096
|
|
541
|
+
if "temperature" in err_msg:
|
|
542
|
+
kwargs.pop("temperature", None)
|
|
543
|
+
response = client.chat.completions.create(**kwargs)
|
|
544
|
+
elif "temperature" in err_msg and ("not supported" in err_msg or "unsupported_parameter" in err_msg):
|
|
545
|
+
kwargs.pop("temperature", None)
|
|
546
|
+
response = client.chat.completions.create(**kwargs)
|
|
547
|
+
else:
|
|
548
|
+
raise
|
|
549
|
+
|
|
550
|
+
content = response.choices[0].message.content or ""
|
|
551
|
+
|
|
552
|
+
if schema and (isinstance(schema, dict) or str(schema).lower() == "json"):
|
|
553
|
+
try:
|
|
554
|
+
return json.loads(content)
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
return content
|
|
558
|
+
|
|
559
|
+
@classmethod
|
|
560
|
+
async def _execute_openai_compatible_stream(
|
|
561
|
+
cls,
|
|
562
|
+
base_url: Optional[str],
|
|
563
|
+
api_key: str,
|
|
564
|
+
model_name: str,
|
|
565
|
+
prompt: Optional[str],
|
|
566
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
567
|
+
system_instruction: Optional[str],
|
|
568
|
+
temperature: float,
|
|
569
|
+
thinking: Optional[int],
|
|
570
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
571
|
+
) -> AsyncGenerator[str, None]:
|
|
572
|
+
"""Streams OpenAI / OpenRouter / vLLM inference chunks via OpenAI async client."""
|
|
573
|
+
validate_sdk_base_url(base_url)
|
|
574
|
+
from openai import AsyncOpenAI
|
|
575
|
+
|
|
576
|
+
client = AsyncOpenAI(
|
|
577
|
+
api_key=api_key or "EMPTY",
|
|
578
|
+
base_url=base_url,
|
|
579
|
+
)
|
|
580
|
+
built_messages = cls.build_openai_messages(prompt, messages, system_instruction)
|
|
581
|
+
|
|
582
|
+
is_official_openai = base_url is None or "api.openai.com" in (base_url or "")
|
|
583
|
+
is_reasoning_model = any(model_name.lower().startswith(p) for p in ("o1", "o3", "gpt-5"))
|
|
584
|
+
|
|
585
|
+
kwargs: Dict[str, Any] = {
|
|
586
|
+
"model": model_name,
|
|
587
|
+
"messages": built_messages,
|
|
588
|
+
"stream": True,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if not is_reasoning_model:
|
|
592
|
+
kwargs["temperature"] = temperature
|
|
593
|
+
|
|
594
|
+
if is_official_openai or is_reasoning_model:
|
|
595
|
+
kwargs["max_completion_tokens"] = 4096
|
|
596
|
+
else:
|
|
597
|
+
kwargs["max_tokens"] = 4096
|
|
598
|
+
|
|
599
|
+
if thinking and thinking > 0:
|
|
600
|
+
effort = "low" if thinking <= 2000 else "medium" if thinking <= 8000 else "high"
|
|
601
|
+
kwargs["reasoning_effort"] = effort
|
|
602
|
+
kwargs.pop("temperature", None)
|
|
603
|
+
|
|
604
|
+
if schema:
|
|
605
|
+
if isinstance(schema, dict):
|
|
606
|
+
kwargs["response_format"] = {
|
|
607
|
+
"type": "json_schema",
|
|
608
|
+
"json_schema": {"name": "output_schema", "schema": schema, "strict": True},
|
|
609
|
+
}
|
|
610
|
+
elif str(schema).lower() == "json":
|
|
611
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
stream = await client.chat.completions.create(**kwargs)
|
|
615
|
+
except Exception as e:
|
|
616
|
+
err_msg = str(e).lower()
|
|
617
|
+
if "max_tokens" in err_msg and "max_completion_tokens" in err_msg:
|
|
618
|
+
if "max_tokens" in kwargs:
|
|
619
|
+
kwargs.pop("max_tokens", None)
|
|
620
|
+
kwargs["max_completion_tokens"] = 4096
|
|
621
|
+
else:
|
|
622
|
+
kwargs.pop("max_completion_tokens", None)
|
|
623
|
+
kwargs["max_tokens"] = 4096
|
|
624
|
+
if "temperature" in err_msg:
|
|
625
|
+
kwargs.pop("temperature", None)
|
|
626
|
+
stream = await client.chat.completions.create(**kwargs)
|
|
627
|
+
elif "temperature" in err_msg and ("not supported" in err_msg or "unsupported_parameter" in err_msg):
|
|
628
|
+
kwargs.pop("temperature", None)
|
|
629
|
+
stream = await client.chat.completions.create(**kwargs)
|
|
630
|
+
else:
|
|
631
|
+
raise
|
|
632
|
+
|
|
633
|
+
async for chunk in stream:
|
|
634
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
635
|
+
yield chunk.choices[0].delta.content
|
|
636
|
+
|
|
637
|
+
@classmethod
|
|
638
|
+
def _execute_openai(
|
|
639
|
+
cls,
|
|
640
|
+
api_key: str,
|
|
641
|
+
model_name: str,
|
|
642
|
+
prompt: Optional[str],
|
|
643
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
644
|
+
system_instruction: Optional[str],
|
|
645
|
+
temperature: float,
|
|
646
|
+
thinking: Optional[int],
|
|
647
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
648
|
+
base_url: Optional[str] = None,
|
|
649
|
+
) -> Any:
|
|
650
|
+
return cls._execute_openai_compatible(
|
|
651
|
+
base_url=base_url,
|
|
652
|
+
api_key=api_key or os.getenv("OPENAI_API_KEY", "EMPTY"),
|
|
653
|
+
model_name=model_name,
|
|
654
|
+
prompt=prompt,
|
|
655
|
+
messages=messages,
|
|
656
|
+
system_instruction=system_instruction,
|
|
657
|
+
temperature=temperature,
|
|
658
|
+
thinking=thinking,
|
|
659
|
+
schema=schema,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
@classmethod
|
|
663
|
+
async def _execute_openai_stream(
|
|
664
|
+
cls,
|
|
665
|
+
api_key: str,
|
|
666
|
+
model_name: str,
|
|
667
|
+
prompt: Optional[str],
|
|
668
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
669
|
+
system_instruction: Optional[str],
|
|
670
|
+
temperature: float,
|
|
671
|
+
thinking: Optional[int],
|
|
672
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
673
|
+
base_url: Optional[str] = None,
|
|
674
|
+
) -> AsyncGenerator[str, None]:
|
|
675
|
+
async for chunk in cls._execute_openai_compatible_stream(
|
|
676
|
+
base_url=base_url,
|
|
677
|
+
api_key=api_key or os.getenv("OPENAI_API_KEY", "EMPTY"),
|
|
678
|
+
model_name=model_name,
|
|
679
|
+
prompt=prompt,
|
|
680
|
+
messages=messages,
|
|
681
|
+
system_instruction=system_instruction,
|
|
682
|
+
temperature=temperature,
|
|
683
|
+
thinking=thinking,
|
|
684
|
+
schema=schema,
|
|
685
|
+
):
|
|
686
|
+
yield chunk
|
|
687
|
+
|
|
688
|
+
@classmethod
|
|
689
|
+
def _execute_openrouter(
|
|
690
|
+
cls,
|
|
691
|
+
api_key: str,
|
|
692
|
+
model_name: str,
|
|
693
|
+
prompt: Optional[str],
|
|
694
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
695
|
+
system_instruction: Optional[str],
|
|
696
|
+
temperature: float,
|
|
697
|
+
thinking: Optional[int],
|
|
698
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
699
|
+
base_url: Optional[str] = None,
|
|
700
|
+
) -> Any:
|
|
701
|
+
resolved_base_url = base_url or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
702
|
+
return cls._execute_openai_compatible(
|
|
703
|
+
base_url=resolved_base_url,
|
|
704
|
+
api_key=api_key or os.getenv("OPENROUTER_API_KEY", "EMPTY"),
|
|
705
|
+
model_name=model_name,
|
|
706
|
+
prompt=prompt,
|
|
707
|
+
messages=messages,
|
|
708
|
+
system_instruction=system_instruction,
|
|
709
|
+
temperature=temperature,
|
|
710
|
+
thinking=thinking,
|
|
711
|
+
schema=schema,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
@classmethod
|
|
715
|
+
async def _execute_openrouter_stream(
|
|
716
|
+
cls,
|
|
717
|
+
api_key: str,
|
|
718
|
+
model_name: str,
|
|
719
|
+
prompt: Optional[str],
|
|
720
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
721
|
+
system_instruction: Optional[str],
|
|
722
|
+
temperature: float,
|
|
723
|
+
thinking: Optional[int],
|
|
724
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
725
|
+
base_url: Optional[str] = None,
|
|
726
|
+
) -> AsyncGenerator[str, None]:
|
|
727
|
+
resolved_base_url = base_url or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
728
|
+
async for chunk in cls._execute_openai_compatible_stream(
|
|
729
|
+
base_url=resolved_base_url,
|
|
730
|
+
api_key=api_key or os.getenv("OPENROUTER_API_KEY", "EMPTY"),
|
|
731
|
+
model_name=model_name,
|
|
732
|
+
prompt=prompt,
|
|
733
|
+
messages=messages,
|
|
734
|
+
system_instruction=system_instruction,
|
|
735
|
+
temperature=temperature,
|
|
736
|
+
thinking=thinking,
|
|
737
|
+
schema=schema,
|
|
738
|
+
):
|
|
739
|
+
yield chunk
|
|
740
|
+
|
|
741
|
+
@classmethod
|
|
742
|
+
def _execute_vllm(
|
|
743
|
+
cls,
|
|
744
|
+
model_name: str,
|
|
745
|
+
prompt: Optional[str],
|
|
746
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
747
|
+
system_instruction: Optional[str],
|
|
748
|
+
temperature: float,
|
|
749
|
+
thinking: Optional[int],
|
|
750
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
751
|
+
base_url: Optional[str] = None,
|
|
752
|
+
api_key: Optional[str] = None,
|
|
753
|
+
) -> Any:
|
|
754
|
+
resolved_base_url = (
|
|
755
|
+
base_url
|
|
756
|
+
or os.getenv("VLLM_BASE_URL")
|
|
757
|
+
or "http://localhost:8000/v1"
|
|
758
|
+
)
|
|
759
|
+
return cls._execute_openai_compatible(
|
|
760
|
+
base_url=resolved_base_url,
|
|
761
|
+
api_key=api_key or os.getenv("VLLM_API_KEY", "EMPTY"),
|
|
762
|
+
model_name=model_name,
|
|
763
|
+
prompt=prompt,
|
|
764
|
+
messages=messages,
|
|
765
|
+
system_instruction=system_instruction,
|
|
766
|
+
temperature=temperature,
|
|
767
|
+
thinking=thinking,
|
|
768
|
+
schema=schema,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
@classmethod
|
|
772
|
+
async def _execute_vllm_stream(
|
|
773
|
+
cls,
|
|
774
|
+
model_name: str,
|
|
775
|
+
prompt: Optional[str],
|
|
776
|
+
messages: Optional[List[Dict[str, Any]]],
|
|
777
|
+
system_instruction: Optional[str],
|
|
778
|
+
temperature: float,
|
|
779
|
+
thinking: Optional[int],
|
|
780
|
+
schema: Optional[Union[str, Dict[str, Any]]],
|
|
781
|
+
base_url: Optional[str] = None,
|
|
782
|
+
api_key: Optional[str] = None,
|
|
783
|
+
) -> AsyncGenerator[str, None]:
|
|
784
|
+
resolved_base_url = (
|
|
785
|
+
base_url
|
|
786
|
+
or os.getenv("VLLM_BASE_URL")
|
|
787
|
+
or "http://localhost:8000/v1"
|
|
788
|
+
)
|
|
789
|
+
async for chunk in cls._execute_openai_compatible_stream(
|
|
790
|
+
base_url=resolved_base_url,
|
|
791
|
+
api_key=api_key or os.getenv("VLLM_API_KEY", "EMPTY"),
|
|
792
|
+
model_name=model_name,
|
|
793
|
+
prompt=prompt,
|
|
794
|
+
messages=messages,
|
|
795
|
+
system_instruction=system_instruction,
|
|
796
|
+
temperature=temperature,
|
|
797
|
+
thinking=thinking,
|
|
798
|
+
schema=schema,
|
|
799
|
+
):
|
|
800
|
+
yield chunk
|
|
801
|
+
|
|
802
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
803
|
+
# Unified Execution Entry Points
|
|
804
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
@classmethod
|
|
807
|
+
def execute(
|
|
808
|
+
cls,
|
|
809
|
+
model: Union[str, Any],
|
|
810
|
+
prompt: Optional[str] = None,
|
|
811
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
812
|
+
system_instruction: Optional[str] = None,
|
|
813
|
+
temperature: float = 0.1,
|
|
814
|
+
thinking: Optional[int] = None,
|
|
815
|
+
base_url: Optional[str] = None,
|
|
816
|
+
tools: Optional[Union[List[Any], Dict[str, Any]]] = None,
|
|
817
|
+
output_format: Optional[Union[str, Dict[str, Any]]] = None,
|
|
818
|
+
gemini_api_key: Optional[str] = None,
|
|
819
|
+
openai_api_key: Optional[str] = None,
|
|
820
|
+
anthropic_api_key: Optional[str] = None,
|
|
821
|
+
openrouter_api_key: Optional[str] = None,
|
|
822
|
+
vllm_api_key: Optional[str] = None,
|
|
823
|
+
**kwargs: Any,
|
|
824
|
+
) -> Any:
|
|
825
|
+
"""
|
|
826
|
+
Main execution entry point for synchronous completions across supported providers:
|
|
827
|
+
openai, gemini, anthropic, vllm, openrouter.
|
|
828
|
+
Supports passing an LLMPayload object or individual arguments.
|
|
829
|
+
"""
|
|
830
|
+
if not isinstance(model, str):
|
|
831
|
+
payload = model
|
|
832
|
+
model = getattr(payload, "model", "")
|
|
833
|
+
prompt = getattr(payload, "prompt", prompt)
|
|
834
|
+
messages = getattr(payload, "messages", messages)
|
|
835
|
+
system_instruction = getattr(payload, "system_instruction", system_instruction)
|
|
836
|
+
temperature = getattr(payload, "temperature", temperature)
|
|
837
|
+
thinking = getattr(payload, "thinking", thinking)
|
|
838
|
+
base_url = getattr(payload, "base_url", base_url)
|
|
839
|
+
tools = getattr(payload, "tools", tools)
|
|
840
|
+
output_format = getattr(payload, "output_format", output_format)
|
|
841
|
+
|
|
842
|
+
provider, model_name = cls.parse_provider(model)
|
|
843
|
+
schema = cls.resolve_output_schema(tools, output_format)
|
|
844
|
+
native_model = model_name.replace(":batch", "") if provider in ("gemini", "openai", "anthropic") else model_name
|
|
845
|
+
|
|
846
|
+
if provider == "gemini":
|
|
847
|
+
key = gemini_api_key or os.getenv("GEMINI_API_KEY", "")
|
|
848
|
+
return cls._execute_gemini(
|
|
849
|
+
model_name=native_model,
|
|
850
|
+
prompt=prompt,
|
|
851
|
+
messages=messages,
|
|
852
|
+
system_instruction=system_instruction,
|
|
853
|
+
temperature=temperature,
|
|
854
|
+
thinking=thinking,
|
|
855
|
+
schema=schema,
|
|
856
|
+
api_key=key,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
elif provider == "anthropic":
|
|
860
|
+
key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY", "")
|
|
861
|
+
return cls._execute_anthropic(
|
|
862
|
+
model_name=model_name,
|
|
863
|
+
prompt=prompt,
|
|
864
|
+
messages=messages,
|
|
865
|
+
system_instruction=system_instruction,
|
|
866
|
+
temperature=temperature,
|
|
867
|
+
thinking=thinking,
|
|
868
|
+
schema=schema,
|
|
869
|
+
api_key=key,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
elif provider == "openrouter":
|
|
873
|
+
key = openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "")
|
|
874
|
+
return cls._execute_openrouter(
|
|
875
|
+
api_key=key,
|
|
876
|
+
model_name=model_name,
|
|
877
|
+
prompt=prompt,
|
|
878
|
+
messages=messages,
|
|
879
|
+
system_instruction=system_instruction,
|
|
880
|
+
temperature=temperature,
|
|
881
|
+
thinking=thinking,
|
|
882
|
+
schema=schema,
|
|
883
|
+
base_url=base_url,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
elif provider in ("vllm", "ollama", "custom", "local"):
|
|
887
|
+
default_url = "http://localhost:11434/v1" if provider == "ollama" else "http://localhost:8000/v1"
|
|
888
|
+
custom_url = base_url or os.getenv(f"{provider.upper()}_BASE_URL") or os.getenv("VLLM_BASE_URL") or default_url
|
|
889
|
+
custom_key = vllm_api_key or os.getenv(f"{provider.upper()}_API_KEY") or os.getenv("VLLM_API_KEY", "EMPTY")
|
|
890
|
+
return cls._execute_vllm(
|
|
891
|
+
model_name=model_name,
|
|
892
|
+
prompt=prompt,
|
|
893
|
+
messages=messages,
|
|
894
|
+
system_instruction=system_instruction,
|
|
895
|
+
temperature=temperature,
|
|
896
|
+
thinking=thinking,
|
|
897
|
+
schema=schema,
|
|
898
|
+
base_url=custom_url,
|
|
899
|
+
api_key=custom_key,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
elif provider == "openai":
|
|
903
|
+
key = openai_api_key or os.getenv("OPENAI_API_KEY", "")
|
|
904
|
+
return cls._execute_openai(
|
|
905
|
+
api_key=key,
|
|
906
|
+
model_name=model_name,
|
|
907
|
+
prompt=prompt,
|
|
908
|
+
messages=messages,
|
|
909
|
+
system_instruction=system_instruction,
|
|
910
|
+
temperature=temperature,
|
|
911
|
+
thinking=thinking,
|
|
912
|
+
schema=schema,
|
|
913
|
+
base_url=base_url,
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
raise OmniaiUnsupportedProviderError(
|
|
917
|
+
f"Unsupported provider '{provider}'. The OmniAI SDK strictly supports: "
|
|
918
|
+
f"{', '.join(cls.SUPPORTED_PROVIDERS)}."
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
@classmethod
|
|
922
|
+
async def execute_stream(
|
|
923
|
+
cls,
|
|
924
|
+
model: Union[str, Any],
|
|
925
|
+
prompt: Optional[str] = None,
|
|
926
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
927
|
+
system_instruction: Optional[str] = None,
|
|
928
|
+
temperature: float = 0.1,
|
|
929
|
+
thinking: Optional[int] = None,
|
|
930
|
+
base_url: Optional[str] = None,
|
|
931
|
+
tools: Optional[Union[List[Any], Dict[str, Any]]] = None,
|
|
932
|
+
output_format: Optional[Union[str, Dict[str, Any]]] = None,
|
|
933
|
+
gemini_api_key: Optional[str] = None,
|
|
934
|
+
openai_api_key: Optional[str] = None,
|
|
935
|
+
anthropic_api_key: Optional[str] = None,
|
|
936
|
+
openrouter_api_key: Optional[str] = None,
|
|
937
|
+
vllm_api_key: Optional[str] = None,
|
|
938
|
+
**kwargs: Any,
|
|
939
|
+
) -> AsyncGenerator[str, None]:
|
|
940
|
+
"""
|
|
941
|
+
Main execution entry point for streaming token chunks across supported providers:
|
|
942
|
+
openai, gemini, anthropic, vllm, openrouter.
|
|
943
|
+
Supports passing an LLMPayload object or individual arguments.
|
|
944
|
+
"""
|
|
945
|
+
if not isinstance(model, str):
|
|
946
|
+
payload = model
|
|
947
|
+
model = getattr(payload, "model", "")
|
|
948
|
+
prompt = getattr(payload, "prompt", prompt)
|
|
949
|
+
messages = getattr(payload, "messages", messages)
|
|
950
|
+
system_instruction = getattr(payload, "system_instruction", system_instruction)
|
|
951
|
+
temperature = getattr(payload, "temperature", temperature)
|
|
952
|
+
thinking = getattr(payload, "thinking", thinking)
|
|
953
|
+
base_url = getattr(payload, "base_url", base_url)
|
|
954
|
+
tools = getattr(payload, "tools", tools)
|
|
955
|
+
output_format = getattr(payload, "output_format", output_format)
|
|
956
|
+
|
|
957
|
+
provider, model_name = cls.parse_provider(model)
|
|
958
|
+
schema = cls.resolve_output_schema(tools, output_format)
|
|
959
|
+
native_model = model_name.replace(":batch", "") if provider in ("gemini", "openai", "anthropic") else model_name
|
|
960
|
+
|
|
961
|
+
if provider == "gemini":
|
|
962
|
+
key = gemini_api_key or os.getenv("GEMINI_API_KEY", "")
|
|
963
|
+
async for chunk in cls._execute_gemini_stream(
|
|
964
|
+
model_name=native_model,
|
|
965
|
+
prompt=prompt,
|
|
966
|
+
messages=messages,
|
|
967
|
+
system_instruction=system_instruction,
|
|
968
|
+
temperature=temperature,
|
|
969
|
+
thinking=thinking,
|
|
970
|
+
schema=schema,
|
|
971
|
+
api_key=key,
|
|
972
|
+
):
|
|
973
|
+
yield chunk
|
|
974
|
+
|
|
975
|
+
elif provider == "anthropic":
|
|
976
|
+
key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY", "")
|
|
977
|
+
async for chunk in cls._execute_anthropic_stream(
|
|
978
|
+
model_name=model_name,
|
|
979
|
+
prompt=prompt,
|
|
980
|
+
messages=messages,
|
|
981
|
+
system_instruction=system_instruction,
|
|
982
|
+
temperature=temperature,
|
|
983
|
+
thinking=thinking,
|
|
984
|
+
schema=schema,
|
|
985
|
+
api_key=key,
|
|
986
|
+
):
|
|
987
|
+
yield chunk
|
|
988
|
+
|
|
989
|
+
elif provider == "openrouter":
|
|
990
|
+
key = openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "")
|
|
991
|
+
async for chunk in cls._execute_openrouter_stream(
|
|
992
|
+
api_key=key,
|
|
993
|
+
model_name=model_name,
|
|
994
|
+
prompt=prompt,
|
|
995
|
+
messages=messages,
|
|
996
|
+
system_instruction=system_instruction,
|
|
997
|
+
temperature=temperature,
|
|
998
|
+
thinking=thinking,
|
|
999
|
+
schema=schema,
|
|
1000
|
+
base_url=base_url,
|
|
1001
|
+
):
|
|
1002
|
+
yield chunk
|
|
1003
|
+
|
|
1004
|
+
elif provider in ("vllm", "ollama", "custom", "local"):
|
|
1005
|
+
default_url = "http://localhost:11434/v1" if provider == "ollama" else "http://localhost:8000/v1"
|
|
1006
|
+
custom_url = base_url or os.getenv(f"{provider.upper()}_BASE_URL") or os.getenv("VLLM_BASE_URL") or default_url
|
|
1007
|
+
custom_key = vllm_api_key or os.getenv(f"{provider.upper()}_API_KEY") or os.getenv("VLLM_API_KEY", "EMPTY")
|
|
1008
|
+
async for chunk in cls._execute_vllm_stream(
|
|
1009
|
+
model_name=model_name,
|
|
1010
|
+
prompt=prompt,
|
|
1011
|
+
messages=messages,
|
|
1012
|
+
system_instruction=system_instruction,
|
|
1013
|
+
temperature=temperature,
|
|
1014
|
+
thinking=thinking,
|
|
1015
|
+
schema=schema,
|
|
1016
|
+
base_url=custom_url,
|
|
1017
|
+
api_key=custom_key,
|
|
1018
|
+
):
|
|
1019
|
+
yield chunk
|
|
1020
|
+
|
|
1021
|
+
elif provider == "openai":
|
|
1022
|
+
key = openai_api_key or os.getenv("OPENAI_API_KEY", "")
|
|
1023
|
+
async for chunk in cls._execute_openai_stream(
|
|
1024
|
+
api_key=key,
|
|
1025
|
+
model_name=model_name,
|
|
1026
|
+
prompt=prompt,
|
|
1027
|
+
messages=messages,
|
|
1028
|
+
system_instruction=system_instruction,
|
|
1029
|
+
temperature=temperature,
|
|
1030
|
+
thinking=thinking,
|
|
1031
|
+
schema=schema,
|
|
1032
|
+
base_url=base_url,
|
|
1033
|
+
):
|
|
1034
|
+
yield chunk
|
|
1035
|
+
|
|
1036
|
+
else:
|
|
1037
|
+
raise OmniaiUnsupportedProviderError(
|
|
1038
|
+
f"Unsupported provider '{provider}'. The OmniAI SDK strictly supports: "
|
|
1039
|
+
f"{', '.join(cls.SUPPORTED_PROVIDERS)}."
|
|
1040
|
+
)
|