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
reactifact/prompts.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""reactifact.prompts — minimal, strict prompt templating (§68).
|
|
2
|
+
|
|
3
|
+
`PromptTemplate` renders a `{var}`-style template with *declared* variables:
|
|
4
|
+
at construction time the placeholders are parsed, at render time a missing
|
|
5
|
+
variable is a `KeyError` (never a silent `format` leak), and `{{`/`}}` stay
|
|
6
|
+
literal braces. Domain model attributes are supported, so a template can take
|
|
7
|
+
a whole artifact: `"Research {question.text} in {topic}"` and be rendered with
|
|
8
|
+
`template.render(question=…, topic=…)`.
|
|
9
|
+
|
|
10
|
+
`MessagesPrompt` is the same idea for a chat sequence of `(role, template)`
|
|
11
|
+
rows — it renders to `list[Message]` ready for an LLM request.
|
|
12
|
+
|
|
13
|
+
This is deliberately small and dependency-free: it sits between the app's
|
|
14
|
+
"domain strings" and `structured_llm`/`LLMAgent.system`, without claiming to be
|
|
15
|
+
a general prompting framework.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import string
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
from .providers import Message, Role
|
|
26
|
+
|
|
27
|
+
_FIELD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\Z")
|
|
28
|
+
|
|
29
|
+
_formatter = string.Formatter()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _root_fields(template: str) -> frozenset[str]:
|
|
33
|
+
"""The top-level variable names referenced by the template."""
|
|
34
|
+
roots: set[str] = set()
|
|
35
|
+
for _, field_name, _, _ in _formatter.parse(template):
|
|
36
|
+
if field_name is None or field_name == "":
|
|
37
|
+
continue
|
|
38
|
+
if _FIELD.match(field_name):
|
|
39
|
+
roots.add(field_name.split(".")[0])
|
|
40
|
+
return frozenset(roots)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PromptTemplate:
|
|
44
|
+
"""A strict `{var}` template over the values passed to `render`."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
template: str,
|
|
49
|
+
*,
|
|
50
|
+
defaults: Mapping[str, Any] | None = None,
|
|
51
|
+
):
|
|
52
|
+
if not isinstance(template, str) or not template.strip():
|
|
53
|
+
raise ValueError("prompt template must be a non-empty string")
|
|
54
|
+
self._template = template
|
|
55
|
+
self._defaults = dict(defaults or {})
|
|
56
|
+
self.variables = _root_fields(template)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def template(self) -> str:
|
|
60
|
+
return self._template
|
|
61
|
+
|
|
62
|
+
def render(self, **values: Any) -> str:
|
|
63
|
+
"""Fills the placeholders; a missing declared variable is a `KeyError`."""
|
|
64
|
+
merged = {**self._defaults, **values}
|
|
65
|
+
missing = self.variables - merged.keys()
|
|
66
|
+
if missing:
|
|
67
|
+
raise KeyError(f"missing prompt variables: {', '.join(sorted(missing))}")
|
|
68
|
+
try:
|
|
69
|
+
return self._template.format(**merged)
|
|
70
|
+
except (AttributeError, IndexError, KeyError) as exc:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"failed to render prompt (template {self._template!r}): {exc}"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"PromptTemplate(variables={sorted(self.variables)})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MessagesPrompt:
|
|
80
|
+
"""A chat prompt: an ordered set of `(role, template)` rows.
|
|
81
|
+
|
|
82
|
+
Renders to `list[Message]`; every row sees the same variables and a missing
|
|
83
|
+
variable anywhere is a `KeyError`.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, messages: Sequence[tuple[str, str]]):
|
|
87
|
+
if not messages:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"MessagesPrompt requires at least one (role, template) row"
|
|
90
|
+
)
|
|
91
|
+
self._rows: list[tuple[Role, PromptTemplate]] = []
|
|
92
|
+
for role, template in messages:
|
|
93
|
+
if role not in ("system", "user", "assistant", "tool"):
|
|
94
|
+
raise ValueError(f"unknown message role in MessagesPrompt: {role!r}")
|
|
95
|
+
self._rows.append((cast(Role, role), PromptTemplate(template)))
|
|
96
|
+
variables: set[str] = set()
|
|
97
|
+
for _, row in self._rows:
|
|
98
|
+
variables |= set(row.variables)
|
|
99
|
+
self.variables = frozenset(variables)
|
|
100
|
+
|
|
101
|
+
def render(self, **values: Any) -> list[Message]:
|
|
102
|
+
return [
|
|
103
|
+
Message(role=role, content=template.render(**values))
|
|
104
|
+
for role, template in self._rows
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return f"MessagesPrompt(roles={[r for r, _ in self._rows]})"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["MessagesPrompt", "PromptTemplate"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Providers package: contracts + implementations (chat, embeddings, images, fakes).
|
|
2
|
+
|
|
3
|
+
The app configures the needed providers when initializing resources;
|
|
4
|
+
the core public API (`reactifact`) exports only contracts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .anthropic import AnthropicProvider, anthropic_llm
|
|
12
|
+
from .azure import azure_llm
|
|
13
|
+
from .cerebras import cerebras_llm
|
|
14
|
+
from .chat import (
|
|
15
|
+
OpenAICompatEmbedder,
|
|
16
|
+
OpenAICompatProvider,
|
|
17
|
+
embedder_from_env,
|
|
18
|
+
llm_from_env,
|
|
19
|
+
)
|
|
20
|
+
from .contracts import (
|
|
21
|
+
EmbeddingProvider,
|
|
22
|
+
LLMProvider,
|
|
23
|
+
LLMRequest,
|
|
24
|
+
LLMResponse,
|
|
25
|
+
LLMResponseChunk,
|
|
26
|
+
Message,
|
|
27
|
+
Role,
|
|
28
|
+
)
|
|
29
|
+
from .deepseek import deepseek_llm
|
|
30
|
+
from .fake import FakeEmbedder, FakeLLM
|
|
31
|
+
from .fireworks import fireworks_embedder, fireworks_llm
|
|
32
|
+
from .gemini import GeminiImageProvider, GeminiProvider, gemini_image, gemini_llm
|
|
33
|
+
from .github_models import github_models_llm
|
|
34
|
+
from .groq import groq_llm, groq_transcriber
|
|
35
|
+
from .image import (
|
|
36
|
+
ImageProvider,
|
|
37
|
+
OpenAICompatImageProvider,
|
|
38
|
+
OpenRouterImageProvider,
|
|
39
|
+
image_from_env,
|
|
40
|
+
)
|
|
41
|
+
from .mistral import mistral_embedder, mistral_llm
|
|
42
|
+
from .nvidia import nvidia_embedder, nvidia_nim_llm
|
|
43
|
+
from .ollama import ollama_llm
|
|
44
|
+
from .openai import openai_embedder, openai_llm
|
|
45
|
+
from .openrouter import (
|
|
46
|
+
openrouter_embedder,
|
|
47
|
+
openrouter_image,
|
|
48
|
+
openrouter_llm,
|
|
49
|
+
openrouter_speech,
|
|
50
|
+
)
|
|
51
|
+
from .perplexity import perplexity_llm
|
|
52
|
+
from .qwen import qwen_embedder, qwen_llm
|
|
53
|
+
from .speech import (
|
|
54
|
+
OpenAICompatSpeech,
|
|
55
|
+
OpenAICompatTranscriber,
|
|
56
|
+
SpeechProvider,
|
|
57
|
+
TranscriberProvider,
|
|
58
|
+
speech_from_env,
|
|
59
|
+
transcriber_from_env,
|
|
60
|
+
)
|
|
61
|
+
from .together import together_embedder, together_llm
|
|
62
|
+
from .video import (
|
|
63
|
+
LumaVideoProvider,
|
|
64
|
+
OpenRouterVideoProvider,
|
|
65
|
+
RunwayVideoProvider,
|
|
66
|
+
SoraVideoProvider,
|
|
67
|
+
VideoProvider,
|
|
68
|
+
VideoResult,
|
|
69
|
+
video_from_env,
|
|
70
|
+
)
|
|
71
|
+
from .xai import xai_llm
|
|
72
|
+
from .zai import zai_llm
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def from_env(**overrides: Any) -> LLMProvider | None:
|
|
76
|
+
"""One-call provider selection: `OPENROUTER_API_KEY` first, else a local
|
|
77
|
+
OpenAI-compatible endpoint via `OPENAI_BASE_URL`, else `None` (offline).
|
|
78
|
+
|
|
79
|
+
This is the exact two-branch selection every example in this repo hand-
|
|
80
|
+
rolls as a local `build_llm()` (deliberately, per example, so a reader
|
|
81
|
+
sees the wiring — see `examples/*/main.py`); use this instead when you
|
|
82
|
+
just want the common default in your own app without copying that block.
|
|
83
|
+
Overrides (`model`, `max_tokens`, `temperature`, ...) are forwarded to
|
|
84
|
+
whichever provider ends up selected.
|
|
85
|
+
"""
|
|
86
|
+
return openrouter_llm(**overrides) or llm_from_env(**overrides)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"AnthropicProvider",
|
|
91
|
+
"EmbeddingProvider",
|
|
92
|
+
"FakeEmbedder",
|
|
93
|
+
"FakeLLM",
|
|
94
|
+
"GeminiImageProvider",
|
|
95
|
+
"GeminiProvider",
|
|
96
|
+
"ImageProvider",
|
|
97
|
+
"LLMProvider",
|
|
98
|
+
"LLMRequest",
|
|
99
|
+
"LLMResponse",
|
|
100
|
+
"LLMResponseChunk",
|
|
101
|
+
"LumaVideoProvider",
|
|
102
|
+
"Message",
|
|
103
|
+
"OpenAICompatEmbedder",
|
|
104
|
+
"OpenAICompatImageProvider",
|
|
105
|
+
"OpenAICompatProvider",
|
|
106
|
+
"OpenAICompatSpeech",
|
|
107
|
+
"OpenAICompatTranscriber",
|
|
108
|
+
"OpenRouterImageProvider",
|
|
109
|
+
"OpenRouterVideoProvider",
|
|
110
|
+
"Role",
|
|
111
|
+
"RunwayVideoProvider",
|
|
112
|
+
"SoraVideoProvider",
|
|
113
|
+
"SpeechProvider",
|
|
114
|
+
"TranscriberProvider",
|
|
115
|
+
"VideoProvider",
|
|
116
|
+
"VideoResult",
|
|
117
|
+
"anthropic_llm",
|
|
118
|
+
"azure_llm",
|
|
119
|
+
"cerebras_llm",
|
|
120
|
+
"deepseek_llm",
|
|
121
|
+
"embedder_from_env",
|
|
122
|
+
"fireworks_embedder",
|
|
123
|
+
"fireworks_llm",
|
|
124
|
+
"from_env",
|
|
125
|
+
"gemini_image",
|
|
126
|
+
"gemini_llm",
|
|
127
|
+
"github_models_llm",
|
|
128
|
+
"groq_llm",
|
|
129
|
+
"groq_transcriber",
|
|
130
|
+
"image_from_env",
|
|
131
|
+
"llm_from_env",
|
|
132
|
+
"mistral_embedder",
|
|
133
|
+
"mistral_llm",
|
|
134
|
+
"nvidia_embedder",
|
|
135
|
+
"nvidia_nim_llm",
|
|
136
|
+
"ollama_llm",
|
|
137
|
+
"openai_embedder",
|
|
138
|
+
"openai_llm",
|
|
139
|
+
"openrouter_embedder",
|
|
140
|
+
"openrouter_image",
|
|
141
|
+
"openrouter_llm",
|
|
142
|
+
"openrouter_speech",
|
|
143
|
+
"perplexity_llm",
|
|
144
|
+
"qwen_embedder",
|
|
145
|
+
"qwen_llm",
|
|
146
|
+
"speech_from_env",
|
|
147
|
+
"together_embedder",
|
|
148
|
+
"together_llm",
|
|
149
|
+
"transcriber_from_env",
|
|
150
|
+
"video_from_env",
|
|
151
|
+
"xai_llm",
|
|
152
|
+
"zai_llm",
|
|
153
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared network-retry helper for HTTP-based LLM providers.
|
|
2
|
+
|
|
3
|
+
Every `complete()` implementation in this package hits the same class of
|
|
4
|
+
transient failures (429 rate limits, 5xx, connection resets/timeouts) and,
|
|
5
|
+
before this module, retried none of them — a single blip failed the call
|
|
6
|
+
outright. Not in `contracts.py`: the core stays free of the httpx dependency
|
|
7
|
+
(see that module's docstring); this lives in the `providers` package instead
|
|
8
|
+
and is imported by the concrete implementations that need it.
|
|
9
|
+
|
|
10
|
+
Scoped to `complete()` only, deliberately not `stream()`: a streaming call
|
|
11
|
+
can fail after already yielding chunks to the caller, and retrying by
|
|
12
|
+
restarting the request would silently duplicate what was already streamed.
|
|
13
|
+
`complete()` fails atomically (nothing to un-yield), so it's safe to retry
|
|
14
|
+
in full.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from typing import TypeVar
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
#: HTTP statuses worth retrying: rate limit + server-side errors. Anything
|
|
28
|
+
#: else (400/401/403/404, ...) is a request/auth problem retrying won't fix.
|
|
29
|
+
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def with_retry(
|
|
33
|
+
call: Callable[[], Awaitable[T]],
|
|
34
|
+
*,
|
|
35
|
+
attempts: int = 3,
|
|
36
|
+
base_delay: float = 0.5,
|
|
37
|
+
) -> T:
|
|
38
|
+
"""Retries `call` on transient HTTP failures with exponential backoff.
|
|
39
|
+
|
|
40
|
+
Retries `httpx.TransportError` (connection reset, timeout, DNS...) and
|
|
41
|
+
`httpx.HTTPStatusError` whose status is in `RETRYABLE_STATUS`; any other
|
|
42
|
+
exception — including a non-retryable status — propagates on the first
|
|
43
|
+
attempt. `attempts=1` disables retrying entirely.
|
|
44
|
+
"""
|
|
45
|
+
for attempt in range(attempts):
|
|
46
|
+
try:
|
|
47
|
+
return await call()
|
|
48
|
+
except httpx.HTTPStatusError as exc:
|
|
49
|
+
if (
|
|
50
|
+
exc.response.status_code not in RETRYABLE_STATUS
|
|
51
|
+
or attempt + 1 >= attempts
|
|
52
|
+
):
|
|
53
|
+
raise
|
|
54
|
+
except httpx.TransportError:
|
|
55
|
+
if attempt + 1 >= attempts:
|
|
56
|
+
raise
|
|
57
|
+
await asyncio.sleep(base_delay * (2**attempt))
|
|
58
|
+
raise AssertionError("unreachable: loop always returns or raises")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["RETRYABLE_STATUS", "with_retry"]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Anthropic — Messages API (not OpenAI-compatible: a separate contract)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._retry import with_retry
|
|
12
|
+
from .contracts import (
|
|
13
|
+
LLMProvider,
|
|
14
|
+
LLMRequest,
|
|
15
|
+
LLMResponse,
|
|
16
|
+
LLMResponseChunk,
|
|
17
|
+
auth_value,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnthropicProvider(LLMProvider):
|
|
22
|
+
"""Provider for the Anthropic Messages API (/v1/messages, x-api-key).
|
|
23
|
+
|
|
24
|
+
Auth defaults to the Anthropic convention (`x-api-key` header, raw key) but
|
|
25
|
+
is configurable like the other providers (auth_header/auth_scheme/proxy).
|
|
26
|
+
`response_format` does not exist in Anthropic — structured output is obtained
|
|
27
|
+
via plain JSON in the text (the tolerant parse_structured covers this).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: str,
|
|
33
|
+
model: str = "claude-3-5-sonnet-latest",
|
|
34
|
+
base_url: str = "https://api.anthropic.com/v1",
|
|
35
|
+
timeout: float = 90.0,
|
|
36
|
+
transport: Any | None = None,
|
|
37
|
+
extra_headers: dict[str, str] | None = None,
|
|
38
|
+
max_tokens: int | None = None,
|
|
39
|
+
temperature: float | None = None,
|
|
40
|
+
proxy: str | None = None,
|
|
41
|
+
auth_header: str = "x-api-key",
|
|
42
|
+
auth_scheme: str | None = None,
|
|
43
|
+
retry_attempts: int = 3,
|
|
44
|
+
):
|
|
45
|
+
self.api_key = api_key
|
|
46
|
+
self.model = model
|
|
47
|
+
self.base_url = base_url.rstrip("/")
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
# Anthropic's Messages API requires `max_tokens`; 4096 is the library
|
|
50
|
+
# default. Pass an explicit value to change it, or override per call.
|
|
51
|
+
self.max_tokens = max_tokens if max_tokens is not None else 4096
|
|
52
|
+
self.temperature = temperature
|
|
53
|
+
self.retry_attempts = retry_attempts
|
|
54
|
+
self._headers = {
|
|
55
|
+
"anthropic-version": "2023-06-01",
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
**(extra_headers or {}),
|
|
58
|
+
}
|
|
59
|
+
self._headers.setdefault(auth_header.lower(), auth_value(api_key, auth_scheme))
|
|
60
|
+
self._transport = transport
|
|
61
|
+
self._proxy = proxy
|
|
62
|
+
self._client: httpx.AsyncClient | None = None
|
|
63
|
+
|
|
64
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
65
|
+
if self._client is None:
|
|
66
|
+
self._client = httpx.AsyncClient(
|
|
67
|
+
timeout=self._timeout,
|
|
68
|
+
transport=self._transport,
|
|
69
|
+
headers=self._headers,
|
|
70
|
+
proxy=self._proxy,
|
|
71
|
+
)
|
|
72
|
+
return self._client
|
|
73
|
+
|
|
74
|
+
def _payload(self, request: LLMRequest, stream: bool) -> dict[str, Any]:
|
|
75
|
+
system = "\n\n".join(m.content for m in request.messages if m.role == "system")
|
|
76
|
+
messages = [
|
|
77
|
+
{"role": m.role, "content": m.content}
|
|
78
|
+
for m in request.messages
|
|
79
|
+
if m.role in ("user", "assistant")
|
|
80
|
+
]
|
|
81
|
+
if not messages:
|
|
82
|
+
messages = [{"role": "user", "content": ""}]
|
|
83
|
+
temperature = (
|
|
84
|
+
request.temperature if request.temperature is not None else self.temperature
|
|
85
|
+
)
|
|
86
|
+
payload: dict[str, Any] = {
|
|
87
|
+
"model": request.extra.get("model") or self.model,
|
|
88
|
+
"max_tokens": (
|
|
89
|
+
request.max_tokens
|
|
90
|
+
if request.max_tokens is not None
|
|
91
|
+
else self.max_tokens
|
|
92
|
+
),
|
|
93
|
+
"messages": messages,
|
|
94
|
+
"stream": stream,
|
|
95
|
+
}
|
|
96
|
+
if temperature is not None:
|
|
97
|
+
payload["temperature"] = temperature
|
|
98
|
+
if system:
|
|
99
|
+
payload["system"] = system
|
|
100
|
+
if request.stop:
|
|
101
|
+
payload["stop_sequences"] = request.stop
|
|
102
|
+
for key, value in request.extra.items():
|
|
103
|
+
if key != "model":
|
|
104
|
+
payload[key] = value
|
|
105
|
+
return payload
|
|
106
|
+
|
|
107
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
108
|
+
async def _call() -> LLMResponse:
|
|
109
|
+
response = await self._get_client().post(
|
|
110
|
+
f"{self.base_url}/messages",
|
|
111
|
+
json=self._payload(request, stream=False),
|
|
112
|
+
)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
data = response.json()
|
|
115
|
+
text = "".join(
|
|
116
|
+
block.get("text", "")
|
|
117
|
+
for block in data.get("content", [])
|
|
118
|
+
if block.get("type") == "text"
|
|
119
|
+
)
|
|
120
|
+
return LLMResponse(
|
|
121
|
+
text=text,
|
|
122
|
+
raw=data,
|
|
123
|
+
finish_reason=data.get("stop_reason"),
|
|
124
|
+
usage=data.get("usage", {}),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return await with_retry(_call, attempts=self.retry_attempts)
|
|
128
|
+
|
|
129
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
|
|
130
|
+
async with self._get_client().stream(
|
|
131
|
+
"POST",
|
|
132
|
+
f"{self.base_url}/messages",
|
|
133
|
+
json=self._payload(request, stream=True),
|
|
134
|
+
) as response:
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
async for line in response.aiter_lines():
|
|
137
|
+
if not line.startswith("data:"):
|
|
138
|
+
continue
|
|
139
|
+
data = line[len("data:") :].strip()
|
|
140
|
+
if not data or data == "[DONE]":
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
blob = json.loads(data)
|
|
144
|
+
except ValueError:
|
|
145
|
+
continue
|
|
146
|
+
if blob.get("type") == "content_block_delta":
|
|
147
|
+
delta = blob.get("delta", {})
|
|
148
|
+
text = delta.get("text")
|
|
149
|
+
if text:
|
|
150
|
+
yield LLMResponseChunk(text=text)
|
|
151
|
+
|
|
152
|
+
async def aclose(self) -> None:
|
|
153
|
+
if self._client is not None:
|
|
154
|
+
await self._client.aclose()
|
|
155
|
+
self._client = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def anthropic_llm(
|
|
159
|
+
api_key: str | None = None,
|
|
160
|
+
model: str = "claude-3-5-sonnet-latest",
|
|
161
|
+
**kwargs: Any,
|
|
162
|
+
) -> AnthropicProvider | None:
|
|
163
|
+
"""Builds an Anthropic provider (key from ANTHROPIC_API_KEY).
|
|
164
|
+
|
|
165
|
+
Optional knobs in env or kwargs: ANTHROPIC_PROXY / proxy,
|
|
166
|
+
ANTHROPIC_AUTH_HEADER / auth_header (default x-api-key),
|
|
167
|
+
ANTHROPIC_AUTH_SCHEME / auth_scheme (default raw key).
|
|
168
|
+
"""
|
|
169
|
+
import os
|
|
170
|
+
|
|
171
|
+
if api_key is None:
|
|
172
|
+
api_key = kwargs.get("api_key") or os.getenv("ANTHROPIC_API_KEY")
|
|
173
|
+
if not api_key:
|
|
174
|
+
return None
|
|
175
|
+
kwargs.setdefault("proxy", os.getenv("ANTHROPIC_PROXY") or None)
|
|
176
|
+
kwargs.setdefault(
|
|
177
|
+
"auth_header",
|
|
178
|
+
os.getenv("ANTHROPIC_AUTH_HEADER") or "x-api-key",
|
|
179
|
+
)
|
|
180
|
+
scheme = kwargs.get("auth_scheme") or os.getenv("ANTHROPIC_AUTH_SCHEME")
|
|
181
|
+
kwargs["auth_scheme"] = scheme if scheme else None
|
|
182
|
+
return AnthropicProvider(api_key=api_key, model=model, **kwargs)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Azure OpenAI — chat (OpenAI-compatible, deployment-as-model)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .chat import OpenAICompatProvider, _network_knobs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def azure_llm(
|
|
11
|
+
endpoint: str,
|
|
12
|
+
api_key: str | None = None,
|
|
13
|
+
deployment: str | None = None,
|
|
14
|
+
**kwargs: Any,
|
|
15
|
+
) -> OpenAICompatProvider:
|
|
16
|
+
"""OpenAI-compatible Azure provider.
|
|
17
|
+
|
|
18
|
+
`endpoint` is the resource URL, e.g.
|
|
19
|
+
https://my-resource.openai.azure.com/openai/deployments/my-deployment;
|
|
20
|
+
pass `deployment` or set AZURE_OPENAI_API_KEY / AZURE_OPENAI_DEPLOYMENT.
|
|
21
|
+
"""
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
if api_key is None:
|
|
25
|
+
api_key = kwargs.get("api_key") or os.getenv("AZURE_OPENAI_API_KEY")
|
|
26
|
+
if deployment is None:
|
|
27
|
+
deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT")
|
|
28
|
+
merged = {**_network_knobs("AZURE", kwargs), **kwargs}
|
|
29
|
+
return OpenAICompatProvider(
|
|
30
|
+
base_url=endpoint, api_key=api_key, model=deployment, **merged
|
|
31
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Cerebras — ultra-fast inference (OpenAI-compatible)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chat import _openai_compat_llm
|
|
6
|
+
|
|
7
|
+
cerebras_llm = _openai_compat_llm(
|
|
8
|
+
env_prefix="CEREBRAS",
|
|
9
|
+
default_model="llama-3.3-70b",
|
|
10
|
+
default_base_url="https://api.cerebras.ai/v1",
|
|
11
|
+
)
|