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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""OpenAI Chat Completions 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
|
+
messages_to_openai,
|
|
11
|
+
openai_tools_payload,
|
|
12
|
+
tool_calls_from_openai_message,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAIProvider:
|
|
17
|
+
name = "openai"
|
|
18
|
+
|
|
19
|
+
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
|
|
20
|
+
self._api_key = api_key
|
|
21
|
+
self._base_url = base_url
|
|
22
|
+
|
|
23
|
+
def complete(
|
|
24
|
+
self,
|
|
25
|
+
messages: list[Message],
|
|
26
|
+
*,
|
|
27
|
+
model: str,
|
|
28
|
+
tools: list[dict[str, Any]] | None = None,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> CompletionResult:
|
|
31
|
+
try:
|
|
32
|
+
from openai import OpenAI
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise LLMError(
|
|
35
|
+
"The OpenAI extra is not installed. Run: pip install 'readyagents[openai]'"
|
|
36
|
+
) from exc
|
|
37
|
+
try:
|
|
38
|
+
client_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
|
39
|
+
if self._base_url:
|
|
40
|
+
client_kwargs["base_url"] = self._base_url
|
|
41
|
+
client = OpenAI(**client_kwargs)
|
|
42
|
+
payload: dict[str, Any] = {
|
|
43
|
+
"model": model,
|
|
44
|
+
"messages": messages_to_openai(messages),
|
|
45
|
+
}
|
|
46
|
+
openai_tools = openai_tools_payload(tools)
|
|
47
|
+
if openai_tools:
|
|
48
|
+
payload["tools"] = openai_tools
|
|
49
|
+
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
|
50
|
+
response = client.chat.completions.create(**payload)
|
|
51
|
+
choice = response.choices[0]
|
|
52
|
+
text = (choice.message.content or "").strip()
|
|
53
|
+
usage: dict[str, Any] = {}
|
|
54
|
+
if response.usage:
|
|
55
|
+
usage = {
|
|
56
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
57
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
58
|
+
"total_tokens": response.usage.total_tokens,
|
|
59
|
+
}
|
|
60
|
+
return CompletionResult(
|
|
61
|
+
text=text,
|
|
62
|
+
model=model,
|
|
63
|
+
raw=response,
|
|
64
|
+
usage=usage,
|
|
65
|
+
tool_calls=tool_calls_from_openai_message(choice.message),
|
|
66
|
+
)
|
|
67
|
+
except LLMError:
|
|
68
|
+
raise
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
raise LLMError(f"OpenAI request failed: {exc}") from exc
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Resolve an LLM provider from settings / model ref."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from readyagents.config import Settings, get_settings, require_api_key
|
|
6
|
+
from readyagents.errors import LLMError
|
|
7
|
+
from readyagents.llm.anthropic_provider import AnthropicProvider
|
|
8
|
+
from readyagents.llm.base import LLMProvider, parse_model_ref
|
|
9
|
+
from readyagents.llm.openai_compat import OpenAICompatProvider
|
|
10
|
+
from readyagents.llm.openai_provider import OpenAIProvider
|
|
11
|
+
from readyagents.logging import get_logger
|
|
12
|
+
|
|
13
|
+
log = get_logger("llm")
|
|
14
|
+
|
|
15
|
+
_COMPAT_NAMES = {"openai-compat", "openai_compat", "compat", "groq", "ollama"}
|
|
16
|
+
|
|
17
|
+
# Documented defaults from .env.example / docs/configuration.md
|
|
18
|
+
_FALLBACK_MODELS = {
|
|
19
|
+
"openai": "gpt-4o-mini",
|
|
20
|
+
"anthropic": "claude-sonnet-4-5",
|
|
21
|
+
"openai-compat": "llama-3.1-8b-instant",
|
|
22
|
+
"groq": "llama-3.1-8b-instant",
|
|
23
|
+
"ollama": "llama3",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _has_key(settings: Settings, provider_name: str, secrets: object = None) -> bool:
|
|
28
|
+
from readyagents.secrets import secret_for_provider
|
|
29
|
+
|
|
30
|
+
if provider_name == "openai":
|
|
31
|
+
name = "openai"
|
|
32
|
+
elif provider_name == "anthropic":
|
|
33
|
+
name = "anthropic"
|
|
34
|
+
elif provider_name in _COMPAT_NAMES:
|
|
35
|
+
name = "openai-compat"
|
|
36
|
+
else:
|
|
37
|
+
return False
|
|
38
|
+
return bool(secret_for_provider(name, settings=settings, secrets=secrets))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _implicit_fallback_ref(settings: Settings, secrets: object = None) -> str | None:
|
|
42
|
+
"""First provider that actually has a key. None if BYOK is empty."""
|
|
43
|
+
from readyagents.secrets import secret_for_provider
|
|
44
|
+
|
|
45
|
+
if secret_for_provider("openai", settings=settings, secrets=secrets):
|
|
46
|
+
return f"openai:{_FALLBACK_MODELS['openai']}"
|
|
47
|
+
if secret_for_provider("anthropic", settings=settings, secrets=secrets):
|
|
48
|
+
return f"anthropic:{_FALLBACK_MODELS['anthropic']}"
|
|
49
|
+
if secret_for_provider("openai-compat", settings=settings, secrets=secrets):
|
|
50
|
+
if settings.openai_compat_base_url:
|
|
51
|
+
return f"openai-compat:{_FALLBACK_MODELS['openai-compat']}"
|
|
52
|
+
return f"groq:{_FALLBACK_MODELS['groq']}"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_provider(
|
|
57
|
+
model_ref: str | None = None,
|
|
58
|
+
*,
|
|
59
|
+
settings: Settings | None = None,
|
|
60
|
+
implicit: bool = False,
|
|
61
|
+
secrets: object = None,
|
|
62
|
+
) -> tuple[LLMProvider, str]:
|
|
63
|
+
"""Return `(provider, model_id)` for a `provider:model` string.
|
|
64
|
+
|
|
65
|
+
When ``implicit`` is true (agent node has no ``model:``), a missing key
|
|
66
|
+
for the default provider falls back to whichever BYOK key is set.
|
|
67
|
+
An explicit model ref never falls back.
|
|
68
|
+
"""
|
|
69
|
+
settings = settings or get_settings()
|
|
70
|
+
ref = model_ref or settings.default_model
|
|
71
|
+
provider_name, model_id = parse_model_ref(ref)
|
|
72
|
+
|
|
73
|
+
if implicit and not _has_key(settings, provider_name, secrets):
|
|
74
|
+
fallback = _implicit_fallback_ref(settings, secrets)
|
|
75
|
+
if fallback:
|
|
76
|
+
new_provider, new_model = parse_model_ref(fallback)
|
|
77
|
+
log.info(
|
|
78
|
+
"No API key for default provider '%s'; using %s:%s",
|
|
79
|
+
provider_name,
|
|
80
|
+
new_provider,
|
|
81
|
+
new_model,
|
|
82
|
+
)
|
|
83
|
+
provider_name, model_id = new_provider, new_model
|
|
84
|
+
|
|
85
|
+
if provider_name == "openai":
|
|
86
|
+
key = require_api_key("openai", settings, secrets=secrets)
|
|
87
|
+
return OpenAIProvider(key), model_id
|
|
88
|
+
|
|
89
|
+
if provider_name == "anthropic":
|
|
90
|
+
key = require_api_key("anthropic", settings, secrets=secrets)
|
|
91
|
+
return AnthropicProvider(key), model_id
|
|
92
|
+
|
|
93
|
+
if provider_name in _COMPAT_NAMES:
|
|
94
|
+
key = require_api_key("openai-compat", settings, secrets=secrets)
|
|
95
|
+
base = settings.openai_compat_base_url
|
|
96
|
+
if not base:
|
|
97
|
+
if provider_name == "groq":
|
|
98
|
+
base = "https://api.groq.com/openai/v1"
|
|
99
|
+
elif provider_name == "ollama":
|
|
100
|
+
base = "http://127.0.0.1:11434/v1"
|
|
101
|
+
else:
|
|
102
|
+
raise LLMError(
|
|
103
|
+
"OpenAI-compatible provider requires OPENAI_COMPAT_BASE_URL "
|
|
104
|
+
"(for example https://api.groq.com/openai/v1 or http://127.0.0.1:11434/v1)."
|
|
105
|
+
)
|
|
106
|
+
api_key = key or "not-needed"
|
|
107
|
+
return OpenAICompatProvider(api_key, base_url=base), model_id
|
|
108
|
+
|
|
109
|
+
raise LLMError(
|
|
110
|
+
f"Unknown LLM provider '{provider_name}'. "
|
|
111
|
+
"Use openai, anthropic, or openai-compat (Groq/Ollama)."
|
|
112
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Budget, model fallback, circuit breaker, and token-cost helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
|
|
8
|
+
from readyagents.errors import BudgetExceeded, CircuitOpen, LLMError
|
|
9
|
+
from readyagents.llm.base import parse_model_ref
|
|
10
|
+
from readyagents.logging import get_logger
|
|
11
|
+
|
|
12
|
+
log = get_logger("llm.resilience")
|
|
13
|
+
|
|
14
|
+
# USD per million tokens (prompt, completion). Approximate public list prices.
|
|
15
|
+
_RATES_PER_MILLION: dict[str, tuple[float, float]] = {
|
|
16
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
17
|
+
"gpt-4o": (2.50, 10.00),
|
|
18
|
+
"claude-sonnet-4-5": (3.00, 15.00),
|
|
19
|
+
"claude-3-5-sonnet": (3.00, 15.00),
|
|
20
|
+
"llama-3.1-8b-instant": (0.05, 0.08),
|
|
21
|
+
"default": (0.15, 0.60),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cost_micros(
|
|
26
|
+
model: str,
|
|
27
|
+
prompt_tokens: int,
|
|
28
|
+
completion_tokens: int,
|
|
29
|
+
*,
|
|
30
|
+
rates: Mapping[str, tuple[float, float]] | None = None,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""USD cost as integer millionths of a dollar (exact to sum)."""
|
|
33
|
+
table = rates or _RATES_PER_MILLION
|
|
34
|
+
key = (model or "").split(":")[-1].strip() or "default"
|
|
35
|
+
prompt_rate, completion_rate = table.get(key) or table.get("default") or (0.15, 0.60)
|
|
36
|
+
usd = (prompt_tokens / 1_000_000) * prompt_rate + (
|
|
37
|
+
completion_tokens / 1_000_000
|
|
38
|
+
) * completion_rate
|
|
39
|
+
return int(round(usd * 1_000_000))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_usage(raw: Mapping[str, Any] | None, *, model: str = "") -> dict[str, int]:
|
|
43
|
+
data = dict(raw or {})
|
|
44
|
+
prompt_raw = data.get("prompt_tokens")
|
|
45
|
+
if prompt_raw is None:
|
|
46
|
+
prompt_raw = data.get("input_tokens")
|
|
47
|
+
prompt = _as_int(prompt_raw)
|
|
48
|
+
completion = _as_int(
|
|
49
|
+
data.get("completion_tokens")
|
|
50
|
+
if data.get("completion_tokens") is not None
|
|
51
|
+
else data.get("output_tokens")
|
|
52
|
+
)
|
|
53
|
+
total = _as_int(data.get("total_tokens")) or (prompt + completion)
|
|
54
|
+
micros = _as_int(data.get("cost_micros"))
|
|
55
|
+
if micros == 0 and (prompt or completion):
|
|
56
|
+
micros = cost_micros(model, prompt, completion)
|
|
57
|
+
out = {
|
|
58
|
+
"prompt_tokens": prompt,
|
|
59
|
+
"completion_tokens": completion,
|
|
60
|
+
"total_tokens": total,
|
|
61
|
+
"cost_micros": micros,
|
|
62
|
+
}
|
|
63
|
+
extra = _as_int(data.get("estimated_tokens"))
|
|
64
|
+
if extra:
|
|
65
|
+
out["estimated_tokens"] = extra
|
|
66
|
+
hits = _as_int(data.get("cache_hits"))
|
|
67
|
+
if hits:
|
|
68
|
+
out["cache_hits"] = hits
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _as_int(raw: Any) -> int:
|
|
73
|
+
if raw is None:
|
|
74
|
+
return 0
|
|
75
|
+
try:
|
|
76
|
+
return int(raw)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def usage_nonzero(usage: Mapping[str, int]) -> bool:
|
|
82
|
+
return any(int(v) for v in usage.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CircuitBreaker:
|
|
86
|
+
"""Process-local breaker keyed by model ref. Not distributed."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
failure_threshold: int = 3,
|
|
92
|
+
cooldown_seconds: float = 60.0,
|
|
93
|
+
clock: Callable[[], float] | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
import time
|
|
96
|
+
|
|
97
|
+
self.failure_threshold = max(1, int(failure_threshold))
|
|
98
|
+
self.cooldown_seconds = max(0.0, float(cooldown_seconds))
|
|
99
|
+
self._clock = clock or time.monotonic
|
|
100
|
+
self._fail_count: dict[str, int] = {}
|
|
101
|
+
self._open_until: dict[str, float] = {}
|
|
102
|
+
|
|
103
|
+
def allow(self, model: str) -> bool:
|
|
104
|
+
until = self._open_until.get(model)
|
|
105
|
+
if until is None:
|
|
106
|
+
return True
|
|
107
|
+
if self._clock() >= until:
|
|
108
|
+
self._open_until.pop(model, None)
|
|
109
|
+
self._fail_count[model] = 0
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def record_success(self, model: str) -> None:
|
|
114
|
+
self._fail_count[model] = 0
|
|
115
|
+
self._open_until.pop(model, None)
|
|
116
|
+
|
|
117
|
+
def record_failure(self, model: str) -> None:
|
|
118
|
+
n = self._fail_count.get(model, 0) + 1
|
|
119
|
+
self._fail_count[model] = n
|
|
120
|
+
if n >= self.failure_threshold:
|
|
121
|
+
self._open_until[model] = self._clock() + self.cooldown_seconds
|
|
122
|
+
|
|
123
|
+
def is_open(self, model: str) -> bool:
|
|
124
|
+
return not self.allow(model)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def model_candidates(
|
|
128
|
+
primary: str | None,
|
|
129
|
+
*fallback_groups: Sequence[str] | None,
|
|
130
|
+
) -> list[str]:
|
|
131
|
+
seen: list[str] = []
|
|
132
|
+
for group in ((primary,) if primary else (), *(fallback_groups or ())):
|
|
133
|
+
if not group:
|
|
134
|
+
continue
|
|
135
|
+
for item in group:
|
|
136
|
+
ref = str(item).strip()
|
|
137
|
+
if ref and ref not in seen:
|
|
138
|
+
seen.append(ref)
|
|
139
|
+
return seen
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def model_id_for(ref: str) -> str:
|
|
143
|
+
if not ref:
|
|
144
|
+
return "mock"
|
|
145
|
+
if ":" in ref:
|
|
146
|
+
return parse_model_ref(ref)[1]
|
|
147
|
+
return ref
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_budget(
|
|
151
|
+
usage: Mapping[str, int],
|
|
152
|
+
*,
|
|
153
|
+
max_tokens: int | None = None,
|
|
154
|
+
max_cost_micros: int | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
tokens = int(usage.get("total_tokens") or 0) + int(usage.get("estimated_tokens") or 0)
|
|
157
|
+
if max_tokens is not None and tokens >= max_tokens:
|
|
158
|
+
raise BudgetExceeded("tokens", tokens, max_tokens)
|
|
159
|
+
cost = int(usage.get("cost_micros") or 0)
|
|
160
|
+
if max_cost_micros is not None and cost >= max_cost_micros:
|
|
161
|
+
raise BudgetExceeded("cost_micros", cost, max_cost_micros)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def usd_to_micros(value: float | int | None) -> int | None:
|
|
165
|
+
if value is None:
|
|
166
|
+
return None
|
|
167
|
+
return int(round(float(value) * 1_000_000))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def raise_exhausted(
|
|
171
|
+
tried: Sequence[str], skipped: Sequence[str], last: BaseException | None
|
|
172
|
+
) -> NoReturn:
|
|
173
|
+
if skipped and not tried:
|
|
174
|
+
raise CircuitOpen(skipped[0])
|
|
175
|
+
if last is not None:
|
|
176
|
+
raise last
|
|
177
|
+
if skipped:
|
|
178
|
+
raise CircuitOpen(skipped[0])
|
|
179
|
+
raise LLMError("No LLM model was available")
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Pure mapping between vendor tool-call payloads and engine ToolCall objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from readyagents.llm.base import Message, ToolCall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_json_arguments(raw: Any) -> dict[str, Any]:
|
|
13
|
+
if raw is None:
|
|
14
|
+
return {}
|
|
15
|
+
if isinstance(raw, dict):
|
|
16
|
+
return dict(raw)
|
|
17
|
+
if isinstance(raw, str):
|
|
18
|
+
stripped = raw.strip()
|
|
19
|
+
if not stripped:
|
|
20
|
+
return {}
|
|
21
|
+
try:
|
|
22
|
+
parsed = json.loads(stripped)
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
return {"_raw": raw}
|
|
25
|
+
if isinstance(parsed, dict):
|
|
26
|
+
return parsed
|
|
27
|
+
return {"value": parsed}
|
|
28
|
+
return {"value": raw}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def spec_from_tool(tool: Any) -> dict[str, Any]:
|
|
32
|
+
raw = getattr(tool, "schema", None)
|
|
33
|
+
if isinstance(raw, dict) and raw:
|
|
34
|
+
schema = dict(raw)
|
|
35
|
+
else:
|
|
36
|
+
schema = {"type": "object", "properties": {}}
|
|
37
|
+
return {
|
|
38
|
+
"name": str(getattr(tool, "name", "")),
|
|
39
|
+
"description": str(getattr(tool, "description", "") or ""),
|
|
40
|
+
"schema": schema,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def openai_tools_payload(tools: Sequence[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
|
45
|
+
if not tools:
|
|
46
|
+
return None
|
|
47
|
+
out: list[dict[str, Any]] = []
|
|
48
|
+
for spec in tools:
|
|
49
|
+
if spec.get("type") == "function" and isinstance(spec.get("function"), dict):
|
|
50
|
+
out.append(dict(spec))
|
|
51
|
+
continue
|
|
52
|
+
name = spec.get("name")
|
|
53
|
+
if not name:
|
|
54
|
+
fn = spec.get("function") if isinstance(spec.get("function"), dict) else {}
|
|
55
|
+
name = fn.get("name")
|
|
56
|
+
if not name:
|
|
57
|
+
continue
|
|
58
|
+
out.append(
|
|
59
|
+
{
|
|
60
|
+
"type": "function",
|
|
61
|
+
"function": {
|
|
62
|
+
"name": name,
|
|
63
|
+
"description": fn.get("description") or spec.get("description") or "",
|
|
64
|
+
"parameters": fn.get("parameters")
|
|
65
|
+
or spec.get("schema")
|
|
66
|
+
or {"type": "object", "properties": {}},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
schema = (
|
|
72
|
+
spec.get("schema") or spec.get("parameters") or {"type": "object", "properties": {}}
|
|
73
|
+
)
|
|
74
|
+
out.append(
|
|
75
|
+
{
|
|
76
|
+
"type": "function",
|
|
77
|
+
"function": {
|
|
78
|
+
"name": name,
|
|
79
|
+
"description": spec.get("description") or "",
|
|
80
|
+
"parameters": schema,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return out or None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def anthropic_tools_payload(tools: Sequence[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
|
88
|
+
if not tools:
|
|
89
|
+
return None
|
|
90
|
+
out: list[dict[str, Any]] = []
|
|
91
|
+
for spec in tools:
|
|
92
|
+
if spec.get("input_schema") and spec.get("name") and spec.get("type") != "function":
|
|
93
|
+
out.append(
|
|
94
|
+
{
|
|
95
|
+
"name": spec["name"],
|
|
96
|
+
"description": spec.get("description") or "",
|
|
97
|
+
"input_schema": spec["input_schema"],
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
if spec.get("type") == "function":
|
|
102
|
+
fn = spec.get("function") if isinstance(spec.get("function"), dict) else {}
|
|
103
|
+
name = fn.get("name")
|
|
104
|
+
if not name:
|
|
105
|
+
continue
|
|
106
|
+
out.append(
|
|
107
|
+
{
|
|
108
|
+
"name": name,
|
|
109
|
+
"description": fn.get("description") or spec.get("description") or "",
|
|
110
|
+
"input_schema": fn.get("parameters")
|
|
111
|
+
or spec.get("schema")
|
|
112
|
+
or {"type": "object", "properties": {}},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
name = spec.get("name")
|
|
117
|
+
if not name:
|
|
118
|
+
continue
|
|
119
|
+
out.append(
|
|
120
|
+
{
|
|
121
|
+
"name": name,
|
|
122
|
+
"description": spec.get("description") or "",
|
|
123
|
+
"input_schema": spec.get("schema")
|
|
124
|
+
or spec.get("parameters")
|
|
125
|
+
or {"type": "object", "properties": {}},
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
return out or None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tool_calls_from_openai_message(message: Any) -> list[ToolCall]:
|
|
132
|
+
raw_calls = getattr(message, "tool_calls", None)
|
|
133
|
+
if raw_calls is None and isinstance(message, dict):
|
|
134
|
+
raw_calls = message.get("tool_calls")
|
|
135
|
+
if not raw_calls:
|
|
136
|
+
return []
|
|
137
|
+
out: list[ToolCall] = []
|
|
138
|
+
for index, call in enumerate(raw_calls):
|
|
139
|
+
if isinstance(call, dict):
|
|
140
|
+
cid = str(call.get("id") or f"call_{index}")
|
|
141
|
+
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
142
|
+
name = str(fn.get("name") or call.get("name") or "")
|
|
143
|
+
args = parse_json_arguments(fn.get("arguments") if fn else call.get("arguments"))
|
|
144
|
+
else:
|
|
145
|
+
cid = str(getattr(call, "id", None) or f"call_{index}")
|
|
146
|
+
fn = getattr(call, "function", None)
|
|
147
|
+
name = str(getattr(fn, "name", None) or getattr(call, "name", "") or "")
|
|
148
|
+
args_raw = (
|
|
149
|
+
getattr(fn, "arguments", None)
|
|
150
|
+
if fn is not None
|
|
151
|
+
else getattr(call, "arguments", None)
|
|
152
|
+
)
|
|
153
|
+
args = parse_json_arguments(args_raw)
|
|
154
|
+
if name:
|
|
155
|
+
out.append(ToolCall(id=cid, name=name, arguments=args))
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def tool_calls_from_anthropic_content(blocks: Any) -> list[ToolCall]:
|
|
160
|
+
if not blocks:
|
|
161
|
+
return []
|
|
162
|
+
out: list[ToolCall] = []
|
|
163
|
+
for index, block in enumerate(blocks):
|
|
164
|
+
if isinstance(block, dict):
|
|
165
|
+
btype = block.get("type")
|
|
166
|
+
name = block.get("name")
|
|
167
|
+
cid = block.get("id")
|
|
168
|
+
inp = block.get("input")
|
|
169
|
+
else:
|
|
170
|
+
btype = getattr(block, "type", None)
|
|
171
|
+
name = getattr(block, "name", None)
|
|
172
|
+
cid = getattr(block, "id", None)
|
|
173
|
+
inp = getattr(block, "input", None)
|
|
174
|
+
if str(btype) != "tool_use" or not name:
|
|
175
|
+
continue
|
|
176
|
+
out.append(
|
|
177
|
+
ToolCall(
|
|
178
|
+
id=str(cid or f"call_{index}"),
|
|
179
|
+
name=str(name),
|
|
180
|
+
arguments=parse_json_arguments(inp),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def messages_to_openai(messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
187
|
+
payload: list[dict[str, Any]] = []
|
|
188
|
+
for message in messages:
|
|
189
|
+
if message.role == "tool":
|
|
190
|
+
row: dict[str, Any] = {
|
|
191
|
+
"role": "tool",
|
|
192
|
+
"content": message.content or "",
|
|
193
|
+
"tool_call_id": message.tool_call_id or "",
|
|
194
|
+
}
|
|
195
|
+
if message.name:
|
|
196
|
+
row["name"] = message.name
|
|
197
|
+
payload.append(row)
|
|
198
|
+
continue
|
|
199
|
+
row = {"role": message.role, "content": message.content}
|
|
200
|
+
if message.tool_calls:
|
|
201
|
+
row["tool_calls"] = [
|
|
202
|
+
{
|
|
203
|
+
"id": call.id,
|
|
204
|
+
"type": "function",
|
|
205
|
+
"function": {
|
|
206
|
+
"name": call.name,
|
|
207
|
+
"arguments": json.dumps(call.arguments, ensure_ascii=False),
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
for call in message.tool_calls
|
|
211
|
+
]
|
|
212
|
+
payload.append(row)
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def messages_to_anthropic(messages: Sequence[Message]) -> tuple[str, list[dict[str, Any]]]:
|
|
217
|
+
system_parts: list[str] = []
|
|
218
|
+
chat: list[dict[str, Any]] = []
|
|
219
|
+
pending: list[dict[str, Any]] = []
|
|
220
|
+
|
|
221
|
+
def flush_tool_results() -> None:
|
|
222
|
+
if pending:
|
|
223
|
+
chat.append({"role": "user", "content": list(pending)})
|
|
224
|
+
pending.clear()
|
|
225
|
+
|
|
226
|
+
for message in messages:
|
|
227
|
+
if message.role == "system":
|
|
228
|
+
if message.content:
|
|
229
|
+
system_parts.append(message.content)
|
|
230
|
+
continue
|
|
231
|
+
if message.role == "tool":
|
|
232
|
+
pending.append(
|
|
233
|
+
{
|
|
234
|
+
"type": "tool_result",
|
|
235
|
+
"tool_use_id": message.tool_call_id or "",
|
|
236
|
+
"content": message.content or "",
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
flush_tool_results()
|
|
241
|
+
if message.role == "assistant" and message.tool_calls:
|
|
242
|
+
blocks: list[dict[str, Any]] = []
|
|
243
|
+
if message.content:
|
|
244
|
+
blocks.append({"type": "text", "text": message.content})
|
|
245
|
+
for call in message.tool_calls:
|
|
246
|
+
blocks.append(
|
|
247
|
+
{
|
|
248
|
+
"type": "tool_use",
|
|
249
|
+
"id": call.id,
|
|
250
|
+
"name": call.name,
|
|
251
|
+
"input": call.arguments,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
chat.append({"role": "assistant", "content": blocks})
|
|
255
|
+
else:
|
|
256
|
+
role = "assistant" if message.role == "assistant" else "user"
|
|
257
|
+
chat.append({"role": role, "content": message.content})
|
|
258
|
+
flush_tool_results()
|
|
259
|
+
return "\n".join(system_parts).strip(), chat
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def tool_calls_to_json(calls: Sequence[ToolCall] | None) -> list[dict[str, Any]]:
|
|
263
|
+
return [
|
|
264
|
+
{"id": call.id, "name": call.name, "arguments": dict(call.arguments)}
|
|
265
|
+
for call in (calls or [])
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def tool_calls_from_json(raw: Any) -> list[ToolCall]:
|
|
270
|
+
if not isinstance(raw, list):
|
|
271
|
+
return []
|
|
272
|
+
out: list[ToolCall] = []
|
|
273
|
+
for index, item in enumerate(raw):
|
|
274
|
+
if not isinstance(item, dict):
|
|
275
|
+
continue
|
|
276
|
+
name = str(item.get("name") or "")
|
|
277
|
+
if not name:
|
|
278
|
+
continue
|
|
279
|
+
out.append(
|
|
280
|
+
ToolCall(
|
|
281
|
+
id=str(item.get("id") or f"call_{index}"),
|
|
282
|
+
name=name,
|
|
283
|
+
arguments=parse_json_arguments(item.get("arguments")),
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return out
|