thinkpool-pair 0.7.179 → 0.7.181
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/README.md +13 -5
- package/bridge.mjs +22 -4
- package/byok-detect.mjs +52 -8
- package/context-windows.mjs +7 -1
- package/package.json +1 -1
- package/providers.mjs +25 -4
package/README.md
CHANGED
|
@@ -120,10 +120,17 @@ ThinkPool Code runs **any model you choose** — not just Anthropic. The agent
|
|
|
120
120
|
talks the Anthropic Messages API, so you point it at any endpoint that speaks
|
|
121
121
|
that format:
|
|
122
122
|
|
|
123
|
-
- **Anthropic-compatible endpoints directly** — Z.ai GLM, OpenRouter, your own proxy.
|
|
124
|
-
- **Any other model via a gateway** — put
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
- **Anthropic-compatible endpoints directly** — Z.ai GLM, Moonshot Kimi, OpenRouter, your own proxy.
|
|
124
|
+
- **Any other model via a translating gateway** — put **LiteLLM** (or a proxy of
|
|
125
|
+
your own) in front and run **GPT, Gemini, Llama, DeepSeek, or a local model**;
|
|
126
|
+
it presents the Anthropic Messages API while calling whatever you pick.
|
|
127
|
+
|
|
128
|
+
> OpenRouter is the exception worth knowing: its `/v1/messages` surface is a
|
|
129
|
+
> pass-through for **Anthropic models only**. Per OpenRouter's own docs, "Claude
|
|
130
|
+
> Code expects Anthropic request semantics, so non-Anthropic models aren't
|
|
131
|
+
> supported through the native endpoint" — and this bridge spawns the `claude`
|
|
132
|
+
> CLI. To reach a non-Anthropic model, use a translating gateway (LiteLLM),
|
|
133
|
+
> not OpenRouter's Anthropic endpoint.
|
|
127
134
|
|
|
128
135
|
```bash
|
|
129
136
|
npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
|
|
@@ -134,7 +141,8 @@ Common base urls (the agent appends `/v1/messages`):
|
|
|
134
141
|
| Provider / gateway | Base url | Models |
|
|
135
142
|
|--------------------|-----------------------------------|------------------------------|
|
|
136
143
|
| Z.ai GLM | `https://api.z.ai/api/anthropic` | GLM |
|
|
137
|
-
|
|
|
144
|
+
| Moonshot Kimi | `https://api.moonshot.ai/anthropic` | Kimi |
|
|
145
|
+
| OpenRouter | `https://openrouter.ai/api` | Anthropic models only |
|
|
138
146
|
| LiteLLM (your own) | `http://localhost:4000` | GPT, Gemini, Llama, local, … |
|
|
139
147
|
|
|
140
148
|
> The endpoint must serve the Anthropic Messages API (`/v1/messages`). A raw
|
package/bridge.mjs
CHANGED
|
@@ -1566,7 +1566,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1566
1566
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1567
1567
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1568
1568
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1569
|
-
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
|
|
1569
|
+
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1570
1570
|
if (sessions.has(id)) return
|
|
1571
1571
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1572
1572
|
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
@@ -1608,6 +1608,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1608
1608
|
// event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
|
|
1609
1609
|
entry.seq = makeSeqCounter(maxSeq(entry.log))
|
|
1610
1610
|
entry.rolePrompt = rolePrompt || null // FL-M6 — persisted so a bridge restart restores the flow role prompt
|
|
1611
|
+
// lastUsage — the terminal's most recent usage/ctx meter. Persisted (sessionData) +
|
|
1612
|
+
// restored so a bridge restart can re-emit it on replay: usage is chrome (kept out of
|
|
1613
|
+
// the replayed transcript log), so without this a (re)joiner sees "—" for context until
|
|
1614
|
+
// the terminal's next turn. correctContext nulls ctx for models whose true window we
|
|
1615
|
+
// can't determine; those restore a null ctx and replay nothing (no misleading %).
|
|
1616
|
+
entry.lastUsage = lastUsage || null
|
|
1611
1617
|
// S5 (slice 1b) — a REVIEW lane's write-block scope: the worktree root(s) of the slice(s)
|
|
1612
1618
|
// it reviews (its deps). Persisted so a bridge restart re-arms the structural gate. Empty/
|
|
1613
1619
|
// absent on builder lanes → reviewGate stays null → builder toolset UNCHANGED.
|
|
@@ -1775,7 +1781,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1775
1781
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1776
1782
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1777
1783
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1778
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
|
|
1784
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null })
|
|
1779
1785
|
const persist = () => saveSession(room, id, sessionData())
|
|
1780
1786
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1781
1787
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -2227,6 +2233,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2227
2233
|
const m = onBuiltin ? (evt.model || evt.ctx?.model) : null
|
|
2228
2234
|
if (m && m !== entry.model) { entry.model = m; announce() }
|
|
2229
2235
|
if (evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
|
|
2236
|
+
// Cache the last usage/ctx meter on the entry (persisted + replayed — see
|
|
2237
|
+
// openStructured). Chrome events bypass pushLog/persist in emitTail below, so
|
|
2238
|
+
// usage needs an explicit persist() here to survive a bridge restart.
|
|
2239
|
+
if (evt.kind === 'usage') { entry.lastUsage = evt; persist() }
|
|
2230
2240
|
// FL-B2 — fold this flow lane's completed-turn output tokens into its budget so the
|
|
2231
2241
|
// autopilot cap can halt the next wave before overrun (output_tokens = the billed
|
|
2232
2242
|
// reasoning+output spend the indicator already tracks; conservative enough for a guard).
|
|
@@ -2712,8 +2722,16 @@ channel
|
|
|
2712
2722
|
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
|
2713
2723
|
// classifyReplay applies the chunks in arrival order. Older history beyond the chunk
|
|
2714
2724
|
// budget is covered by the client's DB transcript snapshot.
|
|
2725
|
+
// Attach the terminal's last usage/ctx meter to the FIRST replay chunk. usage is
|
|
2726
|
+
// chrome (filtered out of `events` by both bridge + client), so without this a
|
|
2727
|
+
// (re)joiner's ctx meter is empty ("—") until the terminal's next turn. The client
|
|
2728
|
+
// applies `payload.lastUsage` in its code-replay handler (which is `to`-targeted, so
|
|
2729
|
+
// only the joiner gets it). Gated on `.ctx` — a suppressed meter (correctContext
|
|
2730
|
+
// nulled it for an unknown non-Claude model) replays nothing, never a misleading %.
|
|
2731
|
+
let firstChunk = true
|
|
2715
2732
|
for (const events of chunkReplayEvents(tail)) {
|
|
2716
|
-
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}) })
|
|
2733
|
+
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
|
|
2734
|
+
firstChunk = false
|
|
2717
2735
|
}
|
|
2718
2736
|
}
|
|
2719
2737
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
@@ -3060,7 +3078,7 @@ channel
|
|
|
3060
3078
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3061
3079
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3062
3080
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3063
|
-
openStructured({ id: rec.id, model: rec.model || undefined, provider: rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
|
|
3081
|
+
openStructured({ id: rec.id, model: rec.model || undefined, provider: rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage,
|
|
3064
3082
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3065
3083
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3066
3084
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
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/context-windows.mjs
CHANGED
|
@@ -86,7 +86,13 @@ export function correctContext(raw, env = process.env) {
|
|
|
86
86
|
const correctedMax = override ?? mapped
|
|
87
87
|
|
|
88
88
|
if (correctedMax == null) {
|
|
89
|
-
// No correction available
|
|
89
|
+
// No correction available. For a NON-Claude model the SDK's max is computed for the
|
|
90
|
+
// wrong model id (it believes it's talking to Claude over ANTHROPIC_BASE_URL) → a %
|
|
91
|
+
// off that wrong max misleads (the GLM-ran-past-100 bug, 2026-07-04). Rather than ship
|
|
92
|
+
// a wrong number, SUPPRESS the meter: return null so the mode row + Session info show
|
|
93
|
+
// nothing / "—" instead. We only display ctx when we know the true window — a Claude
|
|
94
|
+
// id (SDK max trusted), a known-map model (corrected above), or TP_CONTEXT_MAX (override).
|
|
95
|
+
if (!isClaudeModel(raw.model)) return null
|
|
90
96
|
const pct = Number(raw.pct)
|
|
91
97
|
if (Number.isFinite(pct) && pct > 100) return { ...raw, pct: 100, over: true }
|
|
92
98
|
return raw
|
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
|
}
|