thinkpool-pair 0.7.180 → 0.7.182
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.
- package/bridge.mjs +7 -47
- package/byok-detect.mjs +52 -8
- package/package.json +1 -1
- package/providers.mjs +25 -4
package/bridge.mjs
CHANGED
|
@@ -744,18 +744,11 @@ let myServeUid = null // this bridge's authed uid (null = anon)
|
|
|
744
744
|
refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken now set from env OR the stored login)
|
|
745
745
|
|
|
746
746
|
// ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// work. TP_BUDGET_OFF disables the whole path. `roomCapped` caches whether a cap exists so a
|
|
752
|
-
// transient ledger error fails CLOSED only for a room we KNOW is capped, and open otherwise.
|
|
747
|
+
// TP_BUDGET_OFF disables the spend-ledger path entirely (record_spend). The per-room output-
|
|
748
|
+
// token CAP was removed (2026-07) — redundant for BYOK (the provider enforces spend limits),
|
|
749
|
+
// a per-turn check_budget round-trip on every authed turn, and a fail-closed hazard. The
|
|
750
|
+
// spend LEDGER stays: it powers the Session-info "Session tokens" stat.
|
|
753
751
|
const BUDGET_OFF = process.env.TP_BUDGET_OFF === '1'
|
|
754
|
-
let roomCapped = false
|
|
755
|
-
function monthResetLabel () {
|
|
756
|
-
const d = new Date()
|
|
757
|
-
return new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString().slice(0, 10)
|
|
758
|
-
}
|
|
759
752
|
async function budgetRpc (fn, args) {
|
|
760
753
|
const r = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
|
|
761
754
|
method: 'POST',
|
|
@@ -828,36 +821,6 @@ function recordCodeUsage (model, usage) {
|
|
|
828
821
|
budgetRpc('record_code_usage', { p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached })
|
|
829
822
|
.catch(() => { /* best-effort — usage accounting is not load-bearing for the turn */ })
|
|
830
823
|
}
|
|
831
|
-
// Pre-flight gate. Returns true if the turn must be REFUSED (room cap reached). Refusal is
|
|
832
|
-
// legible (a ◆ control line both readers + late-joiners see), never a silent stall.
|
|
833
|
-
async function budgetBlocked (s, term) {
|
|
834
|
-
if (BUDGET_OFF || !codeAuthToken) return false
|
|
835
|
-
const refuse = (text) => {
|
|
836
|
-
const evt = { kind: 'control', text }
|
|
837
|
-
try { pushLog(s, evt) } catch { /* no session log */ }
|
|
838
|
-
bcast('code-event', { term, evt })
|
|
839
|
-
return true
|
|
840
|
-
}
|
|
841
|
-
try {
|
|
842
|
-
const rows = await budgetRpc('check_budget', { p_room: room })
|
|
843
|
-
const r = Array.isArray(rows) ? rows[0] : rows
|
|
844
|
-
roomCapped = !!(r && r.remaining_tokens != null)
|
|
845
|
-
if (r && r.ok === false) {
|
|
846
|
-
process.stderr.write(`\n ${A.yel}◆ turn refused — ${r.tightest_scope} budget cap reached (room ${room}).${A.rst}\n`)
|
|
847
|
-
return refuse(`◆ Turn blocked — this room hit its ${r.tightest_scope || 'budget'} cap. Resets ${monthResetLabel()}, or raise the cap.`)
|
|
848
|
-
}
|
|
849
|
-
return false
|
|
850
|
-
} catch (e) {
|
|
851
|
-
// Fail-closed ONLY where we know a cap exists — a transient ledger/DNS blip must never
|
|
852
|
-
// brick an UNCAPPED room (that would be a worse outage than the cap it's guarding).
|
|
853
|
-
if (roomCapped) {
|
|
854
|
-
process.stderr.write(`\n ${A.yel}◆ turn refused — budget check failed, room is capped (fail-closed).${A.rst}\n`)
|
|
855
|
-
return refuse('◆ Turn blocked — budget check is unavailable and this room has a cap set. Try again in a moment.')
|
|
856
|
-
}
|
|
857
|
-
process.stderr.write(`\n ${A.dim}◆ budget check skipped (${e.message}) — room uncapped, proceeding.${A.rst}\n`)
|
|
858
|
-
return false
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
824
|
// Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
|
|
862
825
|
// the handler re-track()s; without throttle that spirals into the per-client presence
|
|
863
826
|
// rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
|
|
@@ -2244,8 +2207,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2244
2207
|
const b = flowBudgets.get(entry.flowSessionId)
|
|
2245
2208
|
if (b) flowBudgets.set(entry.flowSessionId, recordSpend(b, evt.usage.output_tokens || 0))
|
|
2246
2209
|
}
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2210
|
+
// Fold EVERY completed turn's output tokens into the room's durable monthly ledger
|
|
2211
|
+
// (lanes + normal turns) — powers the Session-info "Session tokens" stat. (The cap that
|
|
2212
|
+
// consumed this ledger was removed; the accounting stays.)
|
|
2249
2213
|
if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
|
|
2250
2214
|
// FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
|
|
2251
2215
|
// to completion in one turn, then narrates "done" and stops. Models often skip the
|
|
@@ -2870,10 +2834,6 @@ channel
|
|
|
2870
2834
|
// control lines (like /model) so both members AND late-joiners see it. Scope is honest —
|
|
2871
2835
|
// the Agent SDK exposes tokens + cost, not plan/rate-limit meters, and the card says so.
|
|
2872
2836
|
if (/^\/usage\s*$/.test(text)) { ctlLine(usageReportLine(s.model, s.log)); return }
|
|
2873
|
-
// Pre-flight budget gate (room scope) — refuse the billable agent turn if the room hit
|
|
2874
|
-
// its cap. The slash controls above (/model, /clear, /compact) are intentionally exempt:
|
|
2875
|
-
// they let a user manage or REDUCE spend even when capped. Legible refusal, no silent stall.
|
|
2876
|
-
if (await budgetBlocked(s, payload.term)) return
|
|
2877
2837
|
// Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
|
|
2878
2838
|
// restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
|
|
2879
2839
|
// — a separate recap turn would race + double-fire) and clear the flag so the init handler
|
package/byok-detect.mjs
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/* ─────────────────────────────────────────────────────────────
|
|
2
2
|
byok-detect — "paste a key → pick a model" for the bridge menu.
|
|
3
|
-
ThinkPool Code is structured-only (Anthropic Messages API)
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
ThinkPool Code is structured-only (Anthropic Messages API). That format
|
|
4
|
+
has become the de-facto standard for agentic coding: Anthropic, Z.ai/GLM,
|
|
5
|
+
DeepSeek, Moonshot/Kimi and MiniMax all serve it natively (each base
|
|
6
|
+
below live-probed 2026-07-10: POST <base>/v1/messages → 401 auth error,
|
|
7
|
+
i.e. the path exists), and LM Studio / llama.cpp serve it on localhost.
|
|
8
|
+
The holdouts are OpenAI + Google — those need a translation gateway
|
|
9
|
+
(LiteLLM etc.). NOTE OpenRouter's native /v1/messages serves ANTHROPIC
|
|
10
|
+
MODELS ONLY (live-probed 2026-07-09, see src/lib/providers.js) — it is
|
|
11
|
+
not a GPT/Gemini path, despite what this file used to claim.
|
|
6
12
|
A bare OpenAI/Gemini/Groq key is DETECTED so we can guide the user to
|
|
7
|
-
|
|
13
|
+
a gateway instead of failing silently.
|
|
8
14
|
Spec: docs/specs/2026-06-28-byok-only-two-tier-pivot.md (addendum)
|
|
9
15
|
───────────────────────────────────────────────────────────── */
|
|
10
16
|
|
|
11
17
|
// Providers whose endpoint speaks the Anthropic Messages API → usable directly
|
|
12
18
|
// in structured mode. `base` is what we set as ANTHROPIC_BASE_URL; `models` is
|
|
13
19
|
// the model-list endpoint; `auth` is how the model-list call authenticates.
|
|
20
|
+
// A wrong/moved `models` URL degrades gracefully — fetchModels() returns []
|
|
21
|
+
// and the menu falls back to a free-text model prompt.
|
|
14
22
|
export const ANTHROPIC_COMPATIBLE = {
|
|
15
23
|
anthropic: {
|
|
16
24
|
name: 'Anthropic',
|
|
@@ -19,16 +27,45 @@ export const ANTHROPIC_COMPATIBLE = {
|
|
|
19
27
|
auth: 'x-api-key',
|
|
20
28
|
},
|
|
21
29
|
openrouter: {
|
|
22
|
-
name: 'OpenRouter
|
|
30
|
+
name: 'OpenRouter (Anthropic models)',
|
|
23
31
|
base: 'https://openrouter.ai/api',
|
|
24
32
|
models: 'https://openrouter.ai/api/v1/models',
|
|
25
33
|
auth: 'bearer',
|
|
26
34
|
},
|
|
35
|
+
zai: {
|
|
36
|
+
name: 'Z.ai — GLM',
|
|
37
|
+
base: 'https://api.z.ai/api/anthropic',
|
|
38
|
+
models: 'https://api.z.ai/api/paas/v4/models',
|
|
39
|
+
auth: 'bearer',
|
|
40
|
+
},
|
|
41
|
+
deepseek: {
|
|
42
|
+
name: 'DeepSeek',
|
|
43
|
+
base: 'https://api.deepseek.com/anthropic',
|
|
44
|
+
models: 'https://api.deepseek.com/models',
|
|
45
|
+
auth: 'bearer',
|
|
46
|
+
},
|
|
47
|
+
moonshot: {
|
|
48
|
+
name: 'Moonshot — Kimi',
|
|
49
|
+
base: 'https://api.moonshot.ai/anthropic',
|
|
50
|
+
models: 'https://api.moonshot.ai/v1/models',
|
|
51
|
+
auth: 'bearer',
|
|
52
|
+
},
|
|
53
|
+
minimax: {
|
|
54
|
+
name: 'MiniMax',
|
|
55
|
+
base: 'https://api.minimax.io/anthropic',
|
|
56
|
+
models: 'https://api.minimax.io/v1/models',
|
|
57
|
+
auth: 'bearer',
|
|
58
|
+
},
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
// Key-prefix → provider. `compatible: true` means it works directly; false
|
|
30
|
-
// means we detected it but it needs
|
|
31
|
-
//
|
|
62
|
+
// means we detected it but it needs a gateway (different wire format).
|
|
63
|
+
// Keep the most specific prefixes first (sk-ant- / sk-or- before sk-).
|
|
64
|
+
// Undetectable-by-prefix but directly compatible: DeepSeek + Moonshot keys are
|
|
65
|
+
// bare `sk-…` (indistinguishable from OpenAI — gatewayHint names the escape),
|
|
66
|
+
// Z.ai keys are `<hex>.<secret>` with no stable prefix. Those route via the
|
|
67
|
+
// dashboard's provider presets (src/lib/providers.js BRIDGE_PROVIDER_PRESETS)
|
|
68
|
+
// or the manual-endpoint path instead.
|
|
32
69
|
const PREFIXES = [
|
|
33
70
|
['sk-ant-', 'anthropic', true, 'Anthropic'],
|
|
34
71
|
['sk-or-', 'openrouter', true, 'OpenRouter'],
|
|
@@ -77,6 +114,13 @@ export async function fetchModels({ modelsUrl, key, auth }) {
|
|
|
77
114
|
}
|
|
78
115
|
|
|
79
116
|
// The friendly message shown when a detected key isn't directly usable.
|
|
117
|
+
// Honest scope (2026-07-10): OpenRouter's native /v1/messages is Anthropic-
|
|
118
|
+
// models-only, so it is NOT the "any model" escape hatch for a GPT/Gemini key —
|
|
119
|
+
// a translation gateway is. And a bare sk- key might not be OpenAI at all:
|
|
120
|
+
// DeepSeek and Moonshot/Kimi keys share the prefix and DO work directly.
|
|
80
121
|
export function gatewayHint(name) {
|
|
81
|
-
|
|
122
|
+
const skAmbiguity = name === 'OpenAI'
|
|
123
|
+
? ' (If that key is actually DeepSeek or Moonshot/Kimi — they also start with sk- — it works directly: pick that provider in the dashboard’s LLM providers panel instead.)'
|
|
124
|
+
: ''
|
|
125
|
+
return `That key looks like ${name}. ThinkPool Code runs on the Anthropic Messages API — the format Claude, GLM, DeepSeek, Kimi, MiniMax and local models (LM Studio, llama.cpp) all speak natively — but that provider doesn't, so the key can't be used directly. Route it through your own gateway (e.g. LiteLLM) instead.${skAmbiguity}`
|
|
82
126
|
}
|
package/package.json
CHANGED
package/providers.mjs
CHANGED
|
@@ -151,12 +151,30 @@ export function unseal(sealedB64) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// ── CRUD ────────────────────────────────────────────────────────────────
|
|
154
|
+
/* Local-LLM exception (2026-07-10): a provider on the SAME machine (LM Studio,
|
|
155
|
+
llama.cpp — both speak the Anthropic Messages API natively) is allowed over
|
|
156
|
+
plain http and without a key. Both relaxations are LOOPBACK-ONLY:
|
|
157
|
+
• https-only stays for every remote host — a key on plaintext http across a
|
|
158
|
+
real wire is the leak the rule exists to stop; on 127.0.0.1 there is no wire.
|
|
159
|
+
• key-required stays for every remote host — local servers simply don't take
|
|
160
|
+
one (resolveProviderEnv synthesizes a placeholder token so the SDK doesn't
|
|
161
|
+
fall back to the host's Claude login against a localhost URL). */
|
|
162
|
+
export function isLoopbackHost(hostname) {
|
|
163
|
+
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
164
|
+
// 127/8 must be a LITERAL IPv4 — '127.evil.io' is a DNS name that can resolve
|
|
165
|
+
// anywhere, so a bare startsWith('127.') would be a spoofable hole.
|
|
166
|
+
return h === 'localhost' || h === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
function validate({ name, baseUrl, key }) {
|
|
155
170
|
if (!name || typeof name !== 'string' || !name.trim()) return 'name is required'
|
|
156
|
-
if (!key || typeof key !== 'string' || !key.trim()) return 'key is required'
|
|
157
171
|
let u
|
|
158
172
|
try { u = new URL(String(baseUrl)) } catch { return 'baseUrl must be a valid https url' }
|
|
159
|
-
|
|
173
|
+
const local = isLoopbackHost(u.hostname)
|
|
174
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) {
|
|
175
|
+
return 'baseUrl must be https (plain http is allowed only for localhost)'
|
|
176
|
+
}
|
|
177
|
+
if (!local && (!key || typeof key !== 'string' || !key.trim())) return 'key is required'
|
|
160
178
|
return null
|
|
161
179
|
}
|
|
162
180
|
|
|
@@ -174,7 +192,7 @@ export function addProvider(fields) {
|
|
|
174
192
|
name: String(fields.name).trim(),
|
|
175
193
|
baseUrl: String(fields.baseUrl).trim(),
|
|
176
194
|
model: fields.model ? String(fields.model).trim() : undefined,
|
|
177
|
-
key: String(fields.key).trim(),
|
|
195
|
+
key: fields.key ? String(fields.key).trim() : '', // '' = keyless local provider
|
|
178
196
|
addedAt: Date.now(),
|
|
179
197
|
})
|
|
180
198
|
saveProviders(arr)
|
|
@@ -344,7 +362,10 @@ export function resolveProviderEnv(id) {
|
|
|
344
362
|
if (!id || id === BUILTIN_ID) return null
|
|
345
363
|
const p = loadProviders().find((x) => x.id === id)
|
|
346
364
|
if (!p) return null
|
|
347
|
-
|
|
365
|
+
// Keyless (loopback) provider: the SDK still needs a non-empty token, or it
|
|
366
|
+
// falls back to the host's Claude login against the localhost URL. Local
|
|
367
|
+
// servers (LM Studio / llama.cpp) accept any bearer value.
|
|
368
|
+
const env = { ANTHROPIC_BASE_URL: p.baseUrl, ANTHROPIC_AUTH_TOKEN: p.key || 'thinkpool-local' }
|
|
348
369
|
if (p.model) env.ANTHROPIC_MODEL = p.model
|
|
349
370
|
return env
|
|
350
371
|
}
|