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/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}")
@@ -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
+ )
@@ -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
+ )