readyagentsdev 0.8.2__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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
readyagents/config.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""BYOK settings: environment, then `.env`, then `.env-ai`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import AliasChoices, Field, field_validator
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
from readyagents.errors import ConfigError, LLMError
|
|
13
|
+
|
|
14
|
+
DEFAULT_MCP_TOKEN_ENV = "READYAGENTS_MCP_TOKEN"
|
|
15
|
+
MAX_CONCURRENT_RUNS_HARD = 32
|
|
16
|
+
MAX_PENDING_RUNS_HARD = 256
|
|
17
|
+
MAX_HTTP_BODY_BYTES = 1_048_576
|
|
18
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _env_files() -> tuple[Path, ...]:
|
|
22
|
+
"""Lowest-priority first: `.env-ai` then `.env`. OS env still wins."""
|
|
23
|
+
cwd = Path.cwd()
|
|
24
|
+
files: list[Path] = []
|
|
25
|
+
for name in (".env-ai", ".env"):
|
|
26
|
+
path = cwd / name
|
|
27
|
+
if path.is_file():
|
|
28
|
+
files.append(path)
|
|
29
|
+
return tuple(files)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseSettings):
|
|
33
|
+
"""Runtime settings. Secrets are never logged by this class."""
|
|
34
|
+
|
|
35
|
+
model_config = SettingsConfigDict(
|
|
36
|
+
extra="ignore",
|
|
37
|
+
env_file_encoding="utf-8",
|
|
38
|
+
case_sensitive=False,
|
|
39
|
+
populate_by_name=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
openai_api_key: str | None = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
validation_alias=AliasChoices("OPENAI_API_KEY", "READYAGENTS_OPENAI_API_KEY"),
|
|
45
|
+
)
|
|
46
|
+
anthropic_api_key: str | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
validation_alias=AliasChoices("ANTHROPIC_API_KEY", "READYAGENTS_ANTHROPIC_API_KEY"),
|
|
49
|
+
)
|
|
50
|
+
openai_compat_api_key: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
validation_alias=AliasChoices(
|
|
53
|
+
"OPENAI_COMPAT_API_KEY",
|
|
54
|
+
"READYAGENTS_OPENAI_COMPAT_API_KEY",
|
|
55
|
+
"GROQ_API_KEY",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
openai_compat_base_url: str | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
validation_alias=AliasChoices(
|
|
61
|
+
"OPENAI_COMPAT_BASE_URL",
|
|
62
|
+
"READYAGENTS_OPENAI_COMPAT_BASE_URL",
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
default_model: str = Field(
|
|
66
|
+
default="openai:gpt-4o-mini",
|
|
67
|
+
validation_alias=AliasChoices("READYAGENTS_DEFAULT_MODEL", "DEFAULT_MODEL"),
|
|
68
|
+
)
|
|
69
|
+
allow_http: bool = Field(
|
|
70
|
+
default=False,
|
|
71
|
+
validation_alias=AliasChoices("READYAGENTS_ALLOW_HTTP"),
|
|
72
|
+
)
|
|
73
|
+
workspace: Path | None = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
validation_alias=AliasChoices("READYAGENTS_WORKSPACE"),
|
|
76
|
+
)
|
|
77
|
+
home: Path = Field(
|
|
78
|
+
default=Path(".readyagents"),
|
|
79
|
+
validation_alias=AliasChoices("READYAGENTS_HOME"),
|
|
80
|
+
)
|
|
81
|
+
log_level: str = Field(
|
|
82
|
+
default="INFO",
|
|
83
|
+
validation_alias=AliasChoices("READYAGENTS_LOG_LEVEL"),
|
|
84
|
+
)
|
|
85
|
+
log_format: str = Field(
|
|
86
|
+
default="text",
|
|
87
|
+
validation_alias=AliasChoices("READYAGENTS_LOG_FORMAT"),
|
|
88
|
+
)
|
|
89
|
+
max_tokens: int | None = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
validation_alias=AliasChoices("READYAGENTS_MAX_TOKENS"),
|
|
92
|
+
)
|
|
93
|
+
max_cost_usd: float | None = Field(
|
|
94
|
+
default=None,
|
|
95
|
+
validation_alias=AliasChoices("READYAGENTS_MAX_COST_USD"),
|
|
96
|
+
)
|
|
97
|
+
fallback_models: str | None = Field(
|
|
98
|
+
default=None,
|
|
99
|
+
validation_alias=AliasChoices("READYAGENTS_FALLBACK_MODELS"),
|
|
100
|
+
)
|
|
101
|
+
circuit_failure_threshold: int = Field(
|
|
102
|
+
default=3,
|
|
103
|
+
validation_alias=AliasChoices("READYAGENTS_CIRCUIT_FAILURE_THRESHOLD"),
|
|
104
|
+
)
|
|
105
|
+
circuit_cooldown_seconds: float = Field(
|
|
106
|
+
default=60.0,
|
|
107
|
+
validation_alias=AliasChoices("READYAGENTS_CIRCUIT_COOLDOWN_SECONDS"),
|
|
108
|
+
)
|
|
109
|
+
llm_cache: bool = Field(
|
|
110
|
+
default=False,
|
|
111
|
+
validation_alias=AliasChoices("READYAGENTS_LLM_CACHE"),
|
|
112
|
+
)
|
|
113
|
+
redact: bool = Field(
|
|
114
|
+
default=False,
|
|
115
|
+
validation_alias=AliasChoices("READYAGENTS_REDACT"),
|
|
116
|
+
)
|
|
117
|
+
redact_literals: str | None = Field(
|
|
118
|
+
default=None,
|
|
119
|
+
validation_alias=AliasChoices("READYAGENTS_REDACT_LITERALS"),
|
|
120
|
+
)
|
|
121
|
+
redact_patterns: str | None = Field(
|
|
122
|
+
default=None,
|
|
123
|
+
validation_alias=AliasChoices("READYAGENTS_REDACT_PATTERNS"),
|
|
124
|
+
)
|
|
125
|
+
actor: str | None = Field(
|
|
126
|
+
default=None,
|
|
127
|
+
validation_alias=AliasChoices("READYAGENTS_ACTOR"),
|
|
128
|
+
)
|
|
129
|
+
pause_notify_url: str | None = Field(
|
|
130
|
+
default=None,
|
|
131
|
+
validation_alias=AliasChoices("READYAGENTS_PAUSE_NOTIFY_URL"),
|
|
132
|
+
)
|
|
133
|
+
mcp_http_host: str = Field(
|
|
134
|
+
default="127.0.0.1",
|
|
135
|
+
validation_alias=AliasChoices("READYAGENTS_MCP_HTTP_HOST"),
|
|
136
|
+
)
|
|
137
|
+
mcp_http_port: int = Field(
|
|
138
|
+
default=8765,
|
|
139
|
+
ge=1,
|
|
140
|
+
le=65535,
|
|
141
|
+
validation_alias=AliasChoices("READYAGENTS_MCP_HTTP_PORT"),
|
|
142
|
+
)
|
|
143
|
+
mcp_max_concurrent_runs: int = Field(
|
|
144
|
+
default=4,
|
|
145
|
+
ge=1,
|
|
146
|
+
validation_alias=AliasChoices("READYAGENTS_MCP_MAX_CONCURRENT_RUNS"),
|
|
147
|
+
)
|
|
148
|
+
mcp_max_pending_runs: int = Field(
|
|
149
|
+
default=32,
|
|
150
|
+
ge=1,
|
|
151
|
+
validation_alias=AliasChoices("READYAGENTS_MCP_MAX_PENDING_RUNS"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def fallback_model_list(self) -> list[str]:
|
|
155
|
+
if not self.fallback_models:
|
|
156
|
+
return []
|
|
157
|
+
return [part.strip() for part in self.fallback_models.split(",") if part.strip()]
|
|
158
|
+
|
|
159
|
+
def redact_literal_list(self) -> list[str]:
|
|
160
|
+
if not self.redact_literals:
|
|
161
|
+
return []
|
|
162
|
+
return [part.strip() for part in self.redact_literals.split(",") if part.strip()]
|
|
163
|
+
|
|
164
|
+
def redact_pattern_list(self) -> list[str]:
|
|
165
|
+
if not self.redact_patterns:
|
|
166
|
+
return []
|
|
167
|
+
return [part.strip() for part in self.redact_patterns.split(",") if part.strip()]
|
|
168
|
+
|
|
169
|
+
def cache_dir(self) -> Path:
|
|
170
|
+
return self.home_path() / "cache"
|
|
171
|
+
|
|
172
|
+
def audit_dir(self) -> Path:
|
|
173
|
+
return self.home_path() / "audit"
|
|
174
|
+
|
|
175
|
+
@field_validator("openai_api_key", "anthropic_api_key", "openai_compat_api_key", mode="before")
|
|
176
|
+
@classmethod
|
|
177
|
+
def _empty_to_none(cls, value: Any) -> Any:
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
if isinstance(value, str) and not value.strip():
|
|
181
|
+
return None
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
@field_validator("mcp_max_concurrent_runs", mode="after")
|
|
185
|
+
@classmethod
|
|
186
|
+
def _clamp_mcp_max_concurrent_runs(cls, value: int) -> int:
|
|
187
|
+
if value > MAX_CONCURRENT_RUNS_HARD:
|
|
188
|
+
return MAX_CONCURRENT_RUNS_HARD
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
@field_validator("mcp_max_pending_runs", mode="after")
|
|
192
|
+
@classmethod
|
|
193
|
+
def _clamp_mcp_max_pending_runs(cls, value: int) -> int:
|
|
194
|
+
if value > MAX_PENDING_RUNS_HARD:
|
|
195
|
+
return MAX_PENDING_RUNS_HARD
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
def workspace_path(self) -> Path:
|
|
199
|
+
return (self.workspace or Path.cwd()).expanduser().resolve()
|
|
200
|
+
|
|
201
|
+
def home_path(self) -> Path:
|
|
202
|
+
path = self.home
|
|
203
|
+
if not path.is_absolute():
|
|
204
|
+
path = Path.cwd() / path
|
|
205
|
+
return path.expanduser().resolve()
|
|
206
|
+
|
|
207
|
+
def runs_dir(self) -> Path:
|
|
208
|
+
return self.home_path() / "runs"
|
|
209
|
+
|
|
210
|
+
def api_key_for(self, provider: str) -> str | None:
|
|
211
|
+
provider = provider.lower()
|
|
212
|
+
if provider in {"openai"}:
|
|
213
|
+
return self.openai_api_key
|
|
214
|
+
if provider in {"anthropic"}:
|
|
215
|
+
return self.anthropic_api_key
|
|
216
|
+
if provider in {"openai-compat", "openai_compat", "compat", "groq", "ollama"}:
|
|
217
|
+
return self.openai_compat_api_key or self.openai_api_key
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def load_settings(*, env_file: tuple[Path, ...] | None = None) -> Settings:
|
|
222
|
+
"""Load settings from OS env, then `.env`, then `.env-ai`."""
|
|
223
|
+
files = env_file if env_file is not None else _env_files()
|
|
224
|
+
try:
|
|
225
|
+
return Settings(_env_file=files) # type: ignore[call-arg]
|
|
226
|
+
except Exception as exc: # noqa: BLE001
|
|
227
|
+
raise ConfigError(f"Failed to load settings: {exc}") from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@lru_cache(maxsize=1)
|
|
231
|
+
def get_settings() -> Settings:
|
|
232
|
+
return load_settings()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def clear_settings_cache() -> None:
|
|
236
|
+
get_settings.cache_clear()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def require_api_key(
|
|
240
|
+
provider: str,
|
|
241
|
+
settings: Settings | None = None,
|
|
242
|
+
*,
|
|
243
|
+
secrets: Any = None,
|
|
244
|
+
) -> str:
|
|
245
|
+
settings = settings or get_settings()
|
|
246
|
+
key = settings.api_key_for(provider)
|
|
247
|
+
if not key and secrets is not None:
|
|
248
|
+
from readyagents.secrets import secret_for_provider
|
|
249
|
+
|
|
250
|
+
key = secret_for_provider(provider, settings=None, secrets=secrets)
|
|
251
|
+
if key:
|
|
252
|
+
return key
|
|
253
|
+
hints = {
|
|
254
|
+
"openai": ("Set OPENAI_API_KEY or READYAGENTS_OPENAI_API_KEY (copy .env.example to .env)."),
|
|
255
|
+
"anthropic": (
|
|
256
|
+
"Set ANTHROPIC_API_KEY or READYAGENTS_ANTHROPIC_API_KEY (copy .env.example to .env)."
|
|
257
|
+
),
|
|
258
|
+
"openai-compat": (
|
|
259
|
+
"Set OPENAI_COMPAT_API_KEY (and OPENAI_COMPAT_BASE_URL) "
|
|
260
|
+
"or OPENAI_API_KEY for a compatible endpoint."
|
|
261
|
+
),
|
|
262
|
+
}
|
|
263
|
+
hint = hints.get(provider.lower(), f"Set an API key for provider '{provider}'.")
|
|
264
|
+
raise LLMError(f"No API key configured for provider '{provider}'. ReadyAgents is BYOK — {hint}")
|
readyagents/errors.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Typed errors for ReadyAgents Core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReadyAgentsError(Exception):
|
|
7
|
+
"""Base error for all ReadyAgents failures."""
|
|
8
|
+
|
|
9
|
+
run_id: str | None = None
|
|
10
|
+
state: object | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(ReadyAgentsError):
|
|
14
|
+
"""Invalid configuration, missing settings, or unreadable files."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkflowError(ReadyAgentsError):
|
|
18
|
+
"""Invalid workflow definition or graph execution problem."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NodeError(ReadyAgentsError):
|
|
22
|
+
"""A single node failed after retries / timeout."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, node_id: str, message: str, *, cause: BaseException | None = None) -> None:
|
|
25
|
+
self.node_id = node_id
|
|
26
|
+
self.cause = cause
|
|
27
|
+
super().__init__(f"Node '{node_id}': {message}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LLMError(ReadyAgentsError):
|
|
31
|
+
"""LLM provider, model, or API-key failure."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MCPError(ReadyAgentsError):
|
|
35
|
+
"""MCP client or server failure."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TemplateError(ReadyAgentsError):
|
|
39
|
+
"""Template interpolation failed (missing variable or bad path)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ToolError(ReadyAgentsError):
|
|
43
|
+
"""Builtin or MCP tool invocation failed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApprovalRequired(ReadyAgentsError):
|
|
47
|
+
"""An approval node is waiting for an explicit operator decision."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
node_id: str,
|
|
52
|
+
run_id: str,
|
|
53
|
+
prompt: str,
|
|
54
|
+
*,
|
|
55
|
+
state: object | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.node_id = node_id
|
|
58
|
+
self.run_id = run_id
|
|
59
|
+
self.prompt = prompt
|
|
60
|
+
self.state = state
|
|
61
|
+
super().__init__(
|
|
62
|
+
f"Approval required at node '{node_id}' (run {run_id}). {prompt} "
|
|
63
|
+
f"Resume with: readyagents resume {run_id} --approve {node_id} "
|
|
64
|
+
f"(or --reject {node_id})"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BudgetExceeded(ReadyAgentsError):
|
|
69
|
+
"""An LLM call was blocked because the run is over its token or cost budget."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, kind: str, used: int, limit: int) -> None:
|
|
72
|
+
self.kind = kind
|
|
73
|
+
self.used = used
|
|
74
|
+
self.limit = limit
|
|
75
|
+
super().__init__(f"Budget exceeded: {kind} used={used} limit={limit}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AuthorizationError(ReadyAgentsError):
|
|
79
|
+
"""An RBAC hook denied run, resume, approve, or reject."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, actor: str | None, action: str, resource: str) -> None:
|
|
82
|
+
self.actor = actor
|
|
83
|
+
self.action = action
|
|
84
|
+
self.resource = resource
|
|
85
|
+
who = actor if actor else "(anonymous)"
|
|
86
|
+
super().__init__(f"Actor '{who}' is not allowed to {action} '{resource}'")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StructuredOutputError(NodeError):
|
|
90
|
+
"""An agent node's LLM output did not match its Pydantic/JSON schema."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CircuitOpen(LLMError):
|
|
94
|
+
"""A model is skipped because its circuit breaker is open."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, model: str) -> None:
|
|
97
|
+
self.model = model
|
|
98
|
+
super().__init__(f"Circuit breaker open for model '{model}'")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CancellationRequested(ReadyAgentsError):
|
|
102
|
+
"""Cooperative cancellation reached an engine safe point."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
message: str = "Run cancellation requested",
|
|
107
|
+
*,
|
|
108
|
+
run_id: str | None = None,
|
|
109
|
+
reason: str | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.reason = reason
|
|
112
|
+
super().__init__(message)
|
|
113
|
+
self.run_id = run_id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RunConflict(ReadyAgentsError):
|
|
117
|
+
"""Run cannot accept this mutation (wrong status, in-flight resume, idempotency mismatch)."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HttpAuthError(MCPError):
|
|
121
|
+
"""Missing or invalid HTTP bearer credentials."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class HttpRequestError(MCPError):
|
|
125
|
+
"""Malformed HTTP extension request (bad JSON, unknown fields, bad id, limits)."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, message: str, *, status_code: int = 400) -> None:
|
|
128
|
+
self.status_code = status_code
|
|
129
|
+
super().__init__(message)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from readyagents.llm.base import CompletionResult, LLMProvider, Message, ToolCall, parse_model_ref
|
|
2
|
+
from readyagents.llm.registry import get_provider
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"CompletionResult",
|
|
6
|
+
"LLMProvider",
|
|
7
|
+
"Message",
|
|
8
|
+
"ToolCall",
|
|
9
|
+
"get_provider",
|
|
10
|
+
"parse_model_ref",
|
|
11
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Anthropic Messages provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from readyagents.errors import LLMError
|
|
8
|
+
from readyagents.llm.base import CompletionResult, Message
|
|
9
|
+
from readyagents.llm.tool_calls import (
|
|
10
|
+
anthropic_tools_payload,
|
|
11
|
+
messages_to_anthropic,
|
|
12
|
+
tool_calls_from_anthropic_content,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnthropicProvider:
|
|
17
|
+
name = "anthropic"
|
|
18
|
+
|
|
19
|
+
def __init__(self, api_key: str) -> None:
|
|
20
|
+
self._api_key = api_key
|
|
21
|
+
|
|
22
|
+
def complete(
|
|
23
|
+
self,
|
|
24
|
+
messages: list[Message],
|
|
25
|
+
*,
|
|
26
|
+
model: str,
|
|
27
|
+
tools: list[dict[str, Any]] | None = None,
|
|
28
|
+
**kwargs: Any,
|
|
29
|
+
) -> CompletionResult:
|
|
30
|
+
try:
|
|
31
|
+
from anthropic import Anthropic
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise LLMError(
|
|
34
|
+
"The Anthropic extra is not installed. Run: pip install 'readyagents[anthropic]'"
|
|
35
|
+
) from exc
|
|
36
|
+
system, chat = messages_to_anthropic(messages)
|
|
37
|
+
if not chat:
|
|
38
|
+
raise LLMError("Anthropic requires at least one non-system message")
|
|
39
|
+
try:
|
|
40
|
+
client = Anthropic(api_key=self._api_key)
|
|
41
|
+
payload: dict[str, Any] = {"model": model, "messages": chat, "max_tokens": 4096}
|
|
42
|
+
if system:
|
|
43
|
+
payload["system"] = system
|
|
44
|
+
anth_tools = anthropic_tools_payload(tools)
|
|
45
|
+
if anth_tools:
|
|
46
|
+
payload["tools"] = anth_tools
|
|
47
|
+
payload.update({k: v for k, v in kwargs.items() if v is not None and k != "max_tokens"})
|
|
48
|
+
if kwargs.get("max_tokens") is not None:
|
|
49
|
+
payload["max_tokens"] = kwargs["max_tokens"]
|
|
50
|
+
response = client.messages.create(**payload)
|
|
51
|
+
parts = []
|
|
52
|
+
for block in response.content:
|
|
53
|
+
text = getattr(block, "text", None)
|
|
54
|
+
if text:
|
|
55
|
+
parts.append(text)
|
|
56
|
+
usage: dict[str, Any] = {}
|
|
57
|
+
if getattr(response, "usage", None):
|
|
58
|
+
usage = {
|
|
59
|
+
"input_tokens": getattr(response.usage, "input_tokens", None),
|
|
60
|
+
"output_tokens": getattr(response.usage, "output_tokens", None),
|
|
61
|
+
}
|
|
62
|
+
return CompletionResult(
|
|
63
|
+
text="".join(parts).strip(),
|
|
64
|
+
model=model,
|
|
65
|
+
raw=response,
|
|
66
|
+
usage=usage,
|
|
67
|
+
tool_calls=tool_calls_from_anthropic_content(response.content),
|
|
68
|
+
)
|
|
69
|
+
except LLMError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as exc: # noqa: BLE001
|
|
72
|
+
raise LLMError(f"Anthropic request failed: {exc}") from exc
|
readyagents/llm/base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Thin LLM provider interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ToolCall:
|
|
11
|
+
"""One model-requested tool invocation (id + name + JSON arguments)."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Message:
|
|
20
|
+
role: str
|
|
21
|
+
content: str
|
|
22
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
23
|
+
tool_call_id: str | None = None
|
|
24
|
+
name: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CompletionResult:
|
|
29
|
+
text: str
|
|
30
|
+
model: str
|
|
31
|
+
raw: Any = None
|
|
32
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LLMProvider(Protocol):
|
|
37
|
+
name: str
|
|
38
|
+
|
|
39
|
+
def complete(
|
|
40
|
+
self,
|
|
41
|
+
messages: list[Message],
|
|
42
|
+
*,
|
|
43
|
+
model: str,
|
|
44
|
+
tools: list[dict[str, Any]] | None = None,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> CompletionResult: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_model_ref(ref: str) -> tuple[str, str]:
|
|
50
|
+
"""Split `provider:model` (or bare model → openai)."""
|
|
51
|
+
ref = ref.strip()
|
|
52
|
+
if not ref:
|
|
53
|
+
raise ValueError("Empty model reference")
|
|
54
|
+
if ":" in ref:
|
|
55
|
+
provider, model = ref.split(":", 1)
|
|
56
|
+
return provider.strip().lower(), model.strip()
|
|
57
|
+
return "openai", ref
|
readyagents/llm/cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Local, opt-in LLM response cache. File-backed, skippable, no network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from readyagents.llm.base import CompletionResult, Message
|
|
13
|
+
from readyagents.llm.tool_calls import tool_calls_from_json, tool_calls_to_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMCache:
|
|
17
|
+
"""Content-addressed completions under ``$READYAGENTS_HOME/cache/``."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, root: Path) -> None:
|
|
20
|
+
self.root = Path(root)
|
|
21
|
+
self.hits = 0
|
|
22
|
+
self.misses = 0
|
|
23
|
+
|
|
24
|
+
def key(
|
|
25
|
+
self,
|
|
26
|
+
model: str,
|
|
27
|
+
messages: list[Message],
|
|
28
|
+
tools: list[dict[str, Any]] | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
payload = {
|
|
31
|
+
"model": model,
|
|
32
|
+
"messages": [_message_payload(m) for m in messages],
|
|
33
|
+
"tools": list(tools or []),
|
|
34
|
+
}
|
|
35
|
+
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
|
36
|
+
return hashlib.sha256(blob).hexdigest()
|
|
37
|
+
|
|
38
|
+
def get(self, key: str) -> CompletionResult | None:
|
|
39
|
+
path = self.root / f"{key}.json"
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
self.misses += 1
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, json.JSONDecodeError):
|
|
46
|
+
self.misses += 1
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(data, dict) or "text" not in data:
|
|
49
|
+
self.misses += 1
|
|
50
|
+
return None
|
|
51
|
+
self.hits += 1
|
|
52
|
+
usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
|
53
|
+
return CompletionResult(
|
|
54
|
+
text=str(data.get("text") or ""),
|
|
55
|
+
model=str(data.get("model") or ""),
|
|
56
|
+
usage=dict(usage),
|
|
57
|
+
tool_calls=tool_calls_from_json(data.get("tool_calls")),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def put(self, key: str, result: CompletionResult) -> None:
|
|
61
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path = self.root / f"{key}.json"
|
|
63
|
+
tmp = self.root / f".{key}.{uuid4().hex}.tmp"
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"text": result.text,
|
|
66
|
+
"model": result.model,
|
|
67
|
+
"usage": dict(result.usage or {}),
|
|
68
|
+
"tool_calls": tool_calls_to_json(result.tool_calls),
|
|
69
|
+
}
|
|
70
|
+
try:
|
|
71
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
72
|
+
os.replace(tmp, path)
|
|
73
|
+
finally:
|
|
74
|
+
if tmp.exists():
|
|
75
|
+
tmp.unlink(missing_ok=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _message_payload(message: Message) -> dict[str, Any]:
|
|
79
|
+
row: dict[str, Any] = {"role": message.role, "content": message.content}
|
|
80
|
+
if message.tool_call_id:
|
|
81
|
+
row["tool_call_id"] = message.tool_call_id
|
|
82
|
+
if message.name:
|
|
83
|
+
row["name"] = message.name
|
|
84
|
+
if message.tool_calls:
|
|
85
|
+
row["tool_calls"] = tool_calls_to_json(message.tool_calls)
|
|
86
|
+
return row
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""OpenAI-compatible endpoints (Groq, Ollama, Together, vLLM, …)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from readyagents.llm.openai_provider import OpenAIProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAICompatProvider(OpenAIProvider):
|
|
9
|
+
name = "openai-compat"
|
|
10
|
+
|
|
11
|
+
def __init__(self, api_key: str, *, base_url: str) -> None:
|
|
12
|
+
super().__init__(api_key, base_url=base_url)
|