framework-agents 0.1.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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- framework_agents-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Model layer. Parity: src/models/index.ts.
|
|
2
|
+
|
|
3
|
+
Pinned semantics:
|
|
4
|
+
- CompleteOptions.tools carries `parameters_schema` (JSON Schema) — models
|
|
5
|
+
must see real argument shapes; omit only for zero-arg tools.
|
|
6
|
+
- ModelCompletion.finish_reason is exposed when the provider reports it
|
|
7
|
+
(truncation observable).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import itertools
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from ..types import ChatMessage, TokenUsage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ToolCallRequest:
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
args: Any = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ModelCompletion:
|
|
28
|
+
text: str = ""
|
|
29
|
+
thinking: str | None = None
|
|
30
|
+
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
|
31
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
32
|
+
finish_reason: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ToolWire:
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
parameters_schema: dict[str, Any] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ResponseFormatWire:
|
|
44
|
+
name: str
|
|
45
|
+
schema: dict[str, Any]
|
|
46
|
+
strict: bool | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CompleteOptions:
|
|
51
|
+
messages: list[ChatMessage]
|
|
52
|
+
tools: list[ToolWire] = field(default_factory=list)
|
|
53
|
+
response_format: ResponseFormatWire | None = None
|
|
54
|
+
on_token: Callable[[str], None] | None = None
|
|
55
|
+
on_thinking: Callable[[str], None] | None = None
|
|
56
|
+
signal: Any | None = None # httpx-compatible abort (client-side done in provider)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class ModelProvider(Protocol):
|
|
61
|
+
id: str
|
|
62
|
+
capabilities: dict[str, bool]
|
|
63
|
+
|
|
64
|
+
async def complete(self, options: CompleteOptions) -> ModelCompletion: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MockModelProvider:
|
|
68
|
+
"""Deterministic mock for tests/examples — no network."""
|
|
69
|
+
|
|
70
|
+
id: str = "mock"
|
|
71
|
+
capabilities = {"structured_output": False, "streaming": False}
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._calls = 0
|
|
75
|
+
self._seq = itertools.count(1)
|
|
76
|
+
|
|
77
|
+
async def complete(self, options: CompleteOptions) -> ModelCompletion:
|
|
78
|
+
self._calls += 1
|
|
79
|
+
text = f"mock-response-{next(self._seq)}"
|
|
80
|
+
options.on_token and options.on_token(text)
|
|
81
|
+
return ModelCompletion(
|
|
82
|
+
text=text,
|
|
83
|
+
tool_calls=[],
|
|
84
|
+
usage=TokenUsage(input_tokens=10, output_tokens=5),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ScriptedModelProvider:
|
|
89
|
+
"""Test stub: returns canned completions in order (last one repeats)."""
|
|
90
|
+
|
|
91
|
+
id: str = "scripted"
|
|
92
|
+
capabilities = {"structured_output": False, "streaming": False}
|
|
93
|
+
|
|
94
|
+
def __init__(self, completions: list[ModelCompletion]) -> None:
|
|
95
|
+
self._completions = completions
|
|
96
|
+
self._n = 0
|
|
97
|
+
|
|
98
|
+
async def complete(self, options: CompleteOptions) -> ModelCompletion:
|
|
99
|
+
self._n += 1
|
|
100
|
+
return self._completions[min(self._n - 1, len(self._completions) - 1)]
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Native Anthropic Messages API provider (httpx-based, no SDK).
|
|
2
|
+
|
|
3
|
+
Parity: packages/typescript/src/models/anthropic.ts (post-sweep semantics):
|
|
4
|
+
|
|
5
|
+
- system messages extracted into the top-level `system` field
|
|
6
|
+
- ALL tool results from one assistant tool_use turn arrive in a SINGLE user
|
|
7
|
+
message (grouping consecutive tool results — parallel calls stay well-formed)
|
|
8
|
+
- reasoning blocks are DROPPED when re-sending history (wire_text_of)
|
|
9
|
+
- constructor FAILS LOUDLY when max_tokens <= thinking_budget (API 400 otherwise)
|
|
10
|
+
- streaming: the `event:` state CARRIES across chunk boundaries (a data line
|
|
11
|
+
landing in a different chunk than its event line must not parse as "") —
|
|
12
|
+
the TS regression fixed live — and usage arrives via message_start /
|
|
13
|
+
message_delta (cumulative output_tokens: assign, never add)
|
|
14
|
+
- stop_reason normalized (max_tokens -> length); a max_tokens stop with ZERO
|
|
15
|
+
usable content raises a loud MODEL_ERROR, and partial content keeps going
|
|
16
|
+
- tool schemas ride `input_schema`; signals accepted for abort.
|
|
17
|
+
|
|
18
|
+
No personal/brand names in code beyond the wire protocol constants the API
|
|
19
|
+
itself requires (header names, paths).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import itertools
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from ..errors import AgentError
|
|
32
|
+
from ..types import ChatMessage, TokenUsage, has_images, wire_text_of
|
|
33
|
+
from . import CompleteOptions, ModelCompletion, ModelProvider, ToolCallRequest
|
|
34
|
+
from .capabilities import assert_can_handle
|
|
35
|
+
|
|
36
|
+
_CALL_SEQ = itertools.count(1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AnthropicOptions:
|
|
41
|
+
api_key: str
|
|
42
|
+
model: str
|
|
43
|
+
base_url: str | None = None
|
|
44
|
+
max_tokens: int = 1024
|
|
45
|
+
temperature: float | None = None
|
|
46
|
+
thinking_budget: int | None = None
|
|
47
|
+
stream: bool = False
|
|
48
|
+
fetch_fn: Callable[[], Any] | None = None # httpx client factory for tests
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AnthropicProvider:
|
|
52
|
+
id: str
|
|
53
|
+
capabilities = {"structured_output": False, "streaming": True}
|
|
54
|
+
|
|
55
|
+
def __init__(self, opts: AnthropicOptions) -> None:
|
|
56
|
+
if not opts.api_key:
|
|
57
|
+
raise ValueError("AnthropicProvider: apiKey is required (.env ANTHROPIC_API_KEY)")
|
|
58
|
+
self.opts = AnthropicOptions(
|
|
59
|
+
base_url=opts.base_url or "https://api.anthropic.com",
|
|
60
|
+
api_key=opts.api_key,
|
|
61
|
+
model=opts.model,
|
|
62
|
+
max_tokens=opts.max_tokens,
|
|
63
|
+
temperature=opts.temperature,
|
|
64
|
+
thinking_budget=opts.thinking_budget,
|
|
65
|
+
stream=opts.stream,
|
|
66
|
+
fetch_fn=opts.fetch_fn,
|
|
67
|
+
)
|
|
68
|
+
self.id = f"anthropic:{opts.model}"
|
|
69
|
+
# Extended thinking requires max_tokens > budget_tokens — fail at
|
|
70
|
+
# construction with the fix, not as a confusing API 400 on first call.
|
|
71
|
+
if self.opts.thinking_budget is not None and self.opts.max_tokens <= self.opts.thinking_budget:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"AnthropicProvider: max_tokens ({self.opts.max_tokens}) must exceed "
|
|
74
|
+
f"thinking_budget ({self.opts.thinking_budget}); raise max_tokens"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _content(self, message: ChatMessage) -> Any:
|
|
78
|
+
if isinstance(message.content, str):
|
|
79
|
+
return message.content
|
|
80
|
+
parts: list[dict[str, Any]] = []
|
|
81
|
+
text = wire_text_of(message.content)
|
|
82
|
+
if text:
|
|
83
|
+
parts.append({"type": "text", "text": text})
|
|
84
|
+
for block in message.blocks():
|
|
85
|
+
kind = block.model_dump()
|
|
86
|
+
if kind["type"] == "image":
|
|
87
|
+
data = kind.get("data")
|
|
88
|
+
if data:
|
|
89
|
+
parts.append({"type": "image", "source": {
|
|
90
|
+
"type": "base64",
|
|
91
|
+
"media_type": kind.get("mimeType") or "image/jpeg",
|
|
92
|
+
"data": data,
|
|
93
|
+
}})
|
|
94
|
+
elif kind.get("url"):
|
|
95
|
+
parts.append({"type": "image", "source": {"type": "url", "url": kind["url"]}})
|
|
96
|
+
elif kind["type"] == "tool_call":
|
|
97
|
+
parts.append({"type": "tool_use", "id": kind["id"], "name": kind["name"], "input": kind.get("args") or {}})
|
|
98
|
+
return parts
|
|
99
|
+
|
|
100
|
+
def _messages(self, all_messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
|
101
|
+
out: list[dict[str, Any]] = []
|
|
102
|
+
for message in all_messages:
|
|
103
|
+
if message.role == "system":
|
|
104
|
+
continue
|
|
105
|
+
if message.role == "tool":
|
|
106
|
+
# ALL tool_results for one tool_use turn belong in a SINGLE
|
|
107
|
+
# user message; group consecutive tool results.
|
|
108
|
+
result = {
|
|
109
|
+
"type": "tool_result",
|
|
110
|
+
"tool_use_id": message.tool_call_id,
|
|
111
|
+
"content": self._content(message),
|
|
112
|
+
}
|
|
113
|
+
if out and out[-1]["role"] == "user" and isinstance(out[-1]["content"], list) \
|
|
114
|
+
and any(b.get("type") == "tool_result" for b in out[-1]["content"]):
|
|
115
|
+
out[-1]["content"].append(result)
|
|
116
|
+
continue
|
|
117
|
+
out.append({"role": "user", "content": [result]})
|
|
118
|
+
continue
|
|
119
|
+
out.append({
|
|
120
|
+
"role": "assistant" if message.role == "assistant" else "user",
|
|
121
|
+
"content": self._content(message),
|
|
122
|
+
})
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
async def complete(self, options: CompleteOptions) -> ModelCompletion:
|
|
126
|
+
opts = self.opts
|
|
127
|
+
# Images: fail fast with MODEL_CAPABILITY when the model can't see.
|
|
128
|
+
assert_can_handle(opts.model, any(has_images(m.content) for m in options.messages))
|
|
129
|
+
body: dict[str, Any] = {
|
|
130
|
+
"model": opts.model,
|
|
131
|
+
"max_tokens": opts.max_tokens,
|
|
132
|
+
"messages": self._messages(options.messages),
|
|
133
|
+
}
|
|
134
|
+
system = "\n\n".join(
|
|
135
|
+
wire_text_of(m.content) for m in options.messages if m.role == "system"
|
|
136
|
+
)
|
|
137
|
+
if system:
|
|
138
|
+
body["system"] = system
|
|
139
|
+
if opts.temperature is not None:
|
|
140
|
+
body["temperature"] = opts.temperature
|
|
141
|
+
if opts.thinking_budget is not None:
|
|
142
|
+
body["thinking"] = {"type": "enabled", "budget_tokens": opts.thinking_budget}
|
|
143
|
+
if opts.stream:
|
|
144
|
+
body["stream"] = True
|
|
145
|
+
if options.tools:
|
|
146
|
+
body["tools"] = [
|
|
147
|
+
{
|
|
148
|
+
"name": t.name,
|
|
149
|
+
"description": t.description,
|
|
150
|
+
"input_schema": t.parameters_schema or {"type": "object"},
|
|
151
|
+
}
|
|
152
|
+
for t in options.tools
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
headers = {
|
|
156
|
+
"content-type": "application/json",
|
|
157
|
+
"x-api-key": opts.api_key,
|
|
158
|
+
"anthropic-version": "2023-06-01",
|
|
159
|
+
**({"accept": "text/event-stream"} if opts.stream else {}),
|
|
160
|
+
}
|
|
161
|
+
client = opts.fetch_fn() if opts.fetch_fn else httpx.AsyncClient(timeout=120.0)
|
|
162
|
+
try:
|
|
163
|
+
res = await client.request(
|
|
164
|
+
"POST",
|
|
165
|
+
f"{(opts.base_url or 'https://api.anthropic.com').rstrip('/')}/v1/messages",
|
|
166
|
+
headers=headers,
|
|
167
|
+
json=body,
|
|
168
|
+
)
|
|
169
|
+
except Exception as err:
|
|
170
|
+
raise AgentError("MODEL_ERROR", f"Model request failed: {err}", retryable=True) from err
|
|
171
|
+
finally:
|
|
172
|
+
if opts.fetch_fn is None:
|
|
173
|
+
await client.aclose()
|
|
174
|
+
|
|
175
|
+
if res.status_code == 429 or res.status_code >= 500:
|
|
176
|
+
raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {res.text[:300]}", retryable=True)
|
|
177
|
+
if res.status_code >= 400:
|
|
178
|
+
raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {res.text[:300]}", retryable=False)
|
|
179
|
+
|
|
180
|
+
if opts.stream:
|
|
181
|
+
return await self._read_stream(res, options)
|
|
182
|
+
return self._read_json(res, options)
|
|
183
|
+
|
|
184
|
+
def _read_json(self, res: Any, options: CompleteOptions) -> ModelCompletion:
|
|
185
|
+
data = res.json()
|
|
186
|
+
text = ""
|
|
187
|
+
thinking = ""
|
|
188
|
+
tool_calls: list[ToolCallRequest] = []
|
|
189
|
+
for block in data.get("content") or []:
|
|
190
|
+
if block.get("type") == "text" and block.get("text"):
|
|
191
|
+
text += block["text"]
|
|
192
|
+
options.on_token and options.on_token(block["text"])
|
|
193
|
+
elif block.get("type") == "thinking" and block.get("thinking"):
|
|
194
|
+
thinking += block["thinking"]
|
|
195
|
+
options.on_thinking and options.on_thinking(block["thinking"])
|
|
196
|
+
elif block.get("type") == "tool_use" and block.get("name"):
|
|
197
|
+
tool_calls.append(ToolCallRequest(
|
|
198
|
+
id=block.get("id") or f"call_{next(_CALL_SEQ)}",
|
|
199
|
+
name=block["name"],
|
|
200
|
+
args=block.get("input") or {},
|
|
201
|
+
))
|
|
202
|
+
normalized = _normalize_stop(data.get("stop_reason"))
|
|
203
|
+
if not text and not tool_calls and thinking and normalized == "stop":
|
|
204
|
+
# Empty text + non-empty thinking + stop: the reasoning/content
|
|
205
|
+
# split put the WHOLE answer in the thinking channel. Never
|
|
206
|
+
# return an empty answer for a thinking model's short reply.
|
|
207
|
+
raise AgentError(
|
|
208
|
+
"MODEL_ERROR",
|
|
209
|
+
"Model stopped with stop_reason=stop having put the entire answer in "
|
|
210
|
+
"the reasoning channel (empty content, no tool calls, thinking present). "
|
|
211
|
+
"The reasoning/content split lost the reply — retry or raise max_tokens.",
|
|
212
|
+
retryable=True,
|
|
213
|
+
)
|
|
214
|
+
if not text and not tool_calls and normalized == "length":
|
|
215
|
+
raise AgentError(
|
|
216
|
+
"MODEL_ERROR",
|
|
217
|
+
f"Model stopped at max_tokens without producing content. Raise max_tokens.",
|
|
218
|
+
retryable=False,
|
|
219
|
+
)
|
|
220
|
+
usage = data.get("usage") or {}
|
|
221
|
+
return ModelCompletion(
|
|
222
|
+
text=text,
|
|
223
|
+
thinking=thinking or None,
|
|
224
|
+
tool_calls=tool_calls,
|
|
225
|
+
finish_reason=normalized,
|
|
226
|
+
usage=TokenUsage(input_tokens=usage.get("input_tokens", 0), output_tokens=usage.get("output_tokens", 0)),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def _read_stream(self, res: Any, options: CompleteOptions) -> ModelCompletion:
|
|
230
|
+
text = ""
|
|
231
|
+
thinking = ""
|
|
232
|
+
input_tokens = 0
|
|
233
|
+
output_tokens = 0
|
|
234
|
+
stop_reason: str | None = None
|
|
235
|
+
blocks: list[dict[str, Any]] = []
|
|
236
|
+
current: dict[str, Any] | None = None
|
|
237
|
+
json_input = ""
|
|
238
|
+
buffer = ""
|
|
239
|
+
event = ""
|
|
240
|
+
async for chunk in res.aiter_text():
|
|
241
|
+
buffer += chunk
|
|
242
|
+
lines = buffer.split("\n")
|
|
243
|
+
buffer = lines.pop()
|
|
244
|
+
for line in lines:
|
|
245
|
+
stripped = line.strip()
|
|
246
|
+
if not stripped:
|
|
247
|
+
continue
|
|
248
|
+
if stripped.startswith("event:"):
|
|
249
|
+
# CRITICAL: the event name must carry across chunk
|
|
250
|
+
# boundaries — re-initializing per chunk silently drops
|
|
251
|
+
# every delta whose event line landed in a prior packet.
|
|
252
|
+
event = stripped[6:].strip()
|
|
253
|
+
continue
|
|
254
|
+
if not stripped.startswith("data:"):
|
|
255
|
+
continue
|
|
256
|
+
payload = stripped[5:].strip()
|
|
257
|
+
if not payload:
|
|
258
|
+
continue
|
|
259
|
+
try:
|
|
260
|
+
data = json.loads(payload)
|
|
261
|
+
except ValueError:
|
|
262
|
+
continue
|
|
263
|
+
if event == "message_start":
|
|
264
|
+
# message_start carries the input-side usage up front.
|
|
265
|
+
message = data.get("message") or {}
|
|
266
|
+
usage = message.get("usage") or {}
|
|
267
|
+
if usage.get("input_tokens") is not None:
|
|
268
|
+
input_tokens = usage["input_tokens"]
|
|
269
|
+
elif event == "message_delta":
|
|
270
|
+
# message_delta.usage.output_tokens is cumulative — assign.
|
|
271
|
+
usage = data.get("usage") or {}
|
|
272
|
+
if usage.get("output_tokens") is not None:
|
|
273
|
+
output_tokens = usage["output_tokens"]
|
|
274
|
+
delta = data.get("delta") or {}
|
|
275
|
+
if delta.get("stop_reason"):
|
|
276
|
+
stop_reason = _normalize_stop(delta["stop_reason"])
|
|
277
|
+
elif event == "content_block_start":
|
|
278
|
+
block = data.get("content_block") or {}
|
|
279
|
+
current = {"type": block.get("type"), "id": block.get("id"), "name": block.get("name")}
|
|
280
|
+
json_input = ""
|
|
281
|
+
elif event == "content_block_delta":
|
|
282
|
+
delta = data.get("delta") or {}
|
|
283
|
+
if delta.get("text"):
|
|
284
|
+
text += delta["text"]
|
|
285
|
+
current = current or {"type": "text"}
|
|
286
|
+
current["text"] = (current.get("text") or "") + delta["text"]
|
|
287
|
+
options.on_token and options.on_token(delta["text"])
|
|
288
|
+
elif delta.get("thinking"):
|
|
289
|
+
thinking += delta["thinking"]
|
|
290
|
+
current = current or {"type": "thinking"}
|
|
291
|
+
current["thinking"] = (current.get("thinking") or "") + delta["thinking"]
|
|
292
|
+
options.on_thinking and options.on_thinking(delta["thinking"])
|
|
293
|
+
elif delta.get("partial_json"):
|
|
294
|
+
json_input += delta["partial_json"]
|
|
295
|
+
elif event == "content_block_stop":
|
|
296
|
+
if current is not None:
|
|
297
|
+
if current.get("type") == "tool_use":
|
|
298
|
+
try:
|
|
299
|
+
current["input"] = json.loads(json_input or "{}")
|
|
300
|
+
except ValueError:
|
|
301
|
+
current["input"] = {}
|
|
302
|
+
blocks.append(current)
|
|
303
|
+
current = None
|
|
304
|
+
if current is not None:
|
|
305
|
+
blocks.append(current)
|
|
306
|
+
|
|
307
|
+
tool_calls = [
|
|
308
|
+
ToolCallRequest(id=b.get("id") or f"call_{next(_CALL_SEQ)}", name=b["name"], args=b.get("input") or {})
|
|
309
|
+
for b in blocks if b.get("type") == "tool_use" and b.get("name")
|
|
310
|
+
]
|
|
311
|
+
normalized = stop_reason
|
|
312
|
+
if not text and not tool_calls and thinking and normalized == "stop":
|
|
313
|
+
# Stream mirror of the readJson reasoning-split guard (above).
|
|
314
|
+
raise AgentError(
|
|
315
|
+
"MODEL_ERROR",
|
|
316
|
+
"Stream ended at stop_reason=stop having put the entire answer in "
|
|
317
|
+
"the reasoning channel (no content, no tool calls, thinking present). "
|
|
318
|
+
"The reasoning/content split lost the reply — retry or raise max_tokens.",
|
|
319
|
+
retryable=True,
|
|
320
|
+
)
|
|
321
|
+
if not text and not tool_calls and normalized == "length":
|
|
322
|
+
raise AgentError(
|
|
323
|
+
"MODEL_ERROR",
|
|
324
|
+
"Stream stopped at max_tokens without producing content. Raise max_tokens.",
|
|
325
|
+
retryable=False,
|
|
326
|
+
)
|
|
327
|
+
return ModelCompletion(
|
|
328
|
+
text=text,
|
|
329
|
+
thinking=thinking or None,
|
|
330
|
+
tool_calls=tool_calls,
|
|
331
|
+
finish_reason=normalized,
|
|
332
|
+
usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _normalize_stop(stop: str | None) -> str | None:
|
|
337
|
+
if not stop:
|
|
338
|
+
return None
|
|
339
|
+
return "length" if stop == "max_tokens" else stop
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Model capability registry. Parity: src/models/capabilities.ts.
|
|
2
|
+
|
|
3
|
+
Researched defaults live here so users never pick tiers. Full per-model
|
|
4
|
+
research (vision, tools, reasoning, context limits) lands later; until then:
|
|
5
|
+
explicit allowlist + fail-fast error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from ..errors import AgentError
|
|
13
|
+
|
|
14
|
+
# Model ids known to accept image inputs (researched 2026-10; extended as
|
|
15
|
+
# models are verified). Mirrors the TS regex table exactly.
|
|
16
|
+
VISION_CAPABLE_PATTERNS: tuple[str, ...] = (
|
|
17
|
+
r"qwen2.*vl",
|
|
18
|
+
r"qwen.*vl",
|
|
19
|
+
r"llama-3\.2-11b-vision",
|
|
20
|
+
r"llama-3\.2-90b-vision",
|
|
21
|
+
r"llava",
|
|
22
|
+
r"minicpm-v",
|
|
23
|
+
r"gpt-4o",
|
|
24
|
+
r"gpt-4\.1",
|
|
25
|
+
r"claude.*sonnet",
|
|
26
|
+
r"claude.*opus",
|
|
27
|
+
r"gemini",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_COMPILED = tuple(re.compile(pattern, re.IGNORECASE) for pattern in VISION_CAPABLE_PATTERNS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def supports_vision(model_id: str) -> bool:
|
|
34
|
+
return any(pattern.search(model_id) for pattern in _COMPILED)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def assert_can_handle(model_id: str, needs_vision: bool) -> None:
|
|
38
|
+
"""Raise AgentError(MODEL_CAPABILITY) when input needs vision the model lacks."""
|
|
39
|
+
if needs_vision and not supports_vision(model_id):
|
|
40
|
+
raise AgentError(
|
|
41
|
+
"MODEL_CAPABILITY",
|
|
42
|
+
f'Model "{model_id}" cannot take image input. Switch TOGETHER_MODEL to a '
|
|
43
|
+
"vision-capable model (e.g. Qwen/Qwen2.5-VL-72B-Instruct) or drop the images.",
|
|
44
|
+
retryable=False,
|
|
45
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Local-first Ollama provider. Parity: src/models/ollama.ts.
|
|
2
|
+
|
|
3
|
+
Via Ollama's OpenAI-compatible endpoint: no API key, no account, no network
|
|
4
|
+
beyond localhost. Tool support depends on the model (qwen2.5, llama3.1+ work
|
|
5
|
+
well). structured_output stays False -> `auto` format uses the tool strategy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from .openai_compatible import OpenAICompatibleOptions, OpenAICompatibleProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OllamaOptions:
|
|
18
|
+
model: str
|
|
19
|
+
# Defaults to local Ollama. No key needed.
|
|
20
|
+
base_url: Optional[str] = None
|
|
21
|
+
temperature: Optional[float] = None
|
|
22
|
+
max_tokens: Optional[int] = None
|
|
23
|
+
stream: Optional[bool] = None
|
|
24
|
+
fetch_fn: Optional[Callable[[], Any]] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OllamaProvider(OpenAICompatibleProvider):
|
|
28
|
+
id: str
|
|
29
|
+
capabilities = {"structured_output": False, "streaming": True}
|
|
30
|
+
|
|
31
|
+
def __init__(self, opts: OllamaOptions) -> None:
|
|
32
|
+
super().__init__(
|
|
33
|
+
OpenAICompatibleOptions(
|
|
34
|
+
base_url=opts.base_url or "http://localhost:11434/v1",
|
|
35
|
+
api_key="ollama",
|
|
36
|
+
model=opts.model,
|
|
37
|
+
temperature=opts.temperature if opts.temperature is not None else 0.2,
|
|
38
|
+
max_tokens=opts.max_tokens,
|
|
39
|
+
stream=bool(opts.stream),
|
|
40
|
+
fetch_fn=opts.fetch_fn,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
self.id = f"ollama:{opts.model}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ollama(model: str, **kwargs: Any) -> OllamaProvider:
|
|
47
|
+
return OllamaProvider(OllamaOptions(model=model, **kwargs))
|