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.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. 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
@@ -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
@@ -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)