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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|