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/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
+ });