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,417 @@
|
|
|
1
|
+
"""Chat and embeddings: OpenAI-compatible providers (OpenAI, Ollama, vLLM,
|
|
2
|
+
Mistral, OpenRouter) and factories from env."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncIterator, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._retry import with_retry
|
|
13
|
+
from .contracts import (
|
|
14
|
+
EmbeddingProvider,
|
|
15
|
+
LLMProvider,
|
|
16
|
+
LLMRequest,
|
|
17
|
+
LLMResponse,
|
|
18
|
+
LLMResponseChunk,
|
|
19
|
+
auth_value,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenAICompatProvider(LLMProvider):
|
|
24
|
+
"""OpenAI-compatible provider: OpenAI, Ollama, vLLM, LM Studio, OpenRouter.
|
|
25
|
+
|
|
26
|
+
Auth is fully configurable because vendors disagree:
|
|
27
|
+
- header name: `Authorization` (default), `X-Api-Key`, etc.
|
|
28
|
+
- key scheme: `Bearer` (default), `OAuth`, `api-key`, or `None` (raw key).
|
|
29
|
+
`proxy` (a URL) is passed to the httpx client for corporate networks.
|
|
30
|
+
`transport` can receive an httpx transport for tests (MockTransport).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
model: str | None = None,
|
|
38
|
+
timeout: float = 60.0,
|
|
39
|
+
transport: Any | None = None,
|
|
40
|
+
extra_headers: dict[str, str] | None = None,
|
|
41
|
+
extra_body: dict[str, Any] | None = None,
|
|
42
|
+
proxy: str | None = None,
|
|
43
|
+
auth_header: str = "Authorization",
|
|
44
|
+
auth_scheme: str | None = "Bearer",
|
|
45
|
+
temperature: float | None = None,
|
|
46
|
+
max_tokens: int | None = None,
|
|
47
|
+
retry_attempts: int = 3,
|
|
48
|
+
):
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
self.api_key = api_key
|
|
51
|
+
self.model = model
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
self._extra_body = dict(extra_body or {})
|
|
54
|
+
self._headers = dict(extra_headers or {})
|
|
55
|
+
if api_key:
|
|
56
|
+
self._headers.setdefault(auth_header, auth_value(api_key, auth_scheme))
|
|
57
|
+
self._transport = transport
|
|
58
|
+
self._proxy = proxy
|
|
59
|
+
self.temperature = temperature
|
|
60
|
+
self.max_tokens = max_tokens
|
|
61
|
+
#: complete()-only retry budget for transient failures (429/5xx/
|
|
62
|
+
#: connection errors, see providers/_retry.py); 1 disables retrying.
|
|
63
|
+
self.retry_attempts = retry_attempts
|
|
64
|
+
self._client: httpx.AsyncClient | None = None
|
|
65
|
+
|
|
66
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
67
|
+
if self._client is None:
|
|
68
|
+
self._client = httpx.AsyncClient(
|
|
69
|
+
timeout=self._timeout,
|
|
70
|
+
transport=self._transport,
|
|
71
|
+
headers=self._headers,
|
|
72
|
+
proxy=self._proxy,
|
|
73
|
+
)
|
|
74
|
+
return self._client
|
|
75
|
+
|
|
76
|
+
def _payload(self, request: LLMRequest, stream: bool) -> dict[str, Any]:
|
|
77
|
+
# A request-level value overrides the provider default; if neither is
|
|
78
|
+
# set, the field is omitted and the API applies its own default.
|
|
79
|
+
temperature = (
|
|
80
|
+
request.temperature if request.temperature is not None else self.temperature
|
|
81
|
+
)
|
|
82
|
+
max_tokens = (
|
|
83
|
+
request.max_tokens if request.max_tokens is not None else self.max_tokens
|
|
84
|
+
)
|
|
85
|
+
payload: dict[str, Any] = {
|
|
86
|
+
"messages": [
|
|
87
|
+
{"role": m.role, "content": m.content} for m in request.messages
|
|
88
|
+
],
|
|
89
|
+
"stream": stream,
|
|
90
|
+
}
|
|
91
|
+
if temperature is not None:
|
|
92
|
+
payload["temperature"] = temperature
|
|
93
|
+
model = request.extra.get("model") or self.model
|
|
94
|
+
if model is not None:
|
|
95
|
+
payload["model"] = model
|
|
96
|
+
if max_tokens is not None:
|
|
97
|
+
payload["max_tokens"] = max_tokens
|
|
98
|
+
if request.stop:
|
|
99
|
+
payload["stop"] = request.stop
|
|
100
|
+
if request.response_format:
|
|
101
|
+
payload["response_format"] = request.response_format
|
|
102
|
+
payload.update(self._extra_body)
|
|
103
|
+
# arbitrary fields (e.g., OpenRouter: {"reasoning": {"enabled": false}})
|
|
104
|
+
for key, value in request.extra.items():
|
|
105
|
+
if key != "model":
|
|
106
|
+
payload[key] = value
|
|
107
|
+
return payload
|
|
108
|
+
|
|
109
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
110
|
+
async def _call() -> LLMResponse:
|
|
111
|
+
response = await self._get_client().post(
|
|
112
|
+
f"{self.base_url}/chat/completions",
|
|
113
|
+
json=self._payload(request, stream=False),
|
|
114
|
+
)
|
|
115
|
+
response.raise_for_status()
|
|
116
|
+
data = response.json()
|
|
117
|
+
choice = data["choices"][0]
|
|
118
|
+
content = choice.get("message", {}).get("content") or ""
|
|
119
|
+
return LLMResponse(
|
|
120
|
+
text=content,
|
|
121
|
+
raw=data,
|
|
122
|
+
finish_reason=choice.get("finish_reason"),
|
|
123
|
+
usage=data.get("usage", {}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return await with_retry(_call, attempts=self.retry_attempts)
|
|
127
|
+
|
|
128
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
|
|
129
|
+
async with self._get_client().stream(
|
|
130
|
+
"POST",
|
|
131
|
+
f"{self.base_url}/chat/completions",
|
|
132
|
+
json=self._payload(request, stream=True),
|
|
133
|
+
) as response:
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
async for line in response.aiter_lines():
|
|
136
|
+
if not line.startswith("data:"):
|
|
137
|
+
continue
|
|
138
|
+
data = line[len("data:") :].strip()
|
|
139
|
+
if data == "[DONE]":
|
|
140
|
+
break
|
|
141
|
+
if not data:
|
|
142
|
+
continue
|
|
143
|
+
chunk_ = json.loads(data)
|
|
144
|
+
delta = chunk_["choices"][0].get("delta", {})
|
|
145
|
+
text = delta.get("content")
|
|
146
|
+
if text:
|
|
147
|
+
yield LLMResponseChunk(text=text)
|
|
148
|
+
|
|
149
|
+
async def aclose(self) -> None:
|
|
150
|
+
if self._client is not None:
|
|
151
|
+
await self._client.aclose()
|
|
152
|
+
self._client = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class OpenAICompatEmbedder(EmbeddingProvider):
|
|
156
|
+
"""OpenAI-compatible embedding generator (OpenAI, Mistral, OpenRouter)."""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
base_url: str,
|
|
161
|
+
api_key: str | None = None,
|
|
162
|
+
model: str = "text-embedding-3-small",
|
|
163
|
+
timeout: float = 60.0,
|
|
164
|
+
transport: Any | None = None,
|
|
165
|
+
proxy: str | None = None,
|
|
166
|
+
auth_header: str = "Authorization",
|
|
167
|
+
auth_scheme: str | None = "Bearer",
|
|
168
|
+
retry_attempts: int = 3,
|
|
169
|
+
):
|
|
170
|
+
self.base_url = base_url.rstrip("/")
|
|
171
|
+
self.api_key = api_key
|
|
172
|
+
self.model = model
|
|
173
|
+
self._timeout = timeout
|
|
174
|
+
self._headers = {"Content-Type": "application/json"}
|
|
175
|
+
if api_key:
|
|
176
|
+
self._headers[auth_header] = auth_value(api_key, auth_scheme)
|
|
177
|
+
self._transport = transport
|
|
178
|
+
self._proxy = proxy
|
|
179
|
+
self.retry_attempts = retry_attempts
|
|
180
|
+
self._client: httpx.AsyncClient | None = None
|
|
181
|
+
|
|
182
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
183
|
+
if self._client is None:
|
|
184
|
+
self._client = httpx.AsyncClient(
|
|
185
|
+
timeout=self._timeout,
|
|
186
|
+
transport=self._transport,
|
|
187
|
+
headers=self._headers,
|
|
188
|
+
proxy=self._proxy,
|
|
189
|
+
)
|
|
190
|
+
return self._client
|
|
191
|
+
|
|
192
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
193
|
+
async def _call() -> list[list[float]]:
|
|
194
|
+
response = await self._get_client().post(
|
|
195
|
+
f"{self.base_url}/embeddings",
|
|
196
|
+
json={"model": self.model, "input": texts},
|
|
197
|
+
)
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
data = response.json()
|
|
200
|
+
rows = sorted(data["data"], key=lambda item: item["index"])
|
|
201
|
+
embeddings = [row["embedding"] for row in rows]
|
|
202
|
+
if len(embeddings) != len(texts):
|
|
203
|
+
return [] # fewer rows than requested — honestly empty
|
|
204
|
+
return embeddings
|
|
205
|
+
|
|
206
|
+
return await with_retry(_call, attempts=self.retry_attempts)
|
|
207
|
+
|
|
208
|
+
async def aclose(self) -> None:
|
|
209
|
+
if self._client is not None:
|
|
210
|
+
await self._client.aclose()
|
|
211
|
+
self._client = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _network_knobs(
|
|
215
|
+
prefix: str, overrides: dict[str, Any] | None = None
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""Provider kwargs for proxy/auth from env (`<PREFIX>_PROXY` /
|
|
218
|
+
`_AUTH_HEADER` / `_AUTH_SCHEME`) or explicit overrides.
|
|
219
|
+
|
|
220
|
+
Returns only the knobs that are actually configured, so a provider's own
|
|
221
|
+
default (e.g. Gemini's `x-goog-api-key`) is respected when nothing is set.
|
|
222
|
+
An empty `AUTH_SCHEME` means the raw key (no prefix).
|
|
223
|
+
"""
|
|
224
|
+
import os
|
|
225
|
+
|
|
226
|
+
ov = overrides or {}
|
|
227
|
+
knobs: dict[str, Any] = {}
|
|
228
|
+
|
|
229
|
+
proxy = ov.get("proxy")
|
|
230
|
+
if proxy is None:
|
|
231
|
+
proxy = os.getenv(f"{prefix}_PROXY")
|
|
232
|
+
if proxy is not None:
|
|
233
|
+
knobs["proxy"] = proxy or None
|
|
234
|
+
|
|
235
|
+
header = ov.get("auth_header")
|
|
236
|
+
if header is None:
|
|
237
|
+
header = os.getenv(f"{prefix}_AUTH_HEADER")
|
|
238
|
+
if header is not None and header != "":
|
|
239
|
+
knobs["auth_header"] = header
|
|
240
|
+
|
|
241
|
+
scheme = ov.get("auth_scheme")
|
|
242
|
+
if scheme is None:
|
|
243
|
+
scheme = os.getenv(f"{prefix}_AUTH_SCHEME")
|
|
244
|
+
if scheme is not None:
|
|
245
|
+
knobs["auth_scheme"] = None if scheme == "" else scheme
|
|
246
|
+
|
|
247
|
+
return knobs
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_env_api_key(explicit: str | None, key_vars: tuple[str, ...]) -> str | None:
|
|
251
|
+
"""`explicit` if given, else the first of `key_vars` that's set in env."""
|
|
252
|
+
if explicit is not None:
|
|
253
|
+
return explicit
|
|
254
|
+
import os
|
|
255
|
+
|
|
256
|
+
for var in key_vars:
|
|
257
|
+
value = os.getenv(var)
|
|
258
|
+
if value:
|
|
259
|
+
return value
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _openai_compat_llm(
|
|
264
|
+
*,
|
|
265
|
+
env_prefix: str,
|
|
266
|
+
default_model: str,
|
|
267
|
+
default_base_url: str,
|
|
268
|
+
env_api_key_vars: tuple[str, ...] | None = None,
|
|
269
|
+
name: str | None = None,
|
|
270
|
+
doc: str = "",
|
|
271
|
+
) -> Callable[..., OpenAICompatProvider]:
|
|
272
|
+
"""Builds a `<vendor>_llm(model=..., base_url=..., api_key=None, **kwargs)`
|
|
273
|
+
factory for a vendor whose API is OpenAI-compatible end to end.
|
|
274
|
+
|
|
275
|
+
This is the one implementation behind every same-shaped vendor factory in
|
|
276
|
+
this package (Cerebras, DeepSeek, Fireworks, GitHub Models, Groq, NVIDIA
|
|
277
|
+
NIM, Perplexity, Qwen, Together, xAI, z.ai — see their one-line modules):
|
|
278
|
+
each only differs in `env_prefix`/`default_model`/`default_base_url`, so
|
|
279
|
+
duplicating the body 11 times just means 11 places to fix the same bug in
|
|
280
|
+
(as `llm_from_env`'s dropped-overrides bug was, before it had one home).
|
|
281
|
+
|
|
282
|
+
`env_api_key_vars` overrides the single `<PREFIX>_API_KEY` default when a
|
|
283
|
+
vendor's key comes from a differently-named variable (or falls back
|
|
284
|
+
through more than one, e.g. GitHub Models' `GITHUB_TOKEN`/`GITHUB_API_KEY`).
|
|
285
|
+
`name` overrides the `<prefix>_llm` default when the public function name
|
|
286
|
+
doesn't match the prefix (`nvidia_nim_llm`, `github_models_llm`).
|
|
287
|
+
"""
|
|
288
|
+
key_vars = env_api_key_vars or (f"{env_prefix}_API_KEY",)
|
|
289
|
+
|
|
290
|
+
def factory(
|
|
291
|
+
model: str = default_model,
|
|
292
|
+
base_url: str = default_base_url,
|
|
293
|
+
api_key: str | None = None,
|
|
294
|
+
**kwargs: Any,
|
|
295
|
+
) -> OpenAICompatProvider:
|
|
296
|
+
merged = {**_network_knobs(env_prefix, kwargs), **kwargs}
|
|
297
|
+
return OpenAICompatProvider(
|
|
298
|
+
base_url=base_url,
|
|
299
|
+
api_key=_resolve_env_api_key(api_key, key_vars),
|
|
300
|
+
model=model,
|
|
301
|
+
**merged,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
factory.__name__ = name or f"{env_prefix.lower()}_llm"
|
|
305
|
+
factory.__qualname__ = factory.__name__
|
|
306
|
+
factory.__doc__ = doc or (
|
|
307
|
+
f"{env_prefix.title()} — OpenAI-compatible chat "
|
|
308
|
+
f"(key from {' or '.join(key_vars)})."
|
|
309
|
+
)
|
|
310
|
+
return factory
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _openai_compat_embedder(
|
|
314
|
+
*,
|
|
315
|
+
env_prefix: str,
|
|
316
|
+
default_model: str,
|
|
317
|
+
default_base_url: str,
|
|
318
|
+
env_api_key_vars: tuple[str, ...] | None = None,
|
|
319
|
+
name: str | None = None,
|
|
320
|
+
doc: str = "",
|
|
321
|
+
) -> Callable[..., OpenAICompatEmbedder]:
|
|
322
|
+
"""Builds a `<vendor>_embedder(model=..., base_url=..., api_key=None,
|
|
323
|
+
**kwargs)` factory — the embedder counterpart of `_openai_compat_llm`,
|
|
324
|
+
for a vendor whose `/embeddings` endpoint is OpenAI-compatible.
|
|
325
|
+
"""
|
|
326
|
+
key_vars = env_api_key_vars or (f"{env_prefix}_API_KEY",)
|
|
327
|
+
|
|
328
|
+
def factory(
|
|
329
|
+
model: str = default_model,
|
|
330
|
+
base_url: str = default_base_url,
|
|
331
|
+
api_key: str | None = None,
|
|
332
|
+
**kwargs: Any,
|
|
333
|
+
) -> OpenAICompatEmbedder:
|
|
334
|
+
merged = {**_network_knobs(env_prefix, kwargs), **kwargs}
|
|
335
|
+
return OpenAICompatEmbedder(
|
|
336
|
+
base_url=base_url,
|
|
337
|
+
api_key=_resolve_env_api_key(api_key, key_vars),
|
|
338
|
+
model=model,
|
|
339
|
+
**merged,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
factory.__name__ = name or f"{env_prefix.lower()}_embedder"
|
|
343
|
+
factory.__qualname__ = factory.__name__
|
|
344
|
+
factory.__doc__ = doc or (
|
|
345
|
+
f"{env_prefix.title()} — OpenAI-compatible embeddings "
|
|
346
|
+
f"(key from {' or '.join(key_vars)})."
|
|
347
|
+
)
|
|
348
|
+
return factory
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def llm_from_env(**overrides: Any) -> OpenAICompatProvider | None:
|
|
352
|
+
"""Builds a provider from OPENAI_BASE_URL/OPENAI_API_KEY/OPENAI_MODEL.
|
|
353
|
+
|
|
354
|
+
OPENAI_EXTRA_BODY (JSON) is added to every request — e.g., for
|
|
355
|
+
OpenRouter: '{"reasoning": {"enabled": false}}'. Optional network/auth
|
|
356
|
+
knobs: OPENAI_PROXY (URL), OPENAI_AUTH_HEADER (default Authorization),
|
|
357
|
+
OPENAI_AUTH_SCHEME (Bearer by default; set to "api-key", "OAuth" or an
|
|
358
|
+
empty value for providers that want the raw key). Returns None if
|
|
359
|
+
BASE_URL is not set — the app runs on its fallbacks.
|
|
360
|
+
|
|
361
|
+
Remaining overrides (`temperature`, `max_tokens`, `timeout`, `transport`,
|
|
362
|
+
`extra_headers`) pass straight through to `OpenAICompatProvider` — no
|
|
363
|
+
api_key is required, so this also covers unauthenticated local/self-hosted
|
|
364
|
+
endpoints (Ollama, vLLM, LM Studio).
|
|
365
|
+
"""
|
|
366
|
+
import os
|
|
367
|
+
|
|
368
|
+
base_url = overrides.get("base_url") or os.getenv("OPENAI_BASE_URL")
|
|
369
|
+
if not base_url:
|
|
370
|
+
return None
|
|
371
|
+
extra_body: dict[str, Any] | None = overrides.get("extra_body")
|
|
372
|
+
if extra_body is None:
|
|
373
|
+
raw = os.getenv("OPENAI_EXTRA_BODY")
|
|
374
|
+
extra_body = json.loads(raw) if raw else None
|
|
375
|
+
consumed = {
|
|
376
|
+
"base_url",
|
|
377
|
+
"api_key",
|
|
378
|
+
"model",
|
|
379
|
+
"extra_body",
|
|
380
|
+
"proxy",
|
|
381
|
+
"auth_header",
|
|
382
|
+
"auth_scheme",
|
|
383
|
+
}
|
|
384
|
+
passthrough = {k: v for k, v in overrides.items() if k not in consumed}
|
|
385
|
+
return OpenAICompatProvider(
|
|
386
|
+
base_url=base_url,
|
|
387
|
+
api_key=overrides.get("api_key") or os.getenv("OPENAI_API_KEY") or None,
|
|
388
|
+
model=overrides.get("model") or os.getenv("OPENAI_MODEL") or None,
|
|
389
|
+
extra_body=extra_body,
|
|
390
|
+
**_network_knobs("OPENAI", overrides),
|
|
391
|
+
**passthrough,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def embedder_from_env(**overrides: Any) -> OpenAICompatEmbedder | None:
|
|
396
|
+
"""Builds an embedder from EMBEDDER_BASE_URL/EMBEDDER_API_KEY/EMBEDDER_MODEL.
|
|
397
|
+
|
|
398
|
+
Optional knobs: EMBEDDER_PROXY, EMBEDDER_AUTH_HEADER, EMBEDDER_AUTH_SCHEME.
|
|
399
|
+
Remaining overrides (`timeout`, `transport`, `retry_attempts`, ...) pass
|
|
400
|
+
straight through to `OpenAICompatEmbedder`.
|
|
401
|
+
"""
|
|
402
|
+
import os
|
|
403
|
+
|
|
404
|
+
base_url = overrides.get("base_url") or os.getenv("EMBEDDER_BASE_URL")
|
|
405
|
+
if not base_url:
|
|
406
|
+
return None
|
|
407
|
+
api_key = overrides.get("api_key") or os.getenv("EMBEDDER_API_KEY")
|
|
408
|
+
model = overrides.get("model") or os.getenv("EMBEDDER_MODEL")
|
|
409
|
+
consumed = {"base_url", "api_key", "model", "proxy", "auth_header", "auth_scheme"}
|
|
410
|
+
passthrough = {k: v for k, v in overrides.items() if k not in consumed}
|
|
411
|
+
return OpenAICompatEmbedder(
|
|
412
|
+
base_url=base_url,
|
|
413
|
+
api_key=api_key,
|
|
414
|
+
model=model if isinstance(model, str) else "text-embedding-3-small",
|
|
415
|
+
**_network_knobs("EMBEDDER", overrides),
|
|
416
|
+
**passthrough,
|
|
417
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""LLM/embedder provider contracts — clean core, no implementations.
|
|
2
|
+
|
|
3
|
+
Concrete providers live in the `providers` package (../providers), along with
|
|
4
|
+
their env-based factories. Only interfaces live here, so the core does not pull in httpx.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal, TypeAlias
|
|
13
|
+
|
|
14
|
+
Role: TypeAlias = Literal["system", "user", "assistant", "tool"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Message:
|
|
19
|
+
"""One chat-completion message.
|
|
20
|
+
|
|
21
|
+
`role` is a closed set of known roles (a `Literal`) so a typo like
|
|
22
|
+
"assistan" is a `ValueError`, not a silent API failure. Use the factories
|
|
23
|
+
for a clearer call site: `Message.system(…)`, `Message.user(…)`,
|
|
24
|
+
`Message.assistant(…)`, `Message.tool(…)`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
role: Role
|
|
28
|
+
content: str
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if self.role not in ("system", "user", "assistant", "tool"):
|
|
32
|
+
raise ValueError(f"unknown message role: {self.role!r}")
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def system(cls, content: str) -> Message:
|
|
36
|
+
return cls(role="system", content=content)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def user(cls, content: str) -> Message:
|
|
40
|
+
return cls(role="user", content=content)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def assistant(cls, content: str) -> Message:
|
|
44
|
+
return cls(role="assistant", content=content)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def tool(cls, content: str) -> Message:
|
|
48
|
+
return cls(role="tool", content=content)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class LLMRequest:
|
|
53
|
+
messages: list[Message]
|
|
54
|
+
|
|
55
|
+
# `None` means "use the provider's default" — the provider decides what to
|
|
56
|
+
# send (or omits the field entirely, letting the API pick). An explicit
|
|
57
|
+
# value here is a per-call override of the provider default.
|
|
58
|
+
temperature: float | None = None
|
|
59
|
+
max_tokens: int | None = None
|
|
60
|
+
stop: list[str] = field(default_factory=list)
|
|
61
|
+
response_format: dict[str, Any] | None = None
|
|
62
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
63
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LLMResponse:
|
|
68
|
+
text: str
|
|
69
|
+
raw: Any = None
|
|
70
|
+
finish_reason: str | None = None
|
|
71
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class LLMResponseChunk:
|
|
76
|
+
text: str
|
|
77
|
+
finish_reason: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LLMProvider(ABC):
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def complete(self, request: LLMRequest) -> LLMResponse: ...
|
|
83
|
+
|
|
84
|
+
# Deliberately not async: implementations are generators (yield), but the contract is an async iterator.
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EmbeddingProvider(ABC):
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
#: Builds the value of an auth header.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def auth_value(api_key: str, scheme: str | None) -> str:
|
|
98
|
+
"""Builds the value of an auth header.
|
|
99
|
+
|
|
100
|
+
`scheme=None` sends the raw key (Anthropic's `x-api-key`, and other
|
|
101
|
+
`api-key`-style APIs); a custom scheme (Bearer/OAuth/api-key/Token) formats
|
|
102
|
+
it as `f"{scheme} {api_key}"`. The header *name* is provider's choice
|
|
103
|
+
(`Authorization`, `X-Api-Key`, ...) and is not decided here.
|
|
104
|
+
"""
|
|
105
|
+
return api_key if scheme is None else f"{scheme} {api_key}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""DeepSeek — cloud chat (deepseek-chat / deepseek-reasoner)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chat import _openai_compat_llm
|
|
6
|
+
|
|
7
|
+
deepseek_llm = _openai_compat_llm(
|
|
8
|
+
env_prefix="DEEPSEEK",
|
|
9
|
+
default_model="deepseek-chat",
|
|
10
|
+
default_base_url="https://api.deepseek.com",
|
|
11
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Deterministic fakes for tests and local runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from .contracts import (
|
|
9
|
+
EmbeddingProvider,
|
|
10
|
+
LLMProvider,
|
|
11
|
+
LLMRequest,
|
|
12
|
+
LLMResponse,
|
|
13
|
+
LLMResponseChunk,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeLLM(LLMProvider):
|
|
18
|
+
def __init__(self, response: str = "This is a fake response."):
|
|
19
|
+
self.response = response
|
|
20
|
+
|
|
21
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
22
|
+
return LLMResponse(text=self.response)
|
|
23
|
+
|
|
24
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
|
|
25
|
+
yield LLMResponseChunk(text=self.response)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeEmbedder(EmbeddingProvider):
|
|
29
|
+
def __init__(self, dim: int = 8):
|
|
30
|
+
self.dim = dim
|
|
31
|
+
|
|
32
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
33
|
+
vectors = []
|
|
34
|
+
for text in texts:
|
|
35
|
+
hash_bytes = hashlib.sha256(text.encode()).digest()
|
|
36
|
+
vec = [float(b) / 255.0 for b in hash_bytes[: self.dim]]
|
|
37
|
+
if len(vec) < self.dim:
|
|
38
|
+
vec.extend([0.0] * (self.dim - len(vec)))
|
|
39
|
+
vectors.append(vec)
|
|
40
|
+
return vectors
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Fireworks AI — production inference (OpenAI-compatible), plus embeddings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chat import _openai_compat_embedder, _openai_compat_llm
|
|
6
|
+
|
|
7
|
+
fireworks_llm = _openai_compat_llm(
|
|
8
|
+
env_prefix="FIREWORKS",
|
|
9
|
+
default_model="accounts/fireworks/models/llama-v3p1-70b-instruct",
|
|
10
|
+
default_base_url="https://api.fireworks.ai/inference/v1",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
fireworks_embedder = _openai_compat_embedder(
|
|
14
|
+
env_prefix="FIREWORKS",
|
|
15
|
+
default_model="nomic-ai/nomic-embed-text-v1.5",
|
|
16
|
+
default_base_url="https://api.fireworks.ai/inference/v1",
|
|
17
|
+
)
|