thinkpool-pair 0.7.177 → 0.7.179
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/account.mjs +12 -1
- package/bridge.mjs +90 -15
- package/claude-session.mjs +12 -1
- package/event-id.mjs +8 -1
- package/package.json +1 -1
- package/providers.mjs +139 -3
- package/switch-provider.mjs +27 -0
package/account.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import os from 'node:os'
|
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
|
|
19
|
-
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
19
|
+
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
@@ -579,6 +579,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
579
579
|
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
|
|
580
580
|
provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
581
581
|
})
|
|
582
|
+
// provider-add-model {id, model, name?, nonce, jwt} → clone the source row's
|
|
583
|
+
// baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
|
|
584
|
+
// `sealed` envelope: the key never leaves the bridge for this op, so there is
|
|
585
|
+
// nothing to decrypt. Owner-authed exactly like its siblings.
|
|
586
|
+
acct.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
|
|
587
|
+
const nonce = payload?.nonce
|
|
588
|
+
if (!(await isOwner(payload?.jwt))) return provReply('provider-add-model-res', { nonce, ok: false, error: 'unauthorized' })
|
|
589
|
+
const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
|
|
590
|
+
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
|
|
591
|
+
provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
592
|
+
})
|
|
582
593
|
// provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
|
|
583
594
|
acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
|
|
584
595
|
const nonce = payload?.nonce
|
package/bridge.mjs
CHANGED
|
@@ -45,8 +45,8 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
|
|
|
45
45
|
// resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
|
|
46
46
|
// registered custom provider, or null for the built-in/unknown (leave the default env intact).
|
|
47
47
|
// Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
|
|
48
|
-
import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
49
|
-
import { validateProviderSwitch, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
48
|
+
import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
|
|
49
|
+
import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { startClaudeSession } from './claude-session.mjs'
|
|
@@ -1568,18 +1568,28 @@ function worktreeSnapshot(cwd) {
|
|
|
1568
1568
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1569
1569
|
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
|
|
1570
1570
|
if (sessions.has(id)) return
|
|
1571
|
+
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1572
|
+
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
1573
|
+
// separately: the label resolved the provider's configured model while the SDK got the
|
|
1574
|
+
// RAW inherited `model`, which OVERRIDES ANTHROPIC_MODEL from resolveProviderEnv. A GLM
|
|
1575
|
+
// lane opened while focused on Fable therefore showed `glm-4.6` and sent
|
|
1576
|
+
// `claude-fable-5` → 400 [1211][Unknown Model]. One value, one truth.
|
|
1577
|
+
// undefined → omit the SDK option entirely and let the provider env/endpoint default.
|
|
1578
|
+
const laneModel = effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
|
|
1571
1579
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1572
1580
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1573
1581
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1574
1582
|
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode,
|
|
1575
|
-
// model: truthful active-model label
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1583
|
+
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
1584
|
+
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
1585
|
+
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
1586
|
+
// laneModel resolves from THAT provider's CONFIGURED model — glm-4.6, not a Claude label.
|
|
1587
|
+
// No configured model → the provider's NAME, never a false Claude id (an inherited
|
|
1588
|
+
// claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
|
|
1589
|
+
// label fallback here). Built-in Anthropic keeps the passed model.
|
|
1580
1590
|
model: (!provider || provider === 'anthropic')
|
|
1581
|
-
? (
|
|
1582
|
-
: (
|
|
1591
|
+
? (laneModel || null)
|
|
1592
|
+
: (laneModel || providerNameMap()[provider] || provider),
|
|
1583
1593
|
provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
|
|
1584
1594
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1585
1595
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
@@ -2095,7 +2105,15 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2095
2105
|
// is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
|
|
2096
2106
|
entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
|
|
2097
2107
|
entry.session = startClaudeSession({
|
|
2098
|
-
|
|
2108
|
+
// laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
|
|
2109
|
+
// ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
|
|
2110
|
+
// Claude id here defeats the whole per-provider env. undefined → option omitted.
|
|
2111
|
+
cwd: cwd || process.cwd(), model: laneModel, resume, mode, rolePrompt,
|
|
2112
|
+
// Haiku composer-prefill is an Anthropic-subscription freebie. This lane's env carries a
|
|
2113
|
+
// CUSTOM provider's base URL + auth token, so on a non-anthropic lane that call would bill
|
|
2114
|
+
// the user's own BYOK key for a feature they didn't ask for (and 400 on strict providers).
|
|
2115
|
+
// Same lane-provider key as the model-truth guard in onEvent below.
|
|
2116
|
+
suggest: !provider || provider === 'anthropic',
|
|
2099
2117
|
// Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
|
|
2100
2118
|
// (same registries the read_terminal roster uses) + active git worktrees (only
|
|
2101
2119
|
// when >1 — a solo checkout has nothing to coordinate about). Keeps every lane
|
|
@@ -2455,7 +2473,16 @@ function respawnStructured(id, provider) {
|
|
|
2455
2473
|
const s = sessions.get(id)
|
|
2456
2474
|
if (!s) return false
|
|
2457
2475
|
// Capture the lane's identity for re-open (NOT resume — fresh SDK session).
|
|
2458
|
-
|
|
2476
|
+
// NOTE the deliberate absence of `model`: a respawn crosses a provider boundary,
|
|
2477
|
+
// and the old lane's model id means nothing on the new backend. Carrying it sent
|
|
2478
|
+
// `glm-4.6` to Anthropic on a GLM→Claude switch, and — once effectiveLaneModel
|
|
2479
|
+
// started honouring explicit non-Claude ids — pinned a GLM→GLM-5.2 switch back to
|
|
2480
|
+
// glm-4.6, so the lane truthfully labelled the model it was wrongly still running
|
|
2481
|
+
// (Max, 2026-07-09: "selecting 5.2 still shows 4.6"). Omitting it lets
|
|
2482
|
+
// openStructured seed from the TARGET provider's configured model, which is the
|
|
2483
|
+
// only model this lane was ever asked for. A same-env model change never reaches
|
|
2484
|
+
// here — that path is an in-place setModel (see provider-switch).
|
|
2485
|
+
const { log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
|
|
2459
2486
|
// Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
|
|
2460
2487
|
// starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
|
|
2461
2488
|
// (before teardown) and hand it to the fresh session as its first turn so the agent
|
|
@@ -2473,7 +2500,33 @@ function respawnStructured(id, provider) {
|
|
|
2473
2500
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
2474
2501
|
// restores the lane on its CURRENT provider, not the original — and its next
|
|
2475
2502
|
// announce carries the new provider badge (additive {id,name} projection).
|
|
2476
|
-
openStructured({ id,
|
|
2503
|
+
openStructured({ id, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
|
|
2504
|
+
return true
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/**
|
|
2508
|
+
* Switch a lane between two providers that share an endpoint AND a key — i.e. two
|
|
2509
|
+
* registry rows for the same z.ai/OpenRouter/proxy account, differing only in model.
|
|
2510
|
+
*
|
|
2511
|
+
* Nothing about the child's env changes except ANTHROPIC_MODEL, and the SDK's `model`
|
|
2512
|
+
* option overrides that anyway, so there is no reason to tear the agent down. setModel()
|
|
2513
|
+
* re-creates the query with `resume`, which keeps the SDK context (and defers a mid-turn
|
|
2514
|
+
* switch to turn-end). The lane keeps its memory; only the model moves.
|
|
2515
|
+
*
|
|
2516
|
+
* Returns false when the lane is gone or the target has no configured model — the caller
|
|
2517
|
+
* falls back to a full respawn rather than leaving the lane on a stale model.
|
|
2518
|
+
*/
|
|
2519
|
+
function switchModelInPlace(id, provider) {
|
|
2520
|
+
const s = sessions.get(id)
|
|
2521
|
+
if (!s?.session) return false
|
|
2522
|
+
const nextModel = providerModel(provider)
|
|
2523
|
+
if (!nextModel) return false // no model to switch TO → let respawn seed the env
|
|
2524
|
+
s.provider = provider || null
|
|
2525
|
+
s.model = nextModel // truthful label; announce reads this
|
|
2526
|
+
try { s.session.setModel(nextModel) } catch { return false }
|
|
2527
|
+
// Persist through the entry's own closure (sessionData is private to openStructured),
|
|
2528
|
+
// so a bridge restart restores the lane on the row it was switched TO, not the origin.
|
|
2529
|
+
try { s.flush?.() } catch { /* disk full / racing close — the debounced save still runs */ }
|
|
2477
2530
|
return true
|
|
2478
2531
|
}
|
|
2479
2532
|
|
|
@@ -2935,7 +2988,9 @@ channel
|
|
|
2935
2988
|
// projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
|
|
2936
2989
|
.on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
|
|
2937
2990
|
const nonce = payload?.nonce
|
|
2938
|
-
|
|
2991
|
+
// `restarted` (0.7.179+): did the agent lose its context? false for a same-key model
|
|
2992
|
+
// change, true for a real provider change. Older clients ignore the extra field.
|
|
2993
|
+
const reply = (ok, error, restarted) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
|
|
2939
2994
|
// AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
|
|
2940
2995
|
// state is touched for a rejected sender (fail closed before any lookup).
|
|
2941
2996
|
if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
|
|
@@ -2950,12 +3005,32 @@ channel
|
|
|
2950
3005
|
registeredIds: listProviders().map((p) => p.id),
|
|
2951
3006
|
})
|
|
2952
3007
|
if (!v.ok) return reply(false, v.error)
|
|
2953
|
-
if (v.noop) return reply(true) // nothing to do; idempotent ok (UI guarantees a change)
|
|
2954
3008
|
const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
|
|
3009
|
+
|
|
3010
|
+
// The three-way choice is pure + unit-tested (switch-provider.mjs). Two registry rows
|
|
3011
|
+
// on ONE endpoint + key (Max's glm-4.6 and glm-5.2 on the same z.ai account) are the
|
|
3012
|
+
// same provider asked for a different model: the child's env is identical, so tearing
|
|
3013
|
+
// the agent down and recapping it is pure loss. Only a different endpoint/key earns a
|
|
3014
|
+
// respawn. An in-place attempt that fails (lane raced closed, setModel threw) falls
|
|
3015
|
+
// back to the respawn rather than leaving the lane on a stale model.
|
|
3016
|
+
const plan = providerSwitchPlan({
|
|
3017
|
+
provider: payload?.provider,
|
|
3018
|
+
currentProvider: s.provider,
|
|
3019
|
+
sameEnv: sameProviderEnv(s.provider, target),
|
|
3020
|
+
targetModel: providerModel(target),
|
|
3021
|
+
})
|
|
3022
|
+
if (plan.action === 'noop') return reply(true, undefined, false) // idempotent (UI guarantees a change)
|
|
3023
|
+
|
|
3024
|
+
if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
|
|
3025
|
+
process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
|
|
3026
|
+
announce()
|
|
3027
|
+
return reply(true, undefined, false)
|
|
3028
|
+
}
|
|
3029
|
+
|
|
2955
3030
|
const done = respawnStructured(term, target)
|
|
2956
3031
|
if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
|
|
2957
3032
|
process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
|
|
2958
|
-
reply(true)
|
|
3033
|
+
reply(true, undefined, true)
|
|
2959
3034
|
})
|
|
2960
3035
|
.subscribe(status => {
|
|
2961
3036
|
if (status === 'SUBSCRIBED') {
|
package/claude-session.mjs
CHANGED
|
@@ -176,6 +176,8 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
176
176
|
* @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
|
|
177
177
|
* called for EVERY tool call with { id, toolName, input, risk };
|
|
178
178
|
* resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
|
|
179
|
+
* @param {boolean=} o.suggest allow the Haiku composer-prefill fallback.
|
|
180
|
+
* MUST be false on a lane running a custom provider — see haikuSuggest.
|
|
179
181
|
* @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
|
|
180
182
|
*/
|
|
181
183
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
@@ -203,7 +205,7 @@ const TP_ROOM_REMINDER = [
|
|
|
203
205
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
204
206
|
].join(' ')
|
|
205
207
|
|
|
206
|
-
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
|
|
208
|
+
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true }) {
|
|
207
209
|
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
208
210
|
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
209
211
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
@@ -746,7 +748,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
746
748
|
// (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
|
|
747
749
|
// settingSources, no MCP, no tools — so it's a fast cold call, not a full session boot.
|
|
748
750
|
// Fed only `lastAssistantText` (trimmed). Never blocks a turn; any failure is silent.
|
|
751
|
+
//
|
|
752
|
+
// BUILT-IN ANTHROPIC PATH ONLY (`suggest`). On a lane running a registered custom
|
|
753
|
+
// provider, opts.env carries that provider's ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN —
|
|
754
|
+
// so this hardcoded `claude-haiku-4-5` call would spend the USER'S OWN BYOK credit on a
|
|
755
|
+
// composer prefill they never asked for, at whatever (wrong, flagship) model the endpoint
|
|
756
|
+
// maps the unknown id to. z.ai silently serves glm for any `claude-*` id, which is exactly
|
|
757
|
+
// why this went unnoticed; a stricter provider 400s. This is an Anthropic-subscription
|
|
758
|
+
// freebie — it does not get a per-provider small model, it gets switched off.
|
|
749
759
|
const haikuSuggest = async () => {
|
|
760
|
+
if (!suggest) return
|
|
750
761
|
const seed = (lastAssistantText || '').trim().slice(-1500)
|
|
751
762
|
if (!seed || closed) return
|
|
752
763
|
const ac2 = new AbortController()
|
package/event-id.mjs
CHANGED
|
@@ -215,6 +215,14 @@ export function summarizeUsage(logEvents) {
|
|
|
215
215
|
return { turns: results.length, per: [...per.values()], totalIn, totalOut, totalCost, haveCost }
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Does this model id name a Claude/Anthropic model? Used by two callers with the
|
|
219
|
+
// same question and NO room for a second opinion:
|
|
220
|
+
// • usageReportLine — token/cost reporting is Claude-only.
|
|
221
|
+
// • providers.mjs/effectiveLaneModel — a lane on a CUSTOM (non-anthropic) provider
|
|
222
|
+
// must never be handed an inherited Claude id; the endpoint 400s on it.
|
|
223
|
+
// Exported (not a local const) precisely so there is exactly one definition.
|
|
224
|
+
export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
|
|
225
|
+
|
|
218
226
|
// model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
|
|
219
227
|
// Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
|
|
220
228
|
export function prettyModelId (m) {
|
|
@@ -229,7 +237,6 @@ export function prettyModelId (m) {
|
|
|
229
237
|
// handler is a thin wrapper: ctlLine(usageReportLine(s.model, s.log)). `sessionModel` is
|
|
230
238
|
// the resolved model id the /model state tracks; `logEvents` the session's retained log.
|
|
231
239
|
export function usageReportLine (sessionModel, logEvents) {
|
|
232
|
-
const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
|
|
233
240
|
const sum = summarizeUsage(logEvents)
|
|
234
241
|
// Gate (Max's ask: "make /usage work if users bridge is on claude llms"). The Code bridge
|
|
235
242
|
// runs the Claude Agent SDK, so a normal lane IS a Claude model — but a session pointed at a
|
package/package.json
CHANGED
package/providers.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import os from 'node:os'
|
|
|
42
42
|
import fs from 'node:fs'
|
|
43
43
|
import path from 'node:path'
|
|
44
44
|
import crypto from 'node:crypto'
|
|
45
|
+
import { claudeish } from './event-id.mjs'
|
|
45
46
|
|
|
46
47
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
47
48
|
const REG_FILE = path.join(DIR, 'providers.json')
|
|
@@ -180,6 +181,59 @@ export function addProvider(fields) {
|
|
|
180
181
|
return { ok: true, id }
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Add ANOTHER model to an already-registered provider — without the user ever
|
|
186
|
+
* retyping the key (Max, 2026-07-09). The registry's unit is a {provider,model}
|
|
187
|
+
* pair, not a provider, and `buildSwitchMenu` already groups rows by brand
|
|
188
|
+
* ("Z.ai GLM 4.5" / "Z.ai GLM Air" collapse under one "z.ai" header) — so a new
|
|
189
|
+
* model is simply a CLONE of the source row with a fresh id and a new model.
|
|
190
|
+
* No schema change, no new menu shape.
|
|
191
|
+
*
|
|
192
|
+
* The clone copies `key` + `baseUrl` HOST-SIDE from the row that already holds
|
|
193
|
+
* them, so no key material crosses the wire for this operation — the caller's
|
|
194
|
+
* RPC carries no `sealed` envelope at all (see account.mjs `provider-add-model`).
|
|
195
|
+
*
|
|
196
|
+
* @param {{id:string, model:string, name?:string}} fields id = the SOURCE provider
|
|
197
|
+
* @returns {{ok:true,id:string} | {ok:false,error:string}}
|
|
198
|
+
*/
|
|
199
|
+
export function addProviderModel({ id, model, name } = {}) {
|
|
200
|
+
if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider has no key to reuse' }
|
|
201
|
+
if (!model || typeof model !== 'string' || !model.trim()) return { ok: false, error: 'model is required' }
|
|
202
|
+
const arr = loadProviders()
|
|
203
|
+
const src = arr.find((p) => p.id === id)
|
|
204
|
+
if (!src) return { ok: false, error: 'no such provider' }
|
|
205
|
+
|
|
206
|
+
const nextModel = String(model).trim()
|
|
207
|
+
// An endpoint+model pair is the identity of a row — a second row for the same
|
|
208
|
+
// pair would render as a duplicate menu entry that switches to nothing new.
|
|
209
|
+
if (arr.some((p) => p.baseUrl === src.baseUrl && (p.model || '') === nextModel)) {
|
|
210
|
+
return { ok: false, error: `${nextModel} is already registered on this provider` }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nextName = (name && String(name).trim()) || `${brandOf(src.name)} ${nextModel}`.trim()
|
|
214
|
+
const newId = crypto.randomUUID()
|
|
215
|
+
arr.push({
|
|
216
|
+
id: newId,
|
|
217
|
+
name: nextName,
|
|
218
|
+
baseUrl: src.baseUrl, // copied host-side
|
|
219
|
+
model: nextModel,
|
|
220
|
+
key: src.key, // copied host-side — never re-sent by the client
|
|
221
|
+
addedAt: Date.now(),
|
|
222
|
+
})
|
|
223
|
+
saveProviders(arr)
|
|
224
|
+
return { ok: true, id: newId }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* First name token — the brand, matching code-constants.js `brandKey`. "Z.ai GLM
|
|
229
|
+
* 4.5" → "Z.ai". Used to derive a default name for a cloned row so the switch
|
|
230
|
+
* menu groups it under the SAME header as its source.
|
|
231
|
+
*/
|
|
232
|
+
function brandOf(name) {
|
|
233
|
+
const first = String(name || '').trim().split(/\s+/)[0]
|
|
234
|
+
return first || 'Provider'
|
|
235
|
+
}
|
|
236
|
+
|
|
183
237
|
/**
|
|
184
238
|
* Remove a provider by id. The built-in `anthropic` refuses.
|
|
185
239
|
* @returns {{ok:true} | {ok:false,error:string}}
|
|
@@ -193,6 +247,55 @@ export function removeProvider(id) {
|
|
|
193
247
|
return { ok: true }
|
|
194
248
|
}
|
|
195
249
|
|
|
250
|
+
/* ── the provider GROUP token (Max, 2026-07-09) ──────────────────────────
|
|
251
|
+
Two registry rows are the SAME PROVIDER when they point at the same endpoint
|
|
252
|
+
with the same key — they differ only in which model they ask it for. That is
|
|
253
|
+
the identity that matters: it decides whether the switch menu unites them, and
|
|
254
|
+
whether switching between them needs a fresh agent (it doesn't — same env).
|
|
255
|
+
|
|
256
|
+
Before this, the clients grouped on `brandKey(name)`: the first whitespace token
|
|
257
|
+
of the user's DISPLAY NAME, lowercased. "GLM" → `glm`, "GLM-5.2" → `glm5.2`, so
|
|
258
|
+
Max's two models on one z.ai key rendered as two unrelated providers, each
|
|
259
|
+
threatening to restart his agent. Identity was being derived from a label.
|
|
260
|
+
|
|
261
|
+
`group` is a one-way digest of baseUrl + key, NOT either value. It is safe on
|
|
262
|
+
the announce (which deliberately carries neither): a 12-hex prefix reveals only
|
|
263
|
+
*sameness*, and preimaging it means preimaging the key. The built-in Anthropic
|
|
264
|
+
has no stored key, so it gets the literal 'anthropic' — it groups with nothing.
|
|
265
|
+
|
|
266
|
+
NUL-separated so ("https://a.io/x", "key") and ("https://a.io/xkey", "") can't
|
|
267
|
+
collide into one group. */
|
|
268
|
+
export function providerGroup(p) {
|
|
269
|
+
if (!p || p.id === BUILTIN_ID) return BUILTIN_ID
|
|
270
|
+
return crypto.createHash('sha256')
|
|
271
|
+
.update(`${p.baseUrl || ''}\0${p.key || ''}`)
|
|
272
|
+
.digest('hex')
|
|
273
|
+
.slice(0, 12)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Do these two registered providers share an endpoint AND a key? Then a switch
|
|
278
|
+
* between them changes only ANTHROPIC_MODEL — the child's env is otherwise
|
|
279
|
+
* identical, so the live session can just setModel() and keep its context
|
|
280
|
+
* instead of being torn down and recapped. Built-in ↔ custom is never same-env.
|
|
281
|
+
*/
|
|
282
|
+
export function sameProviderEnv(idA, idB) {
|
|
283
|
+
const a = effectiveId(idA), b = effectiveId(idB)
|
|
284
|
+
if (a === BUILTIN_ID || b === BUILTIN_ID) return a === b // both built-in, or one of them
|
|
285
|
+
const arr = loadProviders()
|
|
286
|
+
const pa = arr.find((x) => x.id === a), pb = arr.find((x) => x.id === b)
|
|
287
|
+
if (!pa || !pb) return false // unknown id → never same
|
|
288
|
+
return providerGroup(pa) === providerGroup(pb)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const effectiveId = (p) => (!p || p === BUILTIN_ID) ? BUILTIN_ID : String(p)
|
|
292
|
+
|
|
293
|
+
/** The model a registered provider is configured to ask for (null for built-in/unknown). */
|
|
294
|
+
export function providerModel(id) {
|
|
295
|
+
if (!id || id === BUILTIN_ID) return null
|
|
296
|
+
return loadProviders().find((x) => x.id === id)?.model || null
|
|
297
|
+
}
|
|
298
|
+
|
|
196
299
|
// ── read-only projections (never expose the raw key) ────────────────────
|
|
197
300
|
/** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
|
|
198
301
|
export function listProviders() {
|
|
@@ -200,15 +303,16 @@ export function listProviders() {
|
|
|
200
303
|
id: p.id,
|
|
201
304
|
name: p.name,
|
|
202
305
|
model: p.model || null,
|
|
306
|
+
group: providerGroup(p),
|
|
203
307
|
keyHint: keyHint(p.key),
|
|
204
308
|
}))
|
|
205
|
-
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, keyHint: null }, ...custom]
|
|
309
|
+
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null }, ...custom]
|
|
206
310
|
}
|
|
207
311
|
|
|
208
312
|
/** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
|
|
209
313
|
export function announceProviders() {
|
|
210
|
-
const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null }))
|
|
211
|
-
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
|
|
314
|
+
const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null, group: providerGroup(p) }))
|
|
315
|
+
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID }, ...custom]
|
|
212
316
|
}
|
|
213
317
|
|
|
214
318
|
/**
|
|
@@ -244,3 +348,35 @@ export function resolveProviderEnv(id) {
|
|
|
244
348
|
if (p.model) env.ANTHROPIC_MODEL = p.model
|
|
245
349
|
return env
|
|
246
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The ONE model id a lane actually runs on — used for BOTH the display label and the
|
|
354
|
+
* SDK's `model` option, which is the whole point: they drifted, and the SDK option
|
|
355
|
+
* silently OVERRIDES the ANTHROPIC_MODEL env resolveProviderEnv() supplies.
|
|
356
|
+
*
|
|
357
|
+
* The bug this closes: a new lane inherits the focused lane's model (client-side), so
|
|
358
|
+
* opening a GLM lane while on Fable sent `model: 'claude-fable-5'` to
|
|
359
|
+
* https://api.z.ai/api/anthropic → `API Error: 400 [1211][Unknown Model]`, while the
|
|
360
|
+
* chip truthfully read `glm-4.6`. Label and wire disagreed; the wire lost.
|
|
361
|
+
*
|
|
362
|
+
* Rules (custom = any registered, non-built-in provider):
|
|
363
|
+
* • built-in anthropic / no provider → passthrough, unchanged. Default path is sacred.
|
|
364
|
+
* • custom + model equals the configured model → honour it.
|
|
365
|
+
* • custom + model is a Claude id (claudeish) → DROP it. It leaked across a provider
|
|
366
|
+
* boundary and the endpoint cannot serve it. Fall back to the configured model, or
|
|
367
|
+
* to undefined (no `model` option → the env / endpoint default decides).
|
|
368
|
+
* • custom + explicit non-Claude model (glm-4.5, deepseek-…) → honour it. This is a
|
|
369
|
+
* deliberate pick (/model, spawn_terminal({provider, model})), not an inheritance leak.
|
|
370
|
+
*
|
|
371
|
+
* NEVER returns an inherited Claude id on a custom provider — that is the invariant.
|
|
372
|
+
* Pure: `configuredModel` is passed in (not read from disk) so it is directly testable.
|
|
373
|
+
*
|
|
374
|
+
* @param {{provider?:string|null, model?:string|null, configuredModel?:string|null}} a
|
|
375
|
+
* @returns {string|undefined} the model to send; undefined → omit the SDK `model` option
|
|
376
|
+
*/
|
|
377
|
+
export function effectiveLaneModel({ provider, model, configuredModel } = {}) {
|
|
378
|
+
if (!provider || provider === BUILTIN_ID) return model || undefined
|
|
379
|
+
if (model && model === configuredModel) return model
|
|
380
|
+
if (model && !claudeish(model)) return model
|
|
381
|
+
return configuredModel || undefined
|
|
382
|
+
}
|
package/switch-provider.mjs
CHANGED
|
@@ -55,3 +55,30 @@ export function validateProviderSwitch({ provider, currentProvider, registeredId
|
|
|
55
55
|
const noop = effectiveProvider(id) === effectiveProvider(currentProvider)
|
|
56
56
|
return { ok: true, noop }
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* WHAT KIND of switch is this? The pure half of the provider-switch handler, so the
|
|
61
|
+
* three-way choice is testable without booting a bridge (Max, 2026-07-09).
|
|
62
|
+
*
|
|
63
|
+
* 'noop' — same provider; nothing to do.
|
|
64
|
+
* 'in-place' — a different registry row on the SAME endpoint + key (two models on
|
|
65
|
+
* one z.ai account). Only ANTHROPIC_MODEL differs, so the live session
|
|
66
|
+
* just setModel()s and KEEPS ITS CONTEXT. Requires a model to switch to.
|
|
67
|
+
* 'respawn' — a genuinely different endpoint or key. Fresh agent, transcript kept,
|
|
68
|
+
* recap carried. This is the only case that costs the user their context.
|
|
69
|
+
*
|
|
70
|
+
* `sameEnv` is supplied by the caller (providers.mjs `sameProviderEnv`) rather than
|
|
71
|
+
* imported, so this module stays free of registry/disk state — the whole point of it.
|
|
72
|
+
* A same-env target with NO configured model falls back to 'respawn': openStructured
|
|
73
|
+
* would then seed the model from the target's env, which an in-place setModel cannot do.
|
|
74
|
+
*
|
|
75
|
+
* @param {{provider:string, currentProvider?:string|null, sameEnv:boolean, targetModel?:string|null}} input
|
|
76
|
+
* @returns {{action:'noop'|'in-place'|'respawn', restarted:boolean}}
|
|
77
|
+
*/
|
|
78
|
+
export function providerSwitchPlan({ provider, currentProvider, sameEnv, targetModel }) {
|
|
79
|
+
if (effectiveProvider(provider) === effectiveProvider(currentProvider)) {
|
|
80
|
+
return { action: 'noop', restarted: false }
|
|
81
|
+
}
|
|
82
|
+
if (sameEnv && targetModel) return { action: 'in-place', restarted: false }
|
|
83
|
+
return { action: 'respawn', restarted: true }
|
|
84
|
+
}
|