@albinocrabs/o-switcher 0.3.0 → 0.4.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/dist/plugin.js CHANGED
@@ -1,42 +1,6 @@
1
- import { createAuditLogger, createOperatorTools, createProfileTools, switchToNextProfile, generateCorrelationId, loadProfiles, discoverTargetsFromProfiles, discoverTargets, createAuthWatcher, validateConfig, createRegistry, createRoutingEventBus, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createRequestTraceBuffer, createLogSubscriber } from './chunk-XXH633FY.js';
2
- import { mkdir, writeFile, rename } from 'fs/promises';
3
- import { join, dirname } from 'path';
4
- import { homedir } from 'os';
5
-
6
- var STATE_DIR = join(homedir(), ".local", "share", "o-switcher");
7
- var STATE_FILE = join(STATE_DIR, "tui-state.json");
8
- var STATE_TMP = join(STATE_DIR, "tui-state.json.tmp");
9
- var writeStateAtomic = async (state) => {
10
- await mkdir(dirname(STATE_FILE), { recursive: true });
11
- const json = JSON.stringify(state);
12
- await writeFile(STATE_TMP, json, "utf8");
13
- await rename(STATE_TMP, STATE_FILE);
14
- };
15
- var createStateWriter = (debounceMs = 500) => {
16
- let pending;
17
- let timer;
18
- let writePromise = Promise.resolve();
19
- const doWrite = () => {
20
- if (!pending) return;
21
- const snapshot = pending;
22
- pending = void 0;
23
- writePromise = writeStateAtomic(snapshot).catch(() => void 0);
24
- };
25
- const write = (state) => {
26
- pending = state;
27
- if (timer) clearTimeout(timer);
28
- timer = setTimeout(doWrite, debounceMs);
29
- };
30
- const flush = async () => {
31
- if (timer) {
32
- clearTimeout(timer);
33
- timer = void 0;
34
- }
35
- doWrite();
36
- await writePromise;
37
- };
38
- return { write, flush };
39
- };
1
+ import { createOperatorTools, createProfileTools, switchToNextProfile, discoverTargetsFromProfiles, discoverTargets, createAuthWatcher, validateConfig, createRegistry, createRoutingEventBus, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createRequestTraceBuffer, createLogSubscriber } from './chunk-2EYMYNKS.js';
2
+ import { createStateWriter, createProxy } from './chunk-I6RDAZBR.js';
3
+ import { createAuditLogger, generateCorrelationId, loadProfiles } from './chunk-HAEEX3KB.js';
40
4
 
41
5
  // src/plugin.ts
42
6
  var initializeSwitcher = (rawConfig) => {
@@ -163,6 +127,28 @@ var server = async (_input) => {
163
127
  Object.assign(state, initialized);
164
128
  logger.info({ targets: state.registry?.getAllTargets().length }, "O-Switcher initialized");
165
129
  publishTuiState();
130
+ const proxyEnabled = switcherConfig?.["proxy"] !== false && !process.env["VITEST"];
131
+ if (proxyEnabled) {
132
+ const proxyPort = Number(switcherConfig?.["proxy_port"] ?? 4444);
133
+ try {
134
+ const proxy = await createProxy({
135
+ port: proxyPort,
136
+ provider: "openai",
137
+ maxRetries: 3
138
+ });
139
+ await proxy.start();
140
+ logger.info({ port: proxyPort }, "Proxy started \u2014 rotating API keys on 429");
141
+ if (providerConfig) {
142
+ const providerEntry = providerConfig["openai"];
143
+ if (providerEntry && typeof providerEntry === "object") {
144
+ providerEntry["baseURL"] = `http://localhost:${proxyPort}/v1`;
145
+ logger.info("Patched openai provider baseURL to proxy");
146
+ }
147
+ }
148
+ } catch (proxyErr) {
149
+ logger.warn({ err: proxyErr }, "Proxy failed to start \u2014 continuing without key rotation");
150
+ }
151
+ }
166
152
  state.authWatcher = createAuthWatcher({ logger });
167
153
  await state.authWatcher.start();
168
154
  logger.info("Auth watcher started");
@@ -235,27 +221,34 @@ var server = async (_input) => {
235
221
  state.registry.recordObservation(target.target_id, 0);
236
222
  state.circuitBreakers?.get(target.target_id)?.recordFailure();
237
223
  publishTuiState();
238
- state.logger?.info(
239
- { target_id: target.target_id, event_type: event.type },
240
- "Recorded failure from session event"
241
- );
242
- if (target.health_score < 0.3) {
243
- const unhealthyIds = matchingTargets.filter((t) => t.health_score < 0.3).map((t) => t.target_id);
224
+ const errorObj = error;
225
+ const statusCode = errorObj.statusCode ?? errorObj.error?.statusCode;
226
+ const errorName = String(errorObj.name ?? errorObj.error ?? "");
227
+ const isRateLimit = statusCode === 429 || statusCode === "429" || errorName.includes("RateLimitError") || errorName.includes("QuotaExceeded");
228
+ if (isRateLimit) {
229
+ state.logger?.info(
230
+ { target_id: target.target_id, statusCode },
231
+ "Rate limit hit \u2014 switching profile"
232
+ );
244
233
  switchToNextProfile({
245
234
  provider: providerId,
246
235
  currentProfileId: target.target_id,
247
- excludeProfileIds: unhealthyIds,
248
236
  logger: state.logger
249
237
  }).then((result) => {
250
238
  if (result.success) {
251
239
  state.logger?.info(
252
240
  { from: result.from, to: result.to, provider: providerId },
253
- "Auto-switched to next profile after health drop"
241
+ "Switched to next profile after rate limit"
254
242
  );
255
243
  }
256
244
  }).catch((err) => {
257
- state.logger?.warn({ err }, "Failed to auto-switch profile");
245
+ state.logger?.warn({ err }, "Failed to switch profile");
258
246
  });
247
+ } else {
248
+ state.logger?.info(
249
+ { target_id: target.target_id, event_type: event.type },
250
+ "Recorded failure from session event"
251
+ );
259
252
  }
260
253
  }
261
254
  }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ var chunkQKEHCM2F_cjs = require('../chunk-QKEHCM2F.cjs');
5
+ require('../chunk-BHHR2U5B.cjs');
6
+ var promises = require('fs/promises');
7
+ var path = require('path');
8
+ var os = require('os');
9
+
10
+ var OPENCODE_CONFIG = path.join(os.homedir(), ".config", "opencode", "opencode.json");
11
+ var OPENCODE_BACKUP = `${OPENCODE_CONFIG}.osw-backup`;
12
+ var parseArgs = (args) => {
13
+ const result = {};
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg.startsWith("--") && i + 1 < args.length) {
17
+ result[arg.slice(2)] = args[i + 1];
18
+ i++;
19
+ }
20
+ }
21
+ return result;
22
+ };
23
+ var patchConfig = async (provider, port, _upstream) => {
24
+ try {
25
+ const raw = await promises.readFile(OPENCODE_CONFIG, "utf-8");
26
+ const config = JSON.parse(raw);
27
+ await promises.copyFile(OPENCODE_CONFIG, OPENCODE_BACKUP);
28
+ const providers = config["provider"] ?? {};
29
+ const existing = providers[provider] ?? {};
30
+ const originalBaseURL = existing["baseURL"];
31
+ existing["baseURL"] = `http://localhost:${port}/v1`;
32
+ providers[provider] = existing;
33
+ config["provider"] = providers;
34
+ await promises.writeFile(OPENCODE_CONFIG, JSON.stringify(config, null, 2), "utf-8");
35
+ return originalBaseURL ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+ var restoreConfig = async () => {
41
+ try {
42
+ await promises.copyFile(OPENCODE_BACKUP, OPENCODE_CONFIG);
43
+ console.log(" Config restored.");
44
+ } catch {
45
+ console.log(" Warning: could not restore config backup.");
46
+ }
47
+ };
48
+ var main = async () => {
49
+ const args = parseArgs(process.argv.slice(2));
50
+ const config = {
51
+ port: args["port"] ? Number(args["port"]) : 4444,
52
+ upstream: args["upstream"] ?? "https://api.openai.com",
53
+ provider: args["provider"] ?? "openai",
54
+ maxRetries: args["retries"] ? Number(args["retries"]) : 3
55
+ };
56
+ await patchConfig(config.provider, config.port, config.upstream);
57
+ console.log(`
58
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
59
+ \u2502 O-Switcher Proxy \u2502
60
+ \u2502 \u2502
61
+ \u2502 Port: ${String(config.port).padEnd(28)}\u2502
62
+ \u2502 Upstream: ${config.upstream.padEnd(28)}\u2502
63
+ \u2502 Provider: ${config.provider.padEnd(28)}\u2502
64
+ \u2502 Retries: ${String(config.maxRetries).padEnd(28)}\u2502
65
+ \u2502 \u2502
66
+ \u2502 opencode.json patched automatically \u2502
67
+ \u2502 Will restore on exit (Ctrl+C) \u2502
68
+ \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
69
+ `);
70
+ const proxy = await chunkQKEHCM2F_cjs.createProxy(config);
71
+ const { port, close } = await proxy.start();
72
+ console.log(` Listening on http://localhost:${port}`);
73
+ console.log(" Restart OpenCode to pick up the proxy.");
74
+ console.log(" Press Ctrl+C to stop and restore config.");
75
+ const shutdown = async () => {
76
+ console.log("\n Shutting down...");
77
+ close();
78
+ await restoreConfig();
79
+ process.exit(0);
80
+ };
81
+ process.on("SIGINT", () => {
82
+ shutdown();
83
+ });
84
+ process.on("SIGTERM", () => {
85
+ shutdown();
86
+ });
87
+ };
88
+ main().catch((err) => {
89
+ console.error("Failed to start proxy:", err);
90
+ process.exit(1);
91
+ });
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ import { createProxy } from '../chunk-I6RDAZBR.js';
3
+ import '../chunk-HAEEX3KB.js';
4
+ import { readFile, copyFile, writeFile } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+
8
+ var OPENCODE_CONFIG = join(homedir(), ".config", "opencode", "opencode.json");
9
+ var OPENCODE_BACKUP = `${OPENCODE_CONFIG}.osw-backup`;
10
+ var parseArgs = (args) => {
11
+ const result = {};
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+ if (arg.startsWith("--") && i + 1 < args.length) {
15
+ result[arg.slice(2)] = args[i + 1];
16
+ i++;
17
+ }
18
+ }
19
+ return result;
20
+ };
21
+ var patchConfig = async (provider, port, _upstream) => {
22
+ try {
23
+ const raw = await readFile(OPENCODE_CONFIG, "utf-8");
24
+ const config = JSON.parse(raw);
25
+ await copyFile(OPENCODE_CONFIG, OPENCODE_BACKUP);
26
+ const providers = config["provider"] ?? {};
27
+ const existing = providers[provider] ?? {};
28
+ const originalBaseURL = existing["baseURL"];
29
+ existing["baseURL"] = `http://localhost:${port}/v1`;
30
+ providers[provider] = existing;
31
+ config["provider"] = providers;
32
+ await writeFile(OPENCODE_CONFIG, JSON.stringify(config, null, 2), "utf-8");
33
+ return originalBaseURL ?? null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+ var restoreConfig = async () => {
39
+ try {
40
+ await copyFile(OPENCODE_BACKUP, OPENCODE_CONFIG);
41
+ console.log(" Config restored.");
42
+ } catch {
43
+ console.log(" Warning: could not restore config backup.");
44
+ }
45
+ };
46
+ var main = async () => {
47
+ const args = parseArgs(process.argv.slice(2));
48
+ const config = {
49
+ port: args["port"] ? Number(args["port"]) : 4444,
50
+ upstream: args["upstream"] ?? "https://api.openai.com",
51
+ provider: args["provider"] ?? "openai",
52
+ maxRetries: args["retries"] ? Number(args["retries"]) : 3
53
+ };
54
+ await patchConfig(config.provider, config.port, config.upstream);
55
+ console.log(`
56
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
57
+ \u2502 O-Switcher Proxy \u2502
58
+ \u2502 \u2502
59
+ \u2502 Port: ${String(config.port).padEnd(28)}\u2502
60
+ \u2502 Upstream: ${config.upstream.padEnd(28)}\u2502
61
+ \u2502 Provider: ${config.provider.padEnd(28)}\u2502
62
+ \u2502 Retries: ${String(config.maxRetries).padEnd(28)}\u2502
63
+ \u2502 \u2502
64
+ \u2502 opencode.json patched automatically \u2502
65
+ \u2502 Will restore on exit (Ctrl+C) \u2502
66
+ \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
67
+ `);
68
+ const proxy = await createProxy(config);
69
+ const { port, close } = await proxy.start();
70
+ console.log(` Listening on http://localhost:${port}`);
71
+ console.log(" Restart OpenCode to pick up the proxy.");
72
+ console.log(" Press Ctrl+C to stop and restore config.");
73
+ const shutdown = async () => {
74
+ console.log("\n Shutting down...");
75
+ close();
76
+ await restoreConfig();
77
+ process.exit(0);
78
+ };
79
+ process.on("SIGINT", () => {
80
+ shutdown();
81
+ });
82
+ process.on("SIGTERM", () => {
83
+ shutdown();
84
+ });
85
+ };
86
+ main().catch((err) => {
87
+ console.error("Failed to start proxy:", err);
88
+ process.exit(1);
89
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@albinocrabs/o-switcher",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Seamless OpenRouter profile rotation for OpenCode — buy multiple subscriptions, use as one pool",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -27,6 +27,9 @@
27
27
  "LICENSE",
28
28
  "CHANGELOG.md"
29
29
  ],
30
+ "bin": {
31
+ "o-switcher-proxy": "dist/proxy/cli.js"
32
+ },
30
33
  "main": "dist/plugin.js",
31
34
  "module": "dist/plugin.js",
32
35
  "types": "dist/plugin.d.ts",
package/src/tui.tsx CHANGED
@@ -103,7 +103,7 @@ const SidebarFooter = () => {
103
103
  };
104
104
 
105
105
  /** Full status dashboard route. */
106
- const StatusDashboard = () => {
106
+ const StatusDashboard = (props: { api: TuiPluginApi }) => {
107
107
  const state = useTuiState();
108
108
 
109
109
  const content = (): string => {
@@ -167,11 +167,19 @@ const StatusDashboard = () => {
167
167
  const age = Date.now() - s.updated_at;
168
168
  const ageSec = Math.round(age / 1000);
169
169
  lines.push(` Updated ${ageSec}s ago`);
170
+ lines.push('');
171
+ lines.push(' Press Esc or q to go back');
170
172
 
171
173
  return lines.join('\n');
172
174
  };
173
175
 
174
- return <text wrapMode="none">{content()}</text>;
176
+ const onKeyDown = (e: { name: string }) => {
177
+ if (e.name === 'escape' || e.name === 'q') {
178
+ props.api.route.navigate('home');
179
+ }
180
+ };
181
+
182
+ return <box focused onKeyDown={onKeyDown}><text wrapMode="none">{content()}</text></box>;
175
183
  };
176
184
 
177
185
  // ── Plugin entry point ────────────────────────────────────────────
@@ -190,7 +198,7 @@ const tui = async (api: TuiPluginApi) => {
190
198
  api.route.register([
191
199
  {
192
200
  name: 'o-switcher-status',
193
- render: () => <StatusDashboard />,
201
+ render: () => <StatusDashboard api={api} />,
194
202
  },
195
203
  ]);
196
204