opencode-anthropic-fix 0.2.0 → 0.3.0-beta.0
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 +31 -6
- package/cli.mjs +1 -0
- package/index.mjs +160 -231
- package/lib/backoff.mjs +1 -1
- package/lib/cc-credentials.mjs +1 -1
- package/lib/config.mjs +49 -27
- package/lib/mimicry/adapter-input.mjs +671 -0
- package/lib/mimicry/context-hint-threshold.mjs +127 -0
- package/lib/mimicry/headers.mjs +71 -66
- package/lib/mimicry/request-body.mjs +23 -24
- package/lib/mimicry/system-prompt.mjs +54 -23
- package/lib/mimicry/wire-compat.mjs +305 -0
- package/lib/rotation.mjs +16 -0
- package/package.json +3 -3
- package/lib/account-state.test.mjs +0 -109
- package/lib/accounts.test.mjs +0 -912
- package/lib/adaptive-thinking-models.test.mjs +0 -30
- package/lib/backoff.test.mjs +0 -411
- package/lib/cc-credentials.test.mjs +0 -391
- package/lib/config.test.mjs +0 -757
- package/lib/context-hint-persist.mjs +0 -68
- package/lib/diagnose.test.mjs +0 -122
- package/lib/env.test.mjs +0 -25
- package/lib/haiku-call.test.mjs +0 -72
- package/lib/message-transform.test.mjs +0 -173
- package/lib/mimicry/cache.test.mjs +0 -112
- package/lib/mimicry/headers.test.mjs +0 -50
- package/lib/mimicry/models.test.mjs +0 -132
- package/lib/mimicry/request-body.test.mjs +0 -34
- package/lib/mimicry/request-helpers.test.mjs +0 -155
- package/lib/mimicry/response-stream.test.mjs +0 -127
- package/lib/mimicry/system-prompt.test.mjs +0 -73
- package/lib/oauth.test.mjs +0 -343
- package/lib/redact.test.mjs +0 -154
- package/lib/refresh-lock.test.mjs +0 -131
- package/lib/request-headers.test.mjs +0 -164
- package/lib/retry/overload-loop.test.mjs +0 -107
- package/lib/rotation.test.mjs +0 -346
- package/lib/session-metrics.test.mjs +0 -67
- package/lib/storage.test.mjs +0 -696
- package/lib/token-economy/microcompact.test.mjs +0 -28
- package/lib/token-economy/transforms.test.mjs +0 -192
- package/lib/tuning.test.mjs +0 -89
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter from the plugin's host request shape to the shared wire constructor.
|
|
3
|
+
* The logical canonical header order is tested, but order on the fetch wire is
|
|
4
|
+
* NOT promised because the returned HeaderPair array is converted to Headers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { buildClaudeCodeRequest } from "@tormentalabs/claude-code-wire-compat";
|
|
8
|
+
import { stripStainlessHelperMarkers } from "./headers.mjs";
|
|
9
|
+
|
|
10
|
+
const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
|
|
11
|
+
|
|
12
|
+
const BODY_FIELD_NAMES = new Set([
|
|
13
|
+
"model",
|
|
14
|
+
"max_tokens",
|
|
15
|
+
"messages",
|
|
16
|
+
"system",
|
|
17
|
+
"tools",
|
|
18
|
+
"thinking",
|
|
19
|
+
"effort",
|
|
20
|
+
"metadata",
|
|
21
|
+
"context_management",
|
|
22
|
+
"output_config",
|
|
23
|
+
"speed",
|
|
24
|
+
"service_tier",
|
|
25
|
+
"output_format",
|
|
26
|
+
"tool_choice",
|
|
27
|
+
"top_p",
|
|
28
|
+
"top_k",
|
|
29
|
+
"stop_sequences",
|
|
30
|
+
"stream",
|
|
31
|
+
"temperature",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
|
|
36
|
+
* @returns {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue> | undefined}
|
|
37
|
+
*/
|
|
38
|
+
function collectExperimentalBodyFields(body) {
|
|
39
|
+
const fields = Object.fromEntries(Object.entries(body).filter(([name]) => !BODY_FIELD_NAMES.has(name)));
|
|
40
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The shared contract accepts `system` as an array of strings or text blocks.
|
|
45
|
+
* A host may send a bare string instead, which must be wrapped rather than
|
|
46
|
+
* dropped: silently discarding it would lose caller intent with no error.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} system
|
|
49
|
+
* @returns {readonly import('@tormentalabs/claude-code-wire-compat').SystemInput[] | undefined}
|
|
50
|
+
*/
|
|
51
|
+
function normalizeSystem(system) {
|
|
52
|
+
if (system === undefined) return undefined;
|
|
53
|
+
if (typeof system === "string") return [system];
|
|
54
|
+
if (Array.isArray(system)) return system;
|
|
55
|
+
throw new TypeError("Expected request system to be a string or an array");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rewrite DOTTED model version separators to DASHED ones before the id reaches
|
|
60
|
+
* the shared package.
|
|
61
|
+
*
|
|
62
|
+
* WHY THIS EXISTS. The package classifies models by dashed id ONLY:
|
|
63
|
+
* `normalizeModelId` (dist/model-identity.js) tests `includes("claude-opus-4-7")`
|
|
64
|
+
* and never the dotted spelling, because the genuine Claude Code client emits
|
|
65
|
+
* dashed ids. The plugin, by contrast, deliberately TOLERATES dotted ids
|
|
66
|
+
* throughout — `lib/mimicry/models.mjs` matches with the `[._-]` character class
|
|
67
|
+
* — so a host can legitimately hand us `claude-opus-4.7`.
|
|
68
|
+
*
|
|
69
|
+
* Passing a dotted id straight through MISCLASSIFIES it. For
|
|
70
|
+
* `claude-opus-4.7` no `includes` branch matches, so the fallback
|
|
71
|
+
* `/claude-opus-4(?!-\d(?!\d))/` matches (the character after `claude-opus-4`
|
|
72
|
+
* is `.`, not `-`) and the model resolves to `claude-opus-4-0`. That silently
|
|
73
|
+
* drops `adaptiveThinking`, `supportsEffort`, `supportsMaxEffort` and
|
|
74
|
+
* `supportsXhighEffort`, and wrongly turns `supportsTemperature` ON.
|
|
75
|
+
*
|
|
76
|
+
* The tolerance belongs to the plugin, so it is spent here and the package
|
|
77
|
+
* receives the dashed id it is built for. The rewrite is deliberately narrow:
|
|
78
|
+
* a dot is only replaced when it FOLLOWS a letter or digit and PRECEDES a
|
|
79
|
+
* digit, i.e. when it is a version separator (`claude-opus-4.7`,
|
|
80
|
+
* `claude-3.5-sonnet`). A dot before a letter is left alone, so vendor
|
|
81
|
+
* prefixes such as `anthropic.claude-opus-4-6` in a Bedrock ARN survive
|
|
82
|
+
* untouched.
|
|
83
|
+
*
|
|
84
|
+
* NOTE: `wireId = stripModelMarkers(model)` derives from this same string, so
|
|
85
|
+
* the `model` sent on the wire also becomes dashed. That is intended — the
|
|
86
|
+
* real API only accepts the dashed spelling — and is pinned by a test.
|
|
87
|
+
*
|
|
88
|
+
* @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} model
|
|
89
|
+
* @returns {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined}
|
|
90
|
+
*/
|
|
91
|
+
function normalizeModelVersionSeparators(model) {
|
|
92
|
+
if (typeof model !== "string") return model;
|
|
93
|
+
return model.replace(/([A-Za-z0-9])\.(?=\d)/g, "$1-");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reject a malformed `tools` value instead of dropping it, so a host mistake
|
|
98
|
+
* surfaces as an error rather than as a silently toolless request.
|
|
99
|
+
*
|
|
100
|
+
* @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} tools
|
|
101
|
+
* @returns {readonly import('@tormentalabs/claude-code-wire-compat').ToolDefinition[] | undefined}
|
|
102
|
+
*/
|
|
103
|
+
function normalizeTools(tools) {
|
|
104
|
+
if (tools === undefined) return undefined;
|
|
105
|
+
if (Array.isArray(tools)) return tools;
|
|
106
|
+
throw new TypeError("Expected request tools to be an array");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the emergency protocol-profile override. Explicit plugin
|
|
111
|
+
* configuration wins over the environment; an absent override leaves the
|
|
112
|
+
* shared package's pinned profile untouched. JSON and package validation
|
|
113
|
+
* errors intentionally propagate so an emergency override cannot fail open.
|
|
114
|
+
*
|
|
115
|
+
* @param {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride | undefined} configuredOverride
|
|
116
|
+
* @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride | undefined}
|
|
117
|
+
*/
|
|
118
|
+
function resolveProfileOverride(configuredOverride) {
|
|
119
|
+
if (configuredOverride !== undefined) return configuredOverride;
|
|
120
|
+
|
|
121
|
+
const serializedOverride = process.env[PROFILE_OVERRIDE_ENV];
|
|
122
|
+
if (serializedOverride === undefined || serializedOverride === "") return undefined;
|
|
123
|
+
|
|
124
|
+
const parsed = JSON.parse(serializedOverride);
|
|
125
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
126
|
+
throw new TypeError(`${PROFILE_OVERRIDE_ENV} must contain a JSON object`);
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
|
|
133
|
+
* @param {{
|
|
134
|
+
* accessToken: string,
|
|
135
|
+
* clientRequestId: string,
|
|
136
|
+
* runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
|
|
137
|
+
* app?: 'cli' | 'cli-bg',
|
|
138
|
+
* stainlessRetryCount?: number,
|
|
139
|
+
* stainlessHelper?: string,
|
|
140
|
+
* claudeRemoteContainerId?: string,
|
|
141
|
+
* claudeRemoteSessionId?: string,
|
|
142
|
+
* clientApp?: string,
|
|
143
|
+
* anthropicAdditionalProtection?: string,
|
|
144
|
+
* extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
|
|
145
|
+
* extraHeaderPolicy?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeExtraHeaderPolicy,
|
|
146
|
+
* additionalBetas?: readonly string[],
|
|
147
|
+
* suppressBetas?: readonly string[],
|
|
148
|
+
* suppressBillingBlock?: boolean,
|
|
149
|
+
* suppressIdentityBlock?: boolean,
|
|
150
|
+
* betaOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeBetaOverrides,
|
|
151
|
+
* metadataOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeMetadataOverrides,
|
|
152
|
+
* cacheControl?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCacheControlInput,
|
|
153
|
+
* capabilities?: Partial<import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCapabilities>,
|
|
154
|
+
* profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
|
|
155
|
+
* }} transport
|
|
156
|
+
* @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRequestInput}
|
|
157
|
+
*/
|
|
158
|
+
export function toClaudeCodeRequestInput(body, transport) {
|
|
159
|
+
const system = normalizeSystem(body.system);
|
|
160
|
+
const profileOverride = resolveProfileOverride(transport.profileOverride);
|
|
161
|
+
const thinking =
|
|
162
|
+
body.thinking === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: {
|
|
165
|
+
type: body.thinking.type,
|
|
166
|
+
...(body.thinking.budget_tokens === undefined ? {} : { budgetTokens: body.thinking.budget_tokens }),
|
|
167
|
+
...(body.thinking.display === undefined ? {} : { display: body.thinking.display }),
|
|
168
|
+
};
|
|
169
|
+
const outputConfig =
|
|
170
|
+
body.output_config === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: Object.fromEntries(
|
|
173
|
+
Object.entries(body.output_config).map(([name, value]) => [
|
|
174
|
+
name === "max_output_tokens" ? "maxOutputTokens" : name,
|
|
175
|
+
value,
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
const input = {
|
|
179
|
+
accessToken: transport.accessToken,
|
|
180
|
+
model: normalizeModelVersionSeparators(body.model),
|
|
181
|
+
maxTokens: body.max_tokens,
|
|
182
|
+
messages: body.messages,
|
|
183
|
+
runtime: transport.runtime,
|
|
184
|
+
clientRequestId: transport.clientRequestId,
|
|
185
|
+
system,
|
|
186
|
+
tools: normalizeTools(body.tools),
|
|
187
|
+
thinking,
|
|
188
|
+
cacheControl: transport.cacheControl,
|
|
189
|
+
// WHY: the package derives model capabilities from the normalized model id
|
|
190
|
+
// alone. This seam is the only way a consumer can DOWNGRADE one of them
|
|
191
|
+
// (`build-request.js` rejects an UPGRADE with `UNSUPPORTED_CAPABILITY`, but
|
|
192
|
+
// allows `true -> false`). The plugin needs it so
|
|
193
|
+
// `OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING` can turn `adaptiveThinking`
|
|
194
|
+
// off and push `resolveThinking` down its `else` branch, emitting
|
|
195
|
+
// `{budget_tokens, type: "enabled"}` instead of `{type: "adaptive"}`.
|
|
196
|
+
// Omitted entirely when the transport does not set it, so a consumer that
|
|
197
|
+
// downgrades nothing stays byte-identical to the genuine client.
|
|
198
|
+
capabilities: transport.capabilities,
|
|
199
|
+
effort: body.effort,
|
|
200
|
+
metadata: body.metadata,
|
|
201
|
+
experimentalBodyFields: collectExperimentalBodyFields(body),
|
|
202
|
+
contextManagement: body.context_management,
|
|
203
|
+
outputConfig,
|
|
204
|
+
speed: body.speed,
|
|
205
|
+
serviceTier: body.service_tier,
|
|
206
|
+
outputFormat: body.output_format,
|
|
207
|
+
toolChoice: body.tool_choice,
|
|
208
|
+
topP: body.top_p,
|
|
209
|
+
topK: body.top_k,
|
|
210
|
+
stopSequences: body.stop_sequences,
|
|
211
|
+
stream: body.stream,
|
|
212
|
+
temperature: body.temperature,
|
|
213
|
+
app: transport.app,
|
|
214
|
+
stainlessRetryCount: transport.stainlessRetryCount,
|
|
215
|
+
stainlessHelper: transport.stainlessHelper,
|
|
216
|
+
claudeRemoteContainerId: transport.claudeRemoteContainerId,
|
|
217
|
+
claudeRemoteSessionId: transport.claudeRemoteSessionId,
|
|
218
|
+
clientApp: transport.clientApp,
|
|
219
|
+
anthropicAdditionalProtection: transport.anthropicAdditionalProtection,
|
|
220
|
+
extraHeaders: transport.extraHeaders,
|
|
221
|
+
extraHeaderPolicy: transport.extraHeaderPolicy,
|
|
222
|
+
additionalBetas: transport.additionalBetas,
|
|
223
|
+
suppressBetas: transport.suppressBetas,
|
|
224
|
+
suppressBillingBlock: transport.suppressBillingBlock,
|
|
225
|
+
// Sibling of `suppressBillingBlock` at the ROOT of the package input — NOT
|
|
226
|
+
// `cacheControl.suppressIdentityBlock`, which only drops the block's
|
|
227
|
+
// `cache_control` marker and keeps the block itself. This one omits the
|
|
228
|
+
// whole identity block, which is what the lean-system-prompt feature needs
|
|
229
|
+
// now that the adapter path delegates canonical block composition to the
|
|
230
|
+
// package (`suppressCanonicalBlocks` in request-body.mjs).
|
|
231
|
+
suppressIdentityBlock: transport.suppressIdentityBlock,
|
|
232
|
+
// UNCONDITIONAL on purpose. `lib/mimicry/request-body.mjs` never touches
|
|
233
|
+
// `cache_control` on `thinking`/`redacted_thinking` blocks because ANY
|
|
234
|
+
// mutation of those blocks — including deleting the key — makes the real
|
|
235
|
+
// API answer 400 "thinking or redacted_thinking blocks in the latest
|
|
236
|
+
// assistant message cannot be modified". The plugin therefore ALWAYS needs
|
|
237
|
+
// the package to accept and pass the key through verbatim; the need is a
|
|
238
|
+
// property of the API round-trip contract, not of a given request. Gating
|
|
239
|
+
// it on "does some block actually carry the key" would add a scan and a
|
|
240
|
+
// second state to get wrong for zero behavioural gain: with the flag on and
|
|
241
|
+
// no such key present, the package output is byte-identical.
|
|
242
|
+
preserveThinkingBlockCacheControl: true,
|
|
243
|
+
betaOverrides: transport.betaOverrides,
|
|
244
|
+
metadataOverrides: transport.metadataOverrides,
|
|
245
|
+
...(profileOverride === undefined ? {} : { profileOverride }),
|
|
246
|
+
};
|
|
247
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build a Claude Code request from the JSON body received from the host.
|
|
252
|
+
*
|
|
253
|
+
* @param {string | undefined} body
|
|
254
|
+
* @param {{
|
|
255
|
+
* accessToken: string,
|
|
256
|
+
* clientRequestId: string,
|
|
257
|
+
* runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
|
|
258
|
+
* app?: 'cli' | 'cli-bg',
|
|
259
|
+
* stainlessRetryCount?: number,
|
|
260
|
+
* stainlessHelper?: string,
|
|
261
|
+
* claudeRemoteContainerId?: string,
|
|
262
|
+
* claudeRemoteSessionId?: string,
|
|
263
|
+
* clientApp?: string,
|
|
264
|
+
* anthropicAdditionalProtection?: string,
|
|
265
|
+
* extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
|
|
266
|
+
* extraHeaderPolicy?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeExtraHeaderPolicy,
|
|
267
|
+
* additionalBetas?: readonly string[],
|
|
268
|
+
* suppressBetas?: readonly string[],
|
|
269
|
+
* suppressBillingBlock?: boolean,
|
|
270
|
+
* suppressIdentityBlock?: boolean,
|
|
271
|
+
* betaOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeBetaOverrides,
|
|
272
|
+
* metadataOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeMetadataOverrides,
|
|
273
|
+
* cacheControl?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCacheControlInput,
|
|
274
|
+
* capabilities?: Partial<import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCapabilities>,
|
|
275
|
+
* profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
|
|
276
|
+
* }} transport
|
|
277
|
+
* @returns {Promise<Omit<import('@tormentalabs/claude-code-wire-compat').BuiltClaudeCodeRequest, 'headers'> & {headers: Headers}>}
|
|
278
|
+
*/
|
|
279
|
+
export async function buildWireCompatibleRequest(body, transport) {
|
|
280
|
+
if (!body || typeof body !== "string") throw new TypeError("Expected a JSON request body");
|
|
281
|
+
const parsed = JSON.parse(body);
|
|
282
|
+
// The stainless helper markers are an internal signal: buildAdapterTransport
|
|
283
|
+
// already derived `transport.stainlessHelper` from them, and the API has never
|
|
284
|
+
// known those keys — the package rejects them with INVALID_INPUT. `parsed` is
|
|
285
|
+
// a local object owned by this call, so the strip is in place.
|
|
286
|
+
stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
|
|
287
|
+
let built;
|
|
288
|
+
try {
|
|
289
|
+
built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport));
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// The package deliberately sanitizes its errors down to a bare code, and
|
|
292
|
+
// carries the useful part in `safeDetails`, which nothing surfaces. Fold it
|
|
293
|
+
// into the message so a rejection is diagnosable from a stack trace alone.
|
|
294
|
+
// The error object itself is preserved, so `code`, `safeDetails` and
|
|
295
|
+
// `instanceof` all still hold for callers that inspect them.
|
|
296
|
+
if (error instanceof Error && "safeDetails" in error) {
|
|
297
|
+
const detail = Object.entries(error.safeDetails ?? {})
|
|
298
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
299
|
+
.join(" ");
|
|
300
|
+
if (detail) error.message = `${error.message} (${detail})`;
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
return { ...built, headers: new Headers(built.headers) };
|
|
305
|
+
}
|
package/lib/rotation.mjs
CHANGED
|
@@ -259,6 +259,22 @@ export function selectAccount(candidates, strategy, currentIndex, healthTracker,
|
|
|
259
259
|
return next ? { index: next.index, cursor: cursor + 1 } : null;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
case "single": {
|
|
263
|
+
// Single-account mode: use exactly one account and never auto-rotate.
|
|
264
|
+
// Stay on the current account while it is available; if it is
|
|
265
|
+
// rate-limited or otherwise unavailable, STOP (return null) so the
|
|
266
|
+
// caller alerts the user instead of silently switching accounts.
|
|
267
|
+
// Auto-resumes once the account becomes available again (e.g. the
|
|
268
|
+
// rate-limit window expires). See issue #20.
|
|
269
|
+
if (currentIndex !== null) {
|
|
270
|
+
const current = available.find((acc) => acc.index === currentIndex);
|
|
271
|
+
return current ? { index: current.index, cursor } : null;
|
|
272
|
+
}
|
|
273
|
+
// No account selected yet — pick the first available as the single account.
|
|
274
|
+
const next = available[cursor % available.length];
|
|
275
|
+
return next ? { index: next.index, cursor: cursor + 1 } : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
case "hybrid": {
|
|
263
279
|
const scoredCandidates = available
|
|
264
280
|
.filter((acc) => healthTracker.isUsable(acc.index) && tokenTracker.hasTokens(acc.index))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anthropic-fix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.0",
|
|
4
4
|
"license": "GPL-3.0-or-later",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"index.mjs",
|
|
16
16
|
"cli.mjs",
|
|
17
17
|
"lib/**/*.mjs",
|
|
18
|
+
"!lib/**/*.test.mjs",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
@@ -54,7 +55,6 @@
|
|
|
54
55
|
"vitest": "^4.0.18"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@
|
|
58
|
-
"xxhash-wasm": "^1.1.0"
|
|
58
|
+
"@tormentalabs/claude-code-wire-compat": "0.1.0"
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { applyOAuthCredentials, resetAccountTracking, adjustActiveIndexAfterRemoval } from "./account-state.mjs";
|
|
3
|
-
|
|
4
|
-
describe("resetAccountTracking", () => {
|
|
5
|
-
it("resets rate-limit and failure fields", () => {
|
|
6
|
-
const account = {
|
|
7
|
-
rateLimitResetTimes: { anthropic: Date.now() + 60_000 },
|
|
8
|
-
consecutiveFailures: 7,
|
|
9
|
-
lastFailureTime: Date.now(),
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
resetAccountTracking(account);
|
|
13
|
-
|
|
14
|
-
expect(account.rateLimitResetTimes).toEqual({});
|
|
15
|
-
expect(account.consecutiveFailures).toBe(0);
|
|
16
|
-
expect(account.lastFailureTime).toBeNull();
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("applyOAuthCredentials", () => {
|
|
21
|
-
it("applies refresh/access/expiry and optional email", () => {
|
|
22
|
-
const account = {
|
|
23
|
-
refreshToken: "old-refresh",
|
|
24
|
-
access: "old-access",
|
|
25
|
-
expires: 1,
|
|
26
|
-
email: "old@example.com",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
applyOAuthCredentials(account, {
|
|
30
|
-
refresh: "new-refresh",
|
|
31
|
-
access: "new-access",
|
|
32
|
-
expires: 123,
|
|
33
|
-
email: "new@example.com",
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
expect(account).toEqual({
|
|
37
|
-
refreshToken: "new-refresh",
|
|
38
|
-
access: "new-access",
|
|
39
|
-
expires: 123,
|
|
40
|
-
token_updated_at: expect.any(Number),
|
|
41
|
-
email: "new@example.com",
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("preserves existing email when credentials omit email", () => {
|
|
46
|
-
const account = {
|
|
47
|
-
refreshToken: "old-refresh",
|
|
48
|
-
access: "old-access",
|
|
49
|
-
expires: 1,
|
|
50
|
-
email: "old@example.com",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
applyOAuthCredentials(account, {
|
|
54
|
-
refresh: "new-refresh",
|
|
55
|
-
access: "new-access",
|
|
56
|
-
expires: 456,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
expect(account.email).toBe("old@example.com");
|
|
60
|
-
expect(account.refreshToken).toBe("new-refresh");
|
|
61
|
-
expect(account.access).toBe("new-access");
|
|
62
|
-
expect(account.expires).toBe(456);
|
|
63
|
-
expect(account.token_updated_at).toEqual(expect.any(Number));
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("adjustActiveIndexAfterRemoval", () => {
|
|
68
|
-
it("resets activeIndex to 0 when no accounts remain", () => {
|
|
69
|
-
const storage = { accounts: [], activeIndex: 3 };
|
|
70
|
-
adjustActiveIndexAfterRemoval(storage, 0);
|
|
71
|
-
expect(storage.activeIndex).toBe(0);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("clamps activeIndex when it falls out of range", () => {
|
|
75
|
-
const storage = {
|
|
76
|
-
accounts: [{ id: "a" }, { id: "b" }],
|
|
77
|
-
activeIndex: 2,
|
|
78
|
-
};
|
|
79
|
-
adjustActiveIndexAfterRemoval(storage, 0);
|
|
80
|
-
expect(storage.activeIndex).toBe(1);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("decrements activeIndex when removed index is before active", () => {
|
|
84
|
-
const storage = {
|
|
85
|
-
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
86
|
-
activeIndex: 2,
|
|
87
|
-
};
|
|
88
|
-
adjustActiveIndexAfterRemoval(storage, 0);
|
|
89
|
-
expect(storage.activeIndex).toBe(1);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("keeps activeIndex when removed index is after active", () => {
|
|
93
|
-
const storage = {
|
|
94
|
-
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
95
|
-
activeIndex: 0,
|
|
96
|
-
};
|
|
97
|
-
adjustActiveIndexAfterRemoval(storage, 2);
|
|
98
|
-
expect(storage.activeIndex).toBe(0);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("keeps activeIndex when removed index was the active slot", () => {
|
|
102
|
-
const storage = {
|
|
103
|
-
accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
104
|
-
activeIndex: 1,
|
|
105
|
-
};
|
|
106
|
-
adjustActiveIndexAfterRemoval(storage, 1);
|
|
107
|
-
expect(storage.activeIndex).toBe(1);
|
|
108
|
-
});
|
|
109
|
-
});
|