@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/CHANGELOG.md +11 -0
- package/dist/{chunk-XXH633FY.js → chunk-2EYMYNKS.js} +3 -101
- package/dist/chunk-BHHR2U5B.cjs +123 -0
- package/dist/chunk-HAEEX3KB.js +106 -0
- package/dist/chunk-I6RDAZBR.js +231 -0
- package/dist/chunk-QKEHCM2F.cjs +234 -0
- package/dist/{chunk-H72U2MNG.cjs → chunk-TGYAV5TV.cjs} +18 -128
- package/dist/index.cjs +104 -103
- package/dist/index.js +3 -2
- package/dist/plugin.cjs +60 -67
- package/dist/plugin.js +41 -48
- package/dist/proxy/cli.cjs +91 -0
- package/dist/proxy/cli.d.cts +1 -0
- package/dist/proxy/cli.d.ts +1 -0
- package/dist/proxy/cli.js +89 -0
- package/package.json +4 -1
- package/src/tui.tsx +11 -3
package/dist/plugin.js
CHANGED
|
@@ -1,42 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
if (
|
|
243
|
-
|
|
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
|
-
"
|
|
241
|
+
"Switched to next profile after rate limit"
|
|
254
242
|
);
|
|
255
243
|
}
|
|
256
244
|
}).catch((err) => {
|
|
257
|
-
state.logger?.warn({ err }, "Failed to
|
|
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
|
+
"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
|
-
|
|
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
|
|