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.
@@ -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