reactifact 0.6.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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,284 @@
1
+ """Google Gemini — native contract (generativelanguage API).
2
+
3
+ Chat (`generateContent`/`streamGenerateContent`) and image generation (image
4
+ modal parts in the same endpoint). Unlike OpenAI-compatible providers, auth is
5
+ `x-goog-api-key: <key>` (raw key, no prefix) — but header/scheme and proxy are
6
+ configurable like everywhere else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from ._retry import with_retry
18
+ from .chat import _network_knobs
19
+ from .contracts import (
20
+ LLMProvider,
21
+ LLMRequest,
22
+ LLMResponse,
23
+ LLMResponseChunk,
24
+ auth_value,
25
+ )
26
+ from .image import ImageProvider
27
+
28
+ _DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta"
29
+
30
+
31
+ def _model_path(model: str) -> str:
32
+ return model if model.startswith("models/") else f"models/{model}"
33
+
34
+
35
+ def _parts_text(parts: list[dict[str, Any]]) -> str:
36
+ return "".join(p.get("text", "") for p in parts if p.get("text"))
37
+
38
+
39
+ class GeminiProvider(LLMProvider):
40
+ """Gemini (Google AI Studio) chat provider, native contract.
41
+
42
+ `contents` carries user/model turns; the system instruction is passed
43
+ separately in `systemInstruction`. `response_format` uses
44
+ `generationConfig.response_mime_type` (e.g. application/json).
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ api_key: str,
50
+ model: str = "gemini-2.0-flash",
51
+ base_url: str = _DEFAULT_BASE,
52
+ timeout: float = 120.0,
53
+ transport: Any | None = None,
54
+ proxy: str | None = None,
55
+ auth_header: str = "x-goog-api-key",
56
+ auth_scheme: str | None = None,
57
+ temperature: float | None = None,
58
+ max_tokens: int | None = None,
59
+ retry_attempts: int = 3,
60
+ ):
61
+ self.api_key = api_key
62
+ self.model = model
63
+ self.base_url = base_url.rstrip("/")
64
+ self._timeout = timeout
65
+ self.temperature = temperature
66
+ self.max_tokens = max_tokens
67
+ self.retry_attempts = retry_attempts
68
+ self._headers = {"Content-Type": "application/json"}
69
+ self._headers[auth_header] = auth_value(api_key, auth_scheme)
70
+ self._transport = transport
71
+ self._proxy = proxy
72
+ self._client: httpx.AsyncClient | None = None
73
+
74
+ def _get_client(self) -> httpx.AsyncClient:
75
+ if self._client is None:
76
+ self._client = httpx.AsyncClient(
77
+ timeout=self._timeout,
78
+ transport=self._transport,
79
+ headers=self._headers,
80
+ proxy=self._proxy,
81
+ )
82
+ return self._client
83
+
84
+ def _payload(self, request: LLMRequest, stream: bool) -> dict[str, Any]:
85
+ contents: list[dict[str, Any]] = []
86
+ system_parts: list[str] = []
87
+ for m in request.messages:
88
+ if m.role == "system":
89
+ system_parts.append(m.content)
90
+ else:
91
+ role = "model" if m.role == "assistant" else "user"
92
+ contents.append({"role": role, "parts": [{"text": m.content}]})
93
+ if not contents:
94
+ contents = [{"role": "user", "parts": [{"text": ""}]}]
95
+ payload: dict[str, Any] = {"contents": contents}
96
+ if system_parts:
97
+ payload["systemInstruction"] = {
98
+ "parts": [{"text": "\n".join(system_parts)}]
99
+ }
100
+ temperature = (
101
+ request.temperature if request.temperature is not None else self.temperature
102
+ )
103
+ max_tokens = (
104
+ request.max_tokens if request.max_tokens is not None else self.max_tokens
105
+ )
106
+ generation: dict[str, Any] = {}
107
+ if temperature is not None:
108
+ generation["temperature"] = temperature
109
+ if max_tokens is not None:
110
+ generation["maxOutputTokens"] = max_tokens
111
+ if request.stop:
112
+ generation["stopSequences"] = request.stop
113
+ if request.response_format:
114
+ generation["response_mime_type"] = "application/json"
115
+ payload["generationConfig"] = generation
116
+ return payload
117
+
118
+ def _parse(self, data: dict[str, Any]) -> LLMResponse:
119
+ candidate = (data.get("candidates") or [{}])[0]
120
+ parts = candidate.get("content", {}).get("parts", []) or []
121
+ usage = data.get("usageMetadata") or {}
122
+ return LLMResponse(
123
+ text=_parts_text(parts),
124
+ raw=data,
125
+ finish_reason=candidate.get("finishReason"),
126
+ usage={
127
+ "prompt_tokens": usage.get("promptTokenCount", 0),
128
+ "completion_tokens": usage.get("candidatesTokenCount", 0),
129
+ "total_tokens": usage.get("totalTokenCount", 0),
130
+ },
131
+ )
132
+
133
+ async def complete(self, request: LLMRequest) -> LLMResponse:
134
+ model = request.extra.get("model") or self.model
135
+
136
+ async def _call() -> LLMResponse:
137
+ response = await self._get_client().post(
138
+ f"{self.base_url}/{_model_path(model)}:generateContent",
139
+ json=self._payload(request, stream=False),
140
+ )
141
+ response.raise_for_status()
142
+ return self._parse(response.json())
143
+
144
+ return await with_retry(_call, attempts=self.retry_attempts)
145
+
146
+ async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
147
+ model = request.extra.get("model") or self.model
148
+ async with self._get_client().stream(
149
+ "POST",
150
+ f"{self.base_url}/{_model_path(model)}:streamGenerateContent?alt=sse",
151
+ json=self._payload(request, stream=True),
152
+ ) as response:
153
+ response.raise_for_status()
154
+ async for line in response.aiter_lines():
155
+ if not line.startswith("data:"):
156
+ continue
157
+ data = line[len("data:") :].strip()
158
+ if not data:
159
+ continue
160
+ try:
161
+ blob = _json(data)
162
+ except ValueError:
163
+ continue
164
+ for part in (
165
+ (blob.get("candidates") or [{}])[0]
166
+ .get("content", {})
167
+ .get("parts", [])
168
+ ):
169
+ text = part.get("text")
170
+ if text:
171
+ yield LLMResponseChunk(text=text)
172
+
173
+ async def aclose(self) -> None:
174
+ if self._client is not None:
175
+ await self._client.aclose()
176
+ self._client = None
177
+
178
+
179
+ class GeminiImageProvider(ImageProvider):
180
+ """Image generation via Gemini's image-modal parts (`inlineData`)."""
181
+
182
+ def __init__(
183
+ self,
184
+ api_key: str,
185
+ model: str = "gemini-2.0-flash-exp",
186
+ base_url: str = _DEFAULT_BASE,
187
+ timeout: float = 120.0,
188
+ transport: Any | None = None,
189
+ proxy: str | None = None,
190
+ auth_header: str = "x-goog-api-key",
191
+ auth_scheme: str | None = None,
192
+ ):
193
+ self.api_key = api_key
194
+ self.model = model
195
+ self.base_url = base_url.rstrip("/")
196
+ self._timeout = timeout
197
+ self._headers = {"Content-Type": "application/json"}
198
+ self._headers[auth_header] = auth_value(api_key, auth_scheme)
199
+ self._transport = transport
200
+ self._proxy = proxy
201
+ self._chat = GeminiProvider(
202
+ api_key=api_key,
203
+ model=model,
204
+ base_url=base_url,
205
+ timeout=timeout,
206
+ transport=transport,
207
+ proxy=proxy,
208
+ auth_header=auth_header,
209
+ auth_scheme=auth_scheme,
210
+ )
211
+
212
+ async def generate(self, prompt: str, **params: Any) -> bytes | None:
213
+ payload: dict[str, Any] = {
214
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
215
+ "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
216
+ }
217
+ if params.get("aspect_ratio"):
218
+ payload["generationConfig"]["imageConfig"] = {
219
+ "aspectRatio": params["aspect_ratio"]
220
+ }
221
+
222
+ async def _call() -> dict[str, Any]:
223
+ response = await self._chat._get_client().post(
224
+ f"{self.base_url}/{_model_path(self.model)}:generateContent",
225
+ json=payload,
226
+ )
227
+ response.raise_for_status()
228
+ return dict(response.json())
229
+
230
+ data = await with_retry(_call, attempts=self._chat.retry_attempts)
231
+ parts = (data.get("candidates") or [{}])[0].get("content", {}).get("parts", [])
232
+ for part in parts:
233
+ inline = part.get("inlineData") or {}
234
+ if inline.get("data"):
235
+ return base64.b64decode(inline["data"])
236
+ return None
237
+
238
+ async def aclose(self) -> None:
239
+ await self._chat.aclose()
240
+
241
+
242
+ def _json(text: str) -> dict[str, Any]:
243
+ import json
244
+
245
+ return dict(json.loads(text))
246
+
247
+
248
+ def gemini_llm(
249
+ api_key: str | None = None,
250
+ model: str = "gemini-2.0-flash",
251
+ base_url: str = _DEFAULT_BASE,
252
+ **kwargs: Any,
253
+ ) -> GeminiProvider | None:
254
+ """Builds a Gemini chat provider (key from GEMINI_API_KEY).
255
+
256
+ Knobs: GEMINI_PROXY / GEMINI_AUTH_HEADER / GEMINI_AUTH_SCHEME.
257
+ """
258
+ import os
259
+
260
+ if api_key is None:
261
+ api_key = kwargs.get("api_key") or os.getenv("GEMINI_API_KEY")
262
+ if not api_key:
263
+ return None
264
+ merged = {**_network_knobs("GEMINI", kwargs), **kwargs}
265
+ return GeminiProvider(api_key=api_key, model=model, base_url=base_url, **merged)
266
+
267
+
268
+ def gemini_image(
269
+ api_key: str | None = None,
270
+ model: str = "gemini-2.0-flash-exp",
271
+ base_url: str = _DEFAULT_BASE,
272
+ **kwargs: Any,
273
+ ) -> GeminiImageProvider | None:
274
+ """Builds a Gemini image generator (key from GEMINI_API_KEY)."""
275
+ import os
276
+
277
+ if api_key is None:
278
+ api_key = kwargs.get("api_key") or os.getenv("GEMINI_API_KEY")
279
+ if not api_key:
280
+ return None
281
+ merged = {**_network_knobs("GEMINI", kwargs), **kwargs}
282
+ return GeminiImageProvider(
283
+ api_key=api_key, model=model, base_url=base_url, **merged
284
+ )
@@ -0,0 +1,13 @@
1
+ """GitHub Models — OpenAI-compatible model playground with a free tier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_llm
6
+
7
+ github_models_llm = _openai_compat_llm(
8
+ env_prefix="GITHUB",
9
+ default_model="gpt-4o-mini",
10
+ default_base_url="https://models.github.ai/v1",
11
+ env_api_key_vars=("GITHUB_TOKEN", "GITHUB_API_KEY"),
12
+ name="github_models_llm",
13
+ )
@@ -0,0 +1,18 @@
1
+ """Groq — fast inference (OpenAI-compatible), plus Whisper transcription."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_llm
6
+ from .speech import _openai_compat_transcriber
7
+
8
+ groq_llm = _openai_compat_llm(
9
+ env_prefix="GROQ",
10
+ default_model="llama-3.3-70b-versatile",
11
+ default_base_url="https://api.groq.com/openai/v1",
12
+ )
13
+
14
+ groq_transcriber = _openai_compat_transcriber(
15
+ env_prefix="GROQ",
16
+ default_model="whisper-large-v3-turbo",
17
+ default_base_url="https://api.groq.com/openai/v1",
18
+ )
@@ -0,0 +1,157 @@
1
+ """Image generation: OpenRouter (/images), factory from env.
2
+
3
+ Not part of the core public API — the app wires `ImageProvider`
4
+ into resources if needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from ._retry import with_retry
16
+ from .chat import _network_knobs
17
+ from .contracts import auth_value
18
+
19
+
20
+ class ImageProvider(ABC):
21
+ """Image generation (e.g., OpenRouter /images)."""
22
+
23
+ @abstractmethod
24
+ async def generate(self, prompt: str, **params: Any) -> bytes | None:
25
+ """Returns PNG/JPEG bytes or None if generation failed."""
26
+ ...
27
+
28
+
29
+ class OpenAICompatImageProvider(ImageProvider):
30
+ """OpenAI-compatible image generator (OpenAI images, OpenRouter, Azure, ...).
31
+
32
+ POSTs to `{base}/images` and decodes the `b64_json` of the first result.
33
+ Auth header/scheme and proxy are configurable like the chat providers.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ base_url: str = "https://api.openai.com/v1",
39
+ api_key: str | None = None,
40
+ model: str = "gpt-image-1",
41
+ timeout: float = 120.0,
42
+ transport: Any | None = None,
43
+ proxy: str | None = None,
44
+ auth_header: str = "Authorization",
45
+ auth_scheme: str | None = "Bearer",
46
+ n: int = 1,
47
+ size: str | None = None,
48
+ quality: str | None = None,
49
+ retry_attempts: int = 3,
50
+ ):
51
+ self.base_url = base_url.rstrip("/")
52
+ self.api_key = api_key
53
+ self.model = model
54
+ self.n = n
55
+ self.size = size
56
+ self.quality = quality
57
+ self._timeout = timeout
58
+ self._transport = transport
59
+ self._proxy = proxy
60
+ self._auth_header = auth_header
61
+ self._auth_scheme = auth_scheme
62
+ self.retry_attempts = retry_attempts
63
+ self._client: httpx.AsyncClient | None = None
64
+
65
+ def _get_client(self) -> httpx.AsyncClient:
66
+ if self._client is None:
67
+ headers = {"Content-Type": "application/json"}
68
+ if self.api_key:
69
+ headers[self._auth_header] = auth_value(self.api_key, self._auth_scheme)
70
+ self._client = httpx.AsyncClient(
71
+ timeout=self._timeout,
72
+ transport=self._transport,
73
+ headers=headers,
74
+ proxy=self._proxy,
75
+ )
76
+ return self._client
77
+
78
+ async def generate(self, prompt: str, **params: Any) -> bytes | None:
79
+ # Provider-level defaults (n/size/quality) can be overridden per call.
80
+ payload: dict[str, Any] = {
81
+ "model": self.model,
82
+ "prompt": prompt,
83
+ "n": params.get("n", self.n),
84
+ }
85
+ if "size" in params:
86
+ payload["size"] = params["size"]
87
+ elif self.size is not None:
88
+ payload["size"] = self.size
89
+ if "quality" in params:
90
+ payload["quality"] = params["quality"]
91
+ elif self.quality is not None:
92
+ payload["quality"] = self.quality
93
+ for key in (
94
+ "aspect_ratio",
95
+ "resolution",
96
+ "output_format",
97
+ "style",
98
+ "background",
99
+ "moderation",
100
+ ):
101
+ if key in params:
102
+ payload[key] = params[key]
103
+
104
+ async def _generate() -> dict[str, Any]:
105
+ response = await self._get_client().post(
106
+ f"{self.base_url}/images", json=payload
107
+ )
108
+ response.raise_for_status()
109
+ return dict(response.json())
110
+
111
+ data = await with_retry(_generate, attempts=self.retry_attempts)
112
+ first = (data.get("data") or [None])[0]
113
+ if not first:
114
+ return None
115
+ if first.get("b64_json"):
116
+ return base64.b64decode(first["b64_json"])
117
+ url = first.get("url")
118
+ if url:
119
+
120
+ async def _fetch() -> bytes:
121
+ fetched = await self._get_client().get(url)
122
+ fetched.raise_for_status()
123
+ return fetched.content
124
+
125
+ return await with_retry(_fetch, attempts=self.retry_attempts)
126
+ return None
127
+
128
+ async def aclose(self) -> None:
129
+ if self._client is not None:
130
+ await self._client.aclose()
131
+ self._client = None
132
+
133
+
134
+ # Back-compat alias (the previous name, now that the provider is vendor-neutral).
135
+ OpenRouterImageProvider = OpenAICompatImageProvider
136
+
137
+
138
+ def image_from_env(**overrides: Any) -> OpenAICompatImageProvider | None:
139
+ """Builds an image generator from IMAGE_* / OPENROUTER_*. Returns
140
+ None if no key is set — the app skips renders. Optional knobs:
141
+ IMAGE_PROXY, IMAGE_AUTH_HEADER, IMAGE_AUTH_SCHEME."""
142
+ import os
143
+
144
+ api_key = (
145
+ overrides.get("api_key")
146
+ or os.getenv("IMAGE_API_KEY")
147
+ or os.getenv("OPENROUTER_API_KEY")
148
+ )
149
+ if not api_key:
150
+ return None
151
+ merged = {**_network_knobs("IMAGE", overrides), **overrides}
152
+ return OpenAICompatImageProvider(
153
+ base_url=os.getenv("IMAGE_BASE_URL") or "https://openrouter.ai/api/v1",
154
+ api_key=api_key,
155
+ model=os.getenv("IMAGE_MODEL", "google/gemini-3-pro-create-image-plus"),
156
+ **merged,
157
+ )
@@ -0,0 +1,17 @@
1
+ """Mistral AI — cloud chat and embeddings (OpenAI-compatible)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_embedder, _openai_compat_llm
6
+
7
+ mistral_llm = _openai_compat_llm(
8
+ env_prefix="MISTRAL",
9
+ default_model="mistral-large-latest",
10
+ default_base_url="https://api.mistral.ai/v1",
11
+ )
12
+
13
+ mistral_embedder = _openai_compat_embedder(
14
+ env_prefix="MISTRAL",
15
+ default_model="mistral-embed",
16
+ default_base_url="https://api.mistral.ai/v1",
17
+ )
@@ -0,0 +1,18 @@
1
+ """NVIDIA NIM — hosted open models (OpenAI-compatible), plus embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_embedder, _openai_compat_llm
6
+
7
+ nvidia_nim_llm = _openai_compat_llm(
8
+ env_prefix="NVIDIA",
9
+ default_model="meta/llama-3.3-70b-instruct",
10
+ default_base_url="https://integrate.api.nvidia.com/v1",
11
+ name="nvidia_nim_llm",
12
+ )
13
+
14
+ nvidia_embedder = _openai_compat_embedder(
15
+ env_prefix="NVIDIA",
16
+ default_model="nvidia/llama-3.2-nv-embedqa-1b-v2",
17
+ default_base_url="https://integrate.api.nvidia.com/v1",
18
+ )
@@ -0,0 +1,18 @@
1
+ """Ollama (local, OpenAI-compatible /v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .chat import OpenAICompatProvider
8
+
9
+
10
+ def ollama_llm(
11
+ model: str = "qwen2.5:7b",
12
+ base_url: str = "http://localhost:11434/v1",
13
+ timeout: float = 120.0,
14
+ **kwargs: Any,
15
+ ) -> OpenAICompatProvider:
16
+ return OpenAICompatProvider(
17
+ base_url=base_url, model=model, timeout=timeout, **kwargs
18
+ )
@@ -0,0 +1,44 @@
1
+ """OpenAI — cloud chat and embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .chat import OpenAICompatEmbedder, OpenAICompatProvider, _network_knobs
8
+
9
+
10
+ def openai_llm(
11
+ model: str | None = None,
12
+ base_url: str | None = None,
13
+ api_key: str | None = None,
14
+ **kwargs: Any,
15
+ ) -> OpenAICompatProvider | None:
16
+ if api_key is None:
17
+ import os
18
+
19
+ api_key = os.getenv("OPENAI_API_KEY")
20
+ if not api_key:
21
+ return None # without a key the app runs on its deterministic fallbacks
22
+ merged = {**_network_knobs("OPENAI", kwargs), **kwargs}
23
+ return OpenAICompatProvider(
24
+ base_url=base_url or "https://api.openai.com/v1",
25
+ api_key=api_key,
26
+ model=model or "gpt-4o-mini",
27
+ **merged,
28
+ )
29
+
30
+
31
+ def openai_embedder(
32
+ model: str = "text-embedding-3-small",
33
+ base_url: str = "https://api.openai.com/v1",
34
+ api_key: str | None = None,
35
+ **kwargs: Any,
36
+ ) -> OpenAICompatEmbedder:
37
+ if api_key is None:
38
+ import os
39
+
40
+ api_key = os.getenv("OPENAI_API_KEY")
41
+ merged = {**_network_knobs("OPENAI", kwargs), **kwargs}
42
+ return OpenAICompatEmbedder(
43
+ base_url=base_url, api_key=api_key, model=model, **merged
44
+ )
@@ -0,0 +1,70 @@
1
+ """OpenRouter — model router: chat (fast mode by default), images,
2
+ embeddings, and text-to-speech. Video lives in `reactifact.providers.video`
3
+ (`OpenRouterVideoProvider`, alongside the other video vendors). Speech-to-
4
+ text is *not* offered here: OpenRouter's `/audio/transcriptions` takes a
5
+ base64-JSON body, not the multipart file upload every other transcriber in
6
+ this package expects — a real API difference, not an oversight."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .chat import OpenAICompatProvider, _network_knobs, _openai_compat_embedder
13
+ from .image import OpenAICompatImageProvider
14
+ from .speech import _openai_compat_speech
15
+
16
+
17
+ def openrouter_llm(
18
+ model: str = "deepseek/deepseek-v4-flash",
19
+ base_url: str = "https://openrouter.ai/api/v1",
20
+ api_key: str | None = None,
21
+ **kwargs: Any,
22
+ ) -> OpenAICompatProvider | None:
23
+ import os
24
+
25
+ if api_key is None:
26
+ api_key = os.getenv("OPENROUTER_API_KEY")
27
+ if not api_key:
28
+ return None # without a key OpenRouter does not work — the app falls back
29
+ model = os.getenv("OPENROUTER_MODEL") or model
30
+ extra_body: dict[str, Any] = dict(kwargs.pop("extra_body", None) or {})
31
+ # hybrid models: disable reasoning by default (fast mode)
32
+ extra_body.setdefault("reasoning", {"enabled": False})
33
+ merged = {**_network_knobs("OPENROUTER", kwargs), **kwargs}
34
+ return OpenAICompatProvider(
35
+ base_url=base_url,
36
+ api_key=api_key,
37
+ model=model,
38
+ extra_body=extra_body,
39
+ **merged,
40
+ )
41
+
42
+
43
+ def openrouter_image(
44
+ model: str = "google/gemini-3-pro-create-image-plus",
45
+ base_url: str = "https://openrouter.ai/api/v1",
46
+ api_key: str | None = None,
47
+ **kwargs: Any,
48
+ ) -> OpenAICompatImageProvider:
49
+ if api_key is None:
50
+ import os
51
+
52
+ api_key = os.getenv("OPENROUTER_API_KEY")
53
+ merged = {**_network_knobs("OPENROUTER", kwargs), **kwargs}
54
+ return OpenAICompatImageProvider(
55
+ base_url=base_url, api_key=api_key, model=model, **merged
56
+ )
57
+
58
+
59
+ openrouter_embedder = _openai_compat_embedder(
60
+ env_prefix="OPENROUTER",
61
+ default_model="openai/text-embedding-3-small",
62
+ default_base_url="https://openrouter.ai/api/v1",
63
+ )
64
+
65
+ openrouter_speech = _openai_compat_speech(
66
+ env_prefix="OPENROUTER",
67
+ default_model="openai/gpt-4o-mini-tts",
68
+ default_voice="alloy",
69
+ default_base_url="https://openrouter.ai/api/v1",
70
+ )
@@ -0,0 +1,11 @@
1
+ """Perplexity — Sonar answer API (OpenAI-compatible, built-in web search)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_llm
6
+
7
+ perplexity_llm = _openai_compat_llm(
8
+ env_prefix="PERPLEXITY",
9
+ default_model="sonar-pro",
10
+ default_base_url="https://api.perplexity.ai",
11
+ )
@@ -0,0 +1,17 @@
1
+ """Qwen (Alibaba DashScope) — chat (OpenAI-compatible), plus embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_embedder, _openai_compat_llm
6
+
7
+ qwen_llm = _openai_compat_llm(
8
+ env_prefix="QWEN",
9
+ default_model="qwen-plus",
10
+ default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
11
+ )
12
+
13
+ qwen_embedder = _openai_compat_embedder(
14
+ env_prefix="QWEN",
15
+ default_model="text-embedding-v2",
16
+ default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
17
+ )