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.
@@ -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
+ )