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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>TangleBrain — knob panel</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/logo.png">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--primary: #8BC34A; --primary-bright: #9CCC65; --primary-dark: #558B2F;
|
|
11
|
+
--bg: #000; --card-bg: #0D0D0D; --elevated-bg: #1A1A1A;
|
|
12
|
+
--text: #E8E8E8; --text-muted: #777; --danger: #EF5350; --amber: #FFA726;
|
|
13
|
+
--border: #222;
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body {
|
|
17
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif;
|
|
19
|
+
line-height: 1.5; padding: 24px 16px 64px;
|
|
20
|
+
}
|
|
21
|
+
.wrap { max-width: 920px; margin: 0 auto; }
|
|
22
|
+
header { text-align: center; padding: 8px 0 20px; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
|
|
23
|
+
header .logo { width: 72px; height: 72px; display: block; margin: 0 auto 10px; }
|
|
24
|
+
header h1 { margin: 0; font-size: 1.5rem; letter-spacing: .5px; }
|
|
25
|
+
header h1 .accent { color: var(--primary); }
|
|
26
|
+
header .sub { color: var(--text-muted); font-size: .8rem; margin-top: 4px; }
|
|
27
|
+
h2 { font-size: .95rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); margin: 28px 0 10px; }
|
|
28
|
+
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
|
29
|
+
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
|
30
|
+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
31
|
+
th { color: var(--text-muted); font-weight: 600; text-transform: uppercase; font-size: .68rem; letter-spacing: .5px; }
|
|
32
|
+
td.mono, .mono { font-family: 'SF Mono', Menlo, monospace; font-size: .8rem; }
|
|
33
|
+
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: .7rem; border: 1px solid var(--border); margin: 1px 3px 1px 0; }
|
|
34
|
+
.pill.local { color: var(--primary); border-color: var(--primary-dark); }
|
|
35
|
+
.pill.sub { color: var(--primary-bright); }
|
|
36
|
+
.pill.api { color: var(--amber); }
|
|
37
|
+
.pill.orch { color: var(--primary); border-color: var(--primary-dark); }
|
|
38
|
+
.stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
|
39
|
+
.stat { background: var(--elevated-bg); border-radius: 8px; padding: 12px 14px; }
|
|
40
|
+
.stat .label { color: var(--text-muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .5px; }
|
|
41
|
+
.stat .value { font-size: 1.3rem; margin-top: 4px; }
|
|
42
|
+
.stat .value.big { color: var(--primary); }
|
|
43
|
+
.caveat { color: var(--amber); font-size: .78rem; margin-top: 10px; }
|
|
44
|
+
textarea, input[type=text] {
|
|
45
|
+
width: 100%; background: var(--elevated-bg); border: 1px solid var(--border); color: var(--text);
|
|
46
|
+
border-radius: 8px; padding: 10px; font-family: inherit; font-size: .9rem; resize: vertical;
|
|
47
|
+
}
|
|
48
|
+
textarea:focus, input:focus { outline: none; border-color: var(--primary-dark); }
|
|
49
|
+
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
|
50
|
+
label.chk { color: var(--text-muted); font-size: .82rem; display: flex; align-items: center; gap: 6px; }
|
|
51
|
+
button {
|
|
52
|
+
background: var(--card-bg); color: var(--primary); border: 1px solid var(--primary-dark);
|
|
53
|
+
border-radius: 8px; padding: 9px 18px; font-size: .9rem; cursor: pointer; font-family: inherit;
|
|
54
|
+
}
|
|
55
|
+
button:hover { background: var(--elevated-bg); }
|
|
56
|
+
button:disabled { color: var(--text-muted); border-color: var(--border); cursor: default; }
|
|
57
|
+
pre.out { background: var(--elevated-bg); border: 1px solid var(--border); border-radius: 8px;
|
|
58
|
+
padding: 12px; white-space: pre-wrap; word-break: break-word; margin-top: 12px; font-size: .85rem; }
|
|
59
|
+
.served { color: var(--text-muted); font-size: .78rem; margin-top: 8px; }
|
|
60
|
+
.served b { color: var(--primary); }
|
|
61
|
+
.err { color: var(--danger); }
|
|
62
|
+
.muted { color: var(--text-muted); }
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<div class="wrap">
|
|
67
|
+
<header>
|
|
68
|
+
<img class="logo" src="/logo.png" alt="TangleBrain logo">
|
|
69
|
+
<h1>Tangle<span class="accent">Brain</span> · knob panel</h1>
|
|
70
|
+
<div class="sub">roster & pricing config · local spend-avoided rollup</div>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
<h2>Run a prompt</h2>
|
|
74
|
+
<div class="card">
|
|
75
|
+
<textarea id="prompt" rows="3" placeholder="Ask the router something…"></textarea>
|
|
76
|
+
<div class="row">
|
|
77
|
+
<input type="text" id="task" placeholder="task hint (optional: code, reasoning, long-context)" style="flex:1; min-width:220px;">
|
|
78
|
+
<label class="chk"><input type="checkbox" id="local"> force local ($0)</label>
|
|
79
|
+
<button id="run">Run</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div id="runResult"></div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<h2>Spend avoided <span class="muted">(local rollup)</span></h2>
|
|
85
|
+
<div class="card" id="statsCard"><span class="muted">loading…</span></div>
|
|
86
|
+
|
|
87
|
+
<h2>Roster</h2>
|
|
88
|
+
<div class="card" id="rosterCard"><span class="muted">loading…</span></div>
|
|
89
|
+
|
|
90
|
+
<h2>Pricing reference</h2>
|
|
91
|
+
<div class="card" id="pricingCard"><span class="muted">loading…</span></div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
const $ = (id) => document.getElementById(id);
|
|
96
|
+
// Full 5-char HTML escape — values are inserted into both text nodes AND attribute contexts
|
|
97
|
+
// (e.g. the editable pricing inputs' value="..."), so quotes MUST be escaped too.
|
|
98
|
+
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
99
|
+
|
|
100
|
+
async function getJSON(url) {
|
|
101
|
+
const r = await fetch(url);
|
|
102
|
+
return r.json();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function money(n) { return "$" + (Number(n) || 0).toFixed(2); }
|
|
106
|
+
|
|
107
|
+
async function loadStats() {
|
|
108
|
+
try {
|
|
109
|
+
const d = await getJSON("/api/stats");
|
|
110
|
+
const s = d.summary || {};
|
|
111
|
+
const tiers = Object.entries(s.by_tier || {}).map(([k, v]) => `${esc(k)} ${v}`).join(", ") || "—";
|
|
112
|
+
let html = `<div class="stat-grid">
|
|
113
|
+
<div class="stat"><div class="label">Tasks routed</div><div class="value">${s.tasks || 0}</div></div>
|
|
114
|
+
<div class="stat"><div class="label">Spend avoided</div><div class="value big">${money(s.spend_avoided_usd)}</div></div>
|
|
115
|
+
<div class="stat"><div class="label">By tier</div><div class="value mono" style="font-size:.95rem">${tiers}</div></div>
|
|
116
|
+
<div class="stat"><div class="label">Est. tokens (in / out)</div><div class="value mono" style="font-size:.95rem">${(s.in_tokens_est||0).toLocaleString()} / ${(s.out_tokens_est||0).toLocaleString()}</div></div>
|
|
117
|
+
</div>`;
|
|
118
|
+
if (d.is_placeholder) html += `<div class="caveat">⚠ pricing: PLACEHOLDER — figures illustrative until the anchor is ratified.</div>`;
|
|
119
|
+
const dg = s.delegates || {};
|
|
120
|
+
if (dg.count) {
|
|
121
|
+
const backends = Object.entries(dg.by_backend || {}).map(([k, v]) => `${esc(k)} ${v.count || 0}`).join(", ") || "—";
|
|
122
|
+
// Parent-task tree: how many top-level tasks the sub-calls link back to. Mirrors the CLI's
|
|
123
|
+
// "Linked to: N parent task(s)" line; sub-calls run outside a propagated task are "unlinked".
|
|
124
|
+
const byParent = dg.by_parent || {};
|
|
125
|
+
const linked = Object.keys(byParent).filter((k) => k !== "unlinked").length;
|
|
126
|
+
const unlinked = (byParent.unlinked || {}).count || 0;
|
|
127
|
+
let tree = linked ? `${linked} parent task(s)` : `${unlinked} unlinked`;
|
|
128
|
+
if (linked && unlinked) tree += `, ${unlinked} unlinked`;
|
|
129
|
+
html += `<h3 style="margin:1rem 0 .4rem">Delegated sub-tasks <span class="muted">(offloaded by orchestrators)</span></h3>
|
|
130
|
+
<div class="stat-grid">
|
|
131
|
+
<div class="stat"><div class="label">Sub-tasks</div><div class="value">${dg.count}</div></div>
|
|
132
|
+
<div class="stat"><div class="label">By backend</div><div class="value mono" style="font-size:.95rem">${backends}</div></div>
|
|
133
|
+
<div class="stat"><div class="label">Linked to</div><div class="value mono" style="font-size:.95rem">${esc(tree)}</div></div>
|
|
134
|
+
<div class="stat"><div class="label">Est. tokens (in / out)</div><div class="value mono" style="font-size:.95rem">${(dg.in_tokens_est||0).toLocaleString()} / ${(dg.out_tokens_est||0).toLocaleString()}</div></div>
|
|
135
|
+
<div class="stat"><div class="label">Cloud-equiv</div><div class="value mono" style="font-size:.95rem">${money(dg.cloud_equiv_usd)}</div></div>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="caveat">Informational — delegate spend is already credited within its parent task's “spend avoided”.</div>`;
|
|
138
|
+
}
|
|
139
|
+
$("statsCard").innerHTML = html;
|
|
140
|
+
} catch (e) { $("statsCard").innerHTML = `<span class="err">failed to load stats</span>`; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadRoster() {
|
|
144
|
+
try {
|
|
145
|
+
const [d, settings] = await Promise.all([
|
|
146
|
+
getJSON("/api/roster"),
|
|
147
|
+
getJSON("/api/settings").catch(() => ({})),
|
|
148
|
+
]);
|
|
149
|
+
const billingOn = !!settings.api_billing_enabled;
|
|
150
|
+
const hasApi = (d.entries || []).some(e => e.tier === "api");
|
|
151
|
+
// Surface the paid-API billing gate so an operator never misreads a paid entry's own
|
|
152
|
+
// enabled flag as "live" — paid routing needs the global gate on too (issue #2).
|
|
153
|
+
const banner = hasApi
|
|
154
|
+
? (billingOn
|
|
155
|
+
? `<div class="caveat">Paid-API billing: <b>ON</b> — enabled <code>tier: api</code> entries are routable as the last resort. Real spend possible.</div>`
|
|
156
|
+
: `<div class="muted" style="margin-bottom:10px">Paid-API billing: <b>OFF</b> — every <code>tier: api</code> entry is inert regardless of its own <code>enabled</code> flag.</div>`)
|
|
157
|
+
: "";
|
|
158
|
+
const rows = (d.entries || []).map(e => {
|
|
159
|
+
const inv = e.invoke || {};
|
|
160
|
+
const ref = inv.key_ref ? `<div class="muted mono" style="font-size:.72rem">key: ${esc(inv.key_ref)}</div>` : "";
|
|
161
|
+
const enabled = e.enabled !== false; // view_roster always sends a bool; default on
|
|
162
|
+
const orch = !!e.can_orchestrate;
|
|
163
|
+
const goodat = (e.good_at || []).join(", ");
|
|
164
|
+
const budget = e.budget_usd_month != null ? e.budget_usd_month : "";
|
|
165
|
+
// Editable scalar fields only (enabled/can_orchestrate/good_at/budget_usd_month). Originals
|
|
166
|
+
// are stashed on the cell so saves send ONLY changed fields — no noisy default-value inserts.
|
|
167
|
+
return `<tr>
|
|
168
|
+
<td class="mono">${esc(e.id)}</td>
|
|
169
|
+
<td><span class="pill ${esc(e.tier)}">${esc(e.tier)}</span><div class="muted" style="font-size:.72rem">${esc(e.cost||"")}</div></td>
|
|
170
|
+
<td class="mono">${esc(inv.kind)}${inv.model ? " · " + esc(inv.model) : ""}${ref}</td>
|
|
171
|
+
<td class="cfg" data-id="${esc(e.id)}" data-enabled="${enabled}" data-orch="${orch}" data-budget="${esc(budget)}" data-goodat="${esc(goodat)}">
|
|
172
|
+
<label class="chk"><input type="checkbox" class="f-enabled" ${enabled ? "checked" : ""}> enabled</label>
|
|
173
|
+
<label class="chk"><input type="checkbox" class="f-orch" ${orch ? "checked" : ""}> orchestrator</label>
|
|
174
|
+
<div class="muted" style="font-size:.74rem;margin-top:4px">good at <input class="f-goodat" value="${esc(goodat)}" style="width:200px"></div>
|
|
175
|
+
<div class="muted" style="font-size:.74rem">budget $/mo <input class="f-budget" value="${esc(budget)}" placeholder="—" style="width:80px"></div>
|
|
176
|
+
<button class="rsave" style="margin-top:4px">Save</button> <span class="rmsg muted" style="font-size:.74rem"></span>
|
|
177
|
+
</td>
|
|
178
|
+
</tr>`;
|
|
179
|
+
}).join("");
|
|
180
|
+
$("rosterCard").innerHTML = banner + `<table><thead><tr><th>id</th><th>tier</th><th>invoke</th><th>config <span class="muted" style="font-weight:400">(editable)</span></th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
181
|
+
} catch (e) { $("rosterCard").innerHTML = `<span class="err">failed to load roster</span>`; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderPricing(p) {
|
|
185
|
+
const ph = p.is_placeholder ? "checked" : "";
|
|
186
|
+
$("pricingCard").innerHTML = `
|
|
187
|
+
<div class="row"><span class="muted" style="min-width:130px">reference model</span>
|
|
188
|
+
<input type="text" id="pRef" value="${esc(p.reference_model)}" style="flex:1; min-width:220px;"></div>
|
|
189
|
+
<div class="row"><span class="muted" style="min-width:130px">input $/MTok</span>
|
|
190
|
+
<input type="text" id="pIn" value="${esc(p.input_per_mtok)}" style="width:120px"></div>
|
|
191
|
+
<div class="row"><span class="muted" style="min-width:130px">output $/MTok</span>
|
|
192
|
+
<input type="text" id="pOut" value="${esc(p.output_per_mtok)}" style="width:120px"></div>
|
|
193
|
+
<div class="row"><label class="chk"><input type="checkbox" id="pPlaceholder" ${ph}> mark as placeholder (figures flagged illustrative)</label></div>
|
|
194
|
+
<div class="row"><button id="pSave">Save pricing</button><span id="pMsg" class="muted"></span></div>`;
|
|
195
|
+
$("pSave").addEventListener("click", savePricing);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function loadPricing() {
|
|
199
|
+
try { renderPricing(await getJSON("/api/pricing")); }
|
|
200
|
+
catch (e) { $("pricingCard").innerHTML = `<span class="err">failed to load pricing</span>`; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function savePricing() {
|
|
204
|
+
if (!confirm("Overwrite config/pricing.yaml with these values? A backup is kept.")) return;
|
|
205
|
+
const msg = $("pMsg");
|
|
206
|
+
$("pSave").disabled = true; msg.textContent = "saving…"; msg.className = "muted";
|
|
207
|
+
const body = {
|
|
208
|
+
reference_model: $("pRef").value,
|
|
209
|
+
input_per_mtok: parseFloat($("pIn").value),
|
|
210
|
+
output_per_mtok: parseFloat($("pOut").value),
|
|
211
|
+
placeholder: $("pPlaceholder").checked,
|
|
212
|
+
};
|
|
213
|
+
try {
|
|
214
|
+
const r = await fetch("/api/pricing", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body) });
|
|
215
|
+
const d = await r.json();
|
|
216
|
+
if (d.ok) {
|
|
217
|
+
renderPricing(d.pricing);
|
|
218
|
+
$("pMsg").textContent = "saved ✓"; $("pMsg").className = "pill local";
|
|
219
|
+
loadStats(); // refresh the placeholder caveat / figures
|
|
220
|
+
} else {
|
|
221
|
+
$("pMsg").textContent = d.error || "save failed"; $("pMsg").className = "err";
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
$("pMsg").textContent = "request failed"; $("pMsg").className = "err";
|
|
225
|
+
} finally {
|
|
226
|
+
$("pSave").disabled = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function saveRoster(cell, btn) {
|
|
231
|
+
const id = cell.getAttribute("data-id");
|
|
232
|
+
const msg = cell.querySelector(".rmsg");
|
|
233
|
+
// Current control values vs the originals stashed on the cell — send only what changed.
|
|
234
|
+
const enabled = cell.querySelector(".f-enabled").checked;
|
|
235
|
+
const orch = cell.querySelector(".f-orch").checked;
|
|
236
|
+
const goodat = cell.querySelector(".f-goodat").value.split(",").map(s => s.trim()).filter(Boolean);
|
|
237
|
+
const budgetRaw = cell.querySelector(".f-budget").value.trim();
|
|
238
|
+
const fields = {};
|
|
239
|
+
if (enabled !== (cell.getAttribute("data-enabled") === "true")) fields.enabled = enabled;
|
|
240
|
+
if (orch !== (cell.getAttribute("data-orch") === "true")) fields.can_orchestrate = orch;
|
|
241
|
+
if (goodat.join(", ") !== cell.getAttribute("data-goodat")) fields.good_at = goodat;
|
|
242
|
+
if (budgetRaw !== cell.getAttribute("data-budget")) fields.budget_usd_month = (budgetRaw === "" ? null : Number(budgetRaw));
|
|
243
|
+
if (Object.keys(fields).length === 0) { msg.textContent = "no changes"; msg.className = "rmsg muted"; return; }
|
|
244
|
+
if ("budget_usd_month" in fields && fields.budget_usd_month != null && !(fields.budget_usd_month > 0)) {
|
|
245
|
+
msg.textContent = "budget must be a positive number"; msg.className = "rmsg err"; return;
|
|
246
|
+
}
|
|
247
|
+
if (!confirm(`Overwrite config/roster.yaml — update '${id}' (${Object.keys(fields).join(", ")})? A backup is kept; commit the change in git.`)) return;
|
|
248
|
+
btn.disabled = true; msg.textContent = "saving…"; msg.className = "rmsg muted";
|
|
249
|
+
try {
|
|
250
|
+
const r = await fetch("/api/roster", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ id, fields }) });
|
|
251
|
+
const d = await r.json();
|
|
252
|
+
if (d.ok) { loadRoster(); } // re-render from the persisted file (resets controls + originals)
|
|
253
|
+
else { msg.textContent = d.error || "save failed"; msg.className = "rmsg err"; btn.disabled = false; }
|
|
254
|
+
} catch (e) { msg.textContent = "request failed"; msg.className = "rmsg err"; btn.disabled = false; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function run() {
|
|
258
|
+
const prompt = $("prompt").value.trim();
|
|
259
|
+
const box = $("runResult");
|
|
260
|
+
if (!prompt) { box.innerHTML = `<div class="err" style="margin-top:10px">enter a prompt first</div>`; return; }
|
|
261
|
+
$("run").disabled = true; $("run").textContent = "Running…";
|
|
262
|
+
box.innerHTML = `<div class="muted" style="margin-top:10px">routing…</div>`;
|
|
263
|
+
try {
|
|
264
|
+
const r = await fetch("/api/run", {
|
|
265
|
+
method: "POST", headers: {"Content-Type": "application/json"},
|
|
266
|
+
body: JSON.stringify({ prompt, task: $("task").value.trim() || null, local: $("local").checked })
|
|
267
|
+
});
|
|
268
|
+
const d = await r.json();
|
|
269
|
+
if (d.ok) {
|
|
270
|
+
const s = d.served;
|
|
271
|
+
const served = s ? `<div class="served">served by <b>${esc(s.model)}</b> (${esc(s.tier)} · ${esc(s.path)})</div>` : "";
|
|
272
|
+
box.innerHTML = `<pre class="out">${esc(d.text)}</pre>${served}`;
|
|
273
|
+
loadStats();
|
|
274
|
+
} else {
|
|
275
|
+
box.innerHTML = `<pre class="out err">${esc(d.error)}</pre>`;
|
|
276
|
+
}
|
|
277
|
+
} catch (e) {
|
|
278
|
+
box.innerHTML = `<pre class="out err">request failed: ${esc(e)}</pre>`;
|
|
279
|
+
} finally {
|
|
280
|
+
$("run").disabled = false; $("run").textContent = "Run";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
$("run").addEventListener("click", run);
|
|
285
|
+
// Delegated: the roster card is re-rendered on each refresh, so listen on the stable parent.
|
|
286
|
+
$("rosterCard").addEventListener("click", (ev) => {
|
|
287
|
+
const btn = ev.target.closest(".rsave");
|
|
288
|
+
if (!btn) return;
|
|
289
|
+
const cell = btn.closest(".cfg");
|
|
290
|
+
if (cell) saveRoster(cell, btn);
|
|
291
|
+
});
|
|
292
|
+
loadStats(); loadRoster(); loadPricing();
|
|
293
|
+
</script>
|
|
294
|
+
</body>
|
|
295
|
+
</html>
|
|
Binary file
|
tanglebrain/gui/views.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""View functions for the knob GUI — pure, transport-free, JSON-able dict builders.
|
|
2
|
+
|
|
3
|
+
These hold all the panel's logic so it can be unit-tested without binding a socket or making a
|
|
4
|
+
network call (mirroring how :mod:`tanglebrain.cli` is a thin wrapper over ``run_once``). The HTTP
|
|
5
|
+
layer in :mod:`tanglebrain.gui.server` only routes requests to these and serializes the result.
|
|
6
|
+
|
|
7
|
+
Read-only: nothing here writes config. Secret-safety — :func:`view_roster` emits ``key_ref`` as the
|
|
8
|
+
stored *reference string* only (e.g. ``file:…``, ``env:NAME``); it never resolves the reference or
|
|
9
|
+
reads a key file, so no secret material can reach the browser.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from tanglebrain.adapters import AdapterError
|
|
14
|
+
from tanglebrain.cli import run_once
|
|
15
|
+
from tanglebrain.measurement import load_pricing, read_records, rollup, save_pricing, validate_pricing
|
|
16
|
+
from tanglebrain.roster import RosterError, load_roster
|
|
17
|
+
from tanglebrain.roster_edit import RosterEditError, save_roster_edits
|
|
18
|
+
from tanglebrain.router import RouterError
|
|
19
|
+
from tanglebrain.selector import SelectionError
|
|
20
|
+
from tanglebrain.settings import load_settings
|
|
21
|
+
|
|
22
|
+
# Default panel port (3250).
|
|
23
|
+
DEFAULT_PORT = 3250
|
|
24
|
+
|
|
25
|
+
# Exceptions that represent an expected, user-facing failure of a run (mirrors cli.main()).
|
|
26
|
+
_RUN_ERRORS = (RosterError, SelectionError, RouterError, AdapterError)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def view_roster() -> dict:
|
|
30
|
+
"""Build the roster view: every entry with its tier, cost, tags, and invoke summary.
|
|
31
|
+
|
|
32
|
+
``key_ref`` is passed through verbatim as the reference string — never resolved, so no key
|
|
33
|
+
file contents are read or exposed. ``cmd``/``scrub_env``/``delegate_args`` are deliberately
|
|
34
|
+
omitted (not needed for the panel and keep the payload focused).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
``{"entries": [ {id, tier, cost, good_at, can_orchestrate, enabled, budget_usd_month,
|
|
38
|
+
invoke{...}}, ... ]}``. ``enabled`` / ``budget_usd_month`` matter for ``tier: api`` entries:
|
|
39
|
+
``enabled`` is the per-key kill-switch and ``budget_usd_month`` a display-only cap (enforced
|
|
40
|
+
gateway-side). Whether paid entries are actually routable also depends on the global gate —
|
|
41
|
+
see :func:`view_settings`.
|
|
42
|
+
"""
|
|
43
|
+
roster = load_roster()
|
|
44
|
+
entries = []
|
|
45
|
+
for e in roster.entries:
|
|
46
|
+
entries.append(
|
|
47
|
+
{
|
|
48
|
+
"id": e.id,
|
|
49
|
+
"tier": e.tier,
|
|
50
|
+
"cost": e.cost,
|
|
51
|
+
"good_at": list(e.good_at),
|
|
52
|
+
"can_orchestrate": e.can_orchestrate,
|
|
53
|
+
"enabled": e.enabled,
|
|
54
|
+
"budget_usd_month": e.budget_usd_month,
|
|
55
|
+
"invoke": {
|
|
56
|
+
"kind": e.invoke.kind,
|
|
57
|
+
"base_url": e.invoke.base_url,
|
|
58
|
+
"model": e.invoke.model,
|
|
59
|
+
"parse": e.invoke.parse,
|
|
60
|
+
# Reference string only — see module docstring. Never resolved.
|
|
61
|
+
"key_ref": e.invoke.key_ref,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
return {"entries": entries}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def view_settings() -> dict:
|
|
69
|
+
"""Build the global-settings view — the paid-API billing gate.
|
|
70
|
+
|
|
71
|
+
``api_billing_enabled`` is the master switch: when ``false`` (the default) no
|
|
72
|
+
``tier: api`` entry is routable regardless of its own ``enabled`` flag. The panel surfaces it so
|
|
73
|
+
an operator sees at a glance whether paid routing is live. Reads only ``config/settings.yaml`` —
|
|
74
|
+
no key file or secret is touched.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
``{"api_billing_enabled": bool}``.
|
|
78
|
+
"""
|
|
79
|
+
return {"api_billing_enabled": load_settings().api_billing_enabled}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def view_pricing() -> dict:
|
|
83
|
+
"""Build the pricing view from ``config/pricing.yaml``.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
``{reference_model, input_per_mtok, output_per_mtok, is_placeholder}``.
|
|
87
|
+
"""
|
|
88
|
+
p = load_pricing()
|
|
89
|
+
return {
|
|
90
|
+
"reference_model": p.reference_model,
|
|
91
|
+
"input_per_mtok": p.input_per_mtok,
|
|
92
|
+
"output_per_mtok": p.output_per_mtok,
|
|
93
|
+
"is_placeholder": p.is_placeholder,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def view_stats() -> dict:
|
|
98
|
+
"""Build the spend-avoided rollup view (the local ``--stats`` data).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
``{summary, pricing_ref, is_placeholder}`` where ``summary`` is :func:`rollup`'s dict.
|
|
102
|
+
"""
|
|
103
|
+
pricing = load_pricing()
|
|
104
|
+
return {
|
|
105
|
+
"summary": rollup(read_records()),
|
|
106
|
+
"pricing_ref": pricing.reference_model,
|
|
107
|
+
"is_placeholder": pricing.is_placeholder,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_prompt(payload: dict) -> dict:
|
|
112
|
+
"""Run one prompt through the router and report the result + which tier served it.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
payload: ``{prompt, task?, local?, model?}`` from the panel's run box.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
``{"ok": True, "text": ..., "served": {path, tier, model} | None}`` on success, or
|
|
119
|
+
``{"ok": False, "error": ...}`` on an empty prompt or an expected backend error.
|
|
120
|
+
"""
|
|
121
|
+
prompt = (payload or {}).get("prompt")
|
|
122
|
+
if not prompt or not str(prompt).strip():
|
|
123
|
+
return {"ok": False, "error": "prompt is required"}
|
|
124
|
+
|
|
125
|
+
task = payload.get("task") or None
|
|
126
|
+
model = payload.get("model") or None
|
|
127
|
+
local = bool(payload.get("local", False))
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# return_served gives us the served tier/model directly — no usage-log re-read, no race.
|
|
131
|
+
text, served = run_once(str(prompt), model=model, local=local, task=task, return_served=True)
|
|
132
|
+
except _RUN_ERRORS as exc:
|
|
133
|
+
return {"ok": False, "error": str(exc)}
|
|
134
|
+
|
|
135
|
+
return {"ok": True, "text": text, "served": served}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def save_roster_view(payload: dict) -> dict:
|
|
139
|
+
"""Apply edits to one roster entry's editable fields.
|
|
140
|
+
|
|
141
|
+
Only the focused, comment-safe scalar fields are editable (see
|
|
142
|
+
:mod:`tanglebrain.roster_edit`): ``enabled``, ``can_orchestrate``, ``budget_usd_month``,
|
|
143
|
+
``good_at``. Entries are never added/removed/reordered here and the ``invoke`` block is not
|
|
144
|
+
editable — those stay hand-edits. The write is validated, backed up, and atomic.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
payload: ``{id, fields: {field: value, ...}}``.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
``{"ok": True, "roster": {...}}`` with the re-read roster view on success, or
|
|
151
|
+
``{"ok": False, "error": ...}`` if the id/fields are missing or an edit is rejected
|
|
152
|
+
(nothing is written on failure).
|
|
153
|
+
"""
|
|
154
|
+
entry_id = (payload or {}).get("id")
|
|
155
|
+
fields = (payload or {}).get("fields")
|
|
156
|
+
if not entry_id or not isinstance(fields, dict) or not fields:
|
|
157
|
+
return {"ok": False, "error": "id and a non-empty 'fields' object are required"}
|
|
158
|
+
try:
|
|
159
|
+
save_roster_edits(str(entry_id), fields)
|
|
160
|
+
except RosterEditError as exc:
|
|
161
|
+
return {"ok": False, "error": str(exc)}
|
|
162
|
+
return {"ok": True, "roster": view_roster()}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def save_pricing_view(payload: dict) -> dict:
|
|
166
|
+
"""Validate and persist edited pricing from the panel.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
payload: ``{reference_model, input_per_mtok, output_per_mtok, placeholder}``.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
``{"ok": True, "pricing": {...}}`` with the re-read pricing on success, or
|
|
173
|
+
``{"ok": False, "error": ...}`` if validation fails (nothing is written on failure).
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
pricing = validate_pricing(payload or {})
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
return {"ok": False, "error": str(exc)}
|
|
179
|
+
save_pricing(pricing)
|
|
180
|
+
return {"ok": True, "pricing": view_pricing()}
|