thinkpool-pair 0.7.178 → 0.7.180
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 +86 -11
- package/context-windows.mjs +7 -1
- package/package.json +1 -1
- package/providers.mjs +53 -3
- package/switch-provider.mjs +27 -0
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
|
@@ -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, effectiveLaneModel } 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'
|
|
@@ -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).
|
|
@@ -2473,7 +2483,16 @@ function respawnStructured(id, provider) {
|
|
|
2473
2483
|
const s = sessions.get(id)
|
|
2474
2484
|
if (!s) return false
|
|
2475
2485
|
// Capture the lane's identity for re-open (NOT resume — fresh SDK session).
|
|
2476
|
-
|
|
2486
|
+
// NOTE the deliberate absence of `model`: a respawn crosses a provider boundary,
|
|
2487
|
+
// and the old lane's model id means nothing on the new backend. Carrying it sent
|
|
2488
|
+
// `glm-4.6` to Anthropic on a GLM→Claude switch, and — once effectiveLaneModel
|
|
2489
|
+
// started honouring explicit non-Claude ids — pinned a GLM→GLM-5.2 switch back to
|
|
2490
|
+
// glm-4.6, so the lane truthfully labelled the model it was wrongly still running
|
|
2491
|
+
// (Max, 2026-07-09: "selecting 5.2 still shows 4.6"). Omitting it lets
|
|
2492
|
+
// openStructured seed from the TARGET provider's configured model, which is the
|
|
2493
|
+
// only model this lane was ever asked for. A same-env model change never reaches
|
|
2494
|
+
// here — that path is an in-place setModel (see provider-switch).
|
|
2495
|
+
const { log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
|
|
2477
2496
|
// Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
|
|
2478
2497
|
// starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
|
|
2479
2498
|
// (before teardown) and hand it to the fresh session as its first turn so the agent
|
|
@@ -2491,7 +2510,33 @@ function respawnStructured(id, provider) {
|
|
|
2491
2510
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
2492
2511
|
// restores the lane on its CURRENT provider, not the original — and its next
|
|
2493
2512
|
// announce carries the new provider badge (additive {id,name} projection).
|
|
2494
|
-
openStructured({ id,
|
|
2513
|
+
openStructured({ id, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
|
|
2514
|
+
return true
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
/**
|
|
2518
|
+
* Switch a lane between two providers that share an endpoint AND a key — i.e. two
|
|
2519
|
+
* registry rows for the same z.ai/OpenRouter/proxy account, differing only in model.
|
|
2520
|
+
*
|
|
2521
|
+
* Nothing about the child's env changes except ANTHROPIC_MODEL, and the SDK's `model`
|
|
2522
|
+
* option overrides that anyway, so there is no reason to tear the agent down. setModel()
|
|
2523
|
+
* re-creates the query with `resume`, which keeps the SDK context (and defers a mid-turn
|
|
2524
|
+
* switch to turn-end). The lane keeps its memory; only the model moves.
|
|
2525
|
+
*
|
|
2526
|
+
* Returns false when the lane is gone or the target has no configured model — the caller
|
|
2527
|
+
* falls back to a full respawn rather than leaving the lane on a stale model.
|
|
2528
|
+
*/
|
|
2529
|
+
function switchModelInPlace(id, provider) {
|
|
2530
|
+
const s = sessions.get(id)
|
|
2531
|
+
if (!s?.session) return false
|
|
2532
|
+
const nextModel = providerModel(provider)
|
|
2533
|
+
if (!nextModel) return false // no model to switch TO → let respawn seed the env
|
|
2534
|
+
s.provider = provider || null
|
|
2535
|
+
s.model = nextModel // truthful label; announce reads this
|
|
2536
|
+
try { s.session.setModel(nextModel) } catch { return false }
|
|
2537
|
+
// Persist through the entry's own closure (sessionData is private to openStructured),
|
|
2538
|
+
// so a bridge restart restores the lane on the row it was switched TO, not the origin.
|
|
2539
|
+
try { s.flush?.() } catch { /* disk full / racing close — the debounced save still runs */ }
|
|
2495
2540
|
return true
|
|
2496
2541
|
}
|
|
2497
2542
|
|
|
@@ -2677,8 +2722,16 @@ channel
|
|
|
2677
2722
|
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
|
2678
2723
|
// classifyReplay applies the chunks in arrival order. Older history beyond the chunk
|
|
2679
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
|
|
2680
2732
|
for (const events of chunkReplayEvents(tail)) {
|
|
2681
|
-
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
|
|
2682
2735
|
}
|
|
2683
2736
|
}
|
|
2684
2737
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
@@ -2953,7 +3006,9 @@ channel
|
|
|
2953
3006
|
// projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
|
|
2954
3007
|
.on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
|
|
2955
3008
|
const nonce = payload?.nonce
|
|
2956
|
-
|
|
3009
|
+
// `restarted` (0.7.179+): did the agent lose its context? false for a same-key model
|
|
3010
|
+
// change, true for a real provider change. Older clients ignore the extra field.
|
|
3011
|
+
const reply = (ok, error, restarted) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
|
|
2957
3012
|
// AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
|
|
2958
3013
|
// state is touched for a rejected sender (fail closed before any lookup).
|
|
2959
3014
|
if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
|
|
@@ -2968,12 +3023,32 @@ channel
|
|
|
2968
3023
|
registeredIds: listProviders().map((p) => p.id),
|
|
2969
3024
|
})
|
|
2970
3025
|
if (!v.ok) return reply(false, v.error)
|
|
2971
|
-
if (v.noop) return reply(true) // nothing to do; idempotent ok (UI guarantees a change)
|
|
2972
3026
|
const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
|
|
3027
|
+
|
|
3028
|
+
// The three-way choice is pure + unit-tested (switch-provider.mjs). Two registry rows
|
|
3029
|
+
// on ONE endpoint + key (Max's glm-4.6 and glm-5.2 on the same z.ai account) are the
|
|
3030
|
+
// same provider asked for a different model: the child's env is identical, so tearing
|
|
3031
|
+
// the agent down and recapping it is pure loss. Only a different endpoint/key earns a
|
|
3032
|
+
// respawn. An in-place attempt that fails (lane raced closed, setModel threw) falls
|
|
3033
|
+
// back to the respawn rather than leaving the lane on a stale model.
|
|
3034
|
+
const plan = providerSwitchPlan({
|
|
3035
|
+
provider: payload?.provider,
|
|
3036
|
+
currentProvider: s.provider,
|
|
3037
|
+
sameEnv: sameProviderEnv(s.provider, target),
|
|
3038
|
+
targetModel: providerModel(target),
|
|
3039
|
+
})
|
|
3040
|
+
if (plan.action === 'noop') return reply(true, undefined, false) // idempotent (UI guarantees a change)
|
|
3041
|
+
|
|
3042
|
+
if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
|
|
3043
|
+
process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
|
|
3044
|
+
announce()
|
|
3045
|
+
return reply(true, undefined, false)
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2973
3048
|
const done = respawnStructured(term, target)
|
|
2974
3049
|
if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
|
|
2975
3050
|
process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
|
|
2976
|
-
reply(true)
|
|
3051
|
+
reply(true, undefined, true)
|
|
2977
3052
|
})
|
|
2978
3053
|
.subscribe(status => {
|
|
2979
3054
|
if (status === 'SUBSCRIBED') {
|
|
@@ -3003,7 +3078,7 @@ channel
|
|
|
3003
3078
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3004
3079
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3005
3080
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3006
|
-
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,
|
|
3007
3082
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3008
3083
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3009
3084
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
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
|
@@ -247,6 +247,55 @@ export function removeProvider(id) {
|
|
|
247
247
|
return { ok: true }
|
|
248
248
|
}
|
|
249
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
|
+
|
|
250
299
|
// ── read-only projections (never expose the raw key) ────────────────────
|
|
251
300
|
/** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
|
|
252
301
|
export function listProviders() {
|
|
@@ -254,15 +303,16 @@ export function listProviders() {
|
|
|
254
303
|
id: p.id,
|
|
255
304
|
name: p.name,
|
|
256
305
|
model: p.model || null,
|
|
306
|
+
group: providerGroup(p),
|
|
257
307
|
keyHint: keyHint(p.key),
|
|
258
308
|
}))
|
|
259
|
-
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]
|
|
260
310
|
}
|
|
261
311
|
|
|
262
312
|
/** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
|
|
263
313
|
export function announceProviders() {
|
|
264
|
-
const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null }))
|
|
265
|
-
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]
|
|
266
316
|
}
|
|
267
317
|
|
|
268
318
|
/**
|
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
|
+
}
|