hum-cli 0.0.1__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.
@@ -0,0 +1,71 @@
1
+ """Earned autonomy. The level a policy runs at on a class of tasks is a number
2
+ the world assigns from graded outcomes, not a mode a user picks.
3
+
4
+ Per task class we keep a Beta posterior over "unsupervised policy run passes".
5
+ The level is chosen from a lower confidence bound on that posterior, so a
6
+ class earns ``act`` only with enough evidence, and loses it when outcomes
7
+ turn. Updated online as verdicts arrive; persisted as JSON so it survives
8
+ the session and follows the weights.
9
+
10
+ Levels
11
+ observe policy drafts; every tool call needs a human decision; mutating
12
+ calls are executed only as the human's own turn
13
+ propose every policy turn is gated (accept / edit / reject)
14
+ review read-only turns run; mutating turns are gated
15
+ act ungated; the human may still steer or take over
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ Level = Literal["observe", "propose", "review", "act"]
25
+ _ORDER: list[Level] = ["observe", "propose", "review", "act"]
26
+
27
+
28
+ class AutonomyPolicy:
29
+ def __init__(self, path: Path | None = None, *, prior: tuple[float, float] = (1.0, 1.0), z: float = 1.0,
30
+ thresholds: dict[Level, float] | None = None):
31
+ self.path = path
32
+ self.prior = prior
33
+ self.z = z
34
+ self.thresholds: dict[Level, float] = thresholds or {"propose": 0.5, "review": 0.7, "act": 0.9}
35
+ self.counts: dict[str, tuple[int, int]] = {} # task_class -> (passes, fails)
36
+ if path and path.exists():
37
+ self.counts = {k: tuple(v) for k, v in json.loads(path.read_text()).items()} # type: ignore[misc]
38
+
39
+ def update(self, task_class: str, passed: bool) -> Level:
40
+ p, f = self.counts.get(task_class, (0, 0))
41
+ self.counts[task_class] = (p + int(passed), f + int(not passed))
42
+ if self.path:
43
+ self.path.parent.mkdir(parents=True, exist_ok=True)
44
+ self.path.write_text(json.dumps(self.counts))
45
+ return self.level(task_class)
46
+
47
+ def lower_bound(self, task_class: str) -> float:
48
+ p, f = self.counts.get(task_class, (0, 0))
49
+ a, b = self.prior[0] + p, self.prior[1] + f
50
+ mean = a / (a + b)
51
+ var = (a * b) / ((a + b) ** 2 * (a + b + 1))
52
+ return max(0.0, mean - self.z * math.sqrt(var))
53
+
54
+ def level(self, task_class: str | None) -> Level:
55
+ if task_class is None:
56
+ return "propose"
57
+ lb = self.lower_bound(task_class)
58
+ lvl: Level = "observe"
59
+ for cand in ("propose", "review", "act"):
60
+ if lb >= self.thresholds[cand]:
61
+ lvl = cand
62
+ return lvl
63
+
64
+ @staticmethod
65
+ def gates(level: Level, mutating: bool) -> bool:
66
+ """Does a policy turn need a human decision before it executes?"""
67
+ if level == "act":
68
+ return False
69
+ if level == "review":
70
+ return mutating
71
+ return True
hum/runtime/config.py ADDED
@@ -0,0 +1,101 @@
1
+ """Deployment configuration. Metaphi sets this; the SME never sees it.
2
+
3
+ ~/.hum/config.toml (or HUM_CONFIG):
4
+
5
+ [model]
6
+ name = "openrouter/z-ai/glm-5.3" # or "policy" behind the proxy
7
+ api_base = "https://proxy.metaphi.ai"
8
+ api_key_env = "HUM_API_KEY"
9
+
10
+ [[mcp]] # native tools mounted into every session
11
+ name = "control"
12
+ url = "https://.../mcp"
13
+
14
+ [policy] # backend behaviour — defaults are "be a great agent"
15
+ gate = "off" # off | earned (earned = autonomy-gated sessions)
16
+ shadow = false # fork-and-grade on intervention (needs a grader + forkable executor)
17
+ grader = "" # shell command or "mcp:<server>__<tool>"
18
+ task_class = "" # autonomy bucket; default = workspace name
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import tomllib
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+
30
+ @dataclass
31
+ class MCPConfig:
32
+ name: str
33
+ url: str | None = None
34
+ command: str | None = None
35
+ args: list[str] = field(default_factory=list)
36
+ headers: dict[str, str] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass
40
+ class Config:
41
+ model: str = "openrouter/z-ai/glm-5.3"
42
+ api_base: str | None = None
43
+ api_key: str | None = None
44
+ reasoning_effort: str | None = None
45
+ temperature: float | None = None
46
+ mcp: list[MCPConfig] = field(default_factory=list)
47
+ gate: str = "off"
48
+ shadow: bool = False
49
+ grader: str = ""
50
+ task_class: str = ""
51
+ max_turns: int = 400
52
+ raw: dict[str, Any] = field(default_factory=dict)
53
+ byo: bool = False # the person's own provider key (model turns not ours to train on)
54
+ user: str | None = None
55
+
56
+
57
+ def _env(name: str | None) -> str | None:
58
+ """Empty environment variables are absent, not keys."""
59
+ v = os.environ.get(name) if name else None
60
+ return v if v else None
61
+
62
+
63
+ def _credentials() -> dict[str, Any]:
64
+ p = Path(os.environ.get("HUM_HOME") or Path.home() / ".hum") / "credentials.json"
65
+ try:
66
+ return json.loads(p.read_text()) if p.exists() else {}
67
+ except Exception: # noqa: BLE001
68
+ return {}
69
+
70
+
71
+ def load(path: Path | None = None) -> Config:
72
+ """Precedence: env > ~/.hum/config.toml [model] > the person's login (our proxy)
73
+ > their BYO key for the model's provider > defaults."""
74
+ p = path or Path(os.environ.get("HUM_CONFIG") or Path(os.environ.get("HUM_HOME") or Path.home() / ".hum") / "config.toml")
75
+ d: dict[str, Any] = {}
76
+ if p.exists():
77
+ d = tomllib.loads(p.read_text())
78
+ m, pol = d.get("model", {}), d.get("policy", {})
79
+ cred = _credentials()
80
+ key_env = m.get("api_key_env") or "HUM_API_KEY"
81
+ model = _env("HUM_MODEL") or m.get("name") or cred.get("model") or cred.get("default_model") or Config.model
82
+ api_base = _env("HUM_API_BASE") or m.get("api_base")
83
+ api_key = _env(key_env) or _env("HUM_API_KEY")
84
+ byo = False
85
+ if api_base is None and api_key is None and cred.get("api_base") and cred.get("api_key") and not cred.get("model"):
86
+ api_base, api_key = cred["api_base"], cred["api_key"] # logged in: our proxy
87
+ if api_key is None and api_base is None:
88
+ prov = model.split("/", 1)[0] if "/" in model else "openai"
89
+ env_name = {"openrouter": "OPENROUTER_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "gemini": "GEMINI_API_KEY"}.get(prov)
90
+ api_key = (cred.get("byo") or {}).get(prov) or _env(env_name)
91
+ byo = api_key is not None
92
+ cfg = Config(
93
+ model=model, api_base=api_base, api_key=api_key,
94
+ reasoning_effort=m.get("reasoning_effort"), temperature=m.get("temperature"),
95
+ mcp=[MCPConfig(name=s["name"], url=s.get("url"), command=s.get("command"), args=s.get("args", []), headers=s.get("headers", {})) for s in d.get("mcp", [])],
96
+ gate=pol.get("gate", "off"), shadow=bool(pol.get("shadow", False)), grader=pol.get("grader", ""),
97
+ task_class=pol.get("task_class", ""), max_turns=int(pol.get("max_turns", 400)), raw=d,
98
+ )
99
+ cfg.byo = byo
100
+ cfg.user = cred.get("email")
101
+ return cfg
hum/runtime/drivers.py ADDED
@@ -0,0 +1,79 @@
1
+ """Turn sources. A driver produces the next Turn given the chat so far. The
2
+ loop does not care whether a driver is a model, a person at a web client, or
3
+ a policy continuing in shadow — only who it says it is.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from .llm import LLM
13
+ from .models import Intervention, Turn
14
+
15
+
16
+ class PolicyDriver:
17
+ author = "policy"
18
+
19
+ def __init__(self, llm: LLM, author: str = "policy", on_text: Any = None):
20
+ self.llm = llm
21
+ self.author = author
22
+ self.on_text = on_text
23
+
24
+ async def next(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> Turn:
25
+ t = await self.llm.complete(messages, tools, on_text=self.on_text)
26
+ if t.author != self.author:
27
+ t = t.model_copy(update={"author": self.author})
28
+ return t
29
+
30
+
31
+ class HumanAction(BaseModel):
32
+ """What a human sends into the loop.
33
+
34
+ - ``intervention`` on a gated proposal: accept | edit | reject | takeover
35
+ - ``turn``: the human's own action (tool calls) or steering text
36
+ - ``release``: hand control back to the policy after a takeover
37
+ """
38
+
39
+ intervention: Intervention | None = None
40
+ turn: Turn | None = None
41
+
42
+
43
+ class HumanDriver:
44
+ """The human's two channels into the loop.
45
+
46
+ ``decisions`` answers gated proposals (accept / edit / reject / takeover);
47
+ ``steering`` carries unsolicited input while the policy acts ungated
48
+ (text, a human action, a takeover, a release). Keeping them apart means a
49
+ decision sent before the proposal is surfaced is still a decision, and
50
+ steering is never mistaken for one.
51
+ """
52
+
53
+ author = "human"
54
+
55
+ def __init__(self) -> None:
56
+ self.decisions: asyncio.Queue[HumanAction] = asyncio.Queue()
57
+ self.steering: asyncio.Queue[HumanAction] = asyncio.Queue()
58
+ self.proposals: asyncio.Queue[Turn] = asyncio.Queue() # surfaced to the client
59
+
60
+ # loop side
61
+ async def review(self, proposal: Turn) -> HumanAction:
62
+ await self.proposals.put(proposal)
63
+ return await self.decisions.get()
64
+
65
+ async def next(self) -> HumanAction:
66
+ return await self.steering.get()
67
+
68
+ def pending(self) -> HumanAction | None:
69
+ try:
70
+ return self.steering.get_nowait()
71
+ except asyncio.QueueEmpty:
72
+ return None
73
+
74
+ # client side
75
+ def decide(self, action: HumanAction) -> None:
76
+ self.decisions.put_nowait(action)
77
+
78
+ def send(self, action: HumanAction) -> None:
79
+ self.steering.put_nowait(action)
@@ -0,0 +1,120 @@
1
+ """Where tools run. The runtime never touches a filesystem or a shell directly;
2
+ it goes through an Executor so the same loop drives a local directory, a
3
+ Harbor sandbox, or a hosted SME session.
4
+
5
+ ``snapshot``/``fork`` exist for counterfactual shadow: the fork point is the
6
+ state *before* the human's intervention executes, and the shadow branch needs
7
+ its own copy to continue in.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import shutil
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Protocol, runtime_checkable
17
+
18
+ from pydantic import BaseModel
19
+
20
+ WINDOWS = os.name == "nt"
21
+
22
+
23
+ def pick_shell() -> tuple[str, list[str]]:
24
+ """(name, argv-prefix). POSIX: bash. Windows: Git Bash if installed, else PowerShell."""
25
+ if not WINDOWS:
26
+ return "bash", ["/bin/bash", "-c"]
27
+ for cand in (shutil.which("bash"), r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files\Git\usr\bin\bash.exe"):
28
+ if cand and Path(cand).exists() and "System32" not in cand: # System32\bash.exe is the WSL launcher, not a shell for cwd
29
+ return "bash", [cand, "-c"]
30
+ pwsh = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
31
+ return "powershell", [pwsh, "-NoProfile", "-NonInteractive", "-Command"]
32
+
33
+
34
+ class ExecResult(BaseModel):
35
+ stdout: str
36
+ stderr: str = ""
37
+ returncode: int
38
+ timed_out: bool = False
39
+
40
+ @property
41
+ def text(self) -> str:
42
+ out = self.stdout
43
+ if self.stderr:
44
+ out = f"{out}\n[stderr]\n{self.stderr}" if out else f"[stderr]\n{self.stderr}"
45
+ return out
46
+
47
+
48
+ def decode(data: bytes) -> str:
49
+ """Estate files are latin-1/CRLF; most of the world is UTF-8. Never raise."""
50
+ try:
51
+ return data.decode("utf-8")
52
+ except UnicodeDecodeError:
53
+ return data.decode("latin-1")
54
+
55
+
56
+ @runtime_checkable
57
+ class Executor(Protocol):
58
+ cwd: str
59
+
60
+ async def exec(self, command: str, cwd: str | None = None, timeout_sec: int | None = None) -> ExecResult: ...
61
+ async def read(self, path: str) -> bytes: ...
62
+ async def write(self, path: str, data: bytes) -> None: ...
63
+ async def snapshot(self) -> str: ...
64
+ async def fork(self, snapshot_id: str) -> "Executor": ...
65
+ async def close(self) -> None: ...
66
+
67
+
68
+ class LocalExecutor:
69
+ """A directory on this machine. Used by tests, the terminal client, and
70
+ any SME session that runs where the files are."""
71
+
72
+ def __init__(self, root: Path, snapshots_dir: Path | None = None):
73
+ self.root = Path(root).resolve()
74
+ self.root.mkdir(parents=True, exist_ok=True)
75
+ self.cwd = str(self.root)
76
+ self.snapshots_dir = Path(snapshots_dir) if snapshots_dir else self.root.parent / f".{self.root.name}.snapshots"
77
+ self.shell, self._shell_argv = pick_shell()
78
+
79
+ def _abs(self, path: str) -> Path:
80
+ p = Path(path)
81
+ return p if p.is_absolute() else self.root / p
82
+
83
+ async def exec(self, command: str, cwd: str | None = None, timeout_sec: int | None = None) -> ExecResult:
84
+ proc = await asyncio.create_subprocess_exec(
85
+ *self._shell_argv, command,
86
+ cwd=str(self._abs(cwd)) if cwd else self.cwd,
87
+ stdout=asyncio.subprocess.PIPE,
88
+ stderr=asyncio.subprocess.PIPE,
89
+ )
90
+ try:
91
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
92
+ except asyncio.TimeoutError:
93
+ proc.kill()
94
+ out, err = await proc.communicate()
95
+ return ExecResult(stdout=decode(out), stderr=decode(err), returncode=-9, timed_out=True)
96
+ return ExecResult(stdout=decode(out), stderr=decode(err), returncode=proc.returncode or 0)
97
+
98
+ async def read(self, path: str) -> bytes:
99
+ return self._abs(path).read_bytes()
100
+
101
+ async def write(self, path: str, data: bytes) -> None:
102
+ p = self._abs(path)
103
+ p.parent.mkdir(parents=True, exist_ok=True)
104
+ p.write_bytes(data)
105
+
106
+ async def snapshot(self) -> str:
107
+ sid = uuid.uuid4().hex[:12]
108
+ dst = self.snapshots_dir / sid
109
+ dst.parent.mkdir(parents=True, exist_ok=True)
110
+ await asyncio.to_thread(shutil.copytree, self.root, dst, symlinks=True)
111
+ return sid
112
+
113
+ async def fork(self, snapshot_id: str) -> "LocalExecutor":
114
+ src = self.snapshots_dir / snapshot_id
115
+ dst = self.snapshots_dir / f"{snapshot_id}-fork-{uuid.uuid4().hex[:6]}"
116
+ await asyncio.to_thread(shutil.copytree, src, dst, symlinks=True)
117
+ return LocalExecutor(dst, snapshots_dir=self.snapshots_dir)
118
+
119
+ async def close(self) -> None:
120
+ return None
hum/runtime/grader.py ADDED
@@ -0,0 +1,41 @@
1
+ """The world's opinion. A Grader turns a terminal state into a Verdict. In
2
+ production this is CONTROL through its MCP (blind, gated); in tests it is a
3
+ predicate on the workspace. The runtime treats the grader as the reward AND as
4
+ a tool the agent may call mid-run (verifier in the loop) — same instrument,
5
+ two uses.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Awaitable, Callable, Protocol
10
+
11
+ from .executor import Executor
12
+ from .models import Verdict
13
+
14
+
15
+ class Grader(Protocol):
16
+ name: str
17
+
18
+ async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict: ...
19
+
20
+
21
+ class PredicateGrader:
22
+ def __init__(self, name: str, fn: Callable[[Executor, dict[str, Any]], Awaitable[tuple[bool, float, dict[str, Any]]]]):
23
+ self.name, self.fn = name, fn
24
+
25
+ async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict:
26
+ passed, score, detail = await self.fn(executor, task)
27
+ return Verdict(passed=passed, score=score, grader=self.name, detail=detail)
28
+
29
+
30
+ class MCPGrader:
31
+ """Grade by calling a tool on an already-mounted MCP toolset (e.g. control__grade_case).
32
+ ``to_verdict`` maps the tool's text/JSON output to (passed, score, detail)."""
33
+
34
+ def __init__(self, name: str, call: Callable[[dict[str, Any]], Awaitable[str]], args_for: Callable[[dict[str, Any]], dict[str, Any]],
35
+ to_verdict: Callable[[str], tuple[bool, float, dict[str, Any]]]):
36
+ self.name, self._call, self._args_for, self._to_verdict = name, call, args_for, to_verdict
37
+
38
+ async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict:
39
+ out = await self._call(self._args_for(task))
40
+ passed, score, detail = self._to_verdict(out)
41
+ return Verdict(passed=passed, score=score, grader=self.name, detail=detail)
hum/runtime/llm.py ADDED
@@ -0,0 +1,225 @@
1
+ """Model client with native tool calling over litellm. Anything with an
2
+ OpenAI-compatible endpoint works: frontier APIs, OpenRouter, a LiteLLM proxy
3
+ with per-expert keys, or the policy served from the trainer. When the server
4
+ returns token ids / logprobs they are kept on the Turn for token custody.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Awaitable, Callable, Protocol
10
+
11
+ from .models import ToolCall, Turn, Usage
12
+
13
+ OnText = Callable[[str], None]
14
+
15
+
16
+ class LLM(Protocol):
17
+ model: str
18
+
19
+ async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn: ...
20
+
21
+
22
+ def _raw_usage(resp: Any) -> dict[str, Any]:
23
+ try:
24
+ d = resp.model_dump() if hasattr(resp, "model_dump") else dict(resp)
25
+ return d.get("usage") or {}
26
+ except Exception: # noqa: BLE001
27
+ return {}
28
+
29
+
30
+ def _parse(msg: Any, resp: Any, model: str, author: str, cost_hint: float | None = None) -> Turn:
31
+ calls: list[ToolCall] = []
32
+ for tc in getattr(msg, "tool_calls", None) or []:
33
+ try:
34
+ args = json.loads(tc.function.arguments or "{}")
35
+ except json.JSONDecodeError:
36
+ args = {"_raw": tc.function.arguments}
37
+ calls.append(ToolCall(id=tc.id, name=tc.function.name, arguments=args))
38
+ u = getattr(resp, "usage", None)
39
+ raw = _raw_usage(resp)
40
+ usage = Usage(
41
+ input_tokens=getattr(u, "prompt_tokens", 0) or 0,
42
+ output_tokens=getattr(u, "completion_tokens", 0) or 0,
43
+ cache_tokens=(getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0),
44
+ cost_usd=float(cost_hint or raw.get("cost_usd") or raw.get("cost")
45
+ or (getattr(resp, "_hidden_params", None) or {}).get("response_cost") or 0.0),
46
+ ) if u else None
47
+ return Turn(author=author, content=msg.content or "", reasoning=getattr(msg, "reasoning_content", None), # type: ignore[arg-type]
48
+ tool_calls=calls, model=getattr(resp, "model", model) or model, usage=usage, done=not calls)
49
+
50
+
51
+ class LiteLLMClient:
52
+ def __init__(self, model: str, *, api_base: str | None = None, api_key: str | None = None,
53
+ temperature: float | None = None, reasoning_effort: str | None = None,
54
+ max_tokens: int | None = None, timeout: float = 600.0, extra: dict[str, Any] | None = None,
55
+ author: str = "policy", stream: bool = True):
56
+ self.model = model
57
+ self.api_base, self.api_key = api_base, api_key
58
+ self.temperature, self.reasoning_effort, self.max_tokens, self.timeout = temperature, reasoning_effort, max_tokens, timeout
59
+ self.extra = extra or {}
60
+ self.author = author
61
+ self.stream = stream
62
+
63
+ def _kwargs(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None) -> dict[str, Any]:
64
+ kw: dict[str, Any] = dict(model=self.model, messages=messages, timeout=self.timeout, **self.extra)
65
+ if tools:
66
+ kw["tools"], kw["tool_choice"] = tools, "auto"
67
+ if self.api_base:
68
+ kw["api_base"] = self.api_base
69
+ if self.api_key:
70
+ kw["api_key"] = self.api_key
71
+ if self.temperature is not None:
72
+ kw["temperature"] = self.temperature
73
+ if self.reasoning_effort:
74
+ kw["reasoning_effort"] = self.reasoning_effort
75
+ if self.max_tokens:
76
+ kw["max_tokens"] = self.max_tokens
77
+ return kw
78
+
79
+ async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
80
+ import litellm # lazy: tests never import it
81
+
82
+ litellm.suppress_debug_info = True
83
+ kw = self._kwargs(messages, tools)
84
+ if not (self.stream and on_text):
85
+ resp = await litellm.acompletion(**kw)
86
+ return _parse(resp.choices[0].message, resp, self.model, self.author)
87
+ chunks, cost_hint = [], None
88
+ async for chunk in await litellm.acompletion(stream=True, stream_options={"include_usage": True}, **kw):
89
+ chunks.append(chunk)
90
+ delta = chunk.choices[0].delta if chunk.choices else None
91
+ if delta is not None and getattr(delta, "content", None):
92
+ on_text(delta.content)
93
+ ru = _raw_usage(chunk)
94
+ if ru.get("cost_usd") or ru.get("cost"):
95
+ cost_hint = float(ru.get("cost_usd") or ru.get("cost"))
96
+ resp = litellm.stream_chunk_builder(chunks, messages=messages)
97
+ return _parse(resp.choices[0].message, resp, self.model, self.author, cost_hint=cost_hint)
98
+
99
+
100
+ class ScriptedLLM:
101
+ """Deterministic stand-in for tests and replay: returns pre-written turns in
102
+ order. ``on_call`` runs before each turn is returned (tests use it to
103
+ change the workspace between turns, the way a person would)."""
104
+
105
+ def __init__(self, turns: list[Turn], model: str = "scripted", on_call: Callable[[int], Awaitable[None] | None] | None = None):
106
+ self.turns, self.model, self.calls, self.on_call = list(turns), model, [], on_call
107
+
108
+ async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
109
+ self.calls.append(messages)
110
+ if self.on_call:
111
+ r = self.on_call(len(self.calls))
112
+ if r is not None:
113
+ await r
114
+ if not self.turns:
115
+ return Turn(author="policy", content="(script exhausted)", done=True)
116
+ t = self.turns.pop(0)
117
+ if on_text and t.content:
118
+ on_text(t.content)
119
+ return t
120
+
121
+
122
+ class OpenAICompatClient:
123
+ """Direct OpenAI-compatible client (httpx). Used for our own proxy and any
124
+ OpenAI-compatible base: exact usage/cost passthrough, no provider shims.
125
+ Streams SSE and accumulates tool-call deltas."""
126
+
127
+ def __init__(self, model: str, *, api_base: str, api_key: str | None = None, temperature: float | None = None,
128
+ reasoning_effort: str | None = None, max_tokens: int | None = None, timeout: float = 600.0,
129
+ author: str = "policy", stream: bool = True, transport: Any = None):
130
+ self.model, self.api_base, self.api_key = model.split("/", 1)[1] if model.startswith("openai/") else model, api_base.rstrip("/"), api_key
131
+ self.temperature, self.reasoning_effort, self.max_tokens, self.timeout = temperature, reasoning_effort, max_tokens, timeout
132
+ self.author, self.stream, self._transport = author, stream, transport
133
+
134
+ def _body(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, stream: bool) -> dict[str, Any]:
135
+ b: dict[str, Any] = {"model": self.model, "messages": messages}
136
+ if tools:
137
+ b["tools"], b["tool_choice"] = tools, "auto"
138
+ if self.temperature is not None:
139
+ b["temperature"] = self.temperature
140
+ if self.reasoning_effort:
141
+ b["reasoning_effort"] = self.reasoning_effort
142
+ if self.max_tokens:
143
+ b["max_tokens"] = self.max_tokens
144
+ if stream:
145
+ b["stream"], b["stream_options"] = True, {"include_usage": True}
146
+ return b
147
+
148
+ def _headers(self) -> dict[str, str]:
149
+ h = {"content-type": "application/json"}
150
+ if self.api_key:
151
+ h["authorization"] = f"Bearer {self.api_key}"
152
+ return h
153
+
154
+ @staticmethod
155
+ def _usage(u: dict[str, Any] | None) -> Usage | None:
156
+ if not u:
157
+ return None
158
+ return Usage(input_tokens=u.get("prompt_tokens", 0) or 0, output_tokens=u.get("completion_tokens", 0) or 0,
159
+ cache_tokens=((u.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0),
160
+ cost_usd=float(u.get("cost_usd") or u.get("cost") or 0.0))
161
+
162
+ @staticmethod
163
+ def _calls(raw: list[dict[str, Any]] | None) -> list[ToolCall]:
164
+ out: list[ToolCall] = []
165
+ for tc in raw or []:
166
+ fn = tc.get("function") or {}
167
+ try:
168
+ args = json.loads(fn.get("arguments") or "{}")
169
+ except json.JSONDecodeError:
170
+ args = {"_raw": fn.get("arguments")}
171
+ out.append(ToolCall(id=tc.get("id") or f"call_{len(out)}", name=fn.get("name") or "", arguments=args))
172
+ return out
173
+
174
+ async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
175
+ import httpx
176
+
177
+ url = f"{self.api_base}/chat/completions"
178
+ async with httpx.AsyncClient(timeout=self.timeout, transport=self._transport) as http:
179
+ if not (self.stream and on_text):
180
+ r = await http.post(url, json=self._body(messages, tools, False), headers=self._headers())
181
+ if r.status_code != 200:
182
+ raise RuntimeError(f"{r.status_code} {r.text[:300]}")
183
+ d = r.json()
184
+ msg = d["choices"][0]["message"]
185
+ calls = self._calls(msg.get("tool_calls"))
186
+ return Turn(author=self.author, content=msg.get("content") or "", reasoning=msg.get("reasoning_content"), # type: ignore[arg-type]
187
+ tool_calls=calls, model=d.get("model") or self.model, usage=self._usage(d.get("usage")), done=not calls)
188
+ content, reasoning, usage, model = [], [], None, self.model
189
+ acc: dict[int, dict[str, Any]] = {}
190
+ async with http.stream("POST", url, json=self._body(messages, tools, True), headers=self._headers()) as r:
191
+ if r.status_code != 200:
192
+ body = (await r.aread()).decode(errors="replace")
193
+ raise RuntimeError(f"{r.status_code} {body[:300]}")
194
+ async for line in r.aiter_lines():
195
+ if not line.startswith("data:"):
196
+ continue
197
+ data = line[5:].strip()
198
+ if data == "[DONE]":
199
+ break
200
+ try:
201
+ d = json.loads(data)
202
+ except json.JSONDecodeError:
203
+ continue
204
+ model = d.get("model") or model
205
+ if d.get("usage"):
206
+ usage = d["usage"]
207
+ for ch in d.get("choices") or []:
208
+ delta = ch.get("delta") or {}
209
+ if delta.get("content"):
210
+ content.append(delta["content"]); on_text(delta["content"])
211
+ if delta.get("reasoning_content"):
212
+ reasoning.append(delta["reasoning_content"])
213
+ for tc in delta.get("tool_calls") or []:
214
+ i = tc.get("index", 0)
215
+ a = acc.setdefault(i, {"id": None, "function": {"name": "", "arguments": ""}})
216
+ if tc.get("id"):
217
+ a["id"] = tc["id"]
218
+ fn = tc.get("function") or {}
219
+ if fn.get("name"):
220
+ a["function"]["name"] += fn["name"]
221
+ if fn.get("arguments"):
222
+ a["function"]["arguments"] += fn["arguments"]
223
+ calls = self._calls([acc[i] for i in sorted(acc)])
224
+ return Turn(author=self.author, content="".join(content), reasoning="".join(reasoning) or None, # type: ignore[arg-type]
225
+ tool_calls=calls, model=model, usage=self._usage(usage), done=not calls)