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,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
+ )