opencode-anthropic-fix 0.0.14
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/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} HealthScoreConfig
|
|
11
|
+
* @property {number} initial
|
|
12
|
+
* @property {number} success_reward
|
|
13
|
+
* @property {number} rate_limit_penalty
|
|
14
|
+
* @property {number} failure_penalty
|
|
15
|
+
* @property {number} recovery_rate_per_hour
|
|
16
|
+
* @property {number} min_usable
|
|
17
|
+
* @property {number} max_score
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} TokenBucketConfig
|
|
22
|
+
* @property {number} max_tokens
|
|
23
|
+
* @property {number} regeneration_rate_per_minute
|
|
24
|
+
* @property {number} initial_tokens
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} ToastConfig
|
|
29
|
+
* @property {boolean} quiet - Suppress non-error toasts
|
|
30
|
+
* @property {number} debounce_seconds - Minimum seconds between account-switch toasts
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} OverrideModelLimitsConfig
|
|
35
|
+
* @property {boolean} enabled - When true, overrides model context limits for 1M-window models so OpenCode compacts at the right threshold.
|
|
36
|
+
* @property {number} context - Context window size to inject (tokens). Default: 1_000_000.
|
|
37
|
+
* @property {number} output - Max output tokens to inject. 0 = leave model default unchanged.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} AnthropicAuthConfig
|
|
42
|
+
* @property {AccountSelectionStrategy} account_selection_strategy
|
|
43
|
+
* @property {number} failure_ttl_seconds
|
|
44
|
+
* @property {boolean} debug
|
|
45
|
+
* @property {{ enabled: boolean, fetch_claude_code_version_on_startup: boolean, prompt_compaction: 'minimal' | 'off' }} signature_emulation
|
|
46
|
+
* @property {OverrideModelLimitsConfig} override_model_limits
|
|
47
|
+
* @property {HealthScoreConfig} health_score
|
|
48
|
+
* @property {TokenBucketConfig} token_bucket
|
|
49
|
+
* @property {ToastConfig} toasts
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** @type {AnthropicAuthConfig} */
|
|
53
|
+
export const DEFAULT_CONFIG = {
|
|
54
|
+
account_selection_strategy: "sticky",
|
|
55
|
+
failure_ttl_seconds: 3600,
|
|
56
|
+
debug: false,
|
|
57
|
+
signature_emulation: {
|
|
58
|
+
enabled: true,
|
|
59
|
+
fetch_claude_code_version_on_startup: true,
|
|
60
|
+
prompt_compaction: "minimal",
|
|
61
|
+
},
|
|
62
|
+
override_model_limits: {
|
|
63
|
+
enabled: true,
|
|
64
|
+
context: 1_000_000,
|
|
65
|
+
output: 0,
|
|
66
|
+
},
|
|
67
|
+
health_score: {
|
|
68
|
+
initial: 70,
|
|
69
|
+
success_reward: 1,
|
|
70
|
+
rate_limit_penalty: -10,
|
|
71
|
+
failure_penalty: -20,
|
|
72
|
+
recovery_rate_per_hour: 2,
|
|
73
|
+
min_usable: 50,
|
|
74
|
+
max_score: 100,
|
|
75
|
+
},
|
|
76
|
+
token_bucket: {
|
|
77
|
+
max_tokens: 50,
|
|
78
|
+
regeneration_rate_per_minute: 6,
|
|
79
|
+
initial_tokens: 50,
|
|
80
|
+
},
|
|
81
|
+
toasts: {
|
|
82
|
+
quiet: false,
|
|
83
|
+
debounce_seconds: 30,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
88
|
+
|
|
89
|
+
/** OpenCode's OAuth client ID for Anthropic console auth flows. */
|
|
90
|
+
export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a deep-cloned config object from defaults.
|
|
94
|
+
* @returns {AnthropicAuthConfig}
|
|
95
|
+
*/
|
|
96
|
+
function createDefaultConfig() {
|
|
97
|
+
return {
|
|
98
|
+
...DEFAULT_CONFIG,
|
|
99
|
+
signature_emulation: { ...DEFAULT_CONFIG.signature_emulation },
|
|
100
|
+
override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
|
|
101
|
+
health_score: { ...DEFAULT_CONFIG.health_score },
|
|
102
|
+
token_bucket: { ...DEFAULT_CONFIG.token_bucket },
|
|
103
|
+
toasts: { ...DEFAULT_CONFIG.toasts },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get the OpenCode config directory (XDG-compliant).
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function getConfigDir() {
|
|
112
|
+
const platform = process.platform;
|
|
113
|
+
if (platform === "win32") {
|
|
114
|
+
return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode");
|
|
115
|
+
}
|
|
116
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
117
|
+
return join(xdgConfig, "opencode");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the path to the config file.
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function getConfigPath() {
|
|
125
|
+
return join(getConfigDir(), "anthropic-auth.json");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clamp a number to a range.
|
|
130
|
+
* @param {unknown} value
|
|
131
|
+
* @param {number} min
|
|
132
|
+
* @param {number} max
|
|
133
|
+
* @param {number} fallback
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
function clampNumber(value, min, max, fallback) {
|
|
137
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
138
|
+
return Math.max(min, Math.min(max, value));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate and merge a partial config with defaults.
|
|
143
|
+
* @param {Record<string, unknown>} raw
|
|
144
|
+
* @returns {AnthropicAuthConfig}
|
|
145
|
+
*/
|
|
146
|
+
function validateConfig(raw) {
|
|
147
|
+
const config = createDefaultConfig();
|
|
148
|
+
|
|
149
|
+
if (typeof raw.account_selection_strategy === "string" && VALID_STRATEGIES.includes(raw.account_selection_strategy)) {
|
|
150
|
+
config.account_selection_strategy = /** @type {AccountSelectionStrategy} */ (raw.account_selection_strategy);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
config.failure_ttl_seconds = clampNumber(raw.failure_ttl_seconds, 60, 7200, DEFAULT_CONFIG.failure_ttl_seconds);
|
|
154
|
+
|
|
155
|
+
if (typeof raw.debug === "boolean") {
|
|
156
|
+
config.debug = raw.debug;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Signature emulation sub-config
|
|
160
|
+
if (raw.signature_emulation && typeof raw.signature_emulation === "object") {
|
|
161
|
+
const se = /** @type {Record<string, unknown>} */ (raw.signature_emulation);
|
|
162
|
+
config.signature_emulation = {
|
|
163
|
+
enabled: typeof se.enabled === "boolean" ? se.enabled : DEFAULT_CONFIG.signature_emulation.enabled,
|
|
164
|
+
fetch_claude_code_version_on_startup:
|
|
165
|
+
typeof se.fetch_claude_code_version_on_startup === "boolean"
|
|
166
|
+
? se.fetch_claude_code_version_on_startup
|
|
167
|
+
: DEFAULT_CONFIG.signature_emulation.fetch_claude_code_version_on_startup,
|
|
168
|
+
prompt_compaction:
|
|
169
|
+
se.prompt_compaction === "off" || se.prompt_compaction === "minimal"
|
|
170
|
+
? se.prompt_compaction
|
|
171
|
+
: DEFAULT_CONFIG.signature_emulation.prompt_compaction,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Override model limits sub-config
|
|
176
|
+
if (raw.override_model_limits && typeof raw.override_model_limits === "object") {
|
|
177
|
+
const oml = /** @type {Record<string, unknown>} */ (raw.override_model_limits);
|
|
178
|
+
config.override_model_limits = {
|
|
179
|
+
enabled: typeof oml.enabled === "boolean" ? oml.enabled : DEFAULT_CONFIG.override_model_limits.enabled,
|
|
180
|
+
context: clampNumber(oml.context, 200_000, 2_000_000, DEFAULT_CONFIG.override_model_limits.context),
|
|
181
|
+
output: clampNumber(oml.output, 0, 128_000, DEFAULT_CONFIG.override_model_limits.output),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Health score sub-config
|
|
186
|
+
if (raw.health_score && typeof raw.health_score === "object") {
|
|
187
|
+
const hs = /** @type {Record<string, unknown>} */ (raw.health_score);
|
|
188
|
+
config.health_score = {
|
|
189
|
+
initial: clampNumber(hs.initial, 0, 100, DEFAULT_CONFIG.health_score.initial),
|
|
190
|
+
success_reward: clampNumber(hs.success_reward, 0, 10, DEFAULT_CONFIG.health_score.success_reward),
|
|
191
|
+
rate_limit_penalty: clampNumber(hs.rate_limit_penalty, -50, 0, DEFAULT_CONFIG.health_score.rate_limit_penalty),
|
|
192
|
+
failure_penalty: clampNumber(hs.failure_penalty, -100, 0, DEFAULT_CONFIG.health_score.failure_penalty),
|
|
193
|
+
recovery_rate_per_hour: clampNumber(
|
|
194
|
+
hs.recovery_rate_per_hour,
|
|
195
|
+
0,
|
|
196
|
+
20,
|
|
197
|
+
DEFAULT_CONFIG.health_score.recovery_rate_per_hour,
|
|
198
|
+
),
|
|
199
|
+
min_usable: clampNumber(hs.min_usable, 0, 100, DEFAULT_CONFIG.health_score.min_usable),
|
|
200
|
+
max_score: clampNumber(hs.max_score, 50, 100, DEFAULT_CONFIG.health_score.max_score),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Toast sub-config
|
|
205
|
+
if (raw.toasts && typeof raw.toasts === "object") {
|
|
206
|
+
const t = /** @type {Record<string, unknown>} */ (raw.toasts);
|
|
207
|
+
config.toasts = {
|
|
208
|
+
quiet: typeof t.quiet === "boolean" ? t.quiet : DEFAULT_CONFIG.toasts.quiet,
|
|
209
|
+
debounce_seconds: clampNumber(t.debounce_seconds, 0, 300, DEFAULT_CONFIG.toasts.debounce_seconds),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Token bucket sub-config
|
|
214
|
+
if (raw.token_bucket && typeof raw.token_bucket === "object") {
|
|
215
|
+
const tb = /** @type {Record<string, unknown>} */ (raw.token_bucket);
|
|
216
|
+
config.token_bucket = {
|
|
217
|
+
max_tokens: clampNumber(tb.max_tokens, 1, 1000, DEFAULT_CONFIG.token_bucket.max_tokens),
|
|
218
|
+
regeneration_rate_per_minute: clampNumber(
|
|
219
|
+
tb.regeneration_rate_per_minute,
|
|
220
|
+
0.1,
|
|
221
|
+
60,
|
|
222
|
+
DEFAULT_CONFIG.token_bucket.regeneration_rate_per_minute,
|
|
223
|
+
),
|
|
224
|
+
initial_tokens: clampNumber(tb.initial_tokens, 1, 1000, DEFAULT_CONFIG.token_bucket.initial_tokens),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return config;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply environment variable overrides.
|
|
233
|
+
* @param {AnthropicAuthConfig} config
|
|
234
|
+
* @returns {AnthropicAuthConfig}
|
|
235
|
+
*/
|
|
236
|
+
function applyEnvOverrides(config) {
|
|
237
|
+
const env = process.env;
|
|
238
|
+
|
|
239
|
+
if (env.OPENCODE_ANTHROPIC_STRATEGY && VALID_STRATEGIES.includes(env.OPENCODE_ANTHROPIC_STRATEGY)) {
|
|
240
|
+
config.account_selection_strategy = /** @type {AccountSelectionStrategy} */ (env.OPENCODE_ANTHROPIC_STRATEGY);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (env.OPENCODE_ANTHROPIC_DEBUG === "1" || env.OPENCODE_ANTHROPIC_DEBUG === "true") {
|
|
244
|
+
config.debug = true;
|
|
245
|
+
}
|
|
246
|
+
if (env.OPENCODE_ANTHROPIC_DEBUG === "0" || env.OPENCODE_ANTHROPIC_DEBUG === "false") {
|
|
247
|
+
config.debug = false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (env.OPENCODE_ANTHROPIC_QUIET === "1" || env.OPENCODE_ANTHROPIC_QUIET === "true") {
|
|
251
|
+
config.toasts.quiet = true;
|
|
252
|
+
}
|
|
253
|
+
if (env.OPENCODE_ANTHROPIC_QUIET === "0" || env.OPENCODE_ANTHROPIC_QUIET === "false") {
|
|
254
|
+
config.toasts.quiet = false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE === "1" ||
|
|
259
|
+
env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE === "true"
|
|
260
|
+
) {
|
|
261
|
+
config.signature_emulation.enabled = true;
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE === "0" ||
|
|
265
|
+
env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE === "false"
|
|
266
|
+
) {
|
|
267
|
+
config.signature_emulation.enabled = false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION === "1" ||
|
|
272
|
+
env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION === "true"
|
|
273
|
+
) {
|
|
274
|
+
config.signature_emulation.fetch_claude_code_version_on_startup = true;
|
|
275
|
+
}
|
|
276
|
+
if (
|
|
277
|
+
env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION === "0" ||
|
|
278
|
+
env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION === "false"
|
|
279
|
+
) {
|
|
280
|
+
config.signature_emulation.fetch_claude_code_version_on_startup = false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION === "off") {
|
|
284
|
+
config.signature_emulation.prompt_compaction = "off";
|
|
285
|
+
}
|
|
286
|
+
if (env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION === "minimal") {
|
|
287
|
+
config.signature_emulation.prompt_compaction = "minimal";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "1" || env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "true") {
|
|
291
|
+
config.override_model_limits.enabled = true;
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "0" ||
|
|
295
|
+
env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "false"
|
|
296
|
+
) {
|
|
297
|
+
config.override_model_limits.enabled = false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return config;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Load config from disk, validate, apply env overrides.
|
|
305
|
+
* @returns {AnthropicAuthConfig}
|
|
306
|
+
*/
|
|
307
|
+
export function loadConfig() {
|
|
308
|
+
const configPath = getConfigPath();
|
|
309
|
+
|
|
310
|
+
if (!existsSync(configPath)) {
|
|
311
|
+
return applyEnvOverrides(createDefaultConfig());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const content = readFileSync(configPath, "utf-8");
|
|
316
|
+
const raw = JSON.parse(content);
|
|
317
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
318
|
+
return applyEnvOverrides(createDefaultConfig());
|
|
319
|
+
}
|
|
320
|
+
const config = validateConfig(raw);
|
|
321
|
+
return applyEnvOverrides(config);
|
|
322
|
+
} catch {
|
|
323
|
+
return applyEnvOverrides(createDefaultConfig());
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Load the raw config JSON from disk (without validation or env overrides).
|
|
329
|
+
* Returns an empty object if the file doesn't exist or is invalid.
|
|
330
|
+
* @returns {Record<string, unknown>}
|
|
331
|
+
*/
|
|
332
|
+
export function loadRawConfig() {
|
|
333
|
+
const configPath = getConfigPath();
|
|
334
|
+
if (!existsSync(configPath)) return {};
|
|
335
|
+
try {
|
|
336
|
+
const content = readFileSync(configPath, "utf-8");
|
|
337
|
+
const raw = JSON.parse(content);
|
|
338
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
339
|
+
return raw;
|
|
340
|
+
} catch {
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Save a partial config update to disk (read-modify-write).
|
|
347
|
+
* Only writes the keys you provide; other keys are preserved.
|
|
348
|
+
* Uses atomic write (temp + rename) for safety.
|
|
349
|
+
* @param {Record<string, unknown>} updates - Keys to merge into the config file
|
|
350
|
+
*/
|
|
351
|
+
export function saveConfig(updates) {
|
|
352
|
+
const configPath = getConfigPath();
|
|
353
|
+
const dir = dirname(configPath);
|
|
354
|
+
mkdirSync(dir, { recursive: true });
|
|
355
|
+
|
|
356
|
+
// Read existing raw config
|
|
357
|
+
const existing = loadRawConfig();
|
|
358
|
+
|
|
359
|
+
// Merge updates
|
|
360
|
+
const merged = { ...existing, ...updates };
|
|
361
|
+
|
|
362
|
+
// Atomic write: temp file + rename
|
|
363
|
+
const tmpPath = configPath + `.tmp.${process.pid}`;
|
|
364
|
+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
365
|
+
renameSync(tmpPath, configPath);
|
|
366
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { loadConfig, DEFAULT_CONFIG, getConfigDir, getConfigPath } from "./config.mjs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// Mock fs module
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe("DEFAULT_CONFIG", () => {
|
|
12
|
+
it("has expected default strategy", () => {
|
|
13
|
+
expect(DEFAULT_CONFIG.account_selection_strategy).toBe("sticky");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("has expected health score defaults", () => {
|
|
17
|
+
expect(DEFAULT_CONFIG.health_score.initial).toBe(70);
|
|
18
|
+
expect(DEFAULT_CONFIG.health_score.success_reward).toBe(1);
|
|
19
|
+
expect(DEFAULT_CONFIG.health_score.rate_limit_penalty).toBe(-10);
|
|
20
|
+
expect(DEFAULT_CONFIG.health_score.failure_penalty).toBe(-20);
|
|
21
|
+
expect(DEFAULT_CONFIG.health_score.min_usable).toBe(50);
|
|
22
|
+
expect(DEFAULT_CONFIG.health_score.max_score).toBe(100);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("has expected token bucket defaults", () => {
|
|
26
|
+
expect(DEFAULT_CONFIG.token_bucket.max_tokens).toBe(50);
|
|
27
|
+
expect(DEFAULT_CONFIG.token_bucket.regeneration_rate_per_minute).toBe(6);
|
|
28
|
+
expect(DEFAULT_CONFIG.token_bucket.initial_tokens).toBe(50);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("has debug disabled by default", () => {
|
|
32
|
+
expect(DEFAULT_CONFIG.debug).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("enables signature emulation defaults", () => {
|
|
36
|
+
expect(DEFAULT_CONFIG.signature_emulation.enabled).toBe(true);
|
|
37
|
+
expect(DEFAULT_CONFIG.signature_emulation.fetch_claude_code_version_on_startup).toBe(true);
|
|
38
|
+
expect(DEFAULT_CONFIG.signature_emulation.prompt_compaction).toBe("minimal");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("has toast defaults", () => {
|
|
42
|
+
expect(DEFAULT_CONFIG.toasts.quiet).toBe(false);
|
|
43
|
+
expect(DEFAULT_CONFIG.toasts.debounce_seconds).toBe(30);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("getConfigDir", () => {
|
|
48
|
+
it("returns a path ending with opencode", () => {
|
|
49
|
+
const dir = getConfigDir();
|
|
50
|
+
expect(dir.endsWith("opencode")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("getConfigPath", () => {
|
|
55
|
+
it("returns a path ending with anthropic-auth.json", () => {
|
|
56
|
+
const path = getConfigPath();
|
|
57
|
+
expect(path.endsWith("anthropic-auth.json")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("loadConfig", () => {
|
|
62
|
+
const originalEnv = { ...process.env };
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.resetAllMocks();
|
|
66
|
+
// Clean env overrides
|
|
67
|
+
delete process.env.OPENCODE_ANTHROPIC_STRATEGY;
|
|
68
|
+
delete process.env.OPENCODE_ANTHROPIC_DEBUG;
|
|
69
|
+
delete process.env.OPENCODE_ANTHROPIC_QUIET;
|
|
70
|
+
delete process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE;
|
|
71
|
+
delete process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION;
|
|
72
|
+
delete process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
process.env = { ...originalEnv };
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns defaults when config file does not exist", () => {
|
|
80
|
+
existsSync.mockReturnValue(false);
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns defaults when config file is invalid JSON", () => {
|
|
86
|
+
existsSync.mockReturnValue(true);
|
|
87
|
+
readFileSync.mockReturnValue("not json {{{");
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns defaults when config file is an array", () => {
|
|
93
|
+
existsSync.mockReturnValue(true);
|
|
94
|
+
readFileSync.mockReturnValue("[]");
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns defaults when config file is null", () => {
|
|
100
|
+
existsSync.mockReturnValue(true);
|
|
101
|
+
readFileSync.mockReturnValue("null");
|
|
102
|
+
const config = loadConfig();
|
|
103
|
+
expect(config).toEqual(DEFAULT_CONFIG);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("merges valid strategy from config file", () => {
|
|
107
|
+
existsSync.mockReturnValue(true);
|
|
108
|
+
readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "sticky" }));
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
expect(config.account_selection_strategy).toBe("sticky");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ignores invalid strategy", () => {
|
|
114
|
+
existsSync.mockReturnValue(true);
|
|
115
|
+
readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
expect(config.account_selection_strategy).toBe("sticky");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("accepts boolean debug", () => {
|
|
121
|
+
existsSync.mockReturnValue(true);
|
|
122
|
+
readFileSync.mockReturnValue(JSON.stringify({ debug: true }));
|
|
123
|
+
const config = loadConfig();
|
|
124
|
+
expect(config.debug).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("merges signature emulation sub-config", () => {
|
|
128
|
+
existsSync.mockReturnValue(true);
|
|
129
|
+
readFileSync.mockReturnValue(
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
signature_emulation: {
|
|
132
|
+
enabled: false,
|
|
133
|
+
fetch_claude_code_version_on_startup: false,
|
|
134
|
+
prompt_compaction: "off",
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
const config = loadConfig();
|
|
139
|
+
expect(config.signature_emulation.enabled).toBe(false);
|
|
140
|
+
expect(config.signature_emulation.fetch_claude_code_version_on_startup).toBe(false);
|
|
141
|
+
expect(config.signature_emulation.prompt_compaction).toBe("off");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("merges health_score sub-config", () => {
|
|
145
|
+
existsSync.mockReturnValue(true);
|
|
146
|
+
readFileSync.mockReturnValue(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
health_score: {
|
|
149
|
+
initial: 80,
|
|
150
|
+
success_reward: 5,
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
const config = loadConfig();
|
|
155
|
+
expect(config.health_score.initial).toBe(80);
|
|
156
|
+
expect(config.health_score.success_reward).toBe(5);
|
|
157
|
+
// Other fields should be defaults
|
|
158
|
+
expect(config.health_score.rate_limit_penalty).toBe(-10);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("clamps health_score values to valid ranges", () => {
|
|
162
|
+
existsSync.mockReturnValue(true);
|
|
163
|
+
readFileSync.mockReturnValue(
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
health_score: {
|
|
166
|
+
initial: 200, // max 100
|
|
167
|
+
rate_limit_penalty: -999, // min -50
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
expect(config.health_score.initial).toBe(100);
|
|
173
|
+
expect(config.health_score.rate_limit_penalty).toBe(-50);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("merges token_bucket sub-config", () => {
|
|
177
|
+
existsSync.mockReturnValue(true);
|
|
178
|
+
readFileSync.mockReturnValue(
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
token_bucket: {
|
|
181
|
+
max_tokens: 100,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
const config = loadConfig();
|
|
186
|
+
expect(config.token_bucket.max_tokens).toBe(100);
|
|
187
|
+
expect(config.token_bucket.regeneration_rate_per_minute).toBe(6);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Environment variable overrides
|
|
191
|
+
it("overrides strategy from OPENCODE_ANTHROPIC_STRATEGY", () => {
|
|
192
|
+
existsSync.mockReturnValue(false);
|
|
193
|
+
process.env.OPENCODE_ANTHROPIC_STRATEGY = "round-robin";
|
|
194
|
+
const config = loadConfig();
|
|
195
|
+
expect(config.account_selection_strategy).toBe("round-robin");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("ignores invalid OPENCODE_ANTHROPIC_STRATEGY", () => {
|
|
199
|
+
existsSync.mockReturnValue(false);
|
|
200
|
+
process.env.OPENCODE_ANTHROPIC_STRATEGY = "invalid";
|
|
201
|
+
const config = loadConfig();
|
|
202
|
+
expect(config.account_selection_strategy).toBe("sticky");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("enables debug from OPENCODE_ANTHROPIC_DEBUG=1", () => {
|
|
206
|
+
existsSync.mockReturnValue(false);
|
|
207
|
+
process.env.OPENCODE_ANTHROPIC_DEBUG = "1";
|
|
208
|
+
const config = loadConfig();
|
|
209
|
+
expect(config.debug).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("enables debug from OPENCODE_ANTHROPIC_DEBUG=true", () => {
|
|
213
|
+
existsSync.mockReturnValue(false);
|
|
214
|
+
process.env.OPENCODE_ANTHROPIC_DEBUG = "true";
|
|
215
|
+
const config = loadConfig();
|
|
216
|
+
expect(config.debug).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("disables debug from OPENCODE_ANTHROPIC_DEBUG=0", () => {
|
|
220
|
+
existsSync.mockReturnValue(true);
|
|
221
|
+
readFileSync.mockReturnValue(JSON.stringify({ debug: true }));
|
|
222
|
+
process.env.OPENCODE_ANTHROPIC_DEBUG = "0";
|
|
223
|
+
const config = loadConfig();
|
|
224
|
+
expect(config.debug).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("disables signature emulation from OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE=0", () => {
|
|
228
|
+
existsSync.mockReturnValue(false);
|
|
229
|
+
process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE = "0";
|
|
230
|
+
const config = loadConfig();
|
|
231
|
+
expect(config.signature_emulation.enabled).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("disables version fetch from OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION=0", () => {
|
|
235
|
+
existsSync.mockReturnValue(false);
|
|
236
|
+
process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION = "0";
|
|
237
|
+
const config = loadConfig();
|
|
238
|
+
expect(config.signature_emulation.fetch_claude_code_version_on_startup).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("disables prompt compaction from OPENCODE_ANTHROPIC_PROMPT_COMPACTION=off", () => {
|
|
242
|
+
existsSync.mockReturnValue(false);
|
|
243
|
+
process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION = "off";
|
|
244
|
+
const config = loadConfig();
|
|
245
|
+
expect(config.signature_emulation.prompt_compaction).toBe("off");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("env overrides take precedence over config file", () => {
|
|
249
|
+
existsSync.mockReturnValue(true);
|
|
250
|
+
readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "sticky" }));
|
|
251
|
+
process.env.OPENCODE_ANTHROPIC_STRATEGY = "round-robin";
|
|
252
|
+
const config = loadConfig();
|
|
253
|
+
expect(config.account_selection_strategy).toBe("round-robin");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Toast config
|
|
257
|
+
it("has toast defaults", () => {
|
|
258
|
+
existsSync.mockReturnValue(false);
|
|
259
|
+
const config = loadConfig();
|
|
260
|
+
expect(config.toasts.quiet).toBe(false);
|
|
261
|
+
expect(config.toasts.debounce_seconds).toBe(30);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("merges toasts sub-config", () => {
|
|
265
|
+
existsSync.mockReturnValue(true);
|
|
266
|
+
readFileSync.mockReturnValue(JSON.stringify({ toasts: { quiet: true, debounce_seconds: 10 } }));
|
|
267
|
+
const config = loadConfig();
|
|
268
|
+
expect(config.toasts.quiet).toBe(true);
|
|
269
|
+
expect(config.toasts.debounce_seconds).toBe(10);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("clamps debounce_seconds to valid range", () => {
|
|
273
|
+
existsSync.mockReturnValue(true);
|
|
274
|
+
readFileSync.mockReturnValue(JSON.stringify({ toasts: { debounce_seconds: 999 } }));
|
|
275
|
+
const config = loadConfig();
|
|
276
|
+
expect(config.toasts.debounce_seconds).toBe(300);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("clamps negative debounce_seconds to 0", () => {
|
|
280
|
+
existsSync.mockReturnValue(true);
|
|
281
|
+
readFileSync.mockReturnValue(JSON.stringify({ toasts: { debounce_seconds: -5 } }));
|
|
282
|
+
const config = loadConfig();
|
|
283
|
+
expect(config.toasts.debounce_seconds).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("ignores non-boolean quiet", () => {
|
|
287
|
+
existsSync.mockReturnValue(true);
|
|
288
|
+
readFileSync.mockReturnValue(JSON.stringify({ toasts: { quiet: "yes" } }));
|
|
289
|
+
const config = loadConfig();
|
|
290
|
+
expect(config.toasts.quiet).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("enables quiet from OPENCODE_ANTHROPIC_QUIET=1", () => {
|
|
294
|
+
existsSync.mockReturnValue(false);
|
|
295
|
+
process.env.OPENCODE_ANTHROPIC_QUIET = "1";
|
|
296
|
+
const config = loadConfig();
|
|
297
|
+
expect(config.toasts.quiet).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("disables quiet from OPENCODE_ANTHROPIC_QUIET=0", () => {
|
|
301
|
+
existsSync.mockReturnValue(true);
|
|
302
|
+
readFileSync.mockReturnValue(JSON.stringify({ toasts: { quiet: true } }));
|
|
303
|
+
process.env.OPENCODE_ANTHROPIC_QUIET = "0";
|
|
304
|
+
const config = loadConfig();
|
|
305
|
+
expect(config.toasts.quiet).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|