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/router.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Frontier-first router — pluggable orchestrator selection + rotation + failover.
|
|
2
|
+
|
|
3
|
+
The control plane: route a task to an orchestrator (a ``can_orchestrate`` backend), rotate the
|
|
4
|
+
orchestrator role across the eligible set, and fail over to the next when one errors. Rotation and
|
|
5
|
+
failover give resilience and even load, independent of local delegation.
|
|
6
|
+
|
|
7
|
+
This module stays a deterministic control plane. It does not classify tasks for the caller — the
|
|
8
|
+
caller passes a ``task`` hint or gets pure rotation; the optional classifier gate lives in
|
|
9
|
+
:mod:`tanglebrain.classifier`. Orchestrators offload sub-tasks to the free local backend via the
|
|
10
|
+
``delegate_local`` tool, which the router injects into their invocations.
|
|
11
|
+
|
|
12
|
+
Rotation state persists across processes (each ``tanglebrain`` run is a new process), so successive
|
|
13
|
+
requests rotate across the orchestrators.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Mapping
|
|
22
|
+
|
|
23
|
+
from tanglebrain.adapters import AdapterError
|
|
24
|
+
from tanglebrain.adapters.base import Adapter
|
|
25
|
+
from tanglebrain.roster import RosterEntry, Roster
|
|
26
|
+
from tanglebrain.selector import build_adapter
|
|
27
|
+
from tanglebrain.settings import Settings, load_settings
|
|
28
|
+
|
|
29
|
+
STATE_DIR_ENV = "TANGLEBRAIN_STATE_DIR"
|
|
30
|
+
DEFAULT_STATE_SUBDIR = ".cache/tanglebrain"
|
|
31
|
+
STATE_FILENAME = "router-state.json"
|
|
32
|
+
|
|
33
|
+
# Substrings that mark an orchestrator failure as a rate-limit/capacity issue rather than a hard
|
|
34
|
+
# error. Used only to annotate the failover log — failover happens on *any* AdapterError.
|
|
35
|
+
_RATE_LIMIT_RE = re.compile(r"429|rate.?limit|quota|resource_exhausted|overloaded|too many requests", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RouterError(RuntimeError):
|
|
39
|
+
"""Raised when no orchestrator can serve a request.
|
|
40
|
+
|
|
41
|
+
Two cases: the roster has no orchestrator-capable entry, or every orchestrator tried failed.
|
|
42
|
+
The message names each per-orchestrator failure so the caller can see what went wrong.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _looks_like_rate_limit(message: str) -> bool:
|
|
47
|
+
"""Return whether an error message looks like a rate-limit / capacity rejection.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
message: The error text (e.g. an ``AdapterError`` string).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
``True`` if it matches a known rate-limit/capacity pattern.
|
|
54
|
+
"""
|
|
55
|
+
return bool(_RATE_LIMIT_RE.search(message or ""))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_state_path() -> Path:
|
|
59
|
+
"""Return the rotation-state file path.
|
|
60
|
+
|
|
61
|
+
Honors ``TANGLEBRAIN_STATE_DIR`` (``~`` expanded); otherwise ``~/.cache/tanglebrain/``.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The absolute path to the router state JSON file.
|
|
65
|
+
"""
|
|
66
|
+
base = os.environ.get(STATE_DIR_ENV)
|
|
67
|
+
root = Path(base).expanduser() if base else Path.home() / DEFAULT_STATE_SUBDIR
|
|
68
|
+
return root / STATE_FILENAME
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_cursor(path: Path) -> int:
|
|
72
|
+
"""Read the rotation cursor from the state file, tolerating missing/corrupt state.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: The state file path.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The stored cursor (>= 0), or ``0`` if the file is absent, unreadable, or malformed —
|
|
79
|
+
bad state must never crash routing.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(path.read_text())
|
|
83
|
+
cursor = int(data["cursor"])
|
|
84
|
+
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
|
85
|
+
return 0
|
|
86
|
+
return cursor if cursor >= 0 else 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_cursor(path: Path, cursor: int) -> None:
|
|
90
|
+
"""Persist the rotation cursor, creating the parent directory as needed.
|
|
91
|
+
|
|
92
|
+
The write is not locked or atomic: two concurrent ``--route`` processes can read the same
|
|
93
|
+
cursor and both write (last-writer-wins), so a rotation slot may be skipped or repeated. That
|
|
94
|
+
is intentionally accepted — the cursor is a load-spread *hint*, not a correctness invariant, so
|
|
95
|
+
a lost update only mildly perturbs the spread, never breaks routing.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
path: The state file path.
|
|
99
|
+
cursor: The cursor value to store.
|
|
100
|
+
"""
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
path.write_text(json.dumps({"cursor": cursor}))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Router:
|
|
106
|
+
"""Frontier-first orchestrator router: task-fit selection + rotation + failover."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
roster: Roster,
|
|
111
|
+
state_path: str | os.PathLike[str] | None = None,
|
|
112
|
+
adapter_factory: Callable[..., Adapter] = build_adapter,
|
|
113
|
+
inject_delegate: bool = True,
|
|
114
|
+
settings: Settings | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Configure the router.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
roster: The loaded roster; its ``can_orchestrate`` entries form the rotation set.
|
|
120
|
+
state_path: Path to the rotation-state file. Defaults to :func:`default_state_path`.
|
|
121
|
+
Inject a temp path in tests so they never touch the real cache.
|
|
122
|
+
adapter_factory: Builds an adapter for an entry. Defaults to the selector's
|
|
123
|
+
``build_adapter``; injectable for tests. Called as
|
|
124
|
+
``adapter_factory(entry, inject_delegate=...)``.
|
|
125
|
+
inject_delegate: Make the local-delegate tool available to each orchestrator, so it
|
|
126
|
+
offloads sub-tasks to the free local backend. On by default; set false to route to
|
|
127
|
+
bare orchestrators (e.g. for debugging).
|
|
128
|
+
settings: Global settings carrying the paid-API billing gate. Loaded from the
|
|
129
|
+
packaged ``config/settings.yaml`` when ``None``. The router consults
|
|
130
|
+
``api_billing_enabled`` to decide whether the last-resort paid-API fallback is live;
|
|
131
|
+
with the gate off (the default) the router never reaches a paid tier.
|
|
132
|
+
"""
|
|
133
|
+
self.roster = roster
|
|
134
|
+
self.state_path = Path(state_path) if state_path is not None else default_state_path()
|
|
135
|
+
self._adapter_factory = adapter_factory
|
|
136
|
+
self.inject_delegate = inject_delegate
|
|
137
|
+
self.settings = settings if settings is not None else load_settings()
|
|
138
|
+
# The entry that served the most recent successful route(), or None before any success.
|
|
139
|
+
# Surfaced so the CLI's measurement seam can record which tier/model handled a task
|
|
140
|
+
# without changing route()'s str return type.
|
|
141
|
+
self.last_served: RosterEntry | None = None
|
|
142
|
+
|
|
143
|
+
def route(
|
|
144
|
+
self,
|
|
145
|
+
prompt: str,
|
|
146
|
+
task: str | None = None,
|
|
147
|
+
opts: Mapping[str, object] | None = None,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Route ``prompt`` to an orchestrator sub, with task-fit, rotation, and failover.
|
|
150
|
+
|
|
151
|
+
Selection: among the ``can_orchestrate`` entries, walk in round-robin order starting from
|
|
152
|
+
the persisted cursor; if ``task`` is given, prefer entries whose ``good_at`` contains it
|
|
153
|
+
(falling back to all orchestrators when none match — task-fit is a preference, not a gate).
|
|
154
|
+
Try each in order; on an :class:`~tanglebrain.adapters.base.AdapterError` from one, fail
|
|
155
|
+
over to the next. On success, advance the persisted cursor past the served orchestrator so
|
|
156
|
+
the next request starts elsewhere (load-spread).
|
|
157
|
+
|
|
158
|
+
Last resort: if **every** orchestrator fails and the paid-API billing gate is on
|
|
159
|
+
(``settings.api_billing_enabled``), fall through to the enabled ``tier: api`` entries in
|
|
160
|
+
roster order — the genuine last resort. With the gate off (the default) the router never
|
|
161
|
+
reaches a paid tier. A paid success does not advance the orchestrator rotation cursor.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
prompt: The task prompt.
|
|
165
|
+
task: Optional task-fit hint — a ``good_at`` tag (e.g. ``code``, ``reasoning``,
|
|
166
|
+
``long-context``).
|
|
167
|
+
opts: Optional per-call adapter options (passed straight through to ``adapter.run``).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The serving entry's response text (an orchestrator, or a paid-API fallback when the gate
|
|
171
|
+
is on and all orchestrators failed). The served entry is exposed on ``last_served``.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
RouterError: If the roster has no orchestrator-capable entry, or every candidate tried
|
|
175
|
+
(all orchestrators, plus any enabled paid-API fallback) failed.
|
|
176
|
+
"""
|
|
177
|
+
orchestrators = self.roster.orchestrators()
|
|
178
|
+
if not orchestrators:
|
|
179
|
+
raise RouterError(
|
|
180
|
+
"no orchestrator-capable entries in roster (need at least one can_orchestrate: true)"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
n = len(orchestrators)
|
|
184
|
+
cursor = _read_cursor(self.state_path) % n
|
|
185
|
+
rotated = [orchestrators[(cursor + i) % n] for i in range(n)]
|
|
186
|
+
|
|
187
|
+
if task:
|
|
188
|
+
candidates = [e for e in rotated if task in e.good_at] or rotated
|
|
189
|
+
else:
|
|
190
|
+
candidates = rotated
|
|
191
|
+
|
|
192
|
+
failures: list[tuple[str, str]] = []
|
|
193
|
+
for entry in candidates:
|
|
194
|
+
adapter = self._adapter_factory(entry, inject_delegate=self.inject_delegate)
|
|
195
|
+
try:
|
|
196
|
+
text = adapter.run(prompt, opts)
|
|
197
|
+
except AdapterError as exc:
|
|
198
|
+
failures.append((entry.id, str(exc)))
|
|
199
|
+
continue
|
|
200
|
+
served_pos = next(i for i, e in enumerate(orchestrators) if e.id == entry.id)
|
|
201
|
+
_write_cursor(self.state_path, (served_pos + 1) % n)
|
|
202
|
+
self.last_served = entry
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
# Last-resort paid-API fallback. Only after EVERY orchestrator has failed, and only when
|
|
206
|
+
# billing is explicitly enabled, fall through to a paid `api` entry (the genuine last
|
|
207
|
+
# resort). With the gate off — the default — this block is skipped entirely, so the router
|
|
208
|
+
# can never reach a paid tier. Enabled paid entries are tried in roster order; a paid success
|
|
209
|
+
# does NOT advance the orchestrator rotation cursor (api is not part of the rotation). This
|
|
210
|
+
# requires at least one orchestrator above — the router never paid-routes a roster that has no
|
|
211
|
+
# orchestrators to exhaust (use ``--model`` for an explicit paid call).
|
|
212
|
+
if self.settings.api_billing_enabled:
|
|
213
|
+
attempted = {eid for eid, _ in failures}
|
|
214
|
+
for entry in self.roster.in_tier("api"):
|
|
215
|
+
# Skip disabled entries, and any already tried above (a degenerate roster could flag
|
|
216
|
+
# a paid entry ``can_orchestrate: true``, putting it in the rotation — don't re-run it).
|
|
217
|
+
if not entry.enabled or entry.id in attempted:
|
|
218
|
+
continue
|
|
219
|
+
adapter = self._adapter_factory(entry, inject_delegate=self.inject_delegate)
|
|
220
|
+
try:
|
|
221
|
+
text = adapter.run(prompt, opts)
|
|
222
|
+
except AdapterError as exc:
|
|
223
|
+
failures.append((entry.id, str(exc)))
|
|
224
|
+
continue
|
|
225
|
+
self.last_served = entry
|
|
226
|
+
return text
|
|
227
|
+
|
|
228
|
+
detail = "; ".join(
|
|
229
|
+
f"{eid}{' [rate-limit]' if _looks_like_rate_limit(msg) else ''}: {msg}"
|
|
230
|
+
for eid, msg in failures
|
|
231
|
+
)
|
|
232
|
+
raise RouterError(f"all {len(failures)} candidate(s) failed: {detail}")
|
tanglebrain/selector.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Direct selection — explicit entry / local-first resolution.
|
|
2
|
+
|
|
3
|
+
**This is NOT the router.** The router (:mod:`tanglebrain.router`) — frontier-first decompose,
|
|
4
|
+
task-fit orchestrator selection, rotation, failover — lives in its own module.
|
|
5
|
+
|
|
6
|
+
This module's job is narrow: resolve a single entry (an explicit ``--model`` id, or the free local
|
|
7
|
+
tier) and build its adapter. The logic here is deliberately minimal; do not grow it into the router
|
|
8
|
+
— that belongs in its own module so the two don't get conflated.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from tanglebrain.adapters import ApiAdapter, AdapterError, CliAdapter, OpenAICompatAdapter
|
|
13
|
+
from tanglebrain.adapters.base import Adapter
|
|
14
|
+
from tanglebrain.roster import Roster, RosterEntry
|
|
15
|
+
from tanglebrain.settings import Settings, load_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SelectionError(RuntimeError):
|
|
19
|
+
"""Raised when no suitable roster entry can be selected for a request."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def select_local(roster: Roster) -> RosterEntry:
|
|
23
|
+
"""Select the free local entry to route to.
|
|
24
|
+
|
|
25
|
+
Returns the first ``local``-tier entry invoked via ``openai-compat`` — the free tier the
|
|
26
|
+
openai-compat adapter can actually call.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
roster: The loaded roster.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The selected local :class:`~tanglebrain.roster.RosterEntry`.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
SelectionError: If the roster has no invocable local entry.
|
|
36
|
+
"""
|
|
37
|
+
for entry in roster:
|
|
38
|
+
if entry.tier == "local" and entry.invoke.kind == "openai-compat":
|
|
39
|
+
return entry
|
|
40
|
+
raise SelectionError(
|
|
41
|
+
"no invocable local entry in roster (need tier=local, invoke.kind=openai-compat)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def select_by_id(roster: Roster, entry_id: str) -> RosterEntry:
|
|
46
|
+
"""Select a roster entry by id (lets the CLI drive a named entry end-to-end).
|
|
47
|
+
|
|
48
|
+
This is **not** the router — it makes no routing decision, it just resolves an explicit id the
|
|
49
|
+
caller named. Orchestrator selection / rotation / failover lives in the router.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
roster: The loaded roster.
|
|
53
|
+
entry_id: The id to select.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The matching :class:`~tanglebrain.roster.RosterEntry`.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
SelectionError: If no entry has that id.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return roster.by_id(entry_id)
|
|
63
|
+
except KeyError:
|
|
64
|
+
known = ", ".join(e.id for e in roster) or "(empty roster)"
|
|
65
|
+
raise SelectionError(f"no roster entry with id {entry_id!r}; known ids: {known}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_adapter(
|
|
69
|
+
entry: RosterEntry,
|
|
70
|
+
inject_delegate: bool = False,
|
|
71
|
+
settings: Settings | None = None,
|
|
72
|
+
) -> Adapter:
|
|
73
|
+
"""Build the adapter for a roster entry.
|
|
74
|
+
|
|
75
|
+
Supports the ``openai-compat`` (free local), ``cli`` (authenticated CLI), and ``api`` (paid,
|
|
76
|
+
gateway-fronted) adapters. The paid tier is **gated**: an ``api`` entry only builds when the
|
|
77
|
+
global ``api_billing_enabled`` flag is on *and* the entry's own ``enabled`` flag is on — the
|
|
78
|
+
durable "no paid billing without the explicit toggle" rule. Otherwise it raises clearly rather
|
|
79
|
+
than pretending the entry is routable, so a ``tier: api`` entry parses but stays inert by
|
|
80
|
+
default.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
entry: The roster entry to build an adapter for.
|
|
84
|
+
inject_delegate: For ``cli`` entries, make the local-delegate tool available to the CLI as
|
|
85
|
+
an orchestrator — the router sets this so an orchestrator can offload sub-tasks to the
|
|
86
|
+
free local backend. Ignored for non-``cli`` kinds.
|
|
87
|
+
settings: Global settings carrying the billing gate. Loaded from the packaged
|
|
88
|
+
``config/settings.yaml`` when ``None`` (and only when an ``api`` entry is actually being
|
|
89
|
+
built, so non-paid builds never touch the file). Injectable for tests.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
An :class:`~tanglebrain.adapters.base.Adapter` for the entry.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AdapterError: If an ``api`` entry is selected while billing is gated off or the entry is
|
|
96
|
+
disabled, or if the invoke kind is unknown.
|
|
97
|
+
"""
|
|
98
|
+
if entry.invoke.kind == "openai-compat":
|
|
99
|
+
return OpenAICompatAdapter.from_entry(entry)
|
|
100
|
+
if entry.invoke.kind == "cli":
|
|
101
|
+
return CliAdapter.from_entry(entry, inject_delegate=inject_delegate)
|
|
102
|
+
if entry.invoke.kind == "api":
|
|
103
|
+
if settings is None:
|
|
104
|
+
settings = load_settings()
|
|
105
|
+
if not settings.api_billing_enabled:
|
|
106
|
+
raise AdapterError(
|
|
107
|
+
f"entry {entry.id!r} is a paid-API tier but billing is disabled "
|
|
108
|
+
"(api_billing_enabled=false in config/settings.yaml); it is inert"
|
|
109
|
+
)
|
|
110
|
+
if not entry.enabled:
|
|
111
|
+
raise AdapterError(
|
|
112
|
+
f"paid-API entry {entry.id!r} is disabled (enabled=false); not routable"
|
|
113
|
+
)
|
|
114
|
+
return ApiAdapter.from_entry(entry)
|
|
115
|
+
raise AdapterError(
|
|
116
|
+
f"no adapter for invoke.kind {entry.invoke.kind!r} (entry {entry.id!r})"
|
|
117
|
+
)
|
tanglebrain/settings.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Global settings loader — the paid-API billing gate.
|
|
2
|
+
|
|
3
|
+
The roster (``roster.yaml``) is a *list* of routable backends; per-entry policy lives there. But a
|
|
4
|
+
few knobs are **global**, not per-entry — chief among them the paid-API billing gate. Those live
|
|
5
|
+
here, in a separate ``config/settings.yaml`` mapping, so the roster stays a bare list (folding a
|
|
6
|
+
global flag into it would force a breaking list→mapping parse change across every roster reader).
|
|
7
|
+
|
|
8
|
+
``api_billing_enabled`` is the durable safety contract: **paid billing is OFF unless this flag is
|
|
9
|
+
explicitly true.** When false, ``tier: api`` roster entries still
|
|
10
|
+
parse and are inspectable, but :func:`tanglebrain.selector.build_adapter` refuses to build their
|
|
11
|
+
adapter — they are inert. This is the single switch that makes accidental spend structurally hard.
|
|
12
|
+
|
|
13
|
+
Loading is fault-tolerant in the *missing-file* direction only: an absent settings file yields the
|
|
14
|
+
safe defaults (gate **off**). A present-but-malformed file raises :class:`SettingsError` rather than
|
|
15
|
+
silently falling back, so a typo in the gate can never be mistaken for "billing enabled".
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SettingsError(ValueError):
|
|
27
|
+
"""Raised when the settings YAML is present but malformed or semantically invalid.
|
|
28
|
+
|
|
29
|
+
A subclass of ``ValueError`` so callers can catch it specifically while still treating it as
|
|
30
|
+
the bad-input error it is. A *missing* file is not an error (it yields defaults); only a
|
|
31
|
+
present-but-broken file raises.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Settings:
|
|
37
|
+
"""Global, non-per-entry TangleBrain settings.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
api_billing_enabled: The paid-API billing gate. ``False`` (the default) means every
|
|
41
|
+
``tier: api`` roster entry parses but is **never routable** — the adapter factory
|
|
42
|
+
refuses to build it. Must be flipped to ``True`` explicitly to permit any paid spend.
|
|
43
|
+
classifier_gate_enabled: The local classifier gate. ``False`` (the default) keeps the
|
|
44
|
+
normal frontier-first routing. When ``True``, a cheap local classify runs in front of the
|
|
45
|
+
router: trivial requests go straight to the free local backend, only frontier requests
|
|
46
|
+
reach an orchestrator. Off until an operator turns it on.
|
|
47
|
+
delegate_max_concurrency: Operator cap on how many sub-tasks ``delegate_many`` runs at once.
|
|
48
|
+
``None`` (the default) means TangleBrain derives the cap from the system
|
|
49
|
+
(``os.cpu_count()``). Set a positive int to pin it to the backend's real parallelism
|
|
50
|
+
(e.g. match a local model server's ``OLLAMA_NUM_PARALLEL``) — the true limit TB can't
|
|
51
|
+
portably introspect. A per-call ``max_concurrency`` may lower this but never exceed it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
api_billing_enabled: bool = False
|
|
55
|
+
classifier_gate_enabled: bool = False
|
|
56
|
+
delegate_max_concurrency: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def default_settings_path() -> Path:
|
|
60
|
+
"""Return the path to the settings YAML shipped with the package.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The absolute path to ``tanglebrain/config/settings.yaml``.
|
|
64
|
+
"""
|
|
65
|
+
return Path(__file__).resolve().parent / "config" / "settings.yaml"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_settings(path: str | os.PathLike[str] | None = None) -> Settings:
|
|
69
|
+
"""Load global settings, defaulting safely when the file is absent.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
path: Path to the settings YAML. Defaults to the packaged
|
|
73
|
+
``tanglebrain/config/settings.yaml`` when ``None``.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The parsed :class:`Settings`. A missing file yields :class:`Settings` defaults
|
|
77
|
+
(billing gate **off**).
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
SettingsError: If the file exists but is not a YAML mapping, or ``api_billing_enabled``
|
|
81
|
+
is present and not a boolean. Never falls back silently on a malformed gate.
|
|
82
|
+
"""
|
|
83
|
+
settings_path = Path(path) if path is not None else default_settings_path()
|
|
84
|
+
if not settings_path.exists():
|
|
85
|
+
return Settings()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
raw = yaml.safe_load(settings_path.read_text())
|
|
89
|
+
except yaml.YAMLError as exc:
|
|
90
|
+
raise SettingsError(f"settings file is not valid YAML: {settings_path}: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
# An empty file (``raw is None``) is a valid "use all defaults" statement.
|
|
93
|
+
if raw is None:
|
|
94
|
+
return Settings()
|
|
95
|
+
if not isinstance(raw, dict):
|
|
96
|
+
raise SettingsError(
|
|
97
|
+
f"settings file must be a YAML mapping, got {type(raw).__name__}: {settings_path}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _bool(key: str) -> bool:
|
|
101
|
+
value = raw.get(key, False)
|
|
102
|
+
# Reject non-bool explicitly — `bool` is a subclass of `int`, but a stray `1`/`"true"` in a
|
|
103
|
+
# gate must be a hard error, never a coincidental truthy enable.
|
|
104
|
+
if not isinstance(value, bool):
|
|
105
|
+
raise SettingsError(
|
|
106
|
+
f"settings {key!r} must be a boolean (true/false), got "
|
|
107
|
+
f"{value!r} ({type(value).__name__}): {settings_path}"
|
|
108
|
+
)
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
def _opt_positive_int(key: str) -> int | None:
|
|
112
|
+
value = raw.get(key)
|
|
113
|
+
if value is None:
|
|
114
|
+
return None
|
|
115
|
+
# Reject bool explicitly (bool is an int subclass) so a stray `true` can't pass as a count;
|
|
116
|
+
# reject floats/strings; require a positive count.
|
|
117
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
118
|
+
raise SettingsError(
|
|
119
|
+
f"settings {key!r} must be a positive integer, got "
|
|
120
|
+
f"{value!r} ({type(value).__name__}): {settings_path}"
|
|
121
|
+
)
|
|
122
|
+
if value < 1:
|
|
123
|
+
raise SettingsError(
|
|
124
|
+
f"settings {key!r} must be >= 1, got {value!r}: {settings_path}"
|
|
125
|
+
)
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
return Settings(
|
|
129
|
+
api_billing_enabled=_bool("api_billing_enabled"),
|
|
130
|
+
classifier_gate_enabled=_bool("classifier_gate_enabled"),
|
|
131
|
+
delegate_max_concurrency=_opt_positive_int("delegate_max_concurrency"),
|
|
132
|
+
)
|