tanglebrain 0.16.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.
- tanglebrain/__init__.py +23 -0
- tanglebrain/adapters/__init__.py +15 -0
- tanglebrain/adapters/api.py +65 -0
- tanglebrain/adapters/base.py +46 -0
- tanglebrain/adapters/cli.py +341 -0
- tanglebrain/adapters/openai_compat.py +197 -0
- tanglebrain/classifier.py +99 -0
- tanglebrain/cli.py +251 -0
- tanglebrain/config/pricing.yaml +14 -0
- tanglebrain/config/roster.yaml +129 -0
- tanglebrain/config/settings.yaml +39 -0
- tanglebrain/delegate.py +485 -0
- tanglebrain/gui/__init__.py +10 -0
- tanglebrain/gui/server.py +162 -0
- tanglebrain/gui/static/index.html +295 -0
- tanglebrain/gui/static/logo.png +0 -0
- tanglebrain/gui/views.py +180 -0
- tanglebrain/mcp_server.py +208 -0
- tanglebrain/measurement.py +548 -0
- tanglebrain/roster.py +415 -0
- tanglebrain/roster_edit.py +201 -0
- tanglebrain/router.py +232 -0
- tanglebrain/selector.py +117 -0
- tanglebrain/settings.py +132 -0
- tanglebrain-0.16.0.dist-info/METADATA +369 -0
- tanglebrain-0.16.0.dist-info/RECORD +30 -0
- tanglebrain-0.16.0.dist-info/WHEEL +5 -0
- tanglebrain-0.16.0.dist-info/entry_points.txt +4 -0
- tanglebrain-0.16.0.dist-info/licenses/LICENSE +21 -0
- tanglebrain-0.16.0.dist-info/top_level.txt +1 -0
tanglebrain/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""TangleBrain — a local-first, config-driven router across OpenAI-compatible backends you own.
|
|
2
|
+
|
|
3
|
+
Routes each task to the cheapest capable tier: a free local model (via Ollama or any
|
|
4
|
+
OpenAI-compatible server you run) first, opt-in authenticated CLIs next, and your own paid API keys
|
|
5
|
+
as a last resort.
|
|
6
|
+
|
|
7
|
+
The default path is frontier-first orchestration: a configured orchestrator decomposes the task and
|
|
8
|
+
offloads sub-tasks to the free local backend over an MCP delegate, with rotation across the
|
|
9
|
+
orchestrators and failover when one errors, falling through to a gated, **off-by-default** paid-API
|
|
10
|
+
tier only as a genuine last resort. See ``ARCHITECTURE.md`` for the full design.
|
|
11
|
+
|
|
12
|
+
``__version__`` is read from the installed package metadata — the single source of truth is
|
|
13
|
+
``pyproject.toml`` — so it can never drift from the released version. When imported from a source
|
|
14
|
+
checkout that was never installed, it falls back to a clearly-not-a-release sentinel.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("tanglebrain")
|
|
22
|
+
except PackageNotFoundError: # running from a source tree that hasn't been installed
|
|
23
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Tier adapters.
|
|
2
|
+
|
|
3
|
+
Each tier is a node/adapter behind one uniform interface — ``run(prompt, opts) -> text`` — so
|
|
4
|
+
adding or removing a backend is local and contained. The ``openai-compat`` adapter serves the free
|
|
5
|
+
local tier; the ``cli`` adapter drives an authenticated CLI; the ``api`` adapter serves a paid
|
|
6
|
+
backend.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from tanglebrain.adapters.api import ApiAdapter
|
|
11
|
+
from tanglebrain.adapters.base import Adapter, AdapterError
|
|
12
|
+
from tanglebrain.adapters.cli import CliAdapter
|
|
13
|
+
from tanglebrain.adapters.openai_compat import OpenAICompatAdapter
|
|
14
|
+
|
|
15
|
+
__all__ = ["Adapter", "AdapterError", "ApiAdapter", "CliAdapter", "OpenAICompatAdapter"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Paid-API adapter — the last-resort tier.
|
|
2
|
+
|
|
3
|
+
**The paid-API tier reuses the *same* transport as the free local tier**, on purpose. Paid APIs are
|
|
4
|
+
fronted through an OpenAI-compatible gateway (e.g. LiteLLM): TangleBrain never holds a raw provider
|
|
5
|
+
key — it references a scoped key (via ``key_ref``) and calls the gateway's OpenAI-compatible
|
|
6
|
+
``/chat/completions`` endpoint, exactly as :class:`~tanglebrain.adapters.openai_compat.OpenAICompatAdapter`
|
|
7
|
+
does for a local backend. So this adapter is a thin specialization of that one — the transport is
|
|
8
|
+
identical; what makes the ``api`` tier different is **policy, not plumbing**:
|
|
9
|
+
|
|
10
|
+
- it only exists behind the ``api_billing_enabled`` gate + the entry's ``enabled`` flag
|
|
11
|
+
(enforced in :func:`tanglebrain.selector.build_adapter`, not here), and
|
|
12
|
+
- it is routed **last resort**, and
|
|
13
|
+
- its per-key monthly budget is capped gateway-side on the key.
|
|
14
|
+
|
|
15
|
+
Subclassing keeps that "same transport, different policy" relationship explicit and avoids
|
|
16
|
+
duplicating the httpx/error-handling block. If the paid tier ever needs genuinely different
|
|
17
|
+
transport behaviour, override :meth:`run` here.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from tanglebrain.adapters.base import AdapterError
|
|
22
|
+
from tanglebrain.adapters.openai_compat import OpenAICompatAdapter
|
|
23
|
+
from tanglebrain.roster import RosterEntry
|
|
24
|
+
|
|
25
|
+
__all__ = ["ApiAdapter"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiAdapter(OpenAICompatAdapter):
|
|
29
|
+
"""Adapter for a ``tier: api`` roster entry — a LiteLLM-fronted paid model.
|
|
30
|
+
|
|
31
|
+
Identical transport to :class:`OpenAICompatAdapter` (OpenAI-compat ``/chat/completions`` with a
|
|
32
|
+
Bearer credential resolved from ``key_ref``); it exists as its own type so the routing/selection
|
|
33
|
+
layer can reason about "this is the paid tier" and so the gate/last-resort policy has a clear
|
|
34
|
+
home. The billing gate is enforced by the caller (:func:`tanglebrain.selector.build_adapter`),
|
|
35
|
+
never inside the transport.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_entry(cls, entry: RosterEntry, **overrides: object) -> "ApiAdapter":
|
|
40
|
+
"""Build an adapter from an ``api`` roster entry.
|
|
41
|
+
|
|
42
|
+
The roster loader has already guaranteed ``base_url``, ``model`` and ``key_ref`` are present
|
|
43
|
+
for an ``api`` entry, and the credential is resolved lazily (on first :meth:`run`), so the
|
|
44
|
+
raw virtual key is never read at construction time.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
entry: A roster entry whose ``invoke.kind`` is ``api``.
|
|
48
|
+
**overrides: Optional constructor overrides (``timeout``, ``default_max_tokens``).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A configured :class:`ApiAdapter`.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
AdapterError: If the entry's invoke kind is not ``api``.
|
|
55
|
+
"""
|
|
56
|
+
if entry.invoke.kind != "api":
|
|
57
|
+
raise AdapterError(
|
|
58
|
+
f"entry {entry.id!r} has invoke.kind {entry.invoke.kind!r}, not 'api'"
|
|
59
|
+
)
|
|
60
|
+
return cls(
|
|
61
|
+
base_url=entry.invoke.base_url, # validated non-None by the roster loader
|
|
62
|
+
model=entry.invoke.model,
|
|
63
|
+
key_ref=entry.invoke.key_ref,
|
|
64
|
+
**overrides, # type: ignore[arg-type]
|
|
65
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The uniform adapter interface.
|
|
2
|
+
|
|
3
|
+
Every tier — free local, authenticated CLI, paid API — is invoked through one shape:
|
|
4
|
+
``run(prompt, opts) -> text``. Routing logic above the adapters (the selector and the router) never
|
|
5
|
+
needs to know *how* a tier is reached, only that it can hand it a prompt and get text back. That
|
|
6
|
+
uniformity is what makes adding or removing a backend a local, contained change.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Mapping, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AdapterError(RuntimeError):
|
|
14
|
+
"""Raised when an adapter cannot produce text.
|
|
15
|
+
|
|
16
|
+
Covers bad config, transport/subprocess failure, and unexpected response shape — every
|
|
17
|
+
way a tier can fail to return usable text. It lives here (not in a single adapter module)
|
|
18
|
+
so all adapters and the routing layer share one error type to catch. ``openai_compat``
|
|
19
|
+
re-exports it for backwards-compatible imports.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class Adapter(Protocol):
|
|
25
|
+
"""A callable tier: turn a prompt into text.
|
|
26
|
+
|
|
27
|
+
Implementations call out to a specific transport (an OpenAI-compat HTTP endpoint, a
|
|
28
|
+
subprocess CLI, a paid API) but expose only this uniform method.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def run(self, prompt: str, opts: Mapping[str, object] | None = None) -> str:
|
|
32
|
+
"""Run ``prompt`` against this tier and return the final text.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
prompt: The prompt to send.
|
|
36
|
+
opts: Optional per-call options (e.g. ``max_tokens``). Adapters ignore keys they
|
|
37
|
+
do not understand.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The tier's final response text.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
Exception: Adapters surface transport/protocol failures to the caller rather than
|
|
44
|
+
retrying or falling back silently — the routing layer decides what to do next.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""CLI adapter — the authenticated-CLI tier.
|
|
2
|
+
|
|
3
|
+
Runs a configured command-line tool (e.g. ``claude`` / ``codex`` / ``gemini``) as a subprocess and
|
|
4
|
+
returns its final text, behind the uniform :class:`~tanglebrain.adapters.base.Adapter` interface. The
|
|
5
|
+
routing layer never sees the per-CLI differences below — it hands over a prompt and gets text.
|
|
6
|
+
|
|
7
|
+
Two things vary per CLI and are config-driven from the roster, never hardcoded here:
|
|
8
|
+
|
|
9
|
+
- **How the prompt is passed.** A literal ``{prompt}`` token anywhere in the roster ``cmd`` is
|
|
10
|
+
replaced with the prompt (e.g. gemini's ``-p {prompt}``); with no token the prompt is appended
|
|
11
|
+
as the final argument (claude's ``-p ... <prompt>``, codex's ``exec <prompt>``). The prompt is
|
|
12
|
+
passed through ``argv`` with **no shell** (``shell=True`` is never used), so it cannot be
|
|
13
|
+
interpreted as shell syntax.
|
|
14
|
+
- **How the final text is extracted.** ``invoke.parse`` names a parser (:data:`PARSERS`):
|
|
15
|
+
``claude-json`` (single ``{"result": ...}`` object), ``gemini-json`` (``{"response": ...}``),
|
|
16
|
+
or ``plain`` (stripped stdout — codex prints the answer to stdout, metadata to stderr).
|
|
17
|
+
|
|
18
|
+
The safety-critical piece is **env-scrub**: ``invoke.scrub_env`` names env vars stripped from the
|
|
19
|
+
subprocess environment, so a CLI runs against its own authenticated session rather than an injected
|
|
20
|
+
API key (e.g. ``claude -p`` without ``ANTHROPIC_API_KEY``). Scrubbing operates on a **copy** of the
|
|
21
|
+
environment — the parent process's ``os.environ`` is never mutated.
|
|
22
|
+
|
|
23
|
+
Like the openai-compat adapter, failures (non-zero exit, timeout, missing binary, unparseable
|
|
24
|
+
output) surface as :class:`~tanglebrain.adapters.base.AdapterError`. This layer never retries or
|
|
25
|
+
falls back — the routing layer decides what to do next.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
from typing import Callable, Mapping
|
|
33
|
+
|
|
34
|
+
from tanglebrain.adapters.base import AdapterError
|
|
35
|
+
from tanglebrain.roster import RosterEntry
|
|
36
|
+
|
|
37
|
+
PROMPT_TOKEN = "{prompt}"
|
|
38
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
39
|
+
DEFAULT_PARSER = "plain"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_plain(stdout: str) -> str:
|
|
43
|
+
"""Return stripped stdout as the final text (codex ``exec`` and any plain-text CLI).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
stdout: The subprocess's captured stdout.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The stripped text.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
AdapterError: If stdout is empty/whitespace-only (no answer produced).
|
|
53
|
+
"""
|
|
54
|
+
text = stdout.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
raise AdapterError("CLI produced no stdout to parse as text")
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_json_field(stdout: str, field: str, *, label: str) -> str:
|
|
61
|
+
"""Parse stdout as a single JSON object and return one string field.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
stdout: The subprocess's captured stdout (expected to be one JSON object).
|
|
65
|
+
field: The key whose value is the final text.
|
|
66
|
+
label: Human label for the source CLI, used in error messages.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The value at ``field``.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
AdapterError: If stdout is not valid JSON, is not an object, the field is missing, or
|
|
73
|
+
the field's value is not a (non-empty) string.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(stdout)
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
raise AdapterError(f"{label}: stdout is not valid JSON: {exc}; got {stdout!r}") from exc
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise AdapterError(f"{label}: expected a JSON object, got {type(data).__name__}")
|
|
81
|
+
if field not in data:
|
|
82
|
+
raise AdapterError(f"{label}: response JSON missing {field!r} field: {data!r}")
|
|
83
|
+
value = data[field]
|
|
84
|
+
if not isinstance(value, str) or not value.strip():
|
|
85
|
+
raise AdapterError(f"{label}: {field!r} is not non-empty text: {value!r}")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_claude_json(stdout: str) -> str:
|
|
90
|
+
"""Parse ``claude -p --output-format json`` output and return the result text.
|
|
91
|
+
|
|
92
|
+
Claude emits a single JSON object with an ``is_error`` flag and the answer in ``result``.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
stdout: The subprocess's captured stdout.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The ``result`` text.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
AdapterError: If the JSON is malformed/unexpected, ``is_error`` is true, or ``result``
|
|
102
|
+
is missing or not non-empty text.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(stdout)
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
raise AdapterError(f"claude: stdout is not valid JSON: {exc}; got {stdout!r}") from exc
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise AdapterError(f"claude: expected a JSON object, got {type(data).__name__}")
|
|
110
|
+
if data.get("is_error"):
|
|
111
|
+
raise AdapterError(
|
|
112
|
+
f"claude reported an error (subtype={data.get('subtype')!r}): {data.get('result')!r}"
|
|
113
|
+
)
|
|
114
|
+
return _parse_json_field(stdout, "result", label="claude")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_gemini_json(stdout: str) -> str:
|
|
118
|
+
"""Parse ``gemini -p ... --output-format json`` output and return the response text.
|
|
119
|
+
|
|
120
|
+
Gemini emits a single JSON object with the answer in ``response`` (alongside a ``stats``
|
|
121
|
+
block that is intentionally ignored).
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
stdout: The subprocess's captured stdout.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The ``response`` text.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
AdapterError: If the JSON is malformed/unexpected or ``response`` is missing/not text.
|
|
131
|
+
"""
|
|
132
|
+
return _parse_json_field(stdout, "response", label="gemini")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: Output parsers keyed by ``invoke.parse`` name. Adding a CLI with a new output shape = a new
|
|
136
|
+
#: entry here plus the name in the roster — the routing layer is unaffected.
|
|
137
|
+
PARSERS: dict[str, Callable[[str], str]] = {
|
|
138
|
+
"plain": _parse_plain,
|
|
139
|
+
"claude-json": _parse_claude_json,
|
|
140
|
+
"gemini-json": _parse_gemini_json,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scrubbed_env(scrub_env: list[str]) -> dict[str, str]:
|
|
145
|
+
"""Return a copy of the current environment with ``scrub_env`` names removed.
|
|
146
|
+
|
|
147
|
+
This is the session-vs-key safety boundary: removing ``ANTHROPIC_API_KEY`` makes ``claude -p``
|
|
148
|
+
run against its own authenticated session rather than an injected API key. The parent's
|
|
149
|
+
``os.environ`` is **not** mutated — only the returned copy (handed to the subprocess) is.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
scrub_env: Env var names to remove. Names absent from the environment are ignored.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
A fresh ``dict`` of the environment minus the scrubbed names.
|
|
156
|
+
"""
|
|
157
|
+
env = dict(os.environ)
|
|
158
|
+
for name in scrub_env:
|
|
159
|
+
env.pop(name, None)
|
|
160
|
+
return env
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_argv(cmd: list[str], prompt: str) -> list[str]:
|
|
164
|
+
"""Build the subprocess argv, injecting ``prompt`` into ``cmd``.
|
|
165
|
+
|
|
166
|
+
If any ``cmd`` element contains the literal ``{prompt}`` token, the token is replaced (in
|
|
167
|
+
place, in every element that contains it). Otherwise the prompt is appended as the final
|
|
168
|
+
argument. No shell is involved, so the prompt is never interpreted as shell syntax.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
cmd: The roster ``invoke.cmd`` argv template.
|
|
172
|
+
prompt: The prompt to inject.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The concrete argv to execute.
|
|
176
|
+
"""
|
|
177
|
+
if any(PROMPT_TOKEN in part for part in cmd):
|
|
178
|
+
return [part.replace(PROMPT_TOKEN, prompt) for part in cmd]
|
|
179
|
+
return [*cmd, prompt]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class CliAdapter:
|
|
183
|
+
"""Adapter that runs prompts through a subscription CLI subprocess.
|
|
184
|
+
|
|
185
|
+
Implements the uniform :class:`~tanglebrain.adapters.base.Adapter` interface
|
|
186
|
+
(``run(prompt, opts) -> text``).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
cmd: list[str],
|
|
192
|
+
parse: str | None = None,
|
|
193
|
+
scrub_env: list[str] | None = None,
|
|
194
|
+
delegate_args: list[str] | None = None,
|
|
195
|
+
inject_delegate: bool = False,
|
|
196
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Configure the adapter.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
cmd: The argv template (see :func:`build_argv` for ``{prompt}`` handling).
|
|
202
|
+
parse: Name of the output parser (a key of :data:`PARSERS`). ``None`` uses
|
|
203
|
+
:data:`DEFAULT_PARSER` (``plain``).
|
|
204
|
+
scrub_env: Env var names to strip from the subprocess environment.
|
|
205
|
+
delegate_args: Per-CLI flags that make the local-delegate tool available to this CLI
|
|
206
|
+
as an orchestrator. A ``{delegate_mcp_json}`` token is substituted with the
|
|
207
|
+
delegate's MCP-server JSON. Only applied when ``inject_delegate`` is true.
|
|
208
|
+
inject_delegate: When true, append the (substituted) ``delegate_args`` to the command
|
|
209
|
+
so the orchestrator can offload sub-tasks to the free local backend.
|
|
210
|
+
timeout: Per-call subprocess timeout in seconds.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
AdapterError: If ``cmd`` is empty, or ``parse`` names an unknown parser.
|
|
214
|
+
"""
|
|
215
|
+
if not cmd:
|
|
216
|
+
raise AdapterError("CliAdapter requires a non-empty cmd")
|
|
217
|
+
parser_name = parse or DEFAULT_PARSER
|
|
218
|
+
if parser_name not in PARSERS:
|
|
219
|
+
raise AdapterError(
|
|
220
|
+
f"unknown parser {parser_name!r}; expected one of {sorted(PARSERS)}"
|
|
221
|
+
)
|
|
222
|
+
self.cmd = list(cmd)
|
|
223
|
+
self.parser_name = parser_name
|
|
224
|
+
self.scrub_env = list(scrub_env or [])
|
|
225
|
+
self.delegate_args = list(delegate_args or [])
|
|
226
|
+
self.inject_delegate = inject_delegate
|
|
227
|
+
self.timeout = timeout
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_entry(
|
|
231
|
+
cls, entry: RosterEntry, inject_delegate: bool = False, **overrides: object
|
|
232
|
+
) -> "CliAdapter":
|
|
233
|
+
"""Build an adapter from a ``cli`` roster entry.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
entry: A roster entry whose ``invoke.kind`` is ``cli``.
|
|
237
|
+
inject_delegate: Make the local-delegate tool available to this CLI; honors the
|
|
238
|
+
entry's ``invoke.delegate_args``.
|
|
239
|
+
**overrides: Optional constructor overrides (``timeout``).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A configured :class:`CliAdapter`.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
AdapterError: If the entry's invoke kind is not ``cli``, or ``cmd`` is missing.
|
|
246
|
+
"""
|
|
247
|
+
if entry.invoke.kind != "cli":
|
|
248
|
+
raise AdapterError(
|
|
249
|
+
f"entry {entry.id!r} has invoke.kind {entry.invoke.kind!r}, not 'cli'"
|
|
250
|
+
)
|
|
251
|
+
if not entry.invoke.cmd:
|
|
252
|
+
raise AdapterError(f"entry {entry.id!r}: cli invoke requires a non-empty cmd")
|
|
253
|
+
return cls(
|
|
254
|
+
cmd=entry.invoke.cmd,
|
|
255
|
+
parse=entry.invoke.parse,
|
|
256
|
+
scrub_env=entry.invoke.scrub_env,
|
|
257
|
+
delegate_args=entry.invoke.delegate_args,
|
|
258
|
+
inject_delegate=inject_delegate,
|
|
259
|
+
**overrides, # type: ignore[arg-type]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _effective_cmd(self) -> list[str]:
|
|
263
|
+
"""Return the base ``cmd``, with substituted ``delegate_args`` appended when injecting.
|
|
264
|
+
|
|
265
|
+
The delegate tokens (``{delegate_mcp_json}``, ``{delegate_mcp_command}``) in
|
|
266
|
+
``delegate_args`` are replaced via ``delegate_substitutions()`` (imported lazily so the
|
|
267
|
+
mcp-free import graph is unaffected). Delegate flags land after the base command and before
|
|
268
|
+
the prompt (added by :func:`build_argv`).
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The command with delegate flags applied, or just ``self.cmd`` when not injecting.
|
|
272
|
+
"""
|
|
273
|
+
if not self.inject_delegate or not self.delegate_args:
|
|
274
|
+
return self.cmd
|
|
275
|
+
from tanglebrain.delegate import delegate_substitutions
|
|
276
|
+
|
|
277
|
+
subs = delegate_substitutions()
|
|
278
|
+
injected = []
|
|
279
|
+
for arg in self.delegate_args:
|
|
280
|
+
for token, value in subs.items():
|
|
281
|
+
arg = arg.replace(token, value)
|
|
282
|
+
injected.append(arg)
|
|
283
|
+
return [*self.cmd, *injected]
|
|
284
|
+
|
|
285
|
+
def run(self, prompt: str, opts: Mapping[str, object] | None = None) -> str:
|
|
286
|
+
"""Run the prompt through the CLI subprocess and return the final text.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
prompt: The prompt to send.
|
|
290
|
+
opts: Optional per-call options. Recognized key: ``timeout`` (float, seconds).
|
|
291
|
+
Other keys are ignored (per the adapter contract).
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The CLI's final response text, per this adapter's parser.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
AdapterError: On a missing binary, non-zero exit, timeout, or unparseable output.
|
|
298
|
+
"""
|
|
299
|
+
opts = opts or {}
|
|
300
|
+
timeout = float(opts.get("timeout", self.timeout))
|
|
301
|
+
argv = build_argv(self._effective_cmd(), prompt)
|
|
302
|
+
env = scrubbed_env(self.scrub_env)
|
|
303
|
+
|
|
304
|
+
# When this CLI is an orchestrator carrying the delegate tool, propagate the top-level task
|
|
305
|
+
# id into its environment so the MCP delegate child it spawns can stamp each sub-call's
|
|
306
|
+
# parent_task_id (linking the delegation tree across the process boundary). Gated on
|
|
307
|
+
# inject_delegate: a leaf CLI call spawns no delegate child, so there is nothing to link.
|
|
308
|
+
# Lazy import keeps the constant's home module (measurement) out of this adapter's import
|
|
309
|
+
# graph, mirroring the lazy delegate_substitutions import in _effective_cmd.
|
|
310
|
+
task_id = opts.get("task_id")
|
|
311
|
+
if self.inject_delegate and task_id is not None:
|
|
312
|
+
from tanglebrain.measurement import PARENT_TASK_ID_ENV
|
|
313
|
+
|
|
314
|
+
env[PARENT_TASK_ID_ENV] = str(task_id)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
completed = subprocess.run(
|
|
318
|
+
argv,
|
|
319
|
+
# The prompt travels via argv (see build_argv); close stdin with EOF so a CLI
|
|
320
|
+
# that probes stdin for "additional input" (e.g. codex) does not block.
|
|
321
|
+
input="",
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
timeout=timeout,
|
|
325
|
+
env=env,
|
|
326
|
+
check=False,
|
|
327
|
+
)
|
|
328
|
+
except FileNotFoundError as exc:
|
|
329
|
+
raise AdapterError(f"CLI binary not found: {argv[0]!r} ({exc})") from exc
|
|
330
|
+
except subprocess.TimeoutExpired as exc:
|
|
331
|
+
raise AdapterError(
|
|
332
|
+
f"CLI {argv[0]!r} timed out after {timeout}s"
|
|
333
|
+
) from exc
|
|
334
|
+
|
|
335
|
+
if completed.returncode != 0:
|
|
336
|
+
stderr = (completed.stderr or "").strip()
|
|
337
|
+
raise AdapterError(
|
|
338
|
+
f"CLI {argv[0]!r} exited {completed.returncode}: {stderr or '(no stderr)'}"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return PARSERS[self.parser_name](completed.stdout)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""OpenAI-compat adapter — the free local tier.
|
|
2
|
+
|
|
3
|
+
Calls an OpenAI-compatible ``/chat/completions`` endpoint (e.g. Ollama, or any local/self-hosted
|
|
4
|
+
gateway) and returns the final text. It calls the endpoint **directly** — no MCP server in between.
|
|
5
|
+
|
|
6
|
+
Behaviour:
|
|
7
|
+
|
|
8
|
+
- Returns only ``choices[0].message.content`` — some local reasoning models put chain-of-thought in
|
|
9
|
+
a separate ``reasoning_content`` field, which is intentionally dropped.
|
|
10
|
+
- Defaults ``max_tokens`` to 2048: reasoning models spend part of their budget on internal reasoning
|
|
11
|
+
before emitting the final answer, so a stingy cap can truncate real output.
|
|
12
|
+
- Raises on any non-2xx status, transport failure, or unexpected response shape. This layer
|
|
13
|
+
does NOT retry or fall back — failures surface to the routing layer, which decides.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Mapping
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from tanglebrain.adapters.base import AdapterError
|
|
24
|
+
from tanglebrain.roster import RosterEntry
|
|
25
|
+
|
|
26
|
+
# Re-exported for backwards-compatible imports; the canonical definition lives in
|
|
27
|
+
# ``tanglebrain.adapters.base`` so the CLI adapter and the routing layer share one error type.
|
|
28
|
+
__all__ = ["AdapterError", "OpenAICompatAdapter", "resolve_key_ref"]
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
31
|
+
DEFAULT_MAX_TOKENS = 2048
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_key_ref(key_ref: str | None) -> str | None:
|
|
35
|
+
"""Resolve a roster ``key_ref`` to a credential string, without embedding secrets.
|
|
36
|
+
|
|
37
|
+
Supported forms (see the contract's key-ref convention):
|
|
38
|
+
|
|
39
|
+
- ``file:PATH`` — read the key from a file (``~`` is expanded); the file is the source of
|
|
40
|
+
truth, never the config.
|
|
41
|
+
- ``env:NAME`` — read the key from environment variable ``NAME``.
|
|
42
|
+
- ``none`` (or ``None``) — no credential; the endpoint is open.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
key_ref: The reference string from the roster entry, or ``None``.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The resolved key, or ``None`` for an open endpoint.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
AdapterError: If the form is unrecognized, or the referenced file/env var is missing
|
|
52
|
+
or empty.
|
|
53
|
+
"""
|
|
54
|
+
if key_ref is None or key_ref == "none":
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if key_ref.startswith("file:"):
|
|
58
|
+
raw_path = key_ref[len("file:"):]
|
|
59
|
+
path = Path(raw_path).expanduser()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise AdapterError(f"key_ref file not found: {path}")
|
|
62
|
+
key = path.read_text().strip()
|
|
63
|
+
if not key:
|
|
64
|
+
raise AdapterError(f"key_ref file is empty: {path}")
|
|
65
|
+
return key
|
|
66
|
+
|
|
67
|
+
if key_ref.startswith("env:"):
|
|
68
|
+
name = key_ref[len("env:"):]
|
|
69
|
+
key = os.environ.get(name)
|
|
70
|
+
if not key:
|
|
71
|
+
raise AdapterError(f"key_ref env var not set or empty: {name}")
|
|
72
|
+
return key
|
|
73
|
+
|
|
74
|
+
raise AdapterError(
|
|
75
|
+
f"unrecognized key_ref {key_ref!r}; expected 'file:PATH', 'env:NAME', or 'none'"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OpenAICompatAdapter:
|
|
80
|
+
"""Adapter that runs prompts against an OpenAI-compat chat-completions endpoint.
|
|
81
|
+
|
|
82
|
+
Implements the uniform :class:`~tanglebrain.adapters.base.Adapter` interface
|
|
83
|
+
(``run(prompt, opts) -> text``).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
base_url: str,
|
|
89
|
+
model: str,
|
|
90
|
+
key_ref: str | None = None,
|
|
91
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
92
|
+
default_max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Configure the adapter.
|
|
95
|
+
|
|
96
|
+
The credential is resolved lazily (on first :meth:`run`), so constructing an adapter
|
|
97
|
+
for an entry whose key file is absent does not fail until it is actually invoked.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
base_url: OpenAI-compat base URL (e.g. ``http://localhost:11434/v1``).
|
|
101
|
+
model: Model id/alias to request (e.g. ``gpt-oss-120b``).
|
|
102
|
+
key_ref: Credential reference (``file:PATH`` | ``env:NAME`` | ``none``), or ``None``.
|
|
103
|
+
timeout: Per-request timeout in seconds.
|
|
104
|
+
default_max_tokens: ``max_tokens`` used when a call does not override it.
|
|
105
|
+
"""
|
|
106
|
+
self.base_url = base_url.rstrip("/")
|
|
107
|
+
self.model = model
|
|
108
|
+
self.key_ref = key_ref
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self.default_max_tokens = default_max_tokens
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_entry(cls, entry: RosterEntry, **overrides: object) -> "OpenAICompatAdapter":
|
|
114
|
+
"""Build an adapter from an ``openai-compat`` roster entry.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
entry: A roster entry whose ``invoke.kind`` is ``openai-compat``.
|
|
118
|
+
**overrides: Optional constructor overrides (``timeout``, ``default_max_tokens``).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A configured :class:`OpenAICompatAdapter`.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
AdapterError: If the entry's invoke kind is not ``openai-compat``.
|
|
125
|
+
"""
|
|
126
|
+
if entry.invoke.kind != "openai-compat":
|
|
127
|
+
raise AdapterError(
|
|
128
|
+
f"entry {entry.id!r} has invoke.kind {entry.invoke.kind!r}, "
|
|
129
|
+
"not 'openai-compat'"
|
|
130
|
+
)
|
|
131
|
+
return cls(
|
|
132
|
+
base_url=entry.invoke.base_url, # validated non-None by the roster loader
|
|
133
|
+
model=entry.invoke.model,
|
|
134
|
+
key_ref=entry.invoke.key_ref,
|
|
135
|
+
**overrides, # type: ignore[arg-type]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def run(self, prompt: str, opts: Mapping[str, object] | None = None) -> str:
|
|
139
|
+
"""Send a single-message chat completion and return the final text.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
prompt: The prompt to send as the sole user message.
|
|
143
|
+
opts: Optional per-call options. Recognized keys: ``max_tokens`` (int).
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The model's final ``content`` (``reasoning_content`` is dropped).
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
AdapterError: If ``max_tokens`` < 1, or on non-2xx status, transport failure, or
|
|
150
|
+
unexpected response shape.
|
|
151
|
+
"""
|
|
152
|
+
opts = opts or {}
|
|
153
|
+
max_tokens = int(opts.get("max_tokens", self.default_max_tokens))
|
|
154
|
+
if max_tokens < 1:
|
|
155
|
+
raise AdapterError(
|
|
156
|
+
f"max_tokens must be >= 1, got {max_tokens} "
|
|
157
|
+
"(a local reasoning model needs generous headroom)"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
url = f"{self.base_url}/chat/completions"
|
|
161
|
+
headers = {"Content-Type": "application/json"}
|
|
162
|
+
key = resolve_key_ref(self.key_ref)
|
|
163
|
+
if key:
|
|
164
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
165
|
+
|
|
166
|
+
payload = {
|
|
167
|
+
"model": self.model,
|
|
168
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
169
|
+
"max_tokens": max_tokens,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
174
|
+
response = client.post(url, headers=headers, json=payload)
|
|
175
|
+
response.raise_for_status()
|
|
176
|
+
data = response.json()
|
|
177
|
+
except httpx.HTTPStatusError as exc:
|
|
178
|
+
body = exc.response.text
|
|
179
|
+
raise AdapterError(
|
|
180
|
+
f"LiteLLM returned {exc.response.status_code} for model {self.model!r}: {body}"
|
|
181
|
+
) from exc
|
|
182
|
+
except httpx.HTTPError as exc:
|
|
183
|
+
raise AdapterError(
|
|
184
|
+
f"transport error calling {url} for model {self.model!r}: {exc}"
|
|
185
|
+
) from exc
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
content = data["choices"][0]["message"]["content"]
|
|
189
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
190
|
+
raise AdapterError(f"unexpected response shape from LiteLLM: {data!r}") from exc
|
|
191
|
+
|
|
192
|
+
if content is None:
|
|
193
|
+
raise AdapterError(
|
|
194
|
+
f"LiteLLM returned null content for model {self.model!r} "
|
|
195
|
+
f"(often a truncated response — try a larger max_tokens): {data!r}"
|
|
196
|
+
)
|
|
197
|
+
return content
|