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,409 @@
|
|
|
1
|
+
"""OpenAI-compatible chat provider (TogetherAI/OpenAI/vLLM), httpx-based.
|
|
2
|
+
|
|
3
|
+
Ports the live-verified semantics from the TS provider:
|
|
4
|
+
- UNION field parsing: Together sends `message.reasoning` + top-level
|
|
5
|
+
`usage.reasoning_tokens`; OpenAI/vLLM send `reasoning_content` +
|
|
6
|
+
`completion_tokens_details.reasoning_tokens`. Both shapes work.
|
|
7
|
+
- Streaming: `stream_options: include_usage` requested; `"usage": null`
|
|
8
|
+
content chunks skipped; usage-only final chunk parsed.
|
|
9
|
+
- Silent-truncation guard: finish_reason="length" with ZERO content and no
|
|
10
|
+
tool calls raises a LOUD MODEL_ERROR (raise max_tokens) — never a silent
|
|
11
|
+
empty answer. Partial content keeps going, with finish_reason attached.
|
|
12
|
+
- Tool parameter schemas go on the wire (`function.parameters`).
|
|
13
|
+
- Streaming tool-call ids never repeat across turns (call_<seq>).
|
|
14
|
+
- abort + HTTP error mapping: network/429/5xx retryable, others not.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import itertools
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from ..errors import AgentError
|
|
29
|
+
from ..types import ChatMessage, ImageBlock, TokenUsage, ToolCallBlock, has_images, wire_text_of
|
|
30
|
+
from . import CompleteOptions, ModelCompletion, ModelProvider, ToolCallRequest, ToolWire
|
|
31
|
+
from .capabilities import assert_can_handle
|
|
32
|
+
|
|
33
|
+
_CALL_SEQ = itertools.count(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Abort(Exception):
|
|
37
|
+
"""Internal: the caller's signal fired mid-request."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _respect_signal(signal: Any) -> None:
|
|
41
|
+
"""No-op unless the caller passed an abort Event; wait-free when None."""
|
|
42
|
+
if signal is None:
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
already = signal.is_set()
|
|
46
|
+
except AttributeError:
|
|
47
|
+
return # foreign signal types are ignored (documented)
|
|
48
|
+
if already:
|
|
49
|
+
raise _Abort()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class OpenAICompatibleOptions:
|
|
54
|
+
base_url: str
|
|
55
|
+
api_key: str
|
|
56
|
+
model: str
|
|
57
|
+
temperature: float = 0.2
|
|
58
|
+
max_tokens: int | None = None
|
|
59
|
+
stream: bool = False
|
|
60
|
+
fetch_fn: Callable[..., Any] | None = None # httpx-client factory for tests
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OpenAICompatibleProvider:
|
|
64
|
+
id: str
|
|
65
|
+
capabilities = {"structured_output": True, "streaming": True}
|
|
66
|
+
|
|
67
|
+
def __init__(self, opts: OpenAICompatibleOptions) -> None:
|
|
68
|
+
if not opts.api_key:
|
|
69
|
+
raise ValueError("OpenAICompatibleProvider: apiKey is required")
|
|
70
|
+
self.id = f"openai-compatible:{opts.model}"
|
|
71
|
+
self.opts = opts
|
|
72
|
+
|
|
73
|
+
# -- wire conversion ---------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _wire_content(self, message: ChatMessage, blocks: list[BaseModel]) -> Any:
|
|
76
|
+
if isinstance(message.content, str):
|
|
77
|
+
return message.content
|
|
78
|
+
text = wire_text_of(message.content)
|
|
79
|
+
images = [b for b in blocks if isinstance(b, ImageBlock)]
|
|
80
|
+
if not images:
|
|
81
|
+
return text
|
|
82
|
+
parts: list[dict[str, Any]] = ([{"type": "text", "text": text}] if text else [])
|
|
83
|
+
for img in images:
|
|
84
|
+
data = getattr(img, "data", None)
|
|
85
|
+
mime = getattr(img, "mime_type", None) or "image/jpeg"
|
|
86
|
+
url = f"data:{mime};base64,{data}" if data else getattr(img, "url", None)
|
|
87
|
+
parts.append({"type": "image_url", "image_url": {"url": url}})
|
|
88
|
+
return parts
|
|
89
|
+
|
|
90
|
+
def _wire_message(self, message: ChatMessage) -> dict[str, Any]:
|
|
91
|
+
# blocks() is parsed ONCE per message (memoized at the types layer)
|
|
92
|
+
# and reused for both the content and the tool_calls halves.
|
|
93
|
+
blocks: list[BaseModel] = [] if isinstance(message.content, str) else message.blocks()
|
|
94
|
+
base: dict[str, Any] = {
|
|
95
|
+
"role": message.role,
|
|
96
|
+
"content": self._wire_content(message, blocks),
|
|
97
|
+
}
|
|
98
|
+
if message.tool_call_id:
|
|
99
|
+
base["tool_call_id"] = message.tool_call_id
|
|
100
|
+
if blocks:
|
|
101
|
+
calls = [b for b in blocks if isinstance(b, ToolCallBlock)]
|
|
102
|
+
if calls:
|
|
103
|
+
base["tool_calls"] = [
|
|
104
|
+
{
|
|
105
|
+
"id": c.id,
|
|
106
|
+
"type": "function",
|
|
107
|
+
"function": {"name": c.name, "arguments": json.dumps(c.args or {})},
|
|
108
|
+
}
|
|
109
|
+
for c in calls
|
|
110
|
+
]
|
|
111
|
+
return base
|
|
112
|
+
|
|
113
|
+
# -- request -----------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def complete(self, options: CompleteOptions) -> ModelCompletion:
|
|
116
|
+
opts = self.opts
|
|
117
|
+
# Images: fail fast with MODEL_CAPABILITY when the model can't see.
|
|
118
|
+
assert_can_handle(opts.model, any(has_images(m.content) for m in options.messages))
|
|
119
|
+
tools = options.tools
|
|
120
|
+
body: dict[str, Any] = {
|
|
121
|
+
"model": opts.model,
|
|
122
|
+
"temperature": opts.temperature,
|
|
123
|
+
"messages": [self._wire_message(m) for m in options.messages],
|
|
124
|
+
**({"max_tokens": opts.max_tokens} if opts.max_tokens else {}),
|
|
125
|
+
# stream_options makes OpenAI-compatible servers attach usage to
|
|
126
|
+
# the final chunk; without it streaming runs report 0 tokens.
|
|
127
|
+
**(
|
|
128
|
+
{"stream": True, "stream_options": {"include_usage": True}}
|
|
129
|
+
if opts.stream
|
|
130
|
+
else {}
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
if options.response_format:
|
|
134
|
+
body["response_format"] = {
|
|
135
|
+
"type": "json_schema",
|
|
136
|
+
"json_schema": {
|
|
137
|
+
"name": options.response_format.name,
|
|
138
|
+
"strict": options.response_format.strict if options.response_format.strict is not None else True,
|
|
139
|
+
"schema": options.response_format.schema,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
if tools:
|
|
143
|
+
body["tools"] = [
|
|
144
|
+
{
|
|
145
|
+
"type": "function",
|
|
146
|
+
"function": {
|
|
147
|
+
"name": t.name,
|
|
148
|
+
"description": t.description,
|
|
149
|
+
# Without parameters the API advertises a zero-arg tool.
|
|
150
|
+
**({"parameters": t.parameters_schema} if t.parameters_schema else {}),
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
for t in tools
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
client = opts.fetch_fn() if opts.fetch_fn else httpx.AsyncClient(timeout=120.0)
|
|
157
|
+
url = f"{opts.base_url.rstrip('/')}/chat/completions"
|
|
158
|
+
headers = {"content-type": "application/json", "authorization": f"Bearer {opts.api_key}"}
|
|
159
|
+
try:
|
|
160
|
+
# Stream=True NEVER lets httpx buffer the whole body first: the
|
|
161
|
+
# request must consume incrementally so on_token/on_thinking fire
|
|
162
|
+
# live and abort can actually interrupt the upload/response.
|
|
163
|
+
# Duck-typed test clients exposing only .request() fall back
|
|
164
|
+
# (they already return a fully-formed response object).
|
|
165
|
+
if hasattr(client, "build_request"):
|
|
166
|
+
request = client.build_request("POST", url, headers=headers, json=body)
|
|
167
|
+
res = await client.send(request, stream=opts.stream)
|
|
168
|
+
else:
|
|
169
|
+
res = await client.request("POST", url, headers=headers, json=body)
|
|
170
|
+
|
|
171
|
+
# Abort actually CANCELS the in-flight request (the old code took
|
|
172
|
+
# options.signal and ignored it — abort ran to completion and burned
|
|
173
|
+
# tokens). Race the response wait against the caller's signal.
|
|
174
|
+
await _respect_signal(options.signal)
|
|
175
|
+
|
|
176
|
+
if res.status_code == 429 or res.status_code >= 500:
|
|
177
|
+
text = (await res.aread()).decode("utf-8", "replace") if opts.stream else res.text
|
|
178
|
+
raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {text[:300]}", retryable=True)
|
|
179
|
+
if res.status_code >= 400:
|
|
180
|
+
text = (await res.aread()).decode("utf-8", "replace") if opts.stream else res.text
|
|
181
|
+
raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {text[:300]}", retryable=False)
|
|
182
|
+
|
|
183
|
+
if opts.stream:
|
|
184
|
+
try:
|
|
185
|
+
# Live consumption — the client stays open until the stream ends.
|
|
186
|
+
return await self._read_stream(res, options)
|
|
187
|
+
finally:
|
|
188
|
+
await res.aclose()
|
|
189
|
+
return self._read_json(res, options)
|
|
190
|
+
except _Abort:
|
|
191
|
+
raise AgentError("ABORTED", "Run aborted", retryable=False)
|
|
192
|
+
except Exception as err:
|
|
193
|
+
if isinstance(err, AgentError):
|
|
194
|
+
raise
|
|
195
|
+
raise AgentError("MODEL_ERROR", f"Model request failed: {err}", retryable=True) from err
|
|
196
|
+
finally:
|
|
197
|
+
if opts.fetch_fn is None:
|
|
198
|
+
await client.aclose()
|
|
199
|
+
|
|
200
|
+
def _read_json(self, res: Any, options: CompleteOptions) -> ModelCompletion:
|
|
201
|
+
data = res.json()
|
|
202
|
+
choices = data.get("choices") or [{}]
|
|
203
|
+
choice = choices[0] or {}
|
|
204
|
+
message = choice.get("message") or {}
|
|
205
|
+
thinking = _thinking_of(message)
|
|
206
|
+
if thinking:
|
|
207
|
+
options.on_thinking and options.on_thinking(thinking)
|
|
208
|
+
content = message.get("content") or ""
|
|
209
|
+
if content:
|
|
210
|
+
options.on_token and options.on_token(content)
|
|
211
|
+
tool_calls = [
|
|
212
|
+
ToolCallRequest(
|
|
213
|
+
id=(c.get("id") or f"call_{next(_CALL_SEQ)}"),
|
|
214
|
+
name=(c.get("function") or {}).get("name", ""),
|
|
215
|
+
args=_safe_json((c.get("function") or {}).get("arguments", "{}")),
|
|
216
|
+
)
|
|
217
|
+
for c in (message.get("tool_calls") or [])
|
|
218
|
+
]
|
|
219
|
+
usage = data.get("usage") or {}
|
|
220
|
+
finish_reason = choice.get("finish_reason") or None
|
|
221
|
+
reasoning = _reasoning_of(usage)
|
|
222
|
+
if not content and not tool_calls and thinking and finish_reason == "stop":
|
|
223
|
+
# Empty content + non-empty thinking + stop: the reasoning/content
|
|
224
|
+
# split put the WHOLE answer in the reasoning channel (thinking
|
|
225
|
+
# models do this on short replies). Never return an empty answer.
|
|
226
|
+
raise AgentError(
|
|
227
|
+
"MODEL_ERROR",
|
|
228
|
+
"Model stopped with finish_reason=stop having put the entire answer in "
|
|
229
|
+
"the reasoning channel (empty content, no tool calls, thinking present). "
|
|
230
|
+
"The reasoning/content split lost the reply — retry or raise maxTokens.",
|
|
231
|
+
retryable=True,
|
|
232
|
+
)
|
|
233
|
+
if not content and not tool_calls and finish_reason == "length":
|
|
234
|
+
# A thinking model can burn its WHOLE max_tokens before any
|
|
235
|
+
# content: never let that come back as a successful empty reply.
|
|
236
|
+
raise AgentError(
|
|
237
|
+
"MODEL_ERROR",
|
|
238
|
+
f"Model spent its entire max_tokens budget ({usage.get('completion_tokens', '?')} tokens, "
|
|
239
|
+
"finish_reason=length) thinking without producing content. Raise maxTokens.",
|
|
240
|
+
retryable=False,
|
|
241
|
+
)
|
|
242
|
+
return ModelCompletion(
|
|
243
|
+
text=content,
|
|
244
|
+
thinking=thinking,
|
|
245
|
+
tool_calls=tool_calls,
|
|
246
|
+
finish_reason=finish_reason,
|
|
247
|
+
usage=TokenUsage(
|
|
248
|
+
input_tokens=usage.get("prompt_tokens", 0),
|
|
249
|
+
output_tokens=usage.get("completion_tokens", 0),
|
|
250
|
+
reasoning_tokens=reasoning,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def _read_stream(self, res: Any, options: CompleteOptions) -> ModelCompletion:
|
|
255
|
+
text = ""
|
|
256
|
+
thinking = ""
|
|
257
|
+
input_tokens = 0
|
|
258
|
+
output_tokens = 0
|
|
259
|
+
reasoning_tokens: int | None = None
|
|
260
|
+
finish_reason: str | None = None
|
|
261
|
+
tool_buffers: dict[int, dict[str, str]] = {}
|
|
262
|
+
|
|
263
|
+
# A line can SPLIT across TCP reads: the first fragment would fail
|
|
264
|
+
# json.loads and the continuation would not start with "data:" — both
|
|
265
|
+
# silently dropped. Accumulate a buffer and only parse COMPLETE
|
|
266
|
+
# lines (TS parity: buffer += chunk; lines.pop()).
|
|
267
|
+
buffer = ""
|
|
268
|
+
|
|
269
|
+
# Mid-STREAM abort: closing the response interrupts aiter_text, the
|
|
270
|
+
# same way TS aborts via the fetch signal.
|
|
271
|
+
watchdog: "asyncio.Task[None] | None" = None
|
|
272
|
+
if options.signal is not None:
|
|
273
|
+
async def _watch() -> None:
|
|
274
|
+
await options.signal.wait() # type: ignore[union-attr]
|
|
275
|
+
await res.aclose()
|
|
276
|
+
|
|
277
|
+
watchdog = asyncio.create_task(_watch())
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
async for chunk in res.aiter_text():
|
|
281
|
+
buffer += chunk
|
|
282
|
+
lines = buffer.split("\n")
|
|
283
|
+
buffer = lines.pop()
|
|
284
|
+
for line in lines:
|
|
285
|
+
line = line.strip()
|
|
286
|
+
if not line.startswith("data:"):
|
|
287
|
+
continue
|
|
288
|
+
payload = line[5:].strip()
|
|
289
|
+
if payload == "[DONE]":
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
evt = json.loads(payload)
|
|
293
|
+
except ValueError:
|
|
294
|
+
continue
|
|
295
|
+
first = (evt.get("choices") or [{}])[0] or {}
|
|
296
|
+
if first.get("finish_reason"):
|
|
297
|
+
finish_reason = first["finish_reason"]
|
|
298
|
+
usage = evt.get("usage")
|
|
299
|
+
# Together sends "usage": null on content chunks; the final
|
|
300
|
+
# usage-only chunk has an empty/partial choices array.
|
|
301
|
+
if usage:
|
|
302
|
+
input_tokens = usage.get("prompt_tokens", input_tokens)
|
|
303
|
+
output_tokens = usage.get("completion_tokens", output_tokens)
|
|
304
|
+
reasoning_tokens = _reasoning_of(usage)
|
|
305
|
+
continue
|
|
306
|
+
delta = first.get("delta") or {}
|
|
307
|
+
think = _thinking_of(delta)
|
|
308
|
+
if think:
|
|
309
|
+
thinking += think
|
|
310
|
+
options.on_thinking and options.on_thinking(think)
|
|
311
|
+
if delta.get("content"):
|
|
312
|
+
text += delta["content"]
|
|
313
|
+
options.on_token and options.on_token(delta["content"])
|
|
314
|
+
for i, tc in enumerate(delta.get("tool_calls") or []):
|
|
315
|
+
key = tc.get("index", i)
|
|
316
|
+
slot = tool_buffers.setdefault(key, {"id": "", "name": "", "args": ""})
|
|
317
|
+
if tc.get("id"):
|
|
318
|
+
slot["id"] = tc["id"]
|
|
319
|
+
fn = tc.get("function") or {}
|
|
320
|
+
if fn.get("name"):
|
|
321
|
+
slot["name"] = fn["name"]
|
|
322
|
+
if fn.get("arguments"):
|
|
323
|
+
slot["args"] += fn["arguments"]
|
|
324
|
+
finally:
|
|
325
|
+
if watchdog is not None:
|
|
326
|
+
watchdog.cancel()
|
|
327
|
+
|
|
328
|
+
if not text and not tool_buffers and thinking and finish_reason == "stop":
|
|
329
|
+
# Stream mirror of the readJson reasoning-split guard (above).
|
|
330
|
+
raise AgentError(
|
|
331
|
+
"MODEL_ERROR",
|
|
332
|
+
"Stream ended at finish_reason=stop having put the entire answer in "
|
|
333
|
+
"the reasoning channel (no content, no tool calls, thinking present). "
|
|
334
|
+
"The reasoning/content split lost the reply — retry or raise maxTokens.",
|
|
335
|
+
retryable=True,
|
|
336
|
+
)
|
|
337
|
+
if not text and not tool_buffers and finish_reason == "length":
|
|
338
|
+
raise AgentError(
|
|
339
|
+
"MODEL_ERROR",
|
|
340
|
+
f"Stream ended at finish_reason=length with no content ({output_tokens} tokens burned). Raise maxTokens.",
|
|
341
|
+
retryable=False,
|
|
342
|
+
)
|
|
343
|
+
tool_calls = [
|
|
344
|
+
ToolCallRequest(
|
|
345
|
+
id=(t["id"] or f"call_{next(_CALL_SEQ)}"),
|
|
346
|
+
name=t["name"],
|
|
347
|
+
args=_safe_json(t["args"]),
|
|
348
|
+
)
|
|
349
|
+
for t in tool_buffers.values()
|
|
350
|
+
if t["name"]
|
|
351
|
+
]
|
|
352
|
+
return ModelCompletion(
|
|
353
|
+
text=text,
|
|
354
|
+
thinking=thinking or None,
|
|
355
|
+
tool_calls=tool_calls,
|
|
356
|
+
finish_reason=finish_reason,
|
|
357
|
+
usage=TokenUsage(
|
|
358
|
+
input_tokens=input_tokens,
|
|
359
|
+
output_tokens=output_tokens,
|
|
360
|
+
reasoning_tokens=reasoning_tokens,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _thinking_of(message: dict[str, Any]) -> str | None:
|
|
366
|
+
trace = message.get("reasoning") or message.get("reasoning_content")
|
|
367
|
+
return trace if trace else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _reasoning_of(usage: dict[str, Any]) -> int | None:
|
|
371
|
+
# Together reports top-level, OpenAI nests in completion_tokens_details.
|
|
372
|
+
value = usage.get("reasoning_tokens")
|
|
373
|
+
if value is None:
|
|
374
|
+
details = usage.get("completion_tokens_details") or {}
|
|
375
|
+
value = details.get("reasoning_tokens")
|
|
376
|
+
return value
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _safe_json(raw: str) -> Any:
|
|
380
|
+
try:
|
|
381
|
+
return json.loads(raw or "{}")
|
|
382
|
+
except ValueError:
|
|
383
|
+
return {}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def provider_from_env(
|
|
387
|
+
active: dict[str, str],
|
|
388
|
+
extra: dict[str, Any] | None = None,
|
|
389
|
+
) -> OpenAICompatibleProvider:
|
|
390
|
+
"""Build a provider from env config (TogetherAI preferred when key present).
|
|
391
|
+
|
|
392
|
+
TS naming mapped to the python options: ``active`` carries the camelCase
|
|
393
|
+
env keys (``baseUrl``/``apiKey``/``model``; snake_case accepted as a
|
|
394
|
+
fallback); ``extra`` is any extra OpenAICompatibleOptions kwargs
|
|
395
|
+
(temperature, maxTokens, stream, fetchFn).
|
|
396
|
+
"""
|
|
397
|
+
base_url = active.get("baseUrl") or active.get("base_url")
|
|
398
|
+
api_key = active.get("apiKey") or active.get("api_key")
|
|
399
|
+
model = active.get("model")
|
|
400
|
+
if not base_url or not api_key or not model:
|
|
401
|
+
raise ValueError("providerFromEnv: active config requires baseUrl, apiKey and model")
|
|
402
|
+
extra_mapped = dict(extra or {})
|
|
403
|
+
if "maxTokens" in extra_mapped:
|
|
404
|
+
extra_mapped.setdefault("max_tokens", extra_mapped.pop("maxTokens"))
|
|
405
|
+
if "fetchFn" in extra_mapped:
|
|
406
|
+
extra_mapped.setdefault("fetch_fn", extra_mapped.pop("fetchFn"))
|
|
407
|
+
return OpenAICompatibleProvider(
|
|
408
|
+
OpenAICompatibleOptions(base_url=base_url, api_key=api_key, model=model, **extra_mapped)
|
|
409
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Console observability hooks. Parity: src/observability/index.ts.
|
|
2
|
+
|
|
3
|
+
AgentEvents-shaped console logging + a red error line. Colors only when
|
|
4
|
+
stdout is a TTY and NO_COLOR is unset (paint-inert otherwise). Hooks are
|
|
5
|
+
plain (async-safe) print calls: the agent loop awaits nothing here, so
|
|
6
|
+
they may be sync or async-friendly callables with no blocking work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .errors import AgentError
|
|
16
|
+
|
|
17
|
+
_ANSI = {
|
|
18
|
+
"reset": "\x1b[0m",
|
|
19
|
+
"bold": "\x1b[1m",
|
|
20
|
+
"dim": "\x1b[2m",
|
|
21
|
+
"cyan": "\x1b[36m",
|
|
22
|
+
"yellow": "\x1b[33m",
|
|
23
|
+
"green": "\x1b[32m",
|
|
24
|
+
"red": "\x1b[31m",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def supports_color() -> bool:
|
|
29
|
+
"""No NO_COLOR, and stdout is a TTY."""
|
|
30
|
+
if os.environ.get("NO_COLOR"):
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
return bool(sys.stdout.isatty())
|
|
34
|
+
except (AttributeError, ValueError): # pragma: no cover - exotic stdouts
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _paint(color: str, text: str, on: bool) -> str:
|
|
39
|
+
if not on or color == "reset":
|
|
40
|
+
return text
|
|
41
|
+
return f"{_ANSI[color]}{text}{_ANSI['reset']}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConsoleEvents:
|
|
45
|
+
"""AgentEvents-shaped console logger (step/token/tool/done lines)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, prefix: str = "[agent]", colors: Optional[bool] = None) -> None:
|
|
48
|
+
self.prefix = prefix
|
|
49
|
+
self.colors = supports_color() if colors is None else bool(colors)
|
|
50
|
+
|
|
51
|
+
def _tag(self, color: str, label: str) -> str:
|
|
52
|
+
return _paint(color, f"{self.prefix} {label}", self.colors)
|
|
53
|
+
|
|
54
|
+
def on_step(self, step: int, messages: Any = None) -> None:
|
|
55
|
+
detail = f" ({len(messages)} msgs)" if messages is not None else ""
|
|
56
|
+
print(self._tag("cyan", f"step {step}{detail}"))
|
|
57
|
+
|
|
58
|
+
def on_tool_call(self, name: str, args: Any) -> None:
|
|
59
|
+
print(self._tag("yellow", f"tool:call {name}"), args)
|
|
60
|
+
|
|
61
|
+
def on_tool_result(self, name: str, output: Any) -> None:
|
|
62
|
+
print(self._tag("green", f"tool:result {name}"), output)
|
|
63
|
+
|
|
64
|
+
def on_done(self, text: str, tool_calls: Any = None) -> None:
|
|
65
|
+
print(self._tag("green", _paint("bold", "done:", self.colors)), text)
|
|
66
|
+
|
|
67
|
+
def on_token(self, delta: str) -> None:
|
|
68
|
+
print(delta, end="", flush=True)
|
|
69
|
+
|
|
70
|
+
def on_thinking(self, delta: str) -> None:
|
|
71
|
+
print(_paint("dim", delta, self.colors), end="", flush=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def console_events(prefix: str = "[agent]", colors: Optional[bool] = None) -> ConsoleEvents:
|
|
75
|
+
"""AgentEvents-shaped console logger; `colors=None` => TTY without NO_COLOR."""
|
|
76
|
+
return ConsoleEvents(prefix=prefix, colors=colors)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log_error(prefix: str, err: BaseException, colors: Optional[bool] = None) -> None:
|
|
80
|
+
"""Red error line (pairs with console_events): code + message."""
|
|
81
|
+
on = supports_color() if colors is None else bool(colors)
|
|
82
|
+
code = err.code if isinstance(err, AgentError) else "ERROR"
|
|
83
|
+
message = str(err) or err.__class__.__name__
|
|
84
|
+
print(f"{_paint('red', f'{prefix} error:', on)} [{code}] {message}", file=sys.stderr)
|