cc-x10ded 3.0.16 → 3.0.18

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.
@@ -1,73 +1,203 @@
1
1
  import { intro, outro, spinner } from "@clack/prompts";
2
2
  import { ShellIntegrator } from "../core/shell";
3
3
  import { ConfigManager } from "../core/config";
4
+ import { telemetry } from "../core/telemetry";
5
+ import { circuitBreaker } from "../core/circuit-breaker";
6
+ import { pluginManager } from "../core/plugins";
4
7
  import * as pc from "picocolors";
5
8
  import { existsSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
6
11
 
7
12
  export async function doctorCommand() {
8
13
  intro(pc.bgBlue(pc.white(" ccx Doctor 🩺 ")));
9
14
 
10
15
  const s = spinner();
11
16
  const issues: string[] = [];
17
+ const warnings: string[] = [];
12
18
  const checks: string[] = [];
19
+ const home = homedir();
13
20
 
14
- // 1. Check Config
21
+ // 1. Check for old/shadowing binaries
22
+ s.start("Checking for conflicting binaries...");
23
+ const oldLocations = [
24
+ join(home, ".local", "bin", "ccx"),
25
+ join(home, ".npm-global", "bin", "ccx"),
26
+ join(home, "bin", "ccx"),
27
+ ];
28
+
29
+ const foundOld: string[] = [];
30
+ for (const loc of oldLocations) {
31
+ if (existsSync(loc)) foundOld.push(loc);
32
+ }
33
+
34
+ if (foundOld.length > 0) {
35
+ issues.push("❌ Old ccx binaries found that may shadow the new one:");
36
+ for (const p of foundOld) {
37
+ issues.push(` ${p}`);
38
+ }
39
+ issues.push(" 👉 Run 'ccx update' to remove them automatically");
40
+ } else {
41
+ checks.push("✅ No conflicting binaries");
42
+ }
43
+ s.stop("Binary check complete");
44
+
45
+ // 2. Check PATH priority
46
+ s.start("Checking PATH configuration...");
47
+ const shellInt = new ShellIntegrator();
48
+
49
+ if (!shellInt.isBunBinFirst()) {
50
+ warnings.push("⚠️ ~/.bun/bin is not first in PATH");
51
+ warnings.push(" This may cause the wrong ccx to run.");
52
+ warnings.push(' 👉 Add to TOP of your shell config: export PATH="$HOME/.bun/bin:$PATH"');
53
+ } else {
54
+ checks.push("✅ PATH is correctly configured");
55
+ }
56
+ s.stop("PATH check complete");
57
+
58
+ // 3. Check Config
15
59
  s.start("Checking configuration...");
16
60
  const configManager = new ConfigManager();
17
61
  const config = await configManager.read();
18
-
62
+
19
63
  if (!config.zaiApiKey && !config.minimaxApiKey && Object.keys(config.providers).length === 0) {
20
- issues.push("❌ No API keys configured. Run 'ccx setup'.");
64
+ issues.push("❌ No API keys configured. Run 'ccx setup'.");
21
65
  } else {
22
- checks.push("✅ Configuration loaded");
66
+ const keys: string[] = [];
67
+ if (config.zaiApiKey) keys.push("Z.AI");
68
+ if (config.minimaxApiKey) keys.push("Minimax");
69
+ if (config.providers.openai?.apiKey) keys.push("OpenAI");
70
+ if (config.providers.gemini?.apiKey) keys.push("Gemini");
71
+ checks.push(`✅ API keys configured: ${keys.join(", ")}`);
23
72
  }
24
73
  s.stop("Configuration check complete");
25
74
 
26
- // 2. Check Claude Binary
75
+ // 4. Check Claude Binary
27
76
  s.start("Checking Claude Code installation...");
28
- const shellInt = new ShellIntegrator();
29
77
  const claudePath = await shellInt.findClaudeBinary();
30
-
78
+
31
79
  if (claudePath) {
32
- checks.push(`✅ Claude binary found: ${claudePath}`);
80
+ checks.push(`✅ Claude binary: ${claudePath}`);
33
81
  } else {
34
- issues.push("❌ 'claude' command not found in common locations.");
35
- issues.push(" 👉 Suggestion: Run 'npm install -g @anthropic-ai/claude-code'");
82
+ issues.push("❌ 'claude' command not found.");
83
+ issues.push(" 👉 Run: npm install -g @anthropic-ai/claude-code");
36
84
  }
37
85
  s.stop("Claude check complete");
38
86
 
39
- // 3. Check Shell Integration
87
+ // 5. Check Shell Integration
40
88
  s.start("Checking shell integration...");
41
89
  const shell = shellInt.detectShell();
42
90
  const profile = shellInt.getProfilePath(shell);
43
-
91
+
44
92
  if (shell === "unknown") {
45
- issues.push("⚠️ Could not detect shell type.");
93
+ warnings.push("⚠️ Could not detect shell type.");
46
94
  } else {
47
- checks.push(`✅ Shell detected: ${shell}`);
48
- if (profile && existsSync(profile)) {
49
- const content = await Bun.file(profile).text();
50
- if (content.includes("ccx")) {
51
- checks.push(" Aliases found in profile");
52
- } else {
53
- issues.push(`⚠️ Aliases missing in ${profile}. Run 'ccx setup'.`);
54
- }
95
+ checks.push(`✅ Shell: ${shell}`);
96
+ if (profile && existsSync(profile)) {
97
+ const content = await Bun.file(profile).text();
98
+ if (content.includes("claude-glm-wrapper")) {
99
+ if (content.includes("bunx cc-x10ded")) {
100
+ warnings.push("⚠️ Old bunx-based aliases detected");
101
+ warnings.push(" 👉 Run 'ccx update' to migrate to faster direct aliases");
102
+ } else {
103
+ checks.push("✅ Aliases installed (new format)");
104
+ }
105
+ } else {
106
+ warnings.push(`⚠️ Aliases missing. Run 'ccx setup' to install.`);
55
107
  }
108
+ }
56
109
  }
57
110
  s.stop("Shell check complete");
58
111
 
59
- // 4. Check Network/Proxy (Port 17870)
60
- // We won't actually bind, just check if we can
61
-
112
+ // 6. Telemetry (Local)
113
+ s.start("Checking telemetry...");
114
+ const sessionDuration = telemetry.getSessionDuration();
115
+ const requestCount = telemetry.getRequestCount();
116
+ const providerStats = telemetry.getProviderStats();
117
+ const errors = telemetry.getErrors();
118
+ const fallbacks = telemetry.getFallbacks();
119
+
120
+ console.log("\n" + pc.bold("Telemetry (this session):"));
121
+ console.log(` Session: ${Math.round(sessionDuration / 1000)}s | Requests: ${requestCount}`);
122
+
123
+ if (Object.keys(providerStats).length > 0) {
124
+ console.log("\n Provider Usage:");
125
+ for (const [provider, stats] of Object.entries(providerStats)) {
126
+ const statusIcon = stats.errors > 0 ? "🔴" : "🟢";
127
+ console.log(` ${statusIcon} ${provider}: ${stats.count} requests (avg ${stats.avgLatency}ms)${stats.errors > 0 ? `, ${stats.errors} errors` : ""}`);
128
+ }
129
+ }
130
+
131
+ if (errors.length > 0) {
132
+ console.log("\n Errors:");
133
+ for (const error of errors) {
134
+ console.log(` ${error.provider}: ${error.error} (${error.count})`);
135
+ }
136
+ }
137
+
138
+ if (fallbacks.length > 0) {
139
+ console.log("\n Fallbacks:");
140
+ for (const fallback of fallbacks) {
141
+ console.log(` ${fallback.fromProvider} → ${fallback.toProvider} (${fallback.reason})`);
142
+ }
143
+ }
144
+
145
+ if (requestCount === 0) {
146
+ console.log(" No requests yet in this session.");
147
+ }
148
+ s.stop("Telemetry check complete");
149
+
150
+ // 7. Circuit Breaker Status
151
+ s.start("Checking circuit breaker...");
152
+ const circuitStates = circuitBreaker.getStates();
153
+ if (circuitStates.length > 0) {
154
+ console.log("\n" + pc.bold("Circuit Breaker Status:"));
155
+ for (const state of circuitStates) {
156
+ const icon = state.state === "closed" ? "🟢" : state.state === "half-open" ? "🟡" : "🔴";
157
+ console.log(` ${icon} ${state.provider}: ${state.state} (${state.failures} failures)`);
158
+ }
159
+ } else {
160
+ console.log(" No circuit breaker activity yet.");
161
+ }
162
+ s.stop("Circuit breaker check complete");
163
+
164
+ // 8. Plugins
165
+ s.start("Checking plugins...");
166
+ const pluginCount = pluginManager.getPluginCount();
167
+ if (pluginCount > 0) {
168
+ const plugins = pluginManager.getPlugins();
169
+ console.log(`\n Installed Plugins: ${pluginCount}`);
170
+ for (const plugin of plugins) {
171
+ console.log(` - ${plugin.name} v${plugin.version}`);
172
+ }
173
+ } else {
174
+ console.log(" No plugins installed.");
175
+ }
176
+ s.stop("Plugin check complete");
177
+
178
+ // Report
62
179
  console.log("\n" + pc.bold("Diagnostic Report:"));
63
- checks.forEach(c => console.log(c));
64
- console.log("");
65
-
180
+ for (const c of checks) {
181
+ console.log(c);
182
+ }
183
+
184
+ if (warnings.length > 0) {
185
+ console.log("");
186
+ for (const w of warnings) {
187
+ console.log(pc.yellow(w));
188
+ }
189
+ }
190
+
66
191
  if (issues.length > 0) {
67
- issues.forEach(i => console.log(i));
68
- outro(pc.yellow("Issues found. Please resolve them above."));
69
- process.exit(1);
192
+ console.log("");
193
+ for (const i of issues) {
194
+ console.log(pc.red(i));
195
+ }
196
+ outro(pc.red("Issues found. Please resolve them above."));
197
+ process.exit(1);
198
+ } else if (warnings.length > 0) {
199
+ outro(pc.yellow("Some warnings. ccx should work, but consider fixing them."));
70
200
  } else {
71
- outro(pc.green("All systems operational! 🚀"));
201
+ outro(pc.green("All systems operational! 🚀"));
72
202
  }
73
203
  }
@@ -0,0 +1,71 @@
1
+ import { providerRegistry } from "../core/registry";
2
+ import { pluginManager } from "../core/plugins";
3
+ import { createLogger } from "../core/logger";
4
+
5
+ const logger = createLogger();
6
+
7
+ export async function modelsCommand(): Promise<void> {
8
+ console.log("\n");
9
+
10
+ const providers = providerRegistry.listProviders();
11
+ const plugins = pluginManager.getPlugins();
12
+
13
+ const allModels = providerRegistry.getAllModels();
14
+
15
+ const maxProviderWidth = Math.max(
16
+ ...providers.map(p => p.name.length),
17
+ ...plugins.map(p => p.name.length)
18
+ );
19
+ const maxModelWidth = Math.max(
20
+ ...allModels.map(m => `${m.provider.id}:${m.model.id}`.length),
21
+ 30
22
+ );
23
+
24
+ console.log("╔" + "═".repeat(maxProviderWidth + maxModelWidth + 7) + "╗");
25
+ console.log("║" + " ".repeat(Math.floor((maxProviderWidth + maxModelWidth + 7 - 26) / 2)) + "ccx Available Models" + " ".repeat(Math.ceil((maxProviderWidth + maxModelWidth + 7 - 26) / 2)) + "║");
26
+ console.log("╠" + "═".repeat(maxProviderWidth + maxModelWidth + 7) + "╣");
27
+
28
+ for (const provider of providers) {
29
+ const statusIcon = provider.isNative ? "🔵" : "🟢";
30
+ const keyHint = provider.isNative ? "[Native - No proxy needed]" : `[Requires: ${provider.requiresKey.split(".").pop()?.replace("ApiKey", "_KEY") || "key"}]`;
31
+
32
+ console.log("║");
33
+ console.log(`║ ${statusIcon} ${provider.name.padEnd(maxProviderWidth)} ${keyHint}`);
34
+
35
+ for (const model of provider.models) {
36
+ const defaultMark = model.default ? " (default)" : "";
37
+ const modelLine = ` ├── ${model.id}${defaultMark}`;
38
+ console.log(`║ ${modelLine.padEnd(maxProviderWidth + maxModelWidth + 3)}║`);
39
+ }
40
+ }
41
+
42
+ console.log("║");
43
+ console.log("╠" + "═".repeat(maxProviderWidth + maxModelWidth + 7) + "╣");
44
+
45
+ if (plugins.length > 0) {
46
+ console.log("║");
47
+ console.log("║ Installed Plugins (" + plugins.length + ")");
48
+
49
+ for (const plugin of plugins) {
50
+ console.log(`║ ├── ${plugin.name} v${plugin.version}`);
51
+ for (const model of plugin.models) {
52
+ const modelLine = ` │ ├── ${model.id}`;
53
+ console.log(`║ ${modelLine.padEnd(maxProviderWidth + maxModelWidth + 3)}║`);
54
+ }
55
+ }
56
+ } else {
57
+ console.log("║");
58
+ console.log("║ Plugins: 0 installed");
59
+ console.log("║ To add a plugin, create: ~/.config/claude-glm/plugins/<name>/");
60
+ }
61
+
62
+ console.log("║");
63
+ console.log("╚" + "═".repeat(maxProviderWidth + maxModelWidth + 7) + "╝");
64
+ console.log("\n");
65
+ console.log("Usage:");
66
+ console.log(" ccx # Interactive selection");
67
+ console.log(" ccx --model=glm-4.7 # Use specific model");
68
+ console.log(" ccx --list # Show this list");
69
+ console.log(" ccx setup # Configure API keys");
70
+ console.log("\n");
71
+ }
@@ -2,10 +2,14 @@ import { spawn } from "bun";
2
2
  import { ConfigManager } from "../core/config";
3
3
  import { startProxyServer } from "../proxy/server";
4
4
  import { ShellIntegrator } from "../core/shell";
5
+ import { pluginManager } from "../core/plugins";
5
6
  import { parseProviderModel } from "../proxy/map";
7
+ import { telemetry } from "../core/telemetry";
6
8
  import * as pc from "picocolors";
7
9
 
8
10
  export async function runCommand(args: string[], options: { model?: string; port?: number }) {
11
+ await pluginManager.discoverAndLoad();
12
+
9
13
  const configManager = new ConfigManager();
10
14
  const config = await configManager.read();
11
15
 
@@ -13,53 +17,51 @@ export async function runCommand(args: string[], options: { model?: string; port
13
17
  console.log(pc.yellow("Configuration missing. Running setup..."));
14
18
  const { setupCommand } = await import("./setup");
15
19
  await setupCommand();
16
- // Re-read config
17
20
  Object.assign(config, await configManager.read());
18
21
  }
19
22
 
20
23
  const model = options.model || config.defaults.model || "glm-4.7";
21
24
  const { provider } = parseProviderModel(model);
22
-
23
- // Check if we should use the proxy or fallback to native Claude (for Anthropic without API Key)
25
+
24
26
  let useProxy = true;
25
- if (provider === "anthropic" && !config.providers.anthropic?.apiKey) {
26
- useProxy = false;
27
+
28
+ if (provider === "anthropic") {
29
+ useProxy = false;
27
30
  }
28
31
 
29
- // Port hunting logic
30
32
  let port = options.port || 17870;
31
- let server;
32
-
33
+ let server: ReturnType<typeof startProxyServer> | undefined = undefined;
34
+
33
35
  if (useProxy) {
34
36
  let retries = 0;
35
37
  while (retries < 10) {
36
- try {
37
- server = startProxyServer(config, port);
38
- break;
39
- } catch (e: any) {
40
- if (e.code === "EADDRINUSE" || e.message.includes("EADDRINUSE")) {
41
- port++;
42
- retries++;
43
- } else {
44
- throw e;
45
- }
38
+ try {
39
+ server = startProxyServer(config, port);
40
+ break;
41
+ } catch (e: unknown) {
42
+ const error = e as { code?: string; message?: string };
43
+ if (error.code === "EADDRINUSE" || (error.message && error.message.includes("EADDRINUSE"))) {
44
+ port++;
45
+ retries++;
46
+ } else {
47
+ throw e;
46
48
  }
49
+ }
47
50
  }
48
-
51
+
49
52
  if (!server) {
50
- console.error(pc.red(`Failed to start proxy server after 10 attempts (ports ${options.port || 17870}-${port}).`));
51
- process.exit(1);
53
+ console.error(pc.red(`Failed to start proxy server after 10 attempts (ports ${options.port || 17870}-${port}).`));
54
+ process.exit(1);
52
55
  }
53
56
  }
54
57
 
55
- // Robust binary finding
56
58
  const shellInt = new ShellIntegrator();
57
59
  const claudePath = await shellInt.findClaudeBinary();
58
60
 
59
61
  if (!claudePath) {
60
- console.error(pc.red("Error: 'claude' command not found."));
61
- console.error(pc.yellow("Self-Healing Tip: Run 'ccx setup' or 'npm install -g @anthropic-ai/claude-code'"));
62
- process.exit(1);
62
+ console.error(pc.red("Error: 'claude' command not found."));
63
+ console.error(pc.yellow("Self-Healing Tip: Run 'ccx setup' or 'npm install -g @anthropic-ai/claude-code'"));
64
+ process.exit(1);
63
65
  }
64
66
 
65
67
  const env: Record<string, string | undefined> = {
@@ -67,9 +69,9 @@ export async function runCommand(args: string[], options: { model?: string; port
67
69
  };
68
70
 
69
71
  if (useProxy) {
70
- env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}`;
71
- env.ANTHROPIC_AUTH_TOKEN = "ccx-proxy-token"; // Dummy token for the client
72
- env.ANTHROPIC_MODEL = model;
72
+ env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}`;
73
+ env.ANTHROPIC_AUTH_TOKEN = `ccx-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
74
+ env.ANTHROPIC_MODEL = model;
73
75
  }
74
76
 
75
77
  try {
@@ -85,5 +87,6 @@ export async function runCommand(args: string[], options: { model?: string; port
85
87
  process.exit(1);
86
88
  } finally {
87
89
  if (server) server.stop();
90
+ telemetry.trackEvent({ type: "session_end" });
88
91
  }
89
92
  }
@@ -88,21 +88,32 @@ export async function setupCommand() {
88
88
  if (mmKey) config.minimaxApiKey = mmKey as string;
89
89
  }
90
90
 
91
- // 4. Install ccx globally
91
+ // 4. Clean up old binaries and install ccx globally
92
92
  const s2 = spinner();
93
- s2.start("Installing ccx globally...");
93
+ s2.start("Cleaning up old installations...");
94
+
95
+ // Remove old binaries that might shadow the new one
96
+ const removed = await shellInt.cleanupOldBinaries();
97
+ if (removed.length > 0) {
98
+ s2.stop(pc.yellow(`Removed ${removed.length} old ccx binary(s)`));
99
+ } else {
100
+ s2.stop("No old binaries found");
101
+ }
102
+
103
+ const s3 = spinner();
104
+ s3.start("Installing ccx globally...");
94
105
  try {
95
106
  const proc = spawn(["bun", "install", "-g", "cc-x10ded@latest"], {
96
107
  stdio: ["ignore", "ignore", "ignore"]
97
108
  });
98
109
  await proc.exited;
99
110
  if (proc.exitCode === 0) {
100
- s2.stop(pc.green("ccx installed globally!"));
111
+ s3.stop(pc.green("ccx installed globally!"));
101
112
  } else {
102
- s2.stop(pc.yellow("Global install may have failed. You can still use: bunx cc-x10ded"));
113
+ s3.stop(pc.yellow("Global install may have failed. You can still use: bunx cc-x10ded"));
103
114
  }
104
115
  } catch {
105
- s2.stop(pc.yellow("Could not install globally (bun not found?). Use: bunx cc-x10ded"));
116
+ s3.stop(pc.yellow("Could not install globally (bun not found?). Use: bunx cc-x10ded"));
106
117
  }
107
118
 
108
119
  // 5. Shell Config
@@ -0,0 +1,167 @@
1
+ import type { CircuitState } from "../types";
2
+ import { providerRegistry } from "./registry";
3
+ import { telemetry } from "./telemetry";
4
+ import { createLogger } from "./logger";
5
+
6
+ const logger = createLogger();
7
+
8
+ const CIRCUIT_THRESHOLD = 5;
9
+ const CIRCUIT_TIMEOUT = 30000;
10
+
11
+ export class CircuitBreaker {
12
+ private states: Map<string, CircuitState> = new Map();
13
+ private static instance: CircuitBreaker | null = null;
14
+
15
+ private constructor() {}
16
+
17
+ static getInstance(): CircuitBreaker {
18
+ if (!CircuitBreaker.instance) {
19
+ CircuitBreaker.instance = new CircuitBreaker();
20
+ }
21
+ return CircuitBreaker.instance;
22
+ }
23
+
24
+ private getState(provider: string): CircuitState {
25
+ let state = this.states.get(provider);
26
+ if (!state) {
27
+ state = {
28
+ provider,
29
+ state: "closed",
30
+ failures: 0,
31
+ lastFailure: null
32
+ };
33
+ this.states.set(provider, state);
34
+ }
35
+ return state;
36
+ }
37
+
38
+ isOpen(provider: string): boolean {
39
+ const state = this.getState(provider);
40
+ if (state.state === "open") {
41
+ const timeSinceFailure = Date.now() - (state.lastFailure || 0);
42
+ if (timeSinceFailure > CIRCUIT_TIMEOUT) {
43
+ state.state = "half-open";
44
+ logger.debug("Circuit breaker transitioning to half-open", { provider });
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ isClosed(provider: string): boolean {
53
+ return this.getState(provider).state === "closed";
54
+ }
55
+
56
+ async execute<T>(
57
+ provider: string,
58
+ fn: () => Promise<T>
59
+ ): Promise<T> {
60
+ const state = this.getState(provider);
61
+
62
+ if (state.state === "open") {
63
+ const timeSinceFailure = Date.now() - (state.lastFailure || 0);
64
+ if (timeSinceFailure > CIRCUIT_TIMEOUT) {
65
+ state.state = "half-open";
66
+ logger.debug("Circuit breaker transitioning to half-open", { provider });
67
+ } else {
68
+ const fallbackProvider = this.getFallbackProvider(provider);
69
+ if (fallbackProvider) {
70
+ logger.warn("Circuit open, falling back", { from: provider, to: fallbackProvider });
71
+ telemetry.trackEvent({
72
+ type: "fallback",
73
+ fromProvider: provider,
74
+ toProvider: fallbackProvider,
75
+ reason: "circuit_open"
76
+ });
77
+ return this.execute(fallbackProvider, fn);
78
+ }
79
+ throw new CircuitOpenError(provider, CIRCUIT_TIMEOUT - timeSinceFailure);
80
+ }
81
+ }
82
+
83
+ try {
84
+ const result = await fn();
85
+ this.recordSuccess(provider);
86
+ return result;
87
+ } catch (error) {
88
+ this.recordFailure(provider, error as Error);
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ private getFallbackProvider(currentProvider: string): string | null {
94
+ const order = providerRegistry.getProviderOrder();
95
+ const currentIndex = order.indexOf(currentProvider);
96
+
97
+ for (let i = currentIndex + 1; i < order.length; i++) {
98
+ const candidate = order[i];
99
+ if (candidate && !this.isOpen(candidate) && !providerRegistry.isNative(candidate)) {
100
+ return candidate;
101
+ }
102
+ }
103
+
104
+ for (const provider of order) {
105
+ if (provider !== currentProvider && !this.isOpen(provider) && !providerRegistry.isNative(provider)) {
106
+ return provider;
107
+ }
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ private recordSuccess(provider: string): void {
114
+ const state = this.getState(provider);
115
+
116
+ if (state.state === "half-open") {
117
+ state.state = "closed";
118
+ state.failures = 0;
119
+ state.lastFailure = null;
120
+ logger.debug("Circuit breaker closed", { provider });
121
+ } else if (state.state === "closed" && state.failures > 0) {
122
+ state.failures = Math.max(0, state.failures - 1);
123
+ }
124
+ }
125
+
126
+ private recordFailure(provider: string, error: Error): void {
127
+ const state = this.getState(provider);
128
+ state.failures++;
129
+ state.lastFailure = Date.now();
130
+
131
+ if (state.failures >= CIRCUIT_THRESHOLD && state.state !== "open") {
132
+ state.state = "open";
133
+ logger.warn("Circuit breaker opened", { provider, failures: state.failures });
134
+ }
135
+ }
136
+
137
+ reset(provider: string): void {
138
+ const state = this.getState(provider);
139
+ state.state = "closed";
140
+ state.failures = 0;
141
+ state.lastFailure = null;
142
+ }
143
+
144
+ resetAll(): void {
145
+ this.states.clear();
146
+ }
147
+
148
+ getStates(): CircuitState[] {
149
+ return Array.from(this.states.values());
150
+ }
151
+
152
+ getStateInfo(provider: string): CircuitState {
153
+ return this.getState(provider);
154
+ }
155
+ }
156
+
157
+ export class CircuitOpenError extends Error {
158
+ constructor(
159
+ public readonly provider: string,
160
+ public readonly retryAfterMs: number
161
+ ) {
162
+ super(`Circuit open for ${provider}. Retry after ${Math.ceil(retryAfterMs / 1000)}s`);
163
+ this.name = "CircuitOpenError";
164
+ }
165
+ }
166
+
167
+ export const circuitBreaker = CircuitBreaker.getInstance();