autoforge-trainer 1.0.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.
- autoforge/__init__.py +3 -0
- autoforge/agents/__init__.py +0 -0
- autoforge/agents/loop.py +168 -0
- autoforge/agents/providers.py +272 -0
- autoforge/api.py +330 -0
- autoforge/cli/__init__.py +0 -0
- autoforge/cli/agent.py +102 -0
- autoforge/cli/data.py +42 -0
- autoforge/cli/hpo.py +98 -0
- autoforge/cli/main.py +112 -0
- autoforge/cli/models.py +172 -0
- autoforge/cli/output.py +40 -0
- autoforge/cli/packs.py +185 -0
- autoforge/cli/quickstart.py +61 -0
- autoforge/cli/runs.py +233 -0
- autoforge/config.py +31 -0
- autoforge/data/__init__.py +0 -0
- autoforge/data/profile.py +99 -0
- autoforge/data/registry.py +47 -0
- autoforge/engine/__init__.py +0 -0
- autoforge/engine/context.py +125 -0
- autoforge/engine/diagnose.py +134 -0
- autoforge/engine/hpo.py +355 -0
- autoforge/engine/runner.py +380 -0
- autoforge/engine/store.py +207 -0
- autoforge/engine/validate.py +172 -0
- autoforge/engine/worker.py +68 -0
- autoforge/errors.py +17 -0
- autoforge/examples/__init__.py +0 -0
- autoforge/examples/churn.csv +601 -0
- autoforge/gui/__init__.py +0 -0
- autoforge/gui/app.py +243 -0
- autoforge/gui/static/app.js +546 -0
- autoforge/gui/static/index.html +26 -0
- autoforge/gui/static/style.css +83 -0
- autoforge/hardware/__init__.py +0 -0
- autoforge/hardware/detect.py +161 -0
- autoforge/packs/__init__.py +0 -0
- autoforge/packs/authoring.py +203 -0
- autoforge/packs/errors.py +42 -0
- autoforge/packs/index.py +149 -0
- autoforge/packs/installer.py +241 -0
- autoforge/packs/manifest.py +192 -0
- autoforge/policy/__init__.py +0 -0
- autoforge/policy/privacy.py +68 -0
- autoforge/registry/__init__.py +0 -0
- autoforge/registry/models.py +350 -0
- autoforge/schemas/__init__.py +0 -0
- autoforge/schemas/core.py +137 -0
- autoforge/serve/__init__.py +0 -0
- autoforge/serve/mcp_server.py +293 -0
- autoforge/serve/model_mcp.py +98 -0
- autoforge/serve/rest.py +87 -0
- autoforge_trainer-1.0.2.dist-info/METADATA +216 -0
- autoforge_trainer-1.0.2.dist-info/RECORD +58 -0
- autoforge_trainer-1.0.2.dist-info/WHEEL +4 -0
- autoforge_trainer-1.0.2.dist-info/entry_points.txt +2 -0
- autoforge_trainer-1.0.2.dist-info/licenses/LICENSE +21 -0
autoforge/__init__.py
ADDED
|
File without changes
|
autoforge/agents/loop.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Built-in agent loop (P4b): an LLM drives AutoForge through the same tools
|
|
2
|
+
the MCP server exposes. The tools run in-process via an MCP client, so the
|
|
3
|
+
privacy guard, audit log and error envelopes are identical to P4a.
|
|
4
|
+
|
|
5
|
+
Human approval gate: by default the person confirms before training starts
|
|
6
|
+
(`run_start`, `run_retry`); --yes turns it off. A declined call returns
|
|
7
|
+
USER_DECLINED with the person's feedback so the model can revise the plan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
from mcp import Client
|
|
19
|
+
|
|
20
|
+
from autoforge import config
|
|
21
|
+
from autoforge.agents.providers import Provider, ToolCall, ToolResult, ToolSpec
|
|
22
|
+
from autoforge.schemas.core import BrainKind
|
|
23
|
+
from autoforge.serve.mcp_server import INSTRUCTIONS, build_server
|
|
24
|
+
|
|
25
|
+
GATED_TOOLS = {"run_start", "run_retry", "hpo_start"}
|
|
26
|
+
MAX_RESULT_CHARS = 20_000
|
|
27
|
+
|
|
28
|
+
SYSTEM_PROMPT = """\
|
|
29
|
+
You are AutoForge's training agent. You analyse what the user needs, choose a training method that fits
|
|
30
|
+
their data and hardware, train the model on their machine, and report the result.
|
|
31
|
+
|
|
32
|
+
{instructions}
|
|
33
|
+
|
|
34
|
+
How to work:
|
|
35
|
+
- Start with hardware_profile and data_profile. Base every choice on what the tools return.
|
|
36
|
+
- Search packs for the task and read pack_info's knowledge before choosing a recipe; follow its guidance
|
|
37
|
+
(e.g. start with the simple baseline recipe).
|
|
38
|
+
- Validate the plan, start the run, then poll with run_wait until it finishes. If it fails, read run_logs,
|
|
39
|
+
fix the plan and try again. Compare against the baseline before trying anything heavier.
|
|
40
|
+
- If a tool returns USER_DECLINED, read the user's feedback in the message and revise.
|
|
41
|
+
- If a tool returns PRIVACY_DENIED, stop and explain; never look for another way to read that data.
|
|
42
|
+
- After a working baseline, use run_diagnose for problems and hpo_start to tune (trial 0 is the baseline);
|
|
43
|
+
report whether tuning improved the metric.
|
|
44
|
+
- When the user wants to use the model, register the best run with model_register and show a
|
|
45
|
+
model_predict example.
|
|
46
|
+
- Finish with a short report: the method and why, the metrics, the run_id and the model path.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AgentEvent:
|
|
52
|
+
kind: str # text | tool_call | tool_result | approval | info | done
|
|
53
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AgentResult:
|
|
58
|
+
final_text: str
|
|
59
|
+
steps: int
|
|
60
|
+
stop: str # end | refusal | max_steps | error
|
|
61
|
+
runs: list[str]
|
|
62
|
+
transcript: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
ApproveFn = Callable[[ToolCall], Awaitable[tuple[bool, str]]]
|
|
66
|
+
EventFn = Callable[[AgentEvent], None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _truncate(text: str) -> str:
|
|
70
|
+
if len(text) <= MAX_RESULT_CHARS:
|
|
71
|
+
return text
|
|
72
|
+
return text[:MAX_RESULT_CHARS] + f"\n... [truncated {len(text) - MAX_RESULT_CHARS} chars]"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Transcript:
|
|
76
|
+
"""JSONL record of the whole agent session, next to the runs it started."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
d = config.home() / "agent-sessions"
|
|
80
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self.path = d / f"{time.strftime('%Y%m%d-%H%M%S')}.jsonl"
|
|
82
|
+
|
|
83
|
+
def write(self, kind: str, **data: Any) -> None:
|
|
84
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
85
|
+
f.write(json.dumps({"t": time.time(), "kind": kind, **data}, ensure_ascii=False, default=str) + "\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def run_agent(
|
|
89
|
+
goal: str,
|
|
90
|
+
provider: Provider,
|
|
91
|
+
actor: str,
|
|
92
|
+
brain: BrainKind,
|
|
93
|
+
approve: ApproveFn | None = None,
|
|
94
|
+
on_event: EventFn | None = None,
|
|
95
|
+
max_steps: int = 40,
|
|
96
|
+
data_path: str | None = None,
|
|
97
|
+
) -> AgentResult:
|
|
98
|
+
emit = on_event or (lambda e: None)
|
|
99
|
+
transcript = Transcript()
|
|
100
|
+
runs: list[str] = []
|
|
101
|
+
server = build_server(actor, log_level="WARNING") # the agent reports tool errors itself
|
|
102
|
+
|
|
103
|
+
user_msg = goal
|
|
104
|
+
if data_path:
|
|
105
|
+
user_msg += f"\n\nData: {Path(data_path).expanduser().resolve()}"
|
|
106
|
+
transcript.write("start", goal=goal, actor=actor, brain=brain.value, data_path=data_path)
|
|
107
|
+
|
|
108
|
+
async with Client(server) as mcp:
|
|
109
|
+
listed = await mcp.list_tools()
|
|
110
|
+
tools = [ToolSpec(t.name, t.description or "", t.input_schema) for t in listed.tools]
|
|
111
|
+
provider.start(SYSTEM_PROMPT.format(instructions=INSTRUCTIONS), tools)
|
|
112
|
+
provider.add_user(user_msg)
|
|
113
|
+
|
|
114
|
+
final_text, stop = "", "max_steps"
|
|
115
|
+
for step in range(1, max_steps + 1):
|
|
116
|
+
turn = await provider.complete()
|
|
117
|
+
if turn.text:
|
|
118
|
+
final_text = turn.text
|
|
119
|
+
emit(AgentEvent("text", {"text": turn.text}))
|
|
120
|
+
transcript.write("text", text=turn.text)
|
|
121
|
+
if turn.stop == "refusal":
|
|
122
|
+
stop = "refusal"
|
|
123
|
+
emit(AgentEvent("info", {"message": f"model declined: {turn.detail or ''}"}))
|
|
124
|
+
break
|
|
125
|
+
if not turn.tool_calls:
|
|
126
|
+
if turn.stop == "pause":
|
|
127
|
+
continue # server paused a long turn; resend to let it continue
|
|
128
|
+
stop = "end"
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
results = []
|
|
132
|
+
for call in turn.tool_calls:
|
|
133
|
+
emit(AgentEvent("tool_call", {"name": call.name, "arguments": call.arguments}))
|
|
134
|
+
transcript.write("tool_call", name=call.name, arguments=call.arguments)
|
|
135
|
+
if call.parse_error:
|
|
136
|
+
content = json.dumps({"ok": False, "error": {"code": "BAD_TOOL_CALL", "message": call.parse_error}})
|
|
137
|
+
results.append(ToolResult(call.id, content, is_error=True))
|
|
138
|
+
continue
|
|
139
|
+
if call.name in GATED_TOOLS and approve is not None:
|
|
140
|
+
ok, feedback = await approve(call)
|
|
141
|
+
emit(AgentEvent("approval", {"name": call.name, "approved": ok, "feedback": feedback}))
|
|
142
|
+
transcript.write("approval", name=call.name, approved=ok, feedback=feedback)
|
|
143
|
+
if not ok:
|
|
144
|
+
msg = "The user declined this action." + (f" Feedback: {feedback}" if feedback else "")
|
|
145
|
+
content = json.dumps({"ok": False, "error": {"code": "USER_DECLINED", "message": msg}})
|
|
146
|
+
results.append(ToolResult(call.id, content, is_error=True))
|
|
147
|
+
continue
|
|
148
|
+
res = await mcp.call_tool(call.name, call.arguments)
|
|
149
|
+
envelope = res.structured_content
|
|
150
|
+
if envelope is None: # unstructured (e.g. argument validation error from the MCP layer)
|
|
151
|
+
text = "\n".join(getattr(c, "text", "") for c in res.content)
|
|
152
|
+
envelope = {"ok": False, "error": {"code": "BAD_TOOL_CALL", "message": text}}
|
|
153
|
+
if call.name in ("run_start", "run_retry") and envelope.get("ok"):
|
|
154
|
+
runs.append(envelope["data"]["run_id"])
|
|
155
|
+
if call.name == "hpo_start" and envelope.get("ok"):
|
|
156
|
+
runs.append(envelope["data"]["study_id"])
|
|
157
|
+
content = _truncate(json.dumps(envelope, ensure_ascii=False, default=str))
|
|
158
|
+
emit(AgentEvent("tool_result", {"name": call.name, "ok": envelope.get("ok"),
|
|
159
|
+
"error": (envelope.get("error") or {}).get("code")}))
|
|
160
|
+
transcript.write("tool_result", name=call.name, ok=envelope.get("ok"), content=content[:2000])
|
|
161
|
+
results.append(ToolResult(call.id, content, is_error=not envelope.get("ok", False)))
|
|
162
|
+
provider.add_tool_results(results)
|
|
163
|
+
else:
|
|
164
|
+
step = max_steps
|
|
165
|
+
|
|
166
|
+
transcript.write("done", stop=stop, steps=step, runs=runs)
|
|
167
|
+
emit(AgentEvent("done", {"stop": stop, "steps": step, "runs": runs}))
|
|
168
|
+
return AgentResult(final_text=final_text, steps=step, stop=stop, runs=runs, transcript=str(transcript.path))
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""LLM providers for the built-in agent (P4b).
|
|
2
|
+
|
|
3
|
+
Each provider owns its own conversation state, so the agent loop stays
|
|
4
|
+
provider-neutral:
|
|
5
|
+
p.start(system, tools); p.add_user(text)
|
|
6
|
+
turn = await p.complete() # text + tool calls
|
|
7
|
+
p.add_tool_results([...]) # one result per call, all at once
|
|
8
|
+
|
|
9
|
+
Brain specs:
|
|
10
|
+
anthropic:<model> Claude via the official SDK (cloud)
|
|
11
|
+
openai:<model> OpenAI or any cloud OpenAI-compatible API (--base-url)
|
|
12
|
+
ollama:<model> local Ollama (OpenAI-compatible endpoint)
|
|
13
|
+
local:<model> any local OpenAI-compatible server (vLLM, LM Studio, llama.cpp) with --base-url
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from autoforge.errors import AutoForgeError
|
|
24
|
+
from autoforge.schemas.core import BrainKind
|
|
25
|
+
|
|
26
|
+
DEFAULT_BRAIN = "anthropic:claude-opus-5-5"
|
|
27
|
+
# Models that accept server-side refusal fallbacks (fallbacks: "default").
|
|
28
|
+
CLAUDE_FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderError(AutoForgeError):
|
|
32
|
+
code = "PROVIDER_ERROR"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ToolSpec:
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
input_schema: dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolCall:
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
arguments: dict[str, Any]
|
|
47
|
+
parse_error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ToolResult:
|
|
52
|
+
call_id: str
|
|
53
|
+
content: str
|
|
54
|
+
is_error: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Turn:
|
|
59
|
+
text: str = ""
|
|
60
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
61
|
+
stop: str = "end" # end | tool_use | pause | max_tokens | refusal
|
|
62
|
+
detail: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class BrainSpec:
|
|
67
|
+
provider: str
|
|
68
|
+
model: str
|
|
69
|
+
base_url: str | None = None
|
|
70
|
+
api_key_env: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_local(self) -> bool:
|
|
74
|
+
return self.provider in ("ollama", "local")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def brain_kind(self) -> BrainKind:
|
|
78
|
+
return BrainKind.local if self.is_local else BrainKind.api
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def actor(self) -> str:
|
|
82
|
+
# 'ai:local:...' is what the privacy guard recognises as a local model.
|
|
83
|
+
return f"ai:local:{self.provider}:{self.model}" if self.is_local else f"ai:{self.provider}:{self.model}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_brain(spec: str | None, base_url: str | None = None, api_key_env: str | None = None) -> BrainSpec:
|
|
87
|
+
spec = spec or os.environ.get("AUTOFORGE_BRAIN") or DEFAULT_BRAIN
|
|
88
|
+
provider, sep, model = spec.partition(":")
|
|
89
|
+
if not sep or not model:
|
|
90
|
+
raise ProviderError(f"bad brain '{spec}'", hint="Use provider:model, e.g. anthropic:claude-opus-5-5 or ollama:qwen3")
|
|
91
|
+
if provider not in ("anthropic", "openai", "ollama", "local"):
|
|
92
|
+
raise ProviderError(f"unknown provider '{provider}'", hint="Choose anthropic, openai, ollama or local.")
|
|
93
|
+
if provider == "local" and not base_url:
|
|
94
|
+
raise ProviderError("local:<model> needs --base-url (an OpenAI-compatible endpoint, e.g. http://localhost:8000/v1)")
|
|
95
|
+
return BrainSpec(provider, model, base_url, api_key_env)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def make_provider(spec: BrainSpec) -> "Provider":
|
|
99
|
+
if spec.provider == "anthropic":
|
|
100
|
+
return AnthropicProvider(spec.model)
|
|
101
|
+
if spec.provider == "ollama":
|
|
102
|
+
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
103
|
+
if not host.startswith("http"):
|
|
104
|
+
host = "http://" + host
|
|
105
|
+
return OpenAICompatProvider(spec.model, spec.base_url or f"{host}/v1", api_key=None)
|
|
106
|
+
if spec.provider == "local":
|
|
107
|
+
return OpenAICompatProvider(spec.model, spec.base_url, api_key=None)
|
|
108
|
+
key_env = spec.api_key_env or "OPENAI_API_KEY"
|
|
109
|
+
key = os.environ.get(key_env)
|
|
110
|
+
if not key:
|
|
111
|
+
raise ProviderError(f"{key_env} is not set", hint=f"export {key_env}=... (or pass --api-key-env)")
|
|
112
|
+
return OpenAICompatProvider(spec.model, spec.base_url or "https://api.openai.com/v1", api_key=key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Provider:
|
|
116
|
+
def start(self, system: str, tools: list[ToolSpec]) -> None: ...
|
|
117
|
+
def add_user(self, text: str) -> None: ...
|
|
118
|
+
async def complete(self) -> Turn: ...
|
|
119
|
+
def add_tool_results(self, results: list[ToolResult]) -> None: ...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------- Claude
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AnthropicProvider(Provider):
|
|
126
|
+
"""Claude via the official Anthropic SDK (manual tool-use loop).
|
|
127
|
+
|
|
128
|
+
- Effort 'high' (agentic work); adaptive thinking is the model default.
|
|
129
|
+
- Server-side refusal fallback ('default') on models that support it.
|
|
130
|
+
- The assistant content is appended back unchanged (thinking blocks included),
|
|
131
|
+
so the history stays append-only.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, model: str, client: Any = None, effort: str = "high", max_tokens: int = 16000):
|
|
135
|
+
if client is None:
|
|
136
|
+
try:
|
|
137
|
+
import anthropic
|
|
138
|
+
except ImportError:
|
|
139
|
+
raise ProviderError("the anthropic package is not installed", hint='pip install "autoforge[anthropic]"')
|
|
140
|
+
client = anthropic.AsyncAnthropic()
|
|
141
|
+
self.client = client
|
|
142
|
+
self.model = model
|
|
143
|
+
self.effort = effort
|
|
144
|
+
self.max_tokens = max_tokens
|
|
145
|
+
self.messages: list[dict[str, Any]] = []
|
|
146
|
+
|
|
147
|
+
def start(self, system: str, tools: list[ToolSpec]) -> None:
|
|
148
|
+
self.system = system
|
|
149
|
+
self.tools = [{"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in tools]
|
|
150
|
+
|
|
151
|
+
def add_user(self, text: str) -> None:
|
|
152
|
+
self.messages.append({"role": "user", "content": text})
|
|
153
|
+
|
|
154
|
+
async def complete(self) -> Turn:
|
|
155
|
+
kwargs: dict[str, Any] = dict(
|
|
156
|
+
model=self.model,
|
|
157
|
+
max_tokens=self.max_tokens,
|
|
158
|
+
system=self.system,
|
|
159
|
+
tools=self.tools,
|
|
160
|
+
messages=self.messages,
|
|
161
|
+
output_config={"effort": self.effort},
|
|
162
|
+
cache_control={"type": "ephemeral"}, # tools + system + history are a stable prefix
|
|
163
|
+
)
|
|
164
|
+
if self.model in CLAUDE_FALLBACK_MODELS:
|
|
165
|
+
kwargs |= {"betas": ["server-side-fallback-2026-07-01"], "fallbacks": "default"}
|
|
166
|
+
try:
|
|
167
|
+
response = await self.client.beta.messages.create(**kwargs)
|
|
168
|
+
except Exception as e: # SDK errors: auth, rate limit, network, bad request
|
|
169
|
+
raise ProviderError(f"Claude API call failed: {type(e).__name__}: {e}") from e
|
|
170
|
+
|
|
171
|
+
self.messages.append({"role": "assistant", "content": response.content})
|
|
172
|
+
text = "\n".join(b.text for b in response.content if b.type == "text" and b.text)
|
|
173
|
+
calls = [ToolCall(b.id, b.name, dict(b.input or {})) for b in response.content if b.type == "tool_use"]
|
|
174
|
+
stop = {"end_turn": "end", "tool_use": "tool_use", "pause_turn": "pause",
|
|
175
|
+
"max_tokens": "max_tokens", "refusal": "refusal"}.get(response.stop_reason, "end")
|
|
176
|
+
detail = None
|
|
177
|
+
if stop == "refusal" and getattr(response, "stop_details", None):
|
|
178
|
+
detail = f"{response.stop_details.category}: {response.stop_details.explanation}"
|
|
179
|
+
if stop == "max_tokens":
|
|
180
|
+
# A tool_use block cut off by max_tokens may have incomplete input: don't run it,
|
|
181
|
+
# but every tool_use still needs a tool_result, so report it as an error.
|
|
182
|
+
for c in calls:
|
|
183
|
+
c.parse_error = "response hit max_tokens; tool input may be truncated, call the tool again"
|
|
184
|
+
return Turn(text=text, tool_calls=calls, stop=stop, detail=detail)
|
|
185
|
+
|
|
186
|
+
def add_tool_results(self, results: list[ToolResult]) -> None:
|
|
187
|
+
self.messages.append({
|
|
188
|
+
"role": "user",
|
|
189
|
+
"content": [
|
|
190
|
+
{"type": "tool_result", "tool_use_id": r.call_id, "content": r.content, "is_error": r.is_error}
|
|
191
|
+
for r in results
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------- OpenAI-compatible (Ollama, OpenAI, vLLM ...)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def simplify_schema(schema: Any) -> Any:
|
|
200
|
+
"""Make JSON Schemas easier for small local models: `X | None` (anyOf with null) becomes X,
|
|
201
|
+
and pydantic's 'title' noise is dropped. Defaults and descriptions are kept."""
|
|
202
|
+
if isinstance(schema, list):
|
|
203
|
+
return [simplify_schema(s) for s in schema]
|
|
204
|
+
if not isinstance(schema, dict):
|
|
205
|
+
return schema
|
|
206
|
+
out = {k: simplify_schema(v) for k, v in schema.items() if k != "title"}
|
|
207
|
+
any_of = out.get("anyOf")
|
|
208
|
+
if isinstance(any_of, list):
|
|
209
|
+
non_null = [s for s in any_of if s != {"type": "null"}]
|
|
210
|
+
if len(non_null) == 1 and len(non_null) < len(any_of):
|
|
211
|
+
merged = {k: v for k, v in out.items() if k != "anyOf"}
|
|
212
|
+
return non_null[0] | merged
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class OpenAICompatProvider(Provider):
|
|
217
|
+
"""Chat Completions with function calling over plain HTTP (httpx2)."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, model: str, base_url: str, api_key: str | None, transport: Any = None, timeout: float = 600):
|
|
220
|
+
import httpx2
|
|
221
|
+
|
|
222
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
223
|
+
self.client = httpx2.AsyncClient(base_url=base_url.rstrip("/") + "/", headers=headers,
|
|
224
|
+
timeout=timeout, transport=transport)
|
|
225
|
+
self.model = model
|
|
226
|
+
self.base_url = base_url
|
|
227
|
+
self.messages: list[dict[str, Any]] = []
|
|
228
|
+
|
|
229
|
+
def start(self, system: str, tools: list[ToolSpec]) -> None:
|
|
230
|
+
self.messages = [{"role": "system", "content": system}]
|
|
231
|
+
self.tools = [
|
|
232
|
+
{"type": "function", "function": {"name": t.name, "description": t.description,
|
|
233
|
+
"parameters": simplify_schema(t.input_schema)}}
|
|
234
|
+
for t in tools
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
def add_user(self, text: str) -> None:
|
|
238
|
+
self.messages.append({"role": "user", "content": text})
|
|
239
|
+
|
|
240
|
+
async def complete(self) -> Turn:
|
|
241
|
+
import httpx2
|
|
242
|
+
|
|
243
|
+
body = {"model": self.model, "messages": self.messages, "tools": self.tools}
|
|
244
|
+
try:
|
|
245
|
+
r = await self.client.post("chat/completions", json=body)
|
|
246
|
+
r.raise_for_status()
|
|
247
|
+
data = r.json()
|
|
248
|
+
except httpx2.HTTPStatusError as e:
|
|
249
|
+
raise ProviderError(f"{self.base_url} returned {e.response.status_code}: {e.response.text[:500]}") from e
|
|
250
|
+
except httpx2.RequestError as e:
|
|
251
|
+
raise ProviderError(f"cannot reach {self.base_url}: {e}",
|
|
252
|
+
hint="Is the model server running? For Ollama: `ollama serve` and `ollama pull <model>`.") from e
|
|
253
|
+
choice = data["choices"][0]
|
|
254
|
+
msg = choice["message"]
|
|
255
|
+
self.messages.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls") and v is not None})
|
|
256
|
+
calls = []
|
|
257
|
+
for i, tc in enumerate(msg.get("tool_calls") or []):
|
|
258
|
+
fn = tc.get("function", {})
|
|
259
|
+
raw = fn.get("arguments") or "{}"
|
|
260
|
+
try:
|
|
261
|
+
args = raw if isinstance(raw, dict) else json.loads(raw)
|
|
262
|
+
err = None
|
|
263
|
+
except json.JSONDecodeError as e:
|
|
264
|
+
args, err = {}, f"arguments are not valid JSON: {e}"
|
|
265
|
+
calls.append(ToolCall(tc.get("id") or f"call_{i}", fn.get("name", ""), args, err))
|
|
266
|
+
finish = choice.get("finish_reason")
|
|
267
|
+
stop = "tool_use" if calls else ("max_tokens" if finish == "length" else "end")
|
|
268
|
+
return Turn(text=msg.get("content") or "", tool_calls=calls, stop=stop)
|
|
269
|
+
|
|
270
|
+
def add_tool_results(self, results: list[ToolResult]) -> None:
|
|
271
|
+
for r in results:
|
|
272
|
+
self.messages.append({"role": "tool", "tool_call_id": r.call_id, "content": r.content})
|