@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add HTTP proxy for automatic API key rotation on 429
|
|
8
|
+
- Proxy starts automatically inside plugin — zero config needed
|
|
9
|
+
- Intercepts 429 rate limits and retries with next profile's API key
|
|
10
|
+
- Client never sees the rate limit — switching is transparent
|
|
11
|
+
- Also available as standalone CLI: `npx o-switcher-proxy`
|
|
12
|
+
- TUI sidebar now shows proxy status and active profile
|
|
13
|
+
|
|
3
14
|
## 0.3.0
|
|
4
15
|
|
|
5
16
|
### Minor Changes
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
import { PROFILES_PATH, loadProfiles, removeProfile, saveProfiles, listProfiles, addProfile } from './chunk-HAEEX3KB.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import pino from 'pino';
|
|
3
|
-
import crypto from 'crypto';
|
|
4
3
|
import { EventEmitter } from 'eventemitter3';
|
|
5
4
|
import { CircuitState, ConsecutiveBreaker, CountBreaker, BrokenCircuitError, IsolatedCircuitError, circuitBreaker, handleAll } from 'cockatiel';
|
|
6
5
|
import PQueue from 'p-queue';
|
|
7
6
|
import { tool } from '@opencode-ai/plugin/tool';
|
|
8
|
-
import {
|
|
7
|
+
import { mkdir, writeFile, rename, watch, readFile } from 'fs/promises';
|
|
9
8
|
import { homedir } from 'os';
|
|
10
9
|
import { join, dirname } from 'path';
|
|
11
10
|
|
|
@@ -305,37 +304,6 @@ var TARGET_STATES = [
|
|
|
305
304
|
"Draining",
|
|
306
305
|
"Disabled"
|
|
307
306
|
];
|
|
308
|
-
var REDACT_PATHS = [
|
|
309
|
-
"api_key",
|
|
310
|
-
"token",
|
|
311
|
-
"secret",
|
|
312
|
-
"password",
|
|
313
|
-
"authorization",
|
|
314
|
-
"credential",
|
|
315
|
-
"credentials",
|
|
316
|
-
"*.api_key",
|
|
317
|
-
"*.token",
|
|
318
|
-
"*.secret",
|
|
319
|
-
"*.password",
|
|
320
|
-
"*.authorization",
|
|
321
|
-
"*.credential",
|
|
322
|
-
"*.credentials"
|
|
323
|
-
];
|
|
324
|
-
var createAuditLogger = (options) => {
|
|
325
|
-
const opts = {
|
|
326
|
-
level: options?.level ?? "info",
|
|
327
|
-
redact: {
|
|
328
|
-
paths: [...REDACT_PATHS],
|
|
329
|
-
censor: "[Redacted]"
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
if (options?.destination) {
|
|
333
|
-
return pino(opts, options.destination);
|
|
334
|
-
}
|
|
335
|
-
return pino(opts);
|
|
336
|
-
};
|
|
337
|
-
var createRequestLogger = (baseLogger, requestId) => baseLogger.child({ request_id: requestId });
|
|
338
|
-
var generateCorrelationId = () => crypto.randomUUID();
|
|
339
307
|
var RateLimitedSchema = z.object({
|
|
340
308
|
class: z.literal("RateLimited"),
|
|
341
309
|
retryable: z.literal(true),
|
|
@@ -1427,72 +1395,6 @@ var createOperatorTools = (deps) => ({
|
|
|
1427
1395
|
}
|
|
1428
1396
|
})
|
|
1429
1397
|
});
|
|
1430
|
-
var PROFILES_DIR = join(homedir(), ".local", "share", "o-switcher");
|
|
1431
|
-
var PROFILES_PATH = join(PROFILES_DIR, "profiles.json");
|
|
1432
|
-
var loadProfiles = async (path = PROFILES_PATH) => {
|
|
1433
|
-
try {
|
|
1434
|
-
const content = await readFile(path, "utf-8");
|
|
1435
|
-
return JSON.parse(content);
|
|
1436
|
-
} catch (err) {
|
|
1437
|
-
const code = err.code;
|
|
1438
|
-
if (code === "ENOENT") {
|
|
1439
|
-
return {};
|
|
1440
|
-
}
|
|
1441
|
-
throw err;
|
|
1442
|
-
}
|
|
1443
|
-
};
|
|
1444
|
-
var saveProfiles = async (store, path = PROFILES_PATH, logger) => {
|
|
1445
|
-
const dir = dirname(path);
|
|
1446
|
-
await mkdir(dir, { recursive: true });
|
|
1447
|
-
const tmpPath = `${path}.tmp`;
|
|
1448
|
-
await writeFile(tmpPath, JSON.stringify(store, null, 2), "utf-8");
|
|
1449
|
-
await rename(tmpPath, path);
|
|
1450
|
-
logger?.info({ path }, "Profiles saved to disk");
|
|
1451
|
-
};
|
|
1452
|
-
var credentialsMatch = (a, b) => {
|
|
1453
|
-
if (a.type !== b.type) return false;
|
|
1454
|
-
if (a.type === "api-key" && b.type === "api-key") {
|
|
1455
|
-
return a.key === b.key;
|
|
1456
|
-
}
|
|
1457
|
-
if (a.type === "oauth" && b.type === "oauth") {
|
|
1458
|
-
return a.refresh === b.refresh && a.access === b.access && a.expires === b.expires && a.accountId === b.accountId;
|
|
1459
|
-
}
|
|
1460
|
-
return false;
|
|
1461
|
-
};
|
|
1462
|
-
var addProfile = (store, provider, credentials) => {
|
|
1463
|
-
const isDuplicate = Object.values(store).some(
|
|
1464
|
-
(entry2) => entry2.provider === provider && credentialsMatch(entry2.credentials, credentials)
|
|
1465
|
-
);
|
|
1466
|
-
if (isDuplicate) {
|
|
1467
|
-
return store;
|
|
1468
|
-
}
|
|
1469
|
-
const id = nextProfileId(store, provider);
|
|
1470
|
-
const entry = {
|
|
1471
|
-
id,
|
|
1472
|
-
provider,
|
|
1473
|
-
type: credentials.type,
|
|
1474
|
-
credentials,
|
|
1475
|
-
created: (/* @__PURE__ */ new Date()).toISOString()
|
|
1476
|
-
};
|
|
1477
|
-
return { ...store, [id]: entry };
|
|
1478
|
-
};
|
|
1479
|
-
var removeProfile = (store, id) => {
|
|
1480
|
-
if (store[id] === void 0) {
|
|
1481
|
-
return { store, removed: false };
|
|
1482
|
-
}
|
|
1483
|
-
const { [id]: _removed, ...rest } = store;
|
|
1484
|
-
return { store: rest, removed: true };
|
|
1485
|
-
};
|
|
1486
|
-
var listProfiles = (store) => {
|
|
1487
|
-
return Object.values(store).sort(
|
|
1488
|
-
(a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()
|
|
1489
|
-
);
|
|
1490
|
-
};
|
|
1491
|
-
var nextProfileId = (store, provider) => {
|
|
1492
|
-
const prefix = `${provider}-`;
|
|
1493
|
-
const maxN = Object.keys(store).filter((key) => key.startsWith(prefix)).map((key) => Number(key.slice(prefix.length))).filter((n) => !Number.isNaN(n)).reduce((max, n) => Math.max(max, n), 0);
|
|
1494
|
-
return `${provider}-${maxN + 1}`;
|
|
1495
|
-
};
|
|
1496
1398
|
var AUTH_JSON_PATH = join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
1497
1399
|
var AUTH_DIR = join(homedir(), ".local", "share", "opencode");
|
|
1498
1400
|
var isAuthFile = (filename) => filename === "auth.json" || /^auth-work\d+\.json$/.test(filename);
|
|
@@ -1760,4 +1662,4 @@ var createProfileTools = (options) => ({
|
|
|
1760
1662
|
})
|
|
1761
1663
|
});
|
|
1762
1664
|
|
|
1763
|
-
export { ADMISSION_RESULTS, BackoffConfigSchema, ConfigValidationError, DEFAULT_ALPHA, DEFAULT_BACKOFF_BASE_MS, DEFAULT_BACKOFF_JITTER, DEFAULT_BACKOFF_MAX_MS, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_BACKOFF_PARAMS, DEFAULT_FAILOVER_BUDGET, DEFAULT_RETRY, DEFAULT_RETRY_BUDGET, DEFAULT_TIMEOUT_MS, DualBreaker, EXCLUSION_REASONS, ErrorClassSchema, INITIAL_HEALTH_SCORE,
|
|
1665
|
+
export { ADMISSION_RESULTS, BackoffConfigSchema, ConfigValidationError, DEFAULT_ALPHA, DEFAULT_BACKOFF_BASE_MS, DEFAULT_BACKOFF_JITTER, DEFAULT_BACKOFF_MAX_MS, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_BACKOFF_PARAMS, DEFAULT_FAILOVER_BUDGET, DEFAULT_RETRY, DEFAULT_RETRY_BUDGET, DEFAULT_TIMEOUT_MS, DualBreaker, EXCLUSION_REASONS, ErrorClassSchema, INITIAL_HEALTH_SCORE, SwitcherConfigSchema, TARGET_STATES, TargetConfigSchema, TargetRegistry, applyConfigDiff, checkHardRejects, computeBackoffMs, computeConfigDiff, computeCooldownMs, computeScore, createAdmissionController, createAuthWatcher, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createFailoverOrchestrator, createLogSubscriber, createOperatorTools, createProfileTools, createRegistry, createRequestTraceBuffer, createRetryPolicy, createRoutingEventBus, disableTarget, discoverTargets, discoverTargetsFromProfiles, drainTarget, getExclusionReason, getTargetStateTransition, inspectRequest, isRetryable, listTargets, normalizeLatency, pauseTarget, reloadConfig, resumeTarget, selectTarget, switchProfile, switchToNextProfile, updateHealthScore, updateLatencyEma, validateConfig };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var pino = require('pino');
|
|
4
|
+
var crypto = require('crypto');
|
|
5
|
+
var promises = require('fs/promises');
|
|
6
|
+
var os = require('os');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var pino__default = /*#__PURE__*/_interopDefault(pino);
|
|
12
|
+
var crypto__default = /*#__PURE__*/_interopDefault(crypto);
|
|
13
|
+
|
|
14
|
+
// src/audit/logger.ts
|
|
15
|
+
var REDACT_PATHS = [
|
|
16
|
+
"api_key",
|
|
17
|
+
"token",
|
|
18
|
+
"secret",
|
|
19
|
+
"password",
|
|
20
|
+
"authorization",
|
|
21
|
+
"credential",
|
|
22
|
+
"credentials",
|
|
23
|
+
"*.api_key",
|
|
24
|
+
"*.token",
|
|
25
|
+
"*.secret",
|
|
26
|
+
"*.password",
|
|
27
|
+
"*.authorization",
|
|
28
|
+
"*.credential",
|
|
29
|
+
"*.credentials"
|
|
30
|
+
];
|
|
31
|
+
var createAuditLogger = (options) => {
|
|
32
|
+
const opts = {
|
|
33
|
+
level: options?.level ?? "info",
|
|
34
|
+
redact: {
|
|
35
|
+
paths: [...REDACT_PATHS],
|
|
36
|
+
censor: "[Redacted]"
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
if (options?.destination) {
|
|
40
|
+
return pino__default.default(opts, options.destination);
|
|
41
|
+
}
|
|
42
|
+
return pino__default.default(opts);
|
|
43
|
+
};
|
|
44
|
+
var createRequestLogger = (baseLogger, requestId) => baseLogger.child({ request_id: requestId });
|
|
45
|
+
var generateCorrelationId = () => crypto__default.default.randomUUID();
|
|
46
|
+
var PROFILES_DIR = path.join(os.homedir(), ".local", "share", "o-switcher");
|
|
47
|
+
var PROFILES_PATH = path.join(PROFILES_DIR, "profiles.json");
|
|
48
|
+
var loadProfiles = async (path = PROFILES_PATH) => {
|
|
49
|
+
try {
|
|
50
|
+
const content = await promises.readFile(path, "utf-8");
|
|
51
|
+
return JSON.parse(content);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const code = err.code;
|
|
54
|
+
if (code === "ENOENT") {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var saveProfiles = async (store, path$1 = PROFILES_PATH, logger) => {
|
|
61
|
+
const dir = path.dirname(path$1);
|
|
62
|
+
await promises.mkdir(dir, { recursive: true });
|
|
63
|
+
const tmpPath = `${path$1}.tmp`;
|
|
64
|
+
await promises.writeFile(tmpPath, JSON.stringify(store, null, 2), "utf-8");
|
|
65
|
+
await promises.rename(tmpPath, path$1);
|
|
66
|
+
logger?.info({ path: path$1 }, "Profiles saved to disk");
|
|
67
|
+
};
|
|
68
|
+
var credentialsMatch = (a, b) => {
|
|
69
|
+
if (a.type !== b.type) return false;
|
|
70
|
+
if (a.type === "api-key" && b.type === "api-key") {
|
|
71
|
+
return a.key === b.key;
|
|
72
|
+
}
|
|
73
|
+
if (a.type === "oauth" && b.type === "oauth") {
|
|
74
|
+
return a.refresh === b.refresh && a.access === b.access && a.expires === b.expires && a.accountId === b.accountId;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
};
|
|
78
|
+
var addProfile = (store, provider, credentials) => {
|
|
79
|
+
const isDuplicate = Object.values(store).some(
|
|
80
|
+
(entry2) => entry2.provider === provider && credentialsMatch(entry2.credentials, credentials)
|
|
81
|
+
);
|
|
82
|
+
if (isDuplicate) {
|
|
83
|
+
return store;
|
|
84
|
+
}
|
|
85
|
+
const id = nextProfileId(store, provider);
|
|
86
|
+
const entry = {
|
|
87
|
+
id,
|
|
88
|
+
provider,
|
|
89
|
+
type: credentials.type,
|
|
90
|
+
credentials,
|
|
91
|
+
created: (/* @__PURE__ */ new Date()).toISOString()
|
|
92
|
+
};
|
|
93
|
+
return { ...store, [id]: entry };
|
|
94
|
+
};
|
|
95
|
+
var removeProfile = (store, id) => {
|
|
96
|
+
if (store[id] === void 0) {
|
|
97
|
+
return { store, removed: false };
|
|
98
|
+
}
|
|
99
|
+
const { [id]: _removed, ...rest } = store;
|
|
100
|
+
return { store: rest, removed: true };
|
|
101
|
+
};
|
|
102
|
+
var listProfiles = (store) => {
|
|
103
|
+
return Object.values(store).sort(
|
|
104
|
+
(a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
var nextProfileId = (store, provider) => {
|
|
108
|
+
const prefix = `${provider}-`;
|
|
109
|
+
const maxN = Object.keys(store).filter((key) => key.startsWith(prefix)).map((key) => Number(key.slice(prefix.length))).filter((n) => !Number.isNaN(n)).reduce((max, n) => Math.max(max, n), 0);
|
|
110
|
+
return `${provider}-${maxN + 1}`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
exports.PROFILES_PATH = PROFILES_PATH;
|
|
114
|
+
exports.REDACT_PATHS = REDACT_PATHS;
|
|
115
|
+
exports.addProfile = addProfile;
|
|
116
|
+
exports.createAuditLogger = createAuditLogger;
|
|
117
|
+
exports.createRequestLogger = createRequestLogger;
|
|
118
|
+
exports.generateCorrelationId = generateCorrelationId;
|
|
119
|
+
exports.listProfiles = listProfiles;
|
|
120
|
+
exports.loadProfiles = loadProfiles;
|
|
121
|
+
exports.nextProfileId = nextProfileId;
|
|
122
|
+
exports.removeProfile = removeProfile;
|
|
123
|
+
exports.saveProfiles = saveProfiles;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import { readFile, mkdir, writeFile, rename } from 'fs/promises';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
|
|
7
|
+
// src/audit/logger.ts
|
|
8
|
+
var REDACT_PATHS = [
|
|
9
|
+
"api_key",
|
|
10
|
+
"token",
|
|
11
|
+
"secret",
|
|
12
|
+
"password",
|
|
13
|
+
"authorization",
|
|
14
|
+
"credential",
|
|
15
|
+
"credentials",
|
|
16
|
+
"*.api_key",
|
|
17
|
+
"*.token",
|
|
18
|
+
"*.secret",
|
|
19
|
+
"*.password",
|
|
20
|
+
"*.authorization",
|
|
21
|
+
"*.credential",
|
|
22
|
+
"*.credentials"
|
|
23
|
+
];
|
|
24
|
+
var createAuditLogger = (options) => {
|
|
25
|
+
const opts = {
|
|
26
|
+
level: options?.level ?? "info",
|
|
27
|
+
redact: {
|
|
28
|
+
paths: [...REDACT_PATHS],
|
|
29
|
+
censor: "[Redacted]"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
if (options?.destination) {
|
|
33
|
+
return pino(opts, options.destination);
|
|
34
|
+
}
|
|
35
|
+
return pino(opts);
|
|
36
|
+
};
|
|
37
|
+
var createRequestLogger = (baseLogger, requestId) => baseLogger.child({ request_id: requestId });
|
|
38
|
+
var generateCorrelationId = () => crypto.randomUUID();
|
|
39
|
+
var PROFILES_DIR = join(homedir(), ".local", "share", "o-switcher");
|
|
40
|
+
var PROFILES_PATH = join(PROFILES_DIR, "profiles.json");
|
|
41
|
+
var loadProfiles = async (path = PROFILES_PATH) => {
|
|
42
|
+
try {
|
|
43
|
+
const content = await readFile(path, "utf-8");
|
|
44
|
+
return JSON.parse(content);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const code = err.code;
|
|
47
|
+
if (code === "ENOENT") {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var saveProfiles = async (store, path = PROFILES_PATH, logger) => {
|
|
54
|
+
const dir = dirname(path);
|
|
55
|
+
await mkdir(dir, { recursive: true });
|
|
56
|
+
const tmpPath = `${path}.tmp`;
|
|
57
|
+
await writeFile(tmpPath, JSON.stringify(store, null, 2), "utf-8");
|
|
58
|
+
await rename(tmpPath, path);
|
|
59
|
+
logger?.info({ path }, "Profiles saved to disk");
|
|
60
|
+
};
|
|
61
|
+
var credentialsMatch = (a, b) => {
|
|
62
|
+
if (a.type !== b.type) return false;
|
|
63
|
+
if (a.type === "api-key" && b.type === "api-key") {
|
|
64
|
+
return a.key === b.key;
|
|
65
|
+
}
|
|
66
|
+
if (a.type === "oauth" && b.type === "oauth") {
|
|
67
|
+
return a.refresh === b.refresh && a.access === b.access && a.expires === b.expires && a.accountId === b.accountId;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
var addProfile = (store, provider, credentials) => {
|
|
72
|
+
const isDuplicate = Object.values(store).some(
|
|
73
|
+
(entry2) => entry2.provider === provider && credentialsMatch(entry2.credentials, credentials)
|
|
74
|
+
);
|
|
75
|
+
if (isDuplicate) {
|
|
76
|
+
return store;
|
|
77
|
+
}
|
|
78
|
+
const id = nextProfileId(store, provider);
|
|
79
|
+
const entry = {
|
|
80
|
+
id,
|
|
81
|
+
provider,
|
|
82
|
+
type: credentials.type,
|
|
83
|
+
credentials,
|
|
84
|
+
created: (/* @__PURE__ */ new Date()).toISOString()
|
|
85
|
+
};
|
|
86
|
+
return { ...store, [id]: entry };
|
|
87
|
+
};
|
|
88
|
+
var removeProfile = (store, id) => {
|
|
89
|
+
if (store[id] === void 0) {
|
|
90
|
+
return { store, removed: false };
|
|
91
|
+
}
|
|
92
|
+
const { [id]: _removed, ...rest } = store;
|
|
93
|
+
return { store: rest, removed: true };
|
|
94
|
+
};
|
|
95
|
+
var listProfiles = (store) => {
|
|
96
|
+
return Object.values(store).sort(
|
|
97
|
+
(a, b) => new Date(a.created).getTime() - new Date(b.created).getTime()
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
var nextProfileId = (store, provider) => {
|
|
101
|
+
const prefix = `${provider}-`;
|
|
102
|
+
const maxN = Object.keys(store).filter((key) => key.startsWith(prefix)).map((key) => Number(key.slice(prefix.length))).filter((n) => !Number.isNaN(n)).reduce((max, n) => Math.max(max, n), 0);
|
|
103
|
+
return `${provider}-${maxN + 1}`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export { PROFILES_PATH, REDACT_PATHS, addProfile, createAuditLogger, createRequestLogger, generateCorrelationId, listProfiles, loadProfiles, nextProfileId, removeProfile, saveProfiles };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createAuditLogger, loadProfiles } from './chunk-HAEEX3KB.js';
|
|
2
|
+
import { mkdir, writeFile, rename } from 'fs/promises';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { createServer } from 'http';
|
|
6
|
+
|
|
7
|
+
var STATE_DIR = join(homedir(), ".local", "share", "o-switcher");
|
|
8
|
+
var STATE_FILE = join(STATE_DIR, "tui-state.json");
|
|
9
|
+
var STATE_TMP = join(STATE_DIR, "tui-state.json.tmp");
|
|
10
|
+
var writeStateAtomic = async (state) => {
|
|
11
|
+
await mkdir(dirname(STATE_FILE), { recursive: true });
|
|
12
|
+
const json = JSON.stringify(state);
|
|
13
|
+
await writeFile(STATE_TMP, json, "utf8");
|
|
14
|
+
await rename(STATE_TMP, STATE_FILE);
|
|
15
|
+
};
|
|
16
|
+
var createStateWriter = (debounceMs = 500) => {
|
|
17
|
+
let pending;
|
|
18
|
+
let timer;
|
|
19
|
+
let writePromise = Promise.resolve();
|
|
20
|
+
const doWrite = () => {
|
|
21
|
+
if (!pending) return;
|
|
22
|
+
const snapshot = pending;
|
|
23
|
+
pending = void 0;
|
|
24
|
+
writePromise = writeStateAtomic(snapshot).catch(() => void 0);
|
|
25
|
+
};
|
|
26
|
+
const write = (state) => {
|
|
27
|
+
pending = state;
|
|
28
|
+
if (timer) clearTimeout(timer);
|
|
29
|
+
timer = setTimeout(doWrite, debounceMs);
|
|
30
|
+
};
|
|
31
|
+
const flush = async () => {
|
|
32
|
+
if (timer) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = void 0;
|
|
35
|
+
}
|
|
36
|
+
doWrite();
|
|
37
|
+
await writePromise;
|
|
38
|
+
};
|
|
39
|
+
return { write, flush };
|
|
40
|
+
};
|
|
41
|
+
var DEFAULT_CONFIG = {
|
|
42
|
+
port: 4444,
|
|
43
|
+
upstream: "https://api.openai.com",
|
|
44
|
+
maxRetries: 3,
|
|
45
|
+
provider: "openai"
|
|
46
|
+
};
|
|
47
|
+
var buildProfilePool = (profiles) => profiles.sort((a, b) => a.consecutiveFailures - b.consecutiveFailures);
|
|
48
|
+
var getBearerToken = (profile) => {
|
|
49
|
+
const creds = profile.entry.credentials;
|
|
50
|
+
if (creds.type === "api-key") {
|
|
51
|
+
return creds.key;
|
|
52
|
+
}
|
|
53
|
+
if (creds.type === "oauth") {
|
|
54
|
+
return creds.access;
|
|
55
|
+
}
|
|
56
|
+
return void 0;
|
|
57
|
+
};
|
|
58
|
+
var readBody = (req) => new Promise((resolve, reject) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
61
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
62
|
+
req.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
var forwardRequest = async (method, path, headers, body, upstream) => {
|
|
65
|
+
const url = `${upstream}${path}`;
|
|
66
|
+
const reqHeaders = { ...headers };
|
|
67
|
+
delete reqHeaders["host"];
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
method,
|
|
70
|
+
headers: reqHeaders,
|
|
71
|
+
body: method !== "GET" && method !== "HEAD" ? body : void 0
|
|
72
|
+
});
|
|
73
|
+
const respHeaders = {};
|
|
74
|
+
response.headers.forEach((value, key) => {
|
|
75
|
+
respHeaders[key] = value;
|
|
76
|
+
});
|
|
77
|
+
if (respHeaders["content-type"]?.includes("text/event-stream") && response.body) {
|
|
78
|
+
return { status: response.status, headers: respHeaders, body: response.body };
|
|
79
|
+
}
|
|
80
|
+
const respBody = Buffer.from(await response.arrayBuffer());
|
|
81
|
+
return { status: response.status, headers: respHeaders, body: respBody };
|
|
82
|
+
};
|
|
83
|
+
var publishState = (stateWriter, profiles, activeId) => {
|
|
84
|
+
const targets = profiles.map((p) => ({
|
|
85
|
+
target_id: p.entry.id,
|
|
86
|
+
provider_id: p.entry.provider,
|
|
87
|
+
profile: p.entry.id,
|
|
88
|
+
state: p.consecutiveFailures > 0 ? "CoolingDown" : "Active",
|
|
89
|
+
health_score: Math.max(0, 1 - p.consecutiveFailures * 0.25),
|
|
90
|
+
latency_ema_ms: 0,
|
|
91
|
+
enabled: true
|
|
92
|
+
}));
|
|
93
|
+
const snapshot = {
|
|
94
|
+
version: 1,
|
|
95
|
+
updated_at: Date.now(),
|
|
96
|
+
active_target_id: activeId,
|
|
97
|
+
targets
|
|
98
|
+
};
|
|
99
|
+
stateWriter.write(snapshot);
|
|
100
|
+
};
|
|
101
|
+
var createProxy = async (userConfig = {}) => {
|
|
102
|
+
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
103
|
+
const logger = createAuditLogger({ level: "info" });
|
|
104
|
+
const stateWriter = createStateWriter(300);
|
|
105
|
+
const store = await loadProfiles();
|
|
106
|
+
const providerProfiles = Object.values(store).filter(
|
|
107
|
+
(p) => p.provider === config.provider
|
|
108
|
+
);
|
|
109
|
+
if (providerProfiles.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`No API key profiles found for provider "${config.provider}". Run o-switcher profile tools to add profiles first.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const profiles = providerProfiles.map((entry) => ({
|
|
115
|
+
entry,
|
|
116
|
+
failedAt: null,
|
|
117
|
+
consecutiveFailures: 0
|
|
118
|
+
}));
|
|
119
|
+
logger.info(
|
|
120
|
+
{ provider: config.provider, profiles: profiles.map((p) => p.entry.id), port: config.port },
|
|
121
|
+
"O-Switcher proxy initialized"
|
|
122
|
+
);
|
|
123
|
+
publishState(stateWriter, profiles, profiles[0]?.entry.id);
|
|
124
|
+
const handleRequest = async (req, res) => {
|
|
125
|
+
const method = req.method ?? "GET";
|
|
126
|
+
const path = req.url ?? "/";
|
|
127
|
+
if (path === "/health") {
|
|
128
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
129
|
+
res.end(JSON.stringify({ status: "ok", profiles: profiles.length }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const body = await readBody(req);
|
|
133
|
+
const pool = buildProfilePool(profiles);
|
|
134
|
+
if (pool.length === 0) {
|
|
135
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
136
|
+
res.end(JSON.stringify({ error: "All profiles exhausted" }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const maxAttempts = Math.min(config.maxRetries, pool.length);
|
|
140
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
141
|
+
const profile = pool[attempt];
|
|
142
|
+
const token = getBearerToken(profile);
|
|
143
|
+
if (!token) continue;
|
|
144
|
+
const headers = {};
|
|
145
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
146
|
+
if (value && typeof value === "string") {
|
|
147
|
+
headers[key] = value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
151
|
+
logger.info(
|
|
152
|
+
{ profile: profile.entry.id, attempt: attempt + 1, path },
|
|
153
|
+
"Forwarding request"
|
|
154
|
+
);
|
|
155
|
+
try {
|
|
156
|
+
const result = await forwardRequest(method, path, headers, body, config.upstream);
|
|
157
|
+
if (result.status === 429) {
|
|
158
|
+
profile.consecutiveFailures += 1;
|
|
159
|
+
profile.failedAt = Date.now();
|
|
160
|
+
logger.warn(
|
|
161
|
+
{ profile: profile.entry.id, attempt: attempt + 1, consecutiveFailures: profile.consecutiveFailures },
|
|
162
|
+
"Rate limited \u2014 trying next profile"
|
|
163
|
+
);
|
|
164
|
+
publishState(stateWriter, profiles, pool[attempt + 1]?.entry.id);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
profile.consecutiveFailures = 0;
|
|
168
|
+
profile.failedAt = null;
|
|
169
|
+
publishState(stateWriter, profiles, profile.entry.id);
|
|
170
|
+
res.writeHead(result.status, result.headers);
|
|
171
|
+
if (result.body instanceof ReadableStream) {
|
|
172
|
+
const reader = result.body.getReader();
|
|
173
|
+
const pump = async () => {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) {
|
|
176
|
+
res.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.write(value);
|
|
180
|
+
await pump();
|
|
181
|
+
};
|
|
182
|
+
await pump();
|
|
183
|
+
} else {
|
|
184
|
+
res.end(result.body);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
profile.consecutiveFailures += 1;
|
|
189
|
+
profile.failedAt = Date.now();
|
|
190
|
+
logger.error(
|
|
191
|
+
{ profile: profile.entry.id, attempt: attempt + 1, err },
|
|
192
|
+
"Request failed \u2014 trying next profile"
|
|
193
|
+
);
|
|
194
|
+
publishState(stateWriter, profiles, pool[attempt + 1]?.entry.id);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
res.writeHead(429, { "content-type": "application/json" });
|
|
199
|
+
res.end(JSON.stringify({
|
|
200
|
+
error: {
|
|
201
|
+
message: `All ${maxAttempts} profiles rate-limited. Try again later.`,
|
|
202
|
+
type: "rate_limit_error",
|
|
203
|
+
code: "all_profiles_exhausted"
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
206
|
+
};
|
|
207
|
+
const start = () => new Promise((resolve) => {
|
|
208
|
+
const server = createServer((req, res) => {
|
|
209
|
+
handleRequest(req, res).catch((err) => {
|
|
210
|
+
logger.error({ err }, "Unhandled request error");
|
|
211
|
+
if (!res.headersSent) {
|
|
212
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
213
|
+
}
|
|
214
|
+
res.end(JSON.stringify({ error: "Internal proxy error" }));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
server.listen(config.port, () => {
|
|
218
|
+
logger.info({ port: config.port, upstream: config.upstream }, "O-Switcher proxy listening");
|
|
219
|
+
resolve({
|
|
220
|
+
port: config.port,
|
|
221
|
+
close: () => {
|
|
222
|
+
server.close();
|
|
223
|
+
stateWriter.flush();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
return { start };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export { createProxy, createStateWriter };
|