thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/provider-resilience.mjs
CHANGED
|
@@ -1,356 +1 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Provider resilience policy primitives.
|
|
3
|
-
*
|
|
4
|
-
* This module never makes a network call or reads provider credentials. Phase 1
|
|
5
|
-
* adds an injected same-target controller, but transport remains owned by the
|
|
6
|
-
* Claude session and fallback remains unavailable.
|
|
7
|
-
*/
|
|
8
|
-
import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
|
|
9
|
-
|
|
10
|
-
export const RESILIENCE_VERSION = 1
|
|
11
|
-
export const MAX_NETWORK_ATTEMPTS = 3
|
|
12
|
-
export const MAX_SAME_TARGET_ATTEMPTS = 2
|
|
13
|
-
export const TRANSIENT_FAILURES = new Set(['timeout', 'network', 'rate_limit', 'overload', 'upstream_5xx'])
|
|
14
|
-
const RETRYABLE = [...TRANSIENT_FAILURES]
|
|
15
|
-
const TRACE_FIELDS = new Set(['traceId', 'turnRev', 'attempt', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'outcome', 'failureClass', 'elapsedMs', 'retryAfterMs', 'fallbackFrom'])
|
|
16
|
-
const OUTCOMES = new Set(['started', 'retrying', 'failed', 'fallback', 'succeeded', 'circuit_open', 'cap_blocked'])
|
|
17
|
-
const TRACE_TEXT_FIELDS = new Set(['traceId', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'failureClass', 'fallbackFrom'])
|
|
18
|
-
const TRACE_URL_OR_PATH = /(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i
|
|
19
|
-
|
|
20
|
-
export const DEFAULT_RESILIENCE_POLICY = Object.freeze({
|
|
21
|
-
v: RESILIENCE_VERSION,
|
|
22
|
-
enabled: false,
|
|
23
|
-
timeoutMs: 90000,
|
|
24
|
-
maxAttempts: 2,
|
|
25
|
-
retryOn: RETRYABLE,
|
|
26
|
-
circuit: Object.freeze({ failureThreshold: 3, windowMs: 60000, cooldownMs: 30000 }),
|
|
27
|
-
fallback: Object.freeze([]),
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const plainObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
|
|
31
|
-
const positiveInt = (x) => Number.isInteger(x) && x > 0
|
|
32
|
-
const text = (x) => typeof x === 'string' && x.trim() ? x.trim() : null
|
|
33
|
-
const safeTraceText = (value) => {
|
|
34
|
-
const normalized = text(value)
|
|
35
|
-
if (!normalized || normalized.length > 160 || TRACE_URL_OR_PATH.test(normalized)) return null
|
|
36
|
-
return isSecretFreeRoomPayload({ value: normalized }, 512) ? normalized : null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Validate and canonicalize the non-secret provider policy. Missing policy is
|
|
41
|
-
* the explicit observe-only default. Extra fields are discarded rather than
|
|
42
|
-
* copied into a room-facing projection.
|
|
43
|
-
*/
|
|
44
|
-
export function validateResiliencePolicy(input, { providers = [], primaryProviderId = null } = {}) {
|
|
45
|
-
if (input == null) return { ok: true, value: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...RETRYABLE], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] } }
|
|
46
|
-
if (!plainObject(input) || input.v !== RESILIENCE_VERSION) return { ok: false, error: 'unsupported resilience policy version' }
|
|
47
|
-
if (typeof input.enabled !== 'boolean') return { ok: false, error: 'enabled must be boolean' }
|
|
48
|
-
if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 30000 || input.timeoutMs > 120000) return { ok: false, error: 'timeoutMs must be 30000..120000' }
|
|
49
|
-
if (!positiveInt(input.maxAttempts)) return { ok: false, error: 'maxAttempts must be a positive integer' }
|
|
50
|
-
if (!Array.isArray(input.retryOn) || input.retryOn.some((x) => !TRANSIENT_FAILURES.has(x))) return { ok: false, error: 'retryOn contains an unsupported failure class' }
|
|
51
|
-
if (!plainObject(input.circuit) || !positiveInt(input.circuit.failureThreshold) || !positiveInt(input.circuit.windowMs) || !positiveInt(input.circuit.cooldownMs)) return { ok: false, error: 'invalid circuit policy' }
|
|
52
|
-
if (!Array.isArray(input.fallback)) return { ok: false, error: 'fallback must be an array' }
|
|
53
|
-
const fallback = []
|
|
54
|
-
for (const candidate of input.fallback) {
|
|
55
|
-
if (!plainObject(candidate) || !text(candidate.providerId) || !text(candidate.model) || candidate.allow !== true) return { ok: false, error: 'invalid fallback target' }
|
|
56
|
-
// Phase 0 stores only the conservative v1 declarations. A future UI may add
|
|
57
|
-
// explicit elevated consent, but it must not be smuggled in via this shape.
|
|
58
|
-
if (candidate.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(candidate.costClass)) return { ok: false, error: 'fallback privacy/cost class is not eligible' }
|
|
59
|
-
if (primaryProviderId && candidate.providerId === primaryProviderId) return { ok: false, error: 'fallback cannot target itself' }
|
|
60
|
-
if (providers.length) {
|
|
61
|
-
const row = providers.find((provider) => provider?.id === candidate.providerId)
|
|
62
|
-
if (!row || row.id === 'anthropic' || row.model !== candidate.model) return { ok: false, error: 'fallback must name a registered exact provider/model target' }
|
|
63
|
-
}
|
|
64
|
-
fallback.push({ providerId: text(candidate.providerId), model: text(candidate.model), allow: true, privacyClass: candidate.privacyClass, costClass: candidate.costClass })
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
ok: true,
|
|
68
|
-
value: {
|
|
69
|
-
v: RESILIENCE_VERSION,
|
|
70
|
-
enabled: input.enabled,
|
|
71
|
-
timeoutMs: input.timeoutMs,
|
|
72
|
-
// A malformed high value may never increase live network work.
|
|
73
|
-
maxAttempts: Math.min(input.maxAttempts, MAX_NETWORK_ATTEMPTS),
|
|
74
|
-
retryOn: [...new Set(input.retryOn)],
|
|
75
|
-
circuit: { failureThreshold: input.circuit.failureThreshold, windowMs: input.circuit.windowMs, cooldownMs: input.circuit.cooldownMs },
|
|
76
|
-
fallback,
|
|
77
|
-
},
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Safe allowlist projection for local diagnostics / room-safe metadata. */
|
|
82
|
-
export function resilienceProjection(input) {
|
|
83
|
-
const validated = validateResiliencePolicy(input)
|
|
84
|
-
if (!validated.ok) return null
|
|
85
|
-
const p = validated.value
|
|
86
|
-
return { v: p.v, enabled: p.enabled, timeoutMs: p.timeoutMs, maxAttempts: p.maxAttempts, retryOn: [...p.retryOn], circuit: { ...p.circuit }, fallback: p.fallback.map((x) => ({ ...x })) }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Coarse classification only. It intentionally does not return raw errors. */
|
|
90
|
-
export function classifyProviderFailure(input = {}) {
|
|
91
|
-
const status = Number(input.status || input.statusCode || 0)
|
|
92
|
-
const code = String(input.code || '').toLowerCase()
|
|
93
|
-
const message = String(input.message || input.error?.message || '').toLowerCase()
|
|
94
|
-
if (input.aborted || /\babort(ed|error)?\b|cancelled|canceled/.test(`${code} ${message}`)) return 'user_abort'
|
|
95
|
-
if (input.permissionDenied || /permission denied|forbidden by policy|guardrail/.test(`${code} ${message}`)) return 'permission_denied'
|
|
96
|
-
if (status === 401 || status === 403 || /unauthori[sz]ed|invalid (api )?key|credential/.test(`${code} ${message}`)) return 'auth'
|
|
97
|
-
if (status === 429 || /rate.?limit/.test(`${code} ${message}`)) return 'rate_limit'
|
|
98
|
-
if (status === 529 || /overload|overloaded/.test(`${code} ${message}`)) return 'overload'
|
|
99
|
-
if (status >= 500 && status <= 599) return 'upstream_5xx'
|
|
100
|
-
if (/timeout|timed out|deadline exceeded|etimedout/.test(`${code} ${message}`)) return 'timeout'
|
|
101
|
-
if (/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${code} ${message}`)) return 'network'
|
|
102
|
-
if (status === 400 || /invalid model|unknown model/.test(`${code} ${message}`)) return 'invalid_model'
|
|
103
|
-
if (/context length|request too large|payload too large|too many tokens/.test(`${code} ${message}`)) return 'request_too_large'
|
|
104
|
-
if (status >= 400 && status < 500) return 'invalid_request'
|
|
105
|
-
return 'unknown'
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export const isReplaySafe = ({ outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, nonIdempotent = false } = {}) => !(outputVisible || toolOutputVisible || aborted || permissionDenied || nonIdempotent)
|
|
109
|
-
|
|
110
|
-
/** Resolve only registered custom Claude targets with their exact configured model. */
|
|
111
|
-
export function resolveAttemptTarget({ runtime = 'claude', providers = [], providerId, model } = {}) {
|
|
112
|
-
if (runtime !== 'claude' || !text(providerId) || !text(model)) return null
|
|
113
|
-
const provider = providers.find((p) => p && p.id === providerId && p.id !== 'anthropic' && p.model === model)
|
|
114
|
-
if (!provider) return null
|
|
115
|
-
return { providerId: provider.id, targetProviderName: text(provider.name) || provider.id, actualConfiguredModel: provider.model }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** All eight Phase-2 eligibility gates in a pure, fail-closed form. */
|
|
119
|
-
export function assessFallbackEligibility({ runtime = 'claude', providers = [], primary, candidate, policy, outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, capAllowed = true, circuit = 'closed' } = {}) {
|
|
120
|
-
if (!isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })) return { ok: false, reason: 'replay_unsafe' }
|
|
121
|
-
if (circuit !== 'closed') return { ok: false, reason: 'circuit_open' }
|
|
122
|
-
if (!capAllowed) return { ok: false, reason: 'cap_blocked' }
|
|
123
|
-
const checked = validateResiliencePolicy(policy)
|
|
124
|
-
if (!checked.ok || !checked.value.enabled || runtime !== 'claude') return { ok: false, reason: 'policy_disabled' }
|
|
125
|
-
const target = resolveAttemptTarget({ runtime, providers, providerId: candidate?.providerId, model: candidate?.model })
|
|
126
|
-
if (!target) return { ok: false, reason: 'unknown_target' }
|
|
127
|
-
const configured = checked.value.fallback.find((x) => x.providerId === target.providerId && x.model === target.actualConfiguredModel)
|
|
128
|
-
if (!configured?.allow) return { ok: false, reason: 'not_explicitly_allowed' }
|
|
129
|
-
if (configured.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(configured.costClass)) return { ok: false, reason: 'boundary_not_consented' }
|
|
130
|
-
if (primary?.providerId === target.providerId && primary?.actualConfiguredModel === target.actualConfiguredModel) return { ok: false, reason: 'same_target' }
|
|
131
|
-
return { ok: true, target, fallback: configured }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Local-only total-attempt accounting. It is intentionally not a retry engine. */
|
|
135
|
-
export function createAttemptLedger({ maxAttempts = MAX_NETWORK_ATTEMPTS, traceId = 'local', turnRev = null, onRecord = null } = {}) {
|
|
136
|
-
const ceiling = Math.max(1, Math.min(Number.isInteger(maxAttempts) ? maxAttempts : 1, MAX_NETWORK_ATTEMPTS))
|
|
137
|
-
const records = []
|
|
138
|
-
const record = (entry = {}) => {
|
|
139
|
-
if (records.length >= ceiling) return null
|
|
140
|
-
const next = safeTraceRecord({ traceId, turnRev, attempt: Math.min(records.length + 1, ceiling), ...entry })
|
|
141
|
-
records.push(next)
|
|
142
|
-
try { onRecord?.(next) } catch { /* observation must never affect the session */ }
|
|
143
|
-
return next
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
get ceiling() { return ceiling },
|
|
147
|
-
get attempts() { return records.length },
|
|
148
|
-
get records() { return records.map((x) => ({ ...x })) },
|
|
149
|
-
canSubmit() { return records.length < ceiling },
|
|
150
|
-
record,
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Memory-only circuit breaker keyed by bridge/provider/exact-model. */
|
|
155
|
-
export function createMemoryCircuit({ now = () => Date.now() } = {}) {
|
|
156
|
-
const states = new Map()
|
|
157
|
-
const keyOf = ({ bridgeHostId, providerId, model } = {}) => `${bridgeHostId || ''}\u0000${providerId || ''}\u0000${model || ''}`
|
|
158
|
-
const stateFor = (target) => states.get(keyOf(target)) || { failures: [], openUntil: 0, probing: false }
|
|
159
|
-
const put = (target, state) => states.set(keyOf(target), state)
|
|
160
|
-
return {
|
|
161
|
-
preflight(target) {
|
|
162
|
-
const state = stateFor(target); const t = now()
|
|
163
|
-
if (state.openUntil > t) return { ok: false, state: 'open' }
|
|
164
|
-
if (state.openUntil && state.openUntil <= t) {
|
|
165
|
-
if (state.probing) return { ok: false, state: 'half_open_busy' }
|
|
166
|
-
state.probing = true; put(target, state); return { ok: true, state: 'half_open' }
|
|
167
|
-
}
|
|
168
|
-
return { ok: true, state: 'closed' }
|
|
169
|
-
},
|
|
170
|
-
success(target) { states.delete(keyOf(target)) },
|
|
171
|
-
failure(target, policy, failureClass) {
|
|
172
|
-
if (!TRANSIENT_FAILURES.has(failureClass)) return { state: this.preflight(target).state, counted: false }
|
|
173
|
-
const state = stateFor(target); const t = now(); const circuit = policy?.circuit || DEFAULT_RESILIENCE_POLICY.circuit
|
|
174
|
-
if (state.probing) { state.probing = false; state.failures = []; state.openUntil = t + circuit.cooldownMs; put(target, state); return { state: 'open', counted: true } }
|
|
175
|
-
state.failures = state.failures.filter((at) => at > t - circuit.windowMs)
|
|
176
|
-
state.failures.push(t)
|
|
177
|
-
if (state.failures.length >= circuit.failureThreshold) state.openUntil = t + circuit.cooldownMs
|
|
178
|
-
put(target, state)
|
|
179
|
-
return { state: state.openUntil > t ? 'open' : 'closed', counted: true }
|
|
180
|
-
},
|
|
181
|
-
// Test/diagnostic-only metadata. Never returns credentials, URLs, or prompts.
|
|
182
|
-
snapshot(target) { const s = stateFor(target); return { failures: s.failures.length, openUntil: s.openUntil, probing: s.probing } },
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Apply the only authoritative provider-work cap currently available at the
|
|
188
|
-
* bridge boundary. Ordinary/uncapped lanes remain admissible, but the result
|
|
189
|
-
* names that absence explicitly so the host can record `cap_not_configured`
|
|
190
|
-
* rather than pretending an approval occurred.
|
|
191
|
-
*/
|
|
192
|
-
export function providerResilienceCapAdmission(budget) {
|
|
193
|
-
if (!budget || budget.capTokens == null) {
|
|
194
|
-
return { allowed: true, configured: false, reason: 'cap_not_configured' }
|
|
195
|
-
}
|
|
196
|
-
const capTokens = Number(budget.capTokens)
|
|
197
|
-
const spentTokens = Number(budget.spentTokens)
|
|
198
|
-
const allowed = Number.isFinite(capTokens)
|
|
199
|
-
&& capTokens >= 0
|
|
200
|
-
&& Number.isFinite(spentTokens)
|
|
201
|
-
&& spentTokens >= 0
|
|
202
|
-
&& budget.killed !== true
|
|
203
|
-
&& spentTokens < capTokens
|
|
204
|
-
return { allowed, configured: true, reason: allowed ? 'within_cap' : 'cap_blocked' }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Pure, injected Phase-1 controller for a single exact custom Claude target.
|
|
209
|
-
*
|
|
210
|
-
* The bridge/session integration owns transport and invokes this controller at
|
|
211
|
-
* turn start, before each submission, after visible output, and at terminal
|
|
212
|
-
* success/failure. It intentionally has no fallback path and cannot resolve a
|
|
213
|
-
* built-in Anthropic target. A caller must inject both the authoritative cap
|
|
214
|
-
* admission gate and the bridge-scoped memory circuit; missing or broken gates
|
|
215
|
-
* deny the submission rather than guessing that it is safe.
|
|
216
|
-
*/
|
|
217
|
-
export function createSameTargetResilienceController({
|
|
218
|
-
runtime = 'claude', providers = [], providerId, model, requestedModel = model,
|
|
219
|
-
policy, bridgeHostId = null, circuit, capGate, traceId = 'local', turnRev = null,
|
|
220
|
-
onRecord = null,
|
|
221
|
-
} = {}) {
|
|
222
|
-
let checked
|
|
223
|
-
try { checked = validateResiliencePolicy(policy, { providers, primaryProviderId: providerId }) } catch { checked = { ok: false } }
|
|
224
|
-
let target = null
|
|
225
|
-
try { target = resolveAttemptTarget({ runtime, providers, providerId, model }) } catch { /* malformed registry is not retryable */ }
|
|
226
|
-
const configured = checked?.ok && checked.value.enabled && target && runtime === 'claude'
|
|
227
|
-
const circuitReady = !!circuit && typeof circuit.preflight === 'function' && typeof circuit.failure === 'function' && typeof circuit.success === 'function'
|
|
228
|
-
const ceiling = configured ? Math.min(checked.value.maxAttempts, MAX_SAME_TARGET_ATTEMPTS) : 0
|
|
229
|
-
const targetKey = target && { bridgeHostId, providerId: target.providerId, model: target.actualConfiguredModel }
|
|
230
|
-
const base = target && {
|
|
231
|
-
traceId,
|
|
232
|
-
turnRev,
|
|
233
|
-
targetProviderId: target.providerId,
|
|
234
|
-
targetProviderName: target.targetProviderName,
|
|
235
|
-
requestedModel,
|
|
236
|
-
actualConfiguredModel: target.actualConfiguredModel,
|
|
237
|
-
phase: 'same_target',
|
|
238
|
-
}
|
|
239
|
-
let started = false
|
|
240
|
-
let submissions = 0
|
|
241
|
-
let awaitingOutcome = false
|
|
242
|
-
let retryEligible = false
|
|
243
|
-
let outputVisible = false
|
|
244
|
-
let toolOutputVisible = false
|
|
245
|
-
let aborted = false
|
|
246
|
-
let permissionDenied = false
|
|
247
|
-
let finished = false
|
|
248
|
-
|
|
249
|
-
const makeRecord = (outcome, extra = {}, attempt = submissions) => safeTraceRecord({
|
|
250
|
-
...base,
|
|
251
|
-
attempt: Math.max(1, attempt),
|
|
252
|
-
outcome,
|
|
253
|
-
...extra,
|
|
254
|
-
})
|
|
255
|
-
const emit = (outcome, extra) => {
|
|
256
|
-
const record = makeRecord(outcome, extra)
|
|
257
|
-
try { onRecord?.(record) } catch { /* trace observers are never control flow */ }
|
|
258
|
-
return record
|
|
259
|
-
}
|
|
260
|
-
const blocked = (outcome = 'failed', extra) => ({ admitted: false, record: emit(outcome, extra) })
|
|
261
|
-
const replaySafe = () => isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
/** Mark the controller as belonging to this turn; no provider work occurs here. */
|
|
265
|
-
start() { started = true },
|
|
266
|
-
|
|
267
|
-
/** Once any assistant/tool output is visible, automatic replay is permanently disabled. */
|
|
268
|
-
visibleOutput({ tool = false } = {}) {
|
|
269
|
-
outputVisible = true
|
|
270
|
-
if (tool) toolOutputVisible = true
|
|
271
|
-
retryEligible = false
|
|
272
|
-
},
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Admit exactly one upcoming submission. The cap gate is awaited before
|
|
276
|
-
* transport, and any false/unknown/throwing result denies the attempt.
|
|
277
|
-
*/
|
|
278
|
-
async preflight() {
|
|
279
|
-
if (!started) started = true
|
|
280
|
-
if (!configured || !circuitReady || finished || awaitingOutcome || submissions >= ceiling) return blocked()
|
|
281
|
-
if (submissions > 0 && (!retryEligible || !replaySafe())) return blocked()
|
|
282
|
-
const pending = makeRecord(submissions === 0 ? 'started' : 'retrying', {}, submissions + 1)
|
|
283
|
-
let admitted = false
|
|
284
|
-
try {
|
|
285
|
-
const result = await capGate?.(pending)
|
|
286
|
-
admitted = result === true || result?.allowed === true
|
|
287
|
-
} catch { admitted = false }
|
|
288
|
-
if (!admitted) return blocked('cap_blocked')
|
|
289
|
-
let circuitState
|
|
290
|
-
try { circuitState = circuit.preflight(targetKey) } catch { return blocked() }
|
|
291
|
-
if (!circuitState?.ok) return blocked('circuit_open')
|
|
292
|
-
submissions += 1
|
|
293
|
-
awaitingOutcome = true
|
|
294
|
-
retryEligible = false
|
|
295
|
-
return { admitted: true, record: emit(submissions === 1 ? 'started' : 'retrying') }
|
|
296
|
-
},
|
|
297
|
-
|
|
298
|
-
/** Classify a raw transport failure locally and expose only a coarse trace. */
|
|
299
|
-
failure(input = {}) {
|
|
300
|
-
if (!awaitingOutcome || finished) return null
|
|
301
|
-
awaitingOutcome = false
|
|
302
|
-
const failureClass = classifyProviderFailure(input)
|
|
303
|
-
if (failureClass === 'user_abort') aborted = true
|
|
304
|
-
if (failureClass === 'permission_denied') permissionDenied = true
|
|
305
|
-
let circuitUsable = true
|
|
306
|
-
if (TRANSIENT_FAILURES.has(failureClass)) {
|
|
307
|
-
try {
|
|
308
|
-
const state = circuit.failure(targetKey, checked.value, failureClass)
|
|
309
|
-
if (state?.state === 'open') circuitUsable = false
|
|
310
|
-
} catch { circuitUsable = false }
|
|
311
|
-
}
|
|
312
|
-
retryEligible = circuitUsable && checked.value.retryOn.includes(failureClass) && replaySafe() && submissions < ceiling
|
|
313
|
-
return emit('failed', { failureClass })
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
/** Close/reset the injected circuit after a completed provider response. */
|
|
317
|
-
success() {
|
|
318
|
-
if (!awaitingOutcome || finished) return null
|
|
319
|
-
awaitingOutcome = false
|
|
320
|
-
finished = true
|
|
321
|
-
try { circuit.success(targetKey) } catch { /* a completed response is never replayed */ }
|
|
322
|
-
return emit('succeeded')
|
|
323
|
-
},
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function safeTraceRecord(input = {}) {
|
|
328
|
-
const out = {}
|
|
329
|
-
for (const key of TRACE_FIELDS) {
|
|
330
|
-
if (input[key] === undefined || input[key] === null) continue
|
|
331
|
-
if (TRACE_TEXT_FIELDS.has(key)) {
|
|
332
|
-
const value = safeTraceText(input[key])
|
|
333
|
-
if (value) out[key] = value
|
|
334
|
-
} else if (key === 'turnRev') {
|
|
335
|
-
if (Number.isSafeInteger(input[key]) && input[key] >= 0) out[key] = input[key]
|
|
336
|
-
} else out[key] = input[key]
|
|
337
|
-
}
|
|
338
|
-
out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
|
|
339
|
-
if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
|
|
340
|
-
if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
|
|
341
|
-
for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
|
|
342
|
-
return out
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** A room-safe line assembled exclusively from the allowlisted coarse record. */
|
|
346
|
-
export function formatResilienceTrace(input) {
|
|
347
|
-
const r = safeTraceRecord(input)
|
|
348
|
-
const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
|
|
349
|
-
const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
|
|
350
|
-
if (r.outcome === 'retrying') return `${target} unavailable${failure} — retrying once before output (attempt ${r.attempt} of ${MAX_SAME_TARGET_ATTEMPTS}).`
|
|
351
|
-
if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
|
|
352
|
-
if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
|
|
353
|
-
if (r.outcome === 'succeeded') return `${target} completed.`
|
|
354
|
-
if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
|
|
355
|
-
return `${target} failed${failure}.`
|
|
356
|
-
}
|
|
1
|
+
import{isSecretFreeRoomPayload as e}from"./runtime-contract.mjs";export const RESILIENCE_VERSION=1;export const MAX_NETWORK_ATTEMPTS=3;export const MAX_SAME_TARGET_ATTEMPTS=2;export const TRANSIENT_FAILURES=new Set(["timeout","network","rate_limit","overload","upstream_5xx"]);const t=[...TRANSIENT_FAILURES],r=new Set(["traceId","turnRev","attempt","targetProviderId","targetProviderName","requestedModel","actualConfiguredModel","phase","outcome","failureClass","elapsedMs","retryAfterMs","fallbackFrom"]),o=new Set(["started","retrying","failed","fallback","succeeded","circuit_open","cap_blocked"]),i=new Set(["traceId","targetProviderId","targetProviderName","requestedModel","actualConfiguredModel","phase","failureClass","fallbackFrom"]),a=/(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i;export const DEFAULT_RESILIENCE_POLICY=Object.freeze({v:1,enabled:!1,timeoutMs:9e4,maxAttempts:2,retryOn:t,circuit:Object.freeze({failureThreshold:3,windowMs:6e4,cooldownMs:3e4}),fallback:Object.freeze([])});const n=e=>!!e&&"object"==typeof e&&!Array.isArray(e),l=e=>Number.isInteger(e)&&e>0,s=e=>"string"==typeof e&&e.trim()?e.trim():null,c=t=>{const r=s(t);return!r||r.length>160||a.test(r)?null:e({value:r},512)?r:null};export function validateResiliencePolicy(e,{providers:r=[],primaryProviderId:o=null}={}){if(null==e)return{ok:!0,value:{...DEFAULT_RESILIENCE_POLICY,retryOn:[...t],circuit:{...DEFAULT_RESILIENCE_POLICY.circuit},fallback:[]}};if(!n(e)||1!==e.v)return{ok:!1,error:"unsupported resilience policy version"};if("boolean"!=typeof e.enabled)return{ok:!1,error:"enabled must be boolean"};if(!Number.isInteger(e.timeoutMs)||e.timeoutMs<3e4||e.timeoutMs>12e4)return{ok:!1,error:"timeoutMs must be 30000..120000"};if(!l(e.maxAttempts))return{ok:!1,error:"maxAttempts must be a positive integer"};if(!Array.isArray(e.retryOn)||e.retryOn.some(e=>!TRANSIENT_FAILURES.has(e)))return{ok:!1,error:"retryOn contains an unsupported failure class"};if(!(n(e.circuit)&&l(e.circuit.failureThreshold)&&l(e.circuit.windowMs)&&l(e.circuit.cooldownMs)))return{ok:!1,error:"invalid circuit policy"};if(!Array.isArray(e.fallback))return{ok:!1,error:"fallback must be an array"};const i=[];for(const t of e.fallback){if(!n(t)||!s(t.providerId)||!s(t.model)||!0!==t.allow)return{ok:!1,error:"invalid fallback target"};if("same"!==t.privacyClass||!["same","same-or-lower"].includes(t.costClass))return{ok:!1,error:"fallback privacy/cost class is not eligible"};if(o&&t.providerId===o)return{ok:!1,error:"fallback cannot target itself"};if(r.length){const e=r.find(e=>e?.id===t.providerId);if(!e||"anthropic"===e.id||e.model!==t.model)return{ok:!1,error:"fallback must name a registered exact provider/model target"}}i.push({providerId:s(t.providerId),model:s(t.model),allow:!0,privacyClass:t.privacyClass,costClass:t.costClass})}return{ok:!0,value:{v:1,enabled:e.enabled,timeoutMs:e.timeoutMs,maxAttempts:Math.min(e.maxAttempts,3),retryOn:[...new Set(e.retryOn)],circuit:{failureThreshold:e.circuit.failureThreshold,windowMs:e.circuit.windowMs,cooldownMs:e.circuit.cooldownMs},fallback:i}}}export function resilienceProjection(e){const t=validateResiliencePolicy(e);if(!t.ok)return null;const r=t.value;return{v:r.v,enabled:r.enabled,timeoutMs:r.timeoutMs,maxAttempts:r.maxAttempts,retryOn:[...r.retryOn],circuit:{...r.circuit},fallback:r.fallback.map(e=>({...e}))}}export function classifyProviderFailure(e={}){const t=Number(e.status||e.statusCode||0),r=String(e.code||"").toLowerCase(),o=String(e.message||e.error?.message||"").toLowerCase();return e.aborted||/\babort(ed|error)?\b|cancelled|canceled/.test(`${r} ${o}`)?"user_abort":e.permissionDenied||/permission denied|forbidden by policy|guardrail/.test(`${r} ${o}`)?"permission_denied":401===t||403===t||/unauthori[sz]ed|invalid (api )?key|credential/.test(`${r} ${o}`)?"auth":429===t||/rate.?limit/.test(`${r} ${o}`)?"rate_limit":529===t||/overload|overloaded/.test(`${r} ${o}`)?"overload":t>=500&&t<=599?"upstream_5xx":/timeout|timed out|deadline exceeded|etimedout/.test(`${r} ${o}`)?"timeout":/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${r} ${o}`)?"network":400===t||/invalid model|unknown model/.test(`${r} ${o}`)?"invalid_model":/context length|request too large|payload too large|too many tokens/.test(`${r} ${o}`)?"request_too_large":t>=400&&t<500?"invalid_request":"unknown"}export const isReplaySafe=({outputVisible:e=!1,toolOutputVisible:t=!1,aborted:r=!1,permissionDenied:o=!1,nonIdempotent:i=!1}={})=>!(e||t||r||o||i);export function resolveAttemptTarget({runtime:e="claude",providers:t=[],providerId:r,model:o}={}){if("claude"!==e||!s(r)||!s(o))return null;const i=t.find(e=>e&&e.id===r&&"anthropic"!==e.id&&e.model===o);return i?{providerId:i.id,targetProviderName:s(i.name)||i.id,actualConfiguredModel:i.model}:null}export function assessFallbackEligibility({runtime:e="claude",providers:t=[],primary:r,candidate:o,policy:i,outputVisible:a=!1,toolOutputVisible:n=!1,aborted:l=!1,permissionDenied:s=!1,capAllowed:c=!0,circuit:u="closed"}={}){if(!isReplaySafe({outputVisible:a,toolOutputVisible:n,aborted:l,permissionDenied:s}))return{ok:!1,reason:"replay_unsafe"};if("closed"!==u)return{ok:!1,reason:"circuit_open"};if(!c)return{ok:!1,reason:"cap_blocked"};const d=validateResiliencePolicy(i);if(!d.ok||!d.value.enabled||"claude"!==e)return{ok:!1,reason:"policy_disabled"};const p=resolveAttemptTarget({runtime:e,providers:t,providerId:o?.providerId,model:o?.model});if(!p)return{ok:!1,reason:"unknown_target"};const f=d.value.fallback.find(e=>e.providerId===p.providerId&&e.model===p.actualConfiguredModel);return f?.allow?"same"===f.privacyClass&&["same","same-or-lower"].includes(f.costClass)?r?.providerId===p.providerId&&r?.actualConfiguredModel===p.actualConfiguredModel?{ok:!1,reason:"same_target"}:{ok:!0,target:p,fallback:f}:{ok:!1,reason:"boundary_not_consented"}:{ok:!1,reason:"not_explicitly_allowed"}}export function createAttemptLedger({maxAttempts:e=3,traceId:t="local",turnRev:r=null,onRecord:o=null}={}){const i=Math.max(1,Math.min(Number.isInteger(e)?e:1,3)),a=[];return{get ceiling(){return i},get attempts(){return a.length},get records(){return a.map(e=>({...e}))},canSubmit:()=>a.length<i,record:(e={})=>{if(a.length>=i)return null;const n=safeTraceRecord({traceId:t,turnRev:r,attempt:Math.min(a.length+1,i),...e});a.push(n);try{o?.(n)}catch{}return n}}}export function createMemoryCircuit({now:e=()=>Date.now()}={}){const t=new Map,r=({bridgeHostId:e,providerId:t,model:r}={})=>`${e||""}\0${t||""}\0${r||""}`,o=e=>t.get(r(e))||{failures:[],openUntil:0,probing:!1},i=(e,o)=>t.set(r(e),o);return{preflight(t){const r=o(t),a=e();return r.openUntil>a?{ok:!1,state:"open"}:r.openUntil&&r.openUntil<=a?r.probing?{ok:!1,state:"half_open_busy"}:(r.probing=!0,i(t,r),{ok:!0,state:"half_open"}):{ok:!0,state:"closed"}},success(e){t.delete(r(e))},failure(t,r,a){if(!TRANSIENT_FAILURES.has(a))return{state:this.preflight(t).state,counted:!1};const n=o(t),l=e(),s=r?.circuit||DEFAULT_RESILIENCE_POLICY.circuit;return n.probing?(n.probing=!1,n.failures=[],n.openUntil=l+s.cooldownMs,i(t,n),{state:"open",counted:!0}):(n.failures=n.failures.filter(e=>e>l-s.windowMs),n.failures.push(l),n.failures.length>=s.failureThreshold&&(n.openUntil=l+s.cooldownMs),i(t,n),{state:n.openUntil>l?"open":"closed",counted:!0})},snapshot(e){const t=o(e);return{failures:t.failures.length,openUntil:t.openUntil,probing:t.probing}}}}export function providerResilienceCapAdmission(e){if(!e||null==e.capTokens)return{allowed:!0,configured:!1,reason:"cap_not_configured"};const t=Number(e.capTokens),r=Number(e.spentTokens),o=Number.isFinite(t)&&t>=0&&Number.isFinite(r)&&r>=0&&!0!==e.killed&&r<t;return{allowed:o,configured:!0,reason:o?"within_cap":"cap_blocked"}}export function createSameTargetResilienceController({runtime:e="claude",providers:t=[],providerId:r,model:o,requestedModel:i=o,policy:a,bridgeHostId:n=null,circuit:l,capGate:s,traceId:c="local",turnRev:u=null,onRecord:d=null}={}){let p;try{p=validateResiliencePolicy(a,{providers:t,primaryProviderId:r})}catch{p={ok:!1}}let f=null;try{f=resolveAttemptTarget({runtime:e,providers:t,providerId:r,model:o})}catch{}const m=p?.ok&&p.value.enabled&&f&&"claude"===e,b=!!l&&"function"==typeof l.preflight&&"function"==typeof l.failure&&"function"==typeof l.success,v=m?Math.min(p.value.maxAttempts,2):0,g=f&&{bridgeHostId:n,providerId:f.providerId,model:f.actualConfiguredModel},y=f&&{traceId:c,turnRev:u,targetProviderId:f.providerId,targetProviderName:f.targetProviderName,requestedModel:i,actualConfiguredModel:f.actualConfiguredModel,phase:"same_target"};let k=!1,h=0,I=!1,_=!1,M=!1,w=!1,A=!1,C=!1,R=!1;const T=(e,t={},r=h)=>safeTraceRecord({...y,attempt:Math.max(1,r),outcome:e,...t}),x=(e,t)=>{const r=T(e,t);try{d?.(r)}catch{}return r},E=(e="failed",t)=>({admitted:!1,record:x(e,t)}),N=()=>isReplaySafe({outputVisible:M,toolOutputVisible:w,aborted:A,permissionDenied:C});return{start(){k=!0},visibleOutput({tool:e=!1}={}){M=!0,e&&(w=!0),_=!1},async preflight(){if(k||(k=!0),!m||!b||R||I||h>=v)return E();if(h>0&&(!_||!N()))return E();const e=T(0===h?"started":"retrying",{},h+1);let t,r=!1;try{const t=await(s?.(e));r=!0===t||!0===t?.allowed}catch{r=!1}if(!r)return E("cap_blocked");try{t=l.preflight(g)}catch{return E()}return t?.ok?(h+=1,I=!0,_=!1,{admitted:!0,record:x(1===h?"started":"retrying")}):E("circuit_open")},failure(e={}){if(!I||R)return null;I=!1;const t=classifyProviderFailure(e);"user_abort"===t&&(A=!0),"permission_denied"===t&&(C=!0);let r=!0;if(TRANSIENT_FAILURES.has(t))try{const e=l.failure(g,p.value,t);"open"===e?.state&&(r=!1)}catch{r=!1}return _=r&&p.value.retryOn.includes(t)&&N()&&h<v,x("failed",{failureClass:t})},success(){if(!I||R)return null;I=!1,R=!0;try{l.success(g)}catch{}return x("succeeded")}}}export function safeTraceRecord(e={}){const t={};for(const o of r)if(void 0!==e[o]&&null!==e[o])if(i.has(o)){const r=c(e[o]);r&&(t[o]=r)}else"turnRev"===o?Number.isSafeInteger(e[o])&&e[o]>=0&&(t[o]=e[o]):t[o]=e[o];t.attempt=Math.max(1,Math.min(Number(t.attempt)||1,3)),o.has(t.outcome)||(t.outcome="failed"),t.failureClass&&!/^[a-z0-9_]+$/.test(String(t.failureClass))&&delete t.failureClass;for(const e of["elapsedMs","retryAfterMs"])null!=t[e]&&(t[e]=Math.max(0,Math.floor(Number(t[e])||0)));return t}export function formatResilienceTrace(e){const t=safeTraceRecord(e),r=[t.targetProviderName||t.targetProviderId,t.actualConfiguredModel].filter(Boolean).join(" / ")||"configured provider",o=t.failureClass?` (${t.failureClass.replaceAll("_"," ")})`:"";return"retrying"===t.outcome?`${r} unavailable${o} — retrying once before output (attempt ${t.attempt} of 2).`:"circuit_open"===t.outcome?`${r} is temporarily unavailable; its local circuit is open.`:"cap_blocked"===t.outcome?"Additional provider work was blocked by the applicable cap.":"succeeded"===t.outcome?`${r} completed.`:"fallback"===t.outcome?`Configured fallback selected: ${r}.`:`${r} failed${o}.`}
|
package/provider.mjs
CHANGED
|
@@ -1,133 +1 @@
|
|
|
1
|
-
|
|
2
|
-
provider.mjs — per-machine LLM provider choice for the ThinkPool
|
|
3
|
-
bridge. Lets a Code room run on ANY Anthropic-compatible endpoint
|
|
4
|
-
(Z.ai GLM, OpenRouter, a local proxy, …) instead of plain Anthropic,
|
|
5
|
-
INDEPENDENT of the launching shell. Why this exists: the launchd
|
|
6
|
-
service does NOT source ~/.zshrc, so a shell `export ANTHROPIC_*` can
|
|
7
|
-
never reach a serviced bridge — its spawned Claude children fall back
|
|
8
|
-
to the host's claude.ai login and hit the Anthropic spend wall.
|
|
9
|
-
provider.json is the bridge's OWN source of truth.
|
|
10
|
-
|
|
11
|
-
applyProviderEnv() runs once at the top of bridge.mjs (the universal
|
|
12
|
-
entry point), BEFORE any child is spawned. It sets ANTHROPIC_BASE_URL
|
|
13
|
-
/ ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL on process.env. Because the
|
|
14
|
-
account-supervisor spawn (account.mjs) and the Agent-SDK env
|
|
15
|
-
(claude-session.mjs) both spread `...process.env`, the choice reaches
|
|
16
|
-
every code session with no other change. Default (no file, or
|
|
17
|
-
provider:"anthropic") = the host's regular Claude login, untouched.
|
|
18
|
-
|
|
19
|
-
CLI-agent-compatible ONLY. The bridge spawns the `claude` CLI, which
|
|
20
|
-
talks the Anthropic Messages API — so the base url MUST be an
|
|
21
|
-
Anthropic-compatible root (the SDK appends /v1/messages). An OpenAI-
|
|
22
|
-
style /v1/chat/completions endpoint will NOT work. Common roots:
|
|
23
|
-
Z.ai GLM → https://api.z.ai/api/anthropic
|
|
24
|
-
OpenRouter → https://openrouter.ai/api
|
|
25
|
-
|
|
26
|
-
CLI (dispatched from bridge.mjs):
|
|
27
|
-
npx thinkpool-pair provider # show current
|
|
28
|
-
npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>] # any Anthropic-compatible endpoint
|
|
29
|
-
npx thinkpool-pair provider anthropic # reset to Claude
|
|
30
|
-
───────────────────────────────────────────────────────────── */
|
|
31
|
-
import os from 'node:os'
|
|
32
|
-
import fs from 'node:fs'
|
|
33
|
-
import path from 'node:path'
|
|
34
|
-
import { pairCli } from './command-guidance.mjs'
|
|
35
|
-
|
|
36
|
-
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
37
|
-
const FILE = path.join(DIR, 'provider.json')
|
|
38
|
-
|
|
39
|
-
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
|
|
40
|
-
function opt(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
|
|
41
|
-
|
|
42
|
-
export function loadProvider() {
|
|
43
|
-
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch { return null }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function saveProvider(p) {
|
|
47
|
-
ensureDir()
|
|
48
|
-
fs.writeFileSync(FILE, JSON.stringify(p, null, 2), { mode: 0o600 })
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Merge the persisted provider choice (or TP_* env overrides) into
|
|
53
|
-
* process.env. Call once at startup, before spawning any child. Idempotent.
|
|
54
|
-
* A no-op for the default Anthropic provider — we only ever SET the custom
|
|
55
|
-
* vars, never clobber a host's real Claude login env. Env vars win over the
|
|
56
|
-
* file so a one-off launch can flip providers without writing anything.
|
|
57
|
-
*
|
|
58
|
-
* Provider-name-agnostic: any provider saved to provider.json (glm, custom,
|
|
59
|
-
* a leftover openrouter file, …) is handled by one branch that only cares
|
|
60
|
-
* that baseUrl/authToken are present.
|
|
61
|
-
* @returns {{provider:string,baseUrl?:string,model?:string,source:string}}
|
|
62
|
-
*/
|
|
63
|
-
export function applyProviderEnv() {
|
|
64
|
-
const cfg = loadProvider() || {}
|
|
65
|
-
const provider = (process.env.TP_PROVIDER || cfg.provider || 'anthropic').toLowerCase()
|
|
66
|
-
if (provider === 'anthropic' || !provider) return { provider: 'anthropic', source: 'default' }
|
|
67
|
-
|
|
68
|
-
const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl
|
|
69
|
-
const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
|
|
70
|
-
const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
|
|
71
|
-
if (!baseUrl || !authToken) {
|
|
72
|
-
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>')}\n Falling back to regular Claude for now.\n`)
|
|
73
|
-
return { provider: 'anthropic', source: 'incomplete' }
|
|
74
|
-
}
|
|
75
|
-
process.env.ANTHROPIC_BASE_URL = baseUrl
|
|
76
|
-
process.env.ANTHROPIC_AUTH_TOKEN = authToken
|
|
77
|
-
if (model) process.env.ANTHROPIC_MODEL = model
|
|
78
|
-
return { provider, baseUrl, model, source: cfg.provider ? 'file' : 'env' }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** CLI handler for `thinkpool-pair provider [...]`. */
|
|
82
|
-
export async function runProvider(args) {
|
|
83
|
-
const sub = (args[0] || '').toLowerCase()
|
|
84
|
-
|
|
85
|
-
// ── show ──
|
|
86
|
-
if (!sub || sub === 'show' || sub === 'status') {
|
|
87
|
-
const cfg = loadProvider()
|
|
88
|
-
if (!cfg || cfg.provider === 'anthropic' || !cfg.provider) {
|
|
89
|
-
console.error('\n ◆ provider: anthropic (regular Claude login) — the default.\n')
|
|
90
|
-
} else {
|
|
91
|
-
console.error(`\n ◆ provider: ${cfg.provider}\n base url: ${cfg.baseUrl || '(unset)'}\n model: ${cfg.model || '(unset)'}\n token: ${cfg.authToken ? cfg.authToken.slice(0, 8) + '…' : '(unset)'}\n`)
|
|
92
|
-
}
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── reset to Anthropic ──
|
|
97
|
-
if (sub === 'anthropic' || sub === 'clear' || sub === 'reset') {
|
|
98
|
-
try { fs.unlinkSync(FILE) } catch { /* noop */ }
|
|
99
|
-
console.error('\n ✓ provider reset to anthropic (regular Claude login).\n')
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ── any Anthropic-compatible endpoint ──
|
|
104
|
-
if (sub === 'custom') {
|
|
105
|
-
const token = opt(args, '--token') || opt(args, '--key')
|
|
106
|
-
const baseUrl = opt(args, '--base') || opt(args, '--base-url')
|
|
107
|
-
const model = opt(args, '--model')
|
|
108
|
-
if (!token || !baseUrl) {
|
|
109
|
-
console.error(
|
|
110
|
-
'\n ✗ custom needs --base <url> and --token <key>. [--model <name>] is optional.\n' +
|
|
111
|
-
' The base url is the Anthropic-compatible root the SDK appends /v1/messages to:\n' +
|
|
112
|
-
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
113
|
-
' OpenRouter → https://openrouter.ai/api\n' +
|
|
114
|
-
' Restart the bridge (or its launchd service) to apply.\n'
|
|
115
|
-
)
|
|
116
|
-
process.exit(1)
|
|
117
|
-
}
|
|
118
|
-
saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
|
|
119
|
-
console.error(`\n ✓ provider set to custom.\n base url: ${baseUrl}\n model: ${model || '(default)'}\n token: ${token.slice(0, 8)}…\n Restart the bridge (or its launchd service) to apply.\n`)
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
console.error(
|
|
124
|
-
'\n usage:\n' +
|
|
125
|
-
` ${pairCli('provider')} # show current\n` +
|
|
126
|
-
` ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>', '[--model <m>]')} # any Anthropic-compatible endpoint\n` +
|
|
127
|
-
` ${pairCli('provider', 'anthropic')} # reset to Claude\n\n` +
|
|
128
|
-
' common base urls (SDK appends /v1/messages):\n' +
|
|
129
|
-
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
130
|
-
' OpenRouter → https://openrouter.ai/api\n'
|
|
131
|
-
)
|
|
132
|
-
process.exit(1)
|
|
133
|
-
}
|
|
1
|
+
import e from"node:os";import r from"node:fs";import o from"node:path";import{pairCli as n}from"./command-guidance.mjs";const t=o.join(e.homedir(),".thinkpool-pair"),s=o.join(t,"provider.json");function i(e,r){const o=e.indexOf(r);return o>=0?e[o+1]:void 0}export function loadProvider(){try{return JSON.parse(r.readFileSync(s,"utf8"))}catch{return null}}export function saveProvider(e){!function(){try{r.mkdirSync(t,{recursive:!0})}catch{}}(),r.writeFileSync(s,JSON.stringify(e,null,2),{mode:384})}export function applyProviderEnv(){const e=loadProvider()||{},r=(process.env.TP_PROVIDER||e.provider||"anthropic").toLowerCase();if("anthropic"===r||!r)return{provider:"anthropic",source:"default"};const o=process.env.TP_ANTHROPIC_BASE_URL||e.baseUrl,t=process.env.TP_ANTHROPIC_AUTH_TOKEN||e.authToken,s=process.env.TP_ANTHROPIC_MODEL||e.model;return o&&t?(process.env.ANTHROPIC_BASE_URL=o,process.env.ANTHROPIC_AUTH_TOKEN=t,s&&(process.env.ANTHROPIC_MODEL=s),{provider:r,baseUrl:o,model:s,source:e.provider?"file":"env"}):(process.stderr.write(`\n ◇ provider "${r}" is set but is missing baseUrl/authToken.\n Finish setup: ${n("provider","custom","--base","<url>","--token","<key>")}\n Falling back to regular Claude for now.\n`),{provider:"anthropic",source:"incomplete"})}export async function runProvider(e){const o=(e[0]||"").toLowerCase();if(!o||"show"===o||"status"===o){const e=loadProvider();return void(e&&"anthropic"!==e.provider&&e.provider?console.error(`\n ◆ provider: ${e.provider}\n base url: ${e.baseUrl||"(unset)"}\n model: ${e.model||"(unset)"}\n token: ${e.authToken?e.authToken.slice(0,8)+"…":"(unset)"}\n`):console.error("\n ◆ provider: anthropic (regular Claude login) — the default.\n"))}if("anthropic"!==o&&"clear"!==o&&"reset"!==o){if("custom"===o){const r=i(e,"--token")||i(e,"--key"),o=i(e,"--base")||i(e,"--base-url"),n=i(e,"--model");return r&&o||(console.error("\n ✗ custom needs --base <url> and --token <key>. [--model <name>] is optional.\n The base url is the Anthropic-compatible root the SDK appends /v1/messages to:\n Z.ai GLM → https://api.z.ai/api/anthropic\n OpenRouter → https://openrouter.ai/api\n Restart the bridge (or its launchd service) to apply.\n"),process.exit(1)),saveProvider({provider:"custom",baseUrl:o,authToken:r,model:n||void 0,savedAt:Date.now()}),void console.error(`\n ✓ provider set to custom.\n base url: ${o}\n model: ${n||"(default)"}\n token: ${r.slice(0,8)}…\n Restart the bridge (or its launchd service) to apply.\n`)}console.error(`\n usage:\n ${n("provider")} # show current\n ${n("provider","custom","--base","<url>","--token","<key>","[--model <m>]")} # any Anthropic-compatible endpoint\n ${n("provider","anthropic")} # reset to Claude\n\n common base urls (SDK appends /v1/messages):\n Z.ai GLM → https://api.z.ai/api/anthropic\n OpenRouter → https://openrouter.ai/api\n`),process.exit(1)}else{try{r.unlinkSync(s)}catch{}console.error("\n ✓ provider reset to anthropic (regular Claude login).\n")}}
|