forked from Jason-Vaughan/TangleBrain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathroster.py
More file actions
358 lines (286 loc) · 13.7 KB
/
Copy pathroster.py
File metadata and controls
358 lines (286 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""Roster config loader (plan §5).
The roster is a simple, editable YAML list of routable models — *not* a registry subsystem
(we are explicitly not rebuilding FleetHub). Adding, removing, or reorganizing a model is an
entry edit, never a code change; this modifiability is a first-class requirement.
This module parses that YAML into typed objects. It parses *every* entry regardless of which
adapters are built, so the full roster is always inspectable; whether a given entry is
*invocable* depends on which adapters exist (``openai-compat`` + ``cli``) and, for ``tier: api``
entries, on the global ``api_billing_enabled`` gate plus the entry's own ``enabled`` flag — a
paid entry parses here but stays inert until both are on (issue #2, see :mod:`tanglebrain.settings`).
Each entry (plan §5)::
- id: gpt-oss-120b
tier: local
invoke: { kind: openai-compat, base_url: "http://.../v1", model: "gpt-oss-120b" }
cost: free
good_at: [grunt, code, tools]
``invoke.kind`` is one of ``openai-compat`` | ``cli`` | ``api``. ``scrub_env`` enforces the
§7 sub-vs-key safety rule per adapter. ``can_orchestrate`` flags an entry as eligible for the
§6 orchestrator rotation. ``key_ref`` references a credential without embedding it (see the
contract's key-ref convention): ``file:PATH`` | ``env:NAME`` | ``none``.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
import yaml
VALID_KINDS = ("openai-compat", "cli", "api")
VALID_TIERS = ("local", "sub", "api")
class RosterError(ValueError):
"""Raised when the roster YAML is missing, malformed, or semantically invalid.
A subclass of ``ValueError`` so callers can catch it specifically while still treating
it as the bad-input error it is.
"""
@dataclass(frozen=True)
class Invoke:
"""How to invoke one roster entry — the transport-specific call details (plan §5).
Attributes:
kind: One of ``openai-compat`` | ``cli`` | ``api``.
base_url: OpenAI-compat base URL (e.g. the LiteLLM ``/v1`` endpoint). Required for
``openai-compat``; ``None`` otherwise.
model: Model id/alias to request. Required for ``openai-compat``; ``None`` otherwise.
cmd: Argv for a subprocess CLI invocation. Required for ``cli``; ``None`` otherwise.
A literal ``{prompt}`` token in the argv is replaced with the prompt by the CLI
adapter; if no token is present the prompt is appended as the final argument.
scrub_env: Env var names to remove from a subprocess's environment before launch
(e.g. ``ANTHROPIC_API_KEY``, so ``claude -p`` rides the flat sub, not a billed key).
parse: Name of the output parser the ``cli`` adapter uses to extract the final text
from the subprocess stdout (e.g. ``claude-json``, ``gemini-json``, ``plain``).
``None`` lets the adapter pick its default. Informational for non-``cli`` kinds.
delegate_args: Extra argv appended to ``cmd`` when the router invokes this entry as an
orchestrator with local delegation enabled (C3b) — the per-CLI flags that register the
gpt-oss MCP delegate and allow its tool. A ``{delegate_mcp_json}`` token is substituted
with the delegate's MCP-server JSON at runtime. Empty = the CLI cannot be handed the
delegate per-invocation (or doesn't need it).
key_ref: Credential reference — ``file:PATH`` | ``env:NAME`` | ``none`` — never a raw
secret. ``None`` means no credential is configured for this entry.
"""
kind: str
base_url: str | None = None
model: str | None = None
cmd: list[str] | None = None
scrub_env: list[str] = field(default_factory=list)
parse: str | None = None
delegate_args: list[str] = field(default_factory=list)
key_ref: str | None = None
@dataclass(frozen=True)
class RosterEntry:
"""One routable model in the roster (plan §5).
Attributes:
id: Unique identifier for the entry (e.g. ``gpt-oss-120b``, ``claude``).
tier: Cost tier — ``local`` | ``sub`` | ``api``.
invoke: How to call this entry (see :class:`Invoke`).
cost: Free-form cost annotation (e.g. ``free``, ``flat-rate``); informational.
good_at: Tags describing what the entry is good at (drives task-fit routing in §6).
can_orchestrate: Whether this entry joins the §6 orchestrator rotation.
enabled: Per-entry kill-switch. ``True`` by default. Currently enforced only for
``tier: api`` entries (a disabled paid key is never routable, even with the global
``api_billing_enabled`` gate on); informational for other tiers.
budget_usd_month: Optional monthly USD budget annotation for a paid key (issue #2), which
must be a number ``> 0`` when present. In v1 this is **displayed only** — the hard cap
is enforced LiteLLM-side on the virtual key, not by TangleBrain. ``None`` (the default)
means no budget is recorded.
"""
id: str
tier: str
invoke: Invoke
cost: str | None = None
good_at: list[str] = field(default_factory=list)
can_orchestrate: bool = False
enabled: bool = True
budget_usd_month: float | None = None
class Roster:
"""An ordered collection of :class:`RosterEntry`, with lookup/filter helpers.
Order is preserved from the YAML file because it is meaningful — e.g. the local-first
selector (C1) walks entries in declared order.
"""
def __init__(self, entries: list[RosterEntry]) -> None:
"""Store the entries and build an id index, rejecting duplicate ids.
Args:
entries: The parsed roster entries, in file order.
Raises:
RosterError: If two entries share the same ``id``.
"""
self._entries = list(entries)
self._by_id: dict[str, RosterEntry] = {}
for entry in self._entries:
if entry.id in self._by_id:
raise RosterError(f"duplicate roster entry id: {entry.id!r}")
self._by_id[entry.id] = entry
def __iter__(self):
"""Iterate entries in declared (file) order."""
return iter(self._entries)
def __len__(self) -> int:
"""Return the number of entries in the roster."""
return len(self._entries)
@property
def entries(self) -> list[RosterEntry]:
"""Return a copy of the entries list, in declared order."""
return list(self._entries)
def by_id(self, entry_id: str) -> RosterEntry:
"""Return the entry with the given id.
Args:
entry_id: The id to look up.
Returns:
The matching :class:`RosterEntry`.
Raises:
KeyError: If no entry has that id.
"""
return self._by_id[entry_id]
def in_tier(self, tier: str) -> list[RosterEntry]:
"""Return all entries in the given tier, in declared order.
Args:
tier: The tier to filter by (e.g. ``local``).
Returns:
The matching entries (possibly empty).
"""
return [e for e in self._entries if e.tier == tier]
def orchestrators(self) -> list[RosterEntry]:
"""Return the entries flagged ``can_orchestrate`` (the §6 rotation set).
Returns:
The orchestrator-capable entries, in declared order.
"""
return [e for e in self._entries if e.can_orchestrate]
def _parse_invoke(raw: object, entry_id: str) -> Invoke:
"""Validate and build an :class:`Invoke` from one entry's ``invoke`` block.
Args:
raw: The raw ``invoke`` value from YAML (expected to be a mapping).
entry_id: The owning entry's id, for error messages.
Returns:
A validated :class:`Invoke`.
Raises:
RosterError: If the block is not a mapping, the kind is missing/unknown, or the
fields required for that kind are absent.
"""
if not isinstance(raw, dict):
raise RosterError(f"entry {entry_id!r}: 'invoke' must be a mapping")
kind = raw.get("kind")
if kind not in VALID_KINDS:
raise RosterError(
f"entry {entry_id!r}: invoke.kind must be one of {VALID_KINDS}, got {kind!r}"
)
base_url = raw.get("base_url")
model = raw.get("model")
cmd = raw.get("cmd")
scrub_env = raw.get("scrub_env", []) or []
parse = raw.get("parse")
delegate_args = raw.get("delegate_args", []) or []
key_ref = raw.get("key_ref")
if kind == "openai-compat":
if not base_url or not model:
raise RosterError(
f"entry {entry_id!r}: openai-compat invoke requires 'base_url' and 'model'"
)
elif kind == "cli":
if not cmd or not isinstance(cmd, list):
raise RosterError(f"entry {entry_id!r}: cli invoke requires a non-empty 'cmd' list")
elif kind == "api":
# Paid API is LiteLLM-fronted (plan §7, decision #7): base_url is the LiteLLM endpoint,
# model the alias it exposes, and key_ref a *scoped LiteLLM virtual key* — never a raw
# provider key. All three are required so a paid entry can never be half-configured.
if not base_url or not model:
raise RosterError(
f"entry {entry_id!r}: api invoke requires 'base_url' and 'model' "
"(the LiteLLM-fronted endpoint + model alias)"
)
if not key_ref:
raise RosterError(
f"entry {entry_id!r}: api invoke requires 'key_ref' "
"(a scoped LiteLLM virtual key reference — never a raw provider key)"
)
if not isinstance(scrub_env, list):
raise RosterError(f"entry {entry_id!r}: invoke.scrub_env must be a list")
if parse is not None and not isinstance(parse, str):
raise RosterError(f"entry {entry_id!r}: invoke.parse must be a string")
if not isinstance(delegate_args, list):
raise RosterError(f"entry {entry_id!r}: invoke.delegate_args must be a list")
return Invoke(
kind=kind,
base_url=base_url,
model=model,
cmd=list(cmd) if cmd else None,
scrub_env=list(scrub_env),
parse=parse,
delegate_args=list(delegate_args),
key_ref=key_ref,
)
def _parse_entry(raw: object) -> RosterEntry:
"""Validate and build one :class:`RosterEntry` from a YAML mapping.
Args:
raw: The raw entry value from YAML (expected to be a mapping).
Returns:
A validated :class:`RosterEntry`.
Raises:
RosterError: If the entry is not a mapping, is missing ``id`` / ``tier`` / ``invoke``,
or ``tier`` is not one of ``VALID_TIERS``.
"""
if not isinstance(raw, dict):
raise RosterError(f"each roster entry must be a mapping, got {type(raw).__name__}")
entry_id = raw.get("id")
if not entry_id:
raise RosterError("roster entry is missing required field 'id'")
tier = raw.get("tier")
if not tier:
raise RosterError(f"entry {entry_id!r}: missing required field 'tier'")
if tier not in VALID_TIERS:
raise RosterError(
f"entry {entry_id!r}: tier must be one of {VALID_TIERS}, got {tier!r}"
)
if "invoke" not in raw:
raise RosterError(f"entry {entry_id!r}: missing required field 'invoke'")
good_at = raw.get("good_at", []) or []
if not isinstance(good_at, list):
raise RosterError(f"entry {entry_id!r}: 'good_at' must be a list")
enabled = raw.get("enabled", True)
if not isinstance(enabled, bool):
raise RosterError(
f"entry {entry_id!r}: 'enabled' must be a boolean (true/false), got {enabled!r}"
)
budget = raw.get("budget_usd_month")
if budget is not None:
# Reject bool explicitly (bool is an int subclass) — a budget of `true` is a config error.
if isinstance(budget, bool) or not isinstance(budget, (int, float)):
raise RosterError(
f"entry {entry_id!r}: 'budget_usd_month' must be a number, got {budget!r}"
)
if budget <= 0:
raise RosterError(
f"entry {entry_id!r}: 'budget_usd_month' must be > 0, got {budget!r}"
)
return RosterEntry(
id=entry_id,
tier=tier,
invoke=_parse_invoke(raw["invoke"], entry_id),
cost=raw.get("cost"),
good_at=list(good_at),
can_orchestrate=bool(raw.get("can_orchestrate", False)),
enabled=enabled,
budget_usd_month=float(budget) if budget is not None else None,
)
def default_roster_path() -> Path:
"""Return the path to the roster YAML shipped with the package.
Returns:
The absolute path to ``tanglebrain/config/roster.yaml``.
"""
return Path(__file__).resolve().parent / "config" / "roster.yaml"
def load_roster(path: str | os.PathLike[str] | None = None) -> Roster:
"""Load and validate the roster YAML into a :class:`Roster`.
Args:
path: Path to the roster YAML. Defaults to the packaged
``tanglebrain/config/roster.yaml`` when ``None``.
Returns:
The parsed :class:`Roster`.
Raises:
RosterError: If the file is missing, is not a YAML list, or any entry is invalid.
"""
roster_path = Path(path) if path is not None else default_roster_path()
if not roster_path.exists():
raise RosterError(f"roster file not found: {roster_path}")
try:
raw = yaml.safe_load(roster_path.read_text())
except yaml.YAMLError as exc:
raise RosterError(f"roster file is not valid YAML: {roster_path}: {exc}") from exc
if not isinstance(raw, list):
raise RosterError(
f"roster file must be a YAML list of entries, got {type(raw).__name__}: {roster_path}"
)
return Roster([_parse_entry(item) for item in raw])