@albinocrabs/o-switcher 0.1.1 → 0.3.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 +18 -0
- package/dist/{chunk-VABBGKSR.cjs → chunk-H72U2MNG.cjs} +192 -16
- package/dist/{chunk-IKNWSNAS.js → chunk-XXH633FY.js} +190 -17
- package/dist/index.cjs +88 -145
- package/dist/index.d.cts +43 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +9 -77
- package/dist/plugin.cjs +118 -17
- package/dist/plugin.d.cts +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +103 -2
- package/package.json +11 -5
- package/src/registry/types.ts +65 -0
- package/src/state-bridge.ts +119 -0
- package/src/tui.tsx +218 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Multi-profile auth rotation for OpenAI accounts
|
|
8
|
+
- Watch all `auth-work*.json` files, not just `auth.json`
|
|
9
|
+
- Add `switchProfile` / `switchToNextProfile` for credential rotation
|
|
10
|
+
- Auto-switch to next healthy profile when health drops below 30%
|
|
11
|
+
|
|
12
|
+
## 0.2.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- 57f1eb9: Add TUI sidebar panel and status dashboard for OpenCode
|
|
17
|
+
- Sidebar footer shows active profile, health %, and target count
|
|
18
|
+
- `/switcher` slash command opens full status dashboard with all targets
|
|
19
|
+
- File-based state bridge between server plugin and TUI (atomic writes, debounced)
|
|
20
|
+
|
|
3
21
|
All notable changes to this project will be documented in this file.
|
|
4
22
|
|
|
5
23
|
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
@@ -6,10 +6,10 @@ var crypto = require('crypto');
|
|
|
6
6
|
var eventemitter3 = require('eventemitter3');
|
|
7
7
|
var cockatiel = require('cockatiel');
|
|
8
8
|
var PQueue = require('p-queue');
|
|
9
|
+
var tool = require('@opencode-ai/plugin/tool');
|
|
9
10
|
var promises = require('fs/promises');
|
|
10
11
|
var os = require('os');
|
|
11
12
|
var path = require('path');
|
|
12
|
-
var tool = require('@opencode-ai/plugin/tool');
|
|
13
13
|
|
|
14
14
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
15
15
|
|
|
@@ -1363,6 +1363,78 @@ var reloadConfig = (deps, rawConfig) => {
|
|
|
1363
1363
|
throw err;
|
|
1364
1364
|
}
|
|
1365
1365
|
};
|
|
1366
|
+
var { schema: z3 } = tool.tool;
|
|
1367
|
+
var createOperatorTools = (deps) => ({
|
|
1368
|
+
listTargets: tool.tool({
|
|
1369
|
+
description: "List all routing targets with health scores, states, and circuit breaker status.",
|
|
1370
|
+
args: {},
|
|
1371
|
+
async execute() {
|
|
1372
|
+
deps.logger.info({ op: "listTargets" }, "operator: listTargets");
|
|
1373
|
+
const result = listTargets(deps);
|
|
1374
|
+
return JSON.stringify(result, null, 2);
|
|
1375
|
+
}
|
|
1376
|
+
}),
|
|
1377
|
+
pauseTarget: tool.tool({
|
|
1378
|
+
description: "Pause a target, preventing new requests from being routed to it.",
|
|
1379
|
+
args: { target_id: z3.string().min(1) },
|
|
1380
|
+
async execute(args) {
|
|
1381
|
+
deps.logger.info({ op: "pauseTarget", target_id: args.target_id }, "operator: pauseTarget");
|
|
1382
|
+
const result = pauseTarget(deps, args.target_id);
|
|
1383
|
+
return JSON.stringify(result, null, 2);
|
|
1384
|
+
}
|
|
1385
|
+
}),
|
|
1386
|
+
resumeTarget: tool.tool({
|
|
1387
|
+
description: "Resume a previously paused or disabled target, allowing new requests.",
|
|
1388
|
+
args: { target_id: z3.string().min(1) },
|
|
1389
|
+
async execute(args) {
|
|
1390
|
+
deps.logger.info({ op: "resumeTarget", target_id: args.target_id }, "operator: resumeTarget");
|
|
1391
|
+
const result = resumeTarget(deps, args.target_id);
|
|
1392
|
+
return JSON.stringify(result, null, 2);
|
|
1393
|
+
}
|
|
1394
|
+
}),
|
|
1395
|
+
drainTarget: tool.tool({
|
|
1396
|
+
description: "Drain a target, allowing in-flight requests to complete but preventing new ones.",
|
|
1397
|
+
args: { target_id: z3.string().min(1) },
|
|
1398
|
+
async execute(args) {
|
|
1399
|
+
deps.logger.info({ op: "drainTarget", target_id: args.target_id }, "operator: drainTarget");
|
|
1400
|
+
const result = drainTarget(deps, args.target_id);
|
|
1401
|
+
return JSON.stringify(result, null, 2);
|
|
1402
|
+
}
|
|
1403
|
+
}),
|
|
1404
|
+
disableTarget: tool.tool({
|
|
1405
|
+
description: "Disable a target entirely, removing it from routing.",
|
|
1406
|
+
args: { target_id: z3.string().min(1) },
|
|
1407
|
+
async execute(args) {
|
|
1408
|
+
deps.logger.info(
|
|
1409
|
+
{ op: "disableTarget", target_id: args.target_id },
|
|
1410
|
+
"operator: disableTarget"
|
|
1411
|
+
);
|
|
1412
|
+
const result = disableTarget(deps, args.target_id);
|
|
1413
|
+
return JSON.stringify(result, null, 2);
|
|
1414
|
+
}
|
|
1415
|
+
}),
|
|
1416
|
+
inspectRequest: tool.tool({
|
|
1417
|
+
description: "Inspect a request trace by ID, showing attempts, segments, and outcome.",
|
|
1418
|
+
args: { request_id: z3.string().min(1) },
|
|
1419
|
+
async execute(args) {
|
|
1420
|
+
deps.logger.info(
|
|
1421
|
+
{ op: "inspectRequest", request_id: args.request_id },
|
|
1422
|
+
"operator: inspectRequest"
|
|
1423
|
+
);
|
|
1424
|
+
const result = inspectRequest(deps, args.request_id);
|
|
1425
|
+
return JSON.stringify(result, null, 2);
|
|
1426
|
+
}
|
|
1427
|
+
}),
|
|
1428
|
+
reloadConfig: tool.tool({
|
|
1429
|
+
description: "Reload routing configuration with diff-apply. Validates new config before applying.",
|
|
1430
|
+
args: { config: z3.record(z3.string(), z3.unknown()) },
|
|
1431
|
+
async execute(args) {
|
|
1432
|
+
deps.logger.info({ op: "reloadConfig" }, "operator: reloadConfig");
|
|
1433
|
+
const result = reloadConfig(deps, args.config);
|
|
1434
|
+
return JSON.stringify(result, null, 2);
|
|
1435
|
+
}
|
|
1436
|
+
})
|
|
1437
|
+
});
|
|
1366
1438
|
var PROFILES_DIR = path.join(os.homedir(), ".local", "share", "o-switcher");
|
|
1367
1439
|
var PROFILES_PATH = path.join(PROFILES_DIR, "profiles.json");
|
|
1368
1440
|
var loadProfiles = async (path = PROFILES_PATH) => {
|
|
@@ -1430,6 +1502,8 @@ var nextProfileId = (store, provider) => {
|
|
|
1430
1502
|
return `${provider}-${maxN + 1}`;
|
|
1431
1503
|
};
|
|
1432
1504
|
var AUTH_JSON_PATH = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
|
|
1505
|
+
var AUTH_DIR = path.join(os.homedir(), ".local", "share", "opencode");
|
|
1506
|
+
var isAuthFile = (filename) => filename === "auth.json" || /^auth-work\d+\.json$/.test(filename);
|
|
1433
1507
|
var DEBOUNCE_MS = 100;
|
|
1434
1508
|
var readAuthJson = async (path) => {
|
|
1435
1509
|
try {
|
|
@@ -1439,6 +1513,22 @@ var readAuthJson = async (path) => {
|
|
|
1439
1513
|
return {};
|
|
1440
1514
|
}
|
|
1441
1515
|
};
|
|
1516
|
+
var readAllAuthFiles = async (dir) => {
|
|
1517
|
+
const { readdir } = await import('fs/promises');
|
|
1518
|
+
const merged = {};
|
|
1519
|
+
try {
|
|
1520
|
+
const files = await readdir(dir);
|
|
1521
|
+
for (const file of files.filter(isAuthFile)) {
|
|
1522
|
+
const data = await readAuthJson(path.join(dir, file));
|
|
1523
|
+
for (const [provider, entry] of Object.entries(data)) {
|
|
1524
|
+
const key = `${provider}:${file}`;
|
|
1525
|
+
merged[key] = entry;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
} catch {
|
|
1529
|
+
}
|
|
1530
|
+
return merged;
|
|
1531
|
+
};
|
|
1442
1532
|
var toCredential = (entry) => {
|
|
1443
1533
|
if (entry.type === "oauth" || entry.refresh !== void 0 && entry.access !== void 0) {
|
|
1444
1534
|
return {
|
|
@@ -1460,22 +1550,23 @@ var entriesEqual = (a, b) => {
|
|
|
1460
1550
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1461
1551
|
};
|
|
1462
1552
|
var createAuthWatcher = (options) => {
|
|
1463
|
-
const
|
|
1553
|
+
const authDir = options?.authJsonPath ? path.dirname(options.authJsonPath) : AUTH_DIR;
|
|
1464
1554
|
const profPath = options?.profilesPath ?? PROFILES_PATH;
|
|
1465
1555
|
const log = options?.logger?.child({ component: "profile-watcher" });
|
|
1466
1556
|
let lastKnownAuth = {};
|
|
1467
1557
|
let abortController = null;
|
|
1468
1558
|
let debounceTimer = null;
|
|
1469
1559
|
const processChange = async () => {
|
|
1470
|
-
const newAuth = await
|
|
1560
|
+
const newAuth = await readAllAuthFiles(authDir);
|
|
1471
1561
|
let store = await loadProfiles(profPath);
|
|
1472
1562
|
let changed = false;
|
|
1473
|
-
for (const [
|
|
1563
|
+
for (const [compositeKey, entry] of Object.entries(newAuth)) {
|
|
1474
1564
|
if (!entry) continue;
|
|
1475
|
-
const
|
|
1565
|
+
const provider = compositeKey.split(":")[0] ?? compositeKey;
|
|
1566
|
+
const previousEntry = lastKnownAuth[compositeKey];
|
|
1476
1567
|
if (previousEntry !== void 0 && !entriesEqual(previousEntry, entry)) {
|
|
1477
1568
|
log?.info(
|
|
1478
|
-
{ provider, action: "credential_changed" },
|
|
1569
|
+
{ provider, source: compositeKey, action: "credential_changed" },
|
|
1479
1570
|
"Credential change detected \u2014 saving previous credential"
|
|
1480
1571
|
);
|
|
1481
1572
|
const prevCredential = toCredential(previousEntry);
|
|
@@ -1485,7 +1576,7 @@ var createAuthWatcher = (options) => {
|
|
|
1485
1576
|
changed = true;
|
|
1486
1577
|
}
|
|
1487
1578
|
} else if (previousEntry === void 0) {
|
|
1488
|
-
log?.info({ provider, action: "new_provider" }, "New
|
|
1579
|
+
log?.info({ provider, source: compositeKey, action: "new_provider" }, "New auth file detected");
|
|
1489
1580
|
const credential = toCredential(entry);
|
|
1490
1581
|
const newStore = addProfile(store, provider, credential);
|
|
1491
1582
|
if (newStore !== store) {
|
|
@@ -1512,32 +1603,32 @@ var createAuthWatcher = (options) => {
|
|
|
1512
1603
|
}, DEBOUNCE_MS);
|
|
1513
1604
|
};
|
|
1514
1605
|
const start = async () => {
|
|
1515
|
-
const currentAuth = await
|
|
1606
|
+
const currentAuth = await readAllAuthFiles(authDir);
|
|
1516
1607
|
const currentStore = await loadProfiles(profPath);
|
|
1517
|
-
log?.info({
|
|
1608
|
+
log?.info({ auth_keys: Object.keys(currentAuth).length }, "Auth watcher started");
|
|
1518
1609
|
if (Object.keys(currentStore).length === 0 && Object.keys(currentAuth).length > 0) {
|
|
1519
1610
|
let store = {};
|
|
1520
|
-
for (const [
|
|
1611
|
+
for (const [compositeKey, entry] of Object.entries(currentAuth)) {
|
|
1521
1612
|
if (!entry) continue;
|
|
1613
|
+
const provider = compositeKey.split(":")[0] ?? compositeKey;
|
|
1522
1614
|
const credential = toCredential(entry);
|
|
1523
1615
|
store = addProfile(store, provider, credential);
|
|
1524
1616
|
}
|
|
1525
1617
|
await saveProfiles(store, profPath);
|
|
1526
1618
|
log?.info(
|
|
1527
1619
|
{ profiles_initialized: Object.keys(store).length },
|
|
1528
|
-
"Initialized profiles from auth
|
|
1620
|
+
"Initialized profiles from auth files"
|
|
1529
1621
|
);
|
|
1530
1622
|
}
|
|
1531
1623
|
lastKnownAuth = currentAuth;
|
|
1532
1624
|
abortController = new AbortController();
|
|
1533
|
-
const parentDir = path.dirname(authPath);
|
|
1534
1625
|
(async () => {
|
|
1535
1626
|
try {
|
|
1536
|
-
const watcher = promises.watch(
|
|
1627
|
+
const watcher = promises.watch(authDir, {
|
|
1537
1628
|
signal: abortController.signal
|
|
1538
1629
|
});
|
|
1539
1630
|
for await (const event of watcher) {
|
|
1540
|
-
if (event.filename
|
|
1631
|
+
if (event.filename !== null && isAuthFile(event.filename)) {
|
|
1541
1632
|
onFileChange();
|
|
1542
1633
|
}
|
|
1543
1634
|
}
|
|
@@ -1559,7 +1650,89 @@ var createAuthWatcher = (options) => {
|
|
|
1559
1650
|
};
|
|
1560
1651
|
return { start, stop };
|
|
1561
1652
|
};
|
|
1562
|
-
var
|
|
1653
|
+
var credentialToAuthEntry = (cred) => {
|
|
1654
|
+
if (cred.type === "oauth") {
|
|
1655
|
+
return {
|
|
1656
|
+
type: "oauth",
|
|
1657
|
+
refresh: cred.refresh,
|
|
1658
|
+
access: cred.access,
|
|
1659
|
+
expires: cred.expires,
|
|
1660
|
+
accountId: cred.accountId
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
type: "api-key",
|
|
1665
|
+
key: cred.key
|
|
1666
|
+
};
|
|
1667
|
+
};
|
|
1668
|
+
var readCurrentAuth = async (authPath) => {
|
|
1669
|
+
try {
|
|
1670
|
+
const content = await promises.readFile(authPath, "utf-8");
|
|
1671
|
+
return JSON.parse(content);
|
|
1672
|
+
} catch {
|
|
1673
|
+
return void 0;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
var switchProfile = async (options) => {
|
|
1677
|
+
const profPath = options.profilesPath ?? PROFILES_PATH;
|
|
1678
|
+
const authPath = options.authJsonPath ?? AUTH_JSON_PATH;
|
|
1679
|
+
const log = options.logger?.child({ component: "profile-switcher" });
|
|
1680
|
+
const store = await loadProfiles(profPath);
|
|
1681
|
+
const target = store[options.targetProfileId];
|
|
1682
|
+
if (target === void 0) {
|
|
1683
|
+
log?.warn({ profileId: options.targetProfileId }, "Profile not found");
|
|
1684
|
+
return { success: false, to: options.targetProfileId, reason: "profile_not_found" };
|
|
1685
|
+
}
|
|
1686
|
+
const currentAuth = await readCurrentAuth(authPath);
|
|
1687
|
+
const currentAccountId = currentAuth?.[target.provider] ? currentAuth[target.provider].accountId : void 0;
|
|
1688
|
+
const newAuth = {};
|
|
1689
|
+
newAuth[target.provider] = credentialToAuthEntry(target.credentials);
|
|
1690
|
+
const dir = path.dirname(authPath);
|
|
1691
|
+
await promises.mkdir(dir, { recursive: true });
|
|
1692
|
+
const tmpPath = `${authPath}.tmp`;
|
|
1693
|
+
await promises.writeFile(tmpPath, JSON.stringify(newAuth, null, 2), "utf-8");
|
|
1694
|
+
await promises.rename(tmpPath, authPath);
|
|
1695
|
+
log?.info(
|
|
1696
|
+
{
|
|
1697
|
+
from: currentAccountId,
|
|
1698
|
+
to: target.credentials.type === "oauth" ? target.credentials.accountId : "(api-key)",
|
|
1699
|
+
profileId: target.id,
|
|
1700
|
+
provider: target.provider
|
|
1701
|
+
},
|
|
1702
|
+
"Switched active profile"
|
|
1703
|
+
);
|
|
1704
|
+
return {
|
|
1705
|
+
success: true,
|
|
1706
|
+
from: currentAccountId ?? void 0,
|
|
1707
|
+
to: target.id
|
|
1708
|
+
};
|
|
1709
|
+
};
|
|
1710
|
+
var switchToNextProfile = async (options) => {
|
|
1711
|
+
const profPath = options.profilesPath ?? PROFILES_PATH;
|
|
1712
|
+
const log = options.logger?.child({ component: "profile-switcher" });
|
|
1713
|
+
const store = await loadProfiles(profPath);
|
|
1714
|
+
const excludeSet = new Set(options.excludeProfileIds ?? []);
|
|
1715
|
+
if (options.currentProfileId) {
|
|
1716
|
+
excludeSet.add(options.currentProfileId);
|
|
1717
|
+
}
|
|
1718
|
+
const candidates = Object.values(store).filter((p) => p.provider === options.provider && !excludeSet.has(p.id)).sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
|
1719
|
+
if (candidates.length === 0) {
|
|
1720
|
+
log?.warn({ provider: options.provider, excluded: [...excludeSet] }, "No available profiles to switch to");
|
|
1721
|
+
return {
|
|
1722
|
+
success: false,
|
|
1723
|
+
to: options.currentProfileId ?? "none",
|
|
1724
|
+
reason: "no_candidates"
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const next = candidates[0];
|
|
1728
|
+
return switchProfile({
|
|
1729
|
+
targetProfileId: next.id,
|
|
1730
|
+
profilesPath: profPath,
|
|
1731
|
+
authJsonPath: options.authJsonPath,
|
|
1732
|
+
logger: options.logger
|
|
1733
|
+
});
|
|
1734
|
+
};
|
|
1735
|
+
var { schema: z4 } = tool.tool;
|
|
1563
1736
|
var createProfileTools = (options) => ({
|
|
1564
1737
|
profilesList: tool.tool({
|
|
1565
1738
|
description: "List all saved auth profiles with provider, type, and creation date.",
|
|
@@ -1578,7 +1751,7 @@ var createProfileTools = (options) => ({
|
|
|
1578
1751
|
}),
|
|
1579
1752
|
profilesRemove: tool.tool({
|
|
1580
1753
|
description: "Remove a saved auth profile by ID.",
|
|
1581
|
-
args: { id:
|
|
1754
|
+
args: { id: z4.string().min(1) },
|
|
1582
1755
|
async execute(args) {
|
|
1583
1756
|
const store = await loadProfiles(options?.profilesPath);
|
|
1584
1757
|
const { store: newStore, removed } = removeProfile(store, args.id);
|
|
@@ -1632,6 +1805,7 @@ exports.createConcurrencyTracker = createConcurrencyTracker;
|
|
|
1632
1805
|
exports.createCooldownManager = createCooldownManager;
|
|
1633
1806
|
exports.createFailoverOrchestrator = createFailoverOrchestrator;
|
|
1634
1807
|
exports.createLogSubscriber = createLogSubscriber;
|
|
1808
|
+
exports.createOperatorTools = createOperatorTools;
|
|
1635
1809
|
exports.createProfileTools = createProfileTools;
|
|
1636
1810
|
exports.createRegistry = createRegistry;
|
|
1637
1811
|
exports.createRequestLogger = createRequestLogger;
|
|
@@ -1658,6 +1832,8 @@ exports.removeProfile = removeProfile;
|
|
|
1658
1832
|
exports.resumeTarget = resumeTarget;
|
|
1659
1833
|
exports.saveProfiles = saveProfiles;
|
|
1660
1834
|
exports.selectTarget = selectTarget;
|
|
1835
|
+
exports.switchProfile = switchProfile;
|
|
1836
|
+
exports.switchToNextProfile = switchToNextProfile;
|
|
1661
1837
|
exports.updateHealthScore = updateHealthScore;
|
|
1662
1838
|
exports.updateLatencyEma = updateLatencyEma;
|
|
1663
1839
|
exports.validateConfig = validateConfig;
|
|
@@ -4,10 +4,10 @@ import crypto from 'crypto';
|
|
|
4
4
|
import { EventEmitter } from 'eventemitter3';
|
|
5
5
|
import { CircuitState, ConsecutiveBreaker, CountBreaker, BrokenCircuitError, IsolatedCircuitError, circuitBreaker, handleAll } from 'cockatiel';
|
|
6
6
|
import PQueue from 'p-queue';
|
|
7
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
7
8
|
import { readFile, mkdir, writeFile, rename, watch } from 'fs/promises';
|
|
8
9
|
import { homedir } from 'os';
|
|
9
10
|
import { join, dirname } from 'path';
|
|
10
|
-
import { tool } from '@opencode-ai/plugin/tool';
|
|
11
11
|
|
|
12
12
|
// src/config/defaults.ts
|
|
13
13
|
var DEFAULT_RETRY_BUDGET = 3;
|
|
@@ -1355,6 +1355,78 @@ var reloadConfig = (deps, rawConfig) => {
|
|
|
1355
1355
|
throw err;
|
|
1356
1356
|
}
|
|
1357
1357
|
};
|
|
1358
|
+
var { schema: z3 } = tool;
|
|
1359
|
+
var createOperatorTools = (deps) => ({
|
|
1360
|
+
listTargets: tool({
|
|
1361
|
+
description: "List all routing targets with health scores, states, and circuit breaker status.",
|
|
1362
|
+
args: {},
|
|
1363
|
+
async execute() {
|
|
1364
|
+
deps.logger.info({ op: "listTargets" }, "operator: listTargets");
|
|
1365
|
+
const result = listTargets(deps);
|
|
1366
|
+
return JSON.stringify(result, null, 2);
|
|
1367
|
+
}
|
|
1368
|
+
}),
|
|
1369
|
+
pauseTarget: tool({
|
|
1370
|
+
description: "Pause a target, preventing new requests from being routed to it.",
|
|
1371
|
+
args: { target_id: z3.string().min(1) },
|
|
1372
|
+
async execute(args) {
|
|
1373
|
+
deps.logger.info({ op: "pauseTarget", target_id: args.target_id }, "operator: pauseTarget");
|
|
1374
|
+
const result = pauseTarget(deps, args.target_id);
|
|
1375
|
+
return JSON.stringify(result, null, 2);
|
|
1376
|
+
}
|
|
1377
|
+
}),
|
|
1378
|
+
resumeTarget: tool({
|
|
1379
|
+
description: "Resume a previously paused or disabled target, allowing new requests.",
|
|
1380
|
+
args: { target_id: z3.string().min(1) },
|
|
1381
|
+
async execute(args) {
|
|
1382
|
+
deps.logger.info({ op: "resumeTarget", target_id: args.target_id }, "operator: resumeTarget");
|
|
1383
|
+
const result = resumeTarget(deps, args.target_id);
|
|
1384
|
+
return JSON.stringify(result, null, 2);
|
|
1385
|
+
}
|
|
1386
|
+
}),
|
|
1387
|
+
drainTarget: tool({
|
|
1388
|
+
description: "Drain a target, allowing in-flight requests to complete but preventing new ones.",
|
|
1389
|
+
args: { target_id: z3.string().min(1) },
|
|
1390
|
+
async execute(args) {
|
|
1391
|
+
deps.logger.info({ op: "drainTarget", target_id: args.target_id }, "operator: drainTarget");
|
|
1392
|
+
const result = drainTarget(deps, args.target_id);
|
|
1393
|
+
return JSON.stringify(result, null, 2);
|
|
1394
|
+
}
|
|
1395
|
+
}),
|
|
1396
|
+
disableTarget: tool({
|
|
1397
|
+
description: "Disable a target entirely, removing it from routing.",
|
|
1398
|
+
args: { target_id: z3.string().min(1) },
|
|
1399
|
+
async execute(args) {
|
|
1400
|
+
deps.logger.info(
|
|
1401
|
+
{ op: "disableTarget", target_id: args.target_id },
|
|
1402
|
+
"operator: disableTarget"
|
|
1403
|
+
);
|
|
1404
|
+
const result = disableTarget(deps, args.target_id);
|
|
1405
|
+
return JSON.stringify(result, null, 2);
|
|
1406
|
+
}
|
|
1407
|
+
}),
|
|
1408
|
+
inspectRequest: tool({
|
|
1409
|
+
description: "Inspect a request trace by ID, showing attempts, segments, and outcome.",
|
|
1410
|
+
args: { request_id: z3.string().min(1) },
|
|
1411
|
+
async execute(args) {
|
|
1412
|
+
deps.logger.info(
|
|
1413
|
+
{ op: "inspectRequest", request_id: args.request_id },
|
|
1414
|
+
"operator: inspectRequest"
|
|
1415
|
+
);
|
|
1416
|
+
const result = inspectRequest(deps, args.request_id);
|
|
1417
|
+
return JSON.stringify(result, null, 2);
|
|
1418
|
+
}
|
|
1419
|
+
}),
|
|
1420
|
+
reloadConfig: tool({
|
|
1421
|
+
description: "Reload routing configuration with diff-apply. Validates new config before applying.",
|
|
1422
|
+
args: { config: z3.record(z3.string(), z3.unknown()) },
|
|
1423
|
+
async execute(args) {
|
|
1424
|
+
deps.logger.info({ op: "reloadConfig" }, "operator: reloadConfig");
|
|
1425
|
+
const result = reloadConfig(deps, args.config);
|
|
1426
|
+
return JSON.stringify(result, null, 2);
|
|
1427
|
+
}
|
|
1428
|
+
})
|
|
1429
|
+
});
|
|
1358
1430
|
var PROFILES_DIR = join(homedir(), ".local", "share", "o-switcher");
|
|
1359
1431
|
var PROFILES_PATH = join(PROFILES_DIR, "profiles.json");
|
|
1360
1432
|
var loadProfiles = async (path = PROFILES_PATH) => {
|
|
@@ -1422,6 +1494,8 @@ var nextProfileId = (store, provider) => {
|
|
|
1422
1494
|
return `${provider}-${maxN + 1}`;
|
|
1423
1495
|
};
|
|
1424
1496
|
var AUTH_JSON_PATH = join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
1497
|
+
var AUTH_DIR = join(homedir(), ".local", "share", "opencode");
|
|
1498
|
+
var isAuthFile = (filename) => filename === "auth.json" || /^auth-work\d+\.json$/.test(filename);
|
|
1425
1499
|
var DEBOUNCE_MS = 100;
|
|
1426
1500
|
var readAuthJson = async (path) => {
|
|
1427
1501
|
try {
|
|
@@ -1431,6 +1505,22 @@ var readAuthJson = async (path) => {
|
|
|
1431
1505
|
return {};
|
|
1432
1506
|
}
|
|
1433
1507
|
};
|
|
1508
|
+
var readAllAuthFiles = async (dir) => {
|
|
1509
|
+
const { readdir } = await import('fs/promises');
|
|
1510
|
+
const merged = {};
|
|
1511
|
+
try {
|
|
1512
|
+
const files = await readdir(dir);
|
|
1513
|
+
for (const file of files.filter(isAuthFile)) {
|
|
1514
|
+
const data = await readAuthJson(join(dir, file));
|
|
1515
|
+
for (const [provider, entry] of Object.entries(data)) {
|
|
1516
|
+
const key = `${provider}:${file}`;
|
|
1517
|
+
merged[key] = entry;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
} catch {
|
|
1521
|
+
}
|
|
1522
|
+
return merged;
|
|
1523
|
+
};
|
|
1434
1524
|
var toCredential = (entry) => {
|
|
1435
1525
|
if (entry.type === "oauth" || entry.refresh !== void 0 && entry.access !== void 0) {
|
|
1436
1526
|
return {
|
|
@@ -1452,22 +1542,23 @@ var entriesEqual = (a, b) => {
|
|
|
1452
1542
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1453
1543
|
};
|
|
1454
1544
|
var createAuthWatcher = (options) => {
|
|
1455
|
-
const
|
|
1545
|
+
const authDir = options?.authJsonPath ? dirname(options.authJsonPath) : AUTH_DIR;
|
|
1456
1546
|
const profPath = options?.profilesPath ?? PROFILES_PATH;
|
|
1457
1547
|
const log = options?.logger?.child({ component: "profile-watcher" });
|
|
1458
1548
|
let lastKnownAuth = {};
|
|
1459
1549
|
let abortController = null;
|
|
1460
1550
|
let debounceTimer = null;
|
|
1461
1551
|
const processChange = async () => {
|
|
1462
|
-
const newAuth = await
|
|
1552
|
+
const newAuth = await readAllAuthFiles(authDir);
|
|
1463
1553
|
let store = await loadProfiles(profPath);
|
|
1464
1554
|
let changed = false;
|
|
1465
|
-
for (const [
|
|
1555
|
+
for (const [compositeKey, entry] of Object.entries(newAuth)) {
|
|
1466
1556
|
if (!entry) continue;
|
|
1467
|
-
const
|
|
1557
|
+
const provider = compositeKey.split(":")[0] ?? compositeKey;
|
|
1558
|
+
const previousEntry = lastKnownAuth[compositeKey];
|
|
1468
1559
|
if (previousEntry !== void 0 && !entriesEqual(previousEntry, entry)) {
|
|
1469
1560
|
log?.info(
|
|
1470
|
-
{ provider, action: "credential_changed" },
|
|
1561
|
+
{ provider, source: compositeKey, action: "credential_changed" },
|
|
1471
1562
|
"Credential change detected \u2014 saving previous credential"
|
|
1472
1563
|
);
|
|
1473
1564
|
const prevCredential = toCredential(previousEntry);
|
|
@@ -1477,7 +1568,7 @@ var createAuthWatcher = (options) => {
|
|
|
1477
1568
|
changed = true;
|
|
1478
1569
|
}
|
|
1479
1570
|
} else if (previousEntry === void 0) {
|
|
1480
|
-
log?.info({ provider, action: "new_provider" }, "New
|
|
1571
|
+
log?.info({ provider, source: compositeKey, action: "new_provider" }, "New auth file detected");
|
|
1481
1572
|
const credential = toCredential(entry);
|
|
1482
1573
|
const newStore = addProfile(store, provider, credential);
|
|
1483
1574
|
if (newStore !== store) {
|
|
@@ -1504,32 +1595,32 @@ var createAuthWatcher = (options) => {
|
|
|
1504
1595
|
}, DEBOUNCE_MS);
|
|
1505
1596
|
};
|
|
1506
1597
|
const start = async () => {
|
|
1507
|
-
const currentAuth = await
|
|
1598
|
+
const currentAuth = await readAllAuthFiles(authDir);
|
|
1508
1599
|
const currentStore = await loadProfiles(profPath);
|
|
1509
|
-
log?.info({
|
|
1600
|
+
log?.info({ auth_keys: Object.keys(currentAuth).length }, "Auth watcher started");
|
|
1510
1601
|
if (Object.keys(currentStore).length === 0 && Object.keys(currentAuth).length > 0) {
|
|
1511
1602
|
let store = {};
|
|
1512
|
-
for (const [
|
|
1603
|
+
for (const [compositeKey, entry] of Object.entries(currentAuth)) {
|
|
1513
1604
|
if (!entry) continue;
|
|
1605
|
+
const provider = compositeKey.split(":")[0] ?? compositeKey;
|
|
1514
1606
|
const credential = toCredential(entry);
|
|
1515
1607
|
store = addProfile(store, provider, credential);
|
|
1516
1608
|
}
|
|
1517
1609
|
await saveProfiles(store, profPath);
|
|
1518
1610
|
log?.info(
|
|
1519
1611
|
{ profiles_initialized: Object.keys(store).length },
|
|
1520
|
-
"Initialized profiles from auth
|
|
1612
|
+
"Initialized profiles from auth files"
|
|
1521
1613
|
);
|
|
1522
1614
|
}
|
|
1523
1615
|
lastKnownAuth = currentAuth;
|
|
1524
1616
|
abortController = new AbortController();
|
|
1525
|
-
const parentDir = dirname(authPath);
|
|
1526
1617
|
(async () => {
|
|
1527
1618
|
try {
|
|
1528
|
-
const watcher = watch(
|
|
1619
|
+
const watcher = watch(authDir, {
|
|
1529
1620
|
signal: abortController.signal
|
|
1530
1621
|
});
|
|
1531
1622
|
for await (const event of watcher) {
|
|
1532
|
-
if (event.filename
|
|
1623
|
+
if (event.filename !== null && isAuthFile(event.filename)) {
|
|
1533
1624
|
onFileChange();
|
|
1534
1625
|
}
|
|
1535
1626
|
}
|
|
@@ -1551,7 +1642,89 @@ var createAuthWatcher = (options) => {
|
|
|
1551
1642
|
};
|
|
1552
1643
|
return { start, stop };
|
|
1553
1644
|
};
|
|
1554
|
-
var
|
|
1645
|
+
var credentialToAuthEntry = (cred) => {
|
|
1646
|
+
if (cred.type === "oauth") {
|
|
1647
|
+
return {
|
|
1648
|
+
type: "oauth",
|
|
1649
|
+
refresh: cred.refresh,
|
|
1650
|
+
access: cred.access,
|
|
1651
|
+
expires: cred.expires,
|
|
1652
|
+
accountId: cred.accountId
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
type: "api-key",
|
|
1657
|
+
key: cred.key
|
|
1658
|
+
};
|
|
1659
|
+
};
|
|
1660
|
+
var readCurrentAuth = async (authPath) => {
|
|
1661
|
+
try {
|
|
1662
|
+
const content = await readFile(authPath, "utf-8");
|
|
1663
|
+
return JSON.parse(content);
|
|
1664
|
+
} catch {
|
|
1665
|
+
return void 0;
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
var switchProfile = async (options) => {
|
|
1669
|
+
const profPath = options.profilesPath ?? PROFILES_PATH;
|
|
1670
|
+
const authPath = options.authJsonPath ?? AUTH_JSON_PATH;
|
|
1671
|
+
const log = options.logger?.child({ component: "profile-switcher" });
|
|
1672
|
+
const store = await loadProfiles(profPath);
|
|
1673
|
+
const target = store[options.targetProfileId];
|
|
1674
|
+
if (target === void 0) {
|
|
1675
|
+
log?.warn({ profileId: options.targetProfileId }, "Profile not found");
|
|
1676
|
+
return { success: false, to: options.targetProfileId, reason: "profile_not_found" };
|
|
1677
|
+
}
|
|
1678
|
+
const currentAuth = await readCurrentAuth(authPath);
|
|
1679
|
+
const currentAccountId = currentAuth?.[target.provider] ? currentAuth[target.provider].accountId : void 0;
|
|
1680
|
+
const newAuth = {};
|
|
1681
|
+
newAuth[target.provider] = credentialToAuthEntry(target.credentials);
|
|
1682
|
+
const dir = dirname(authPath);
|
|
1683
|
+
await mkdir(dir, { recursive: true });
|
|
1684
|
+
const tmpPath = `${authPath}.tmp`;
|
|
1685
|
+
await writeFile(tmpPath, JSON.stringify(newAuth, null, 2), "utf-8");
|
|
1686
|
+
await rename(tmpPath, authPath);
|
|
1687
|
+
log?.info(
|
|
1688
|
+
{
|
|
1689
|
+
from: currentAccountId,
|
|
1690
|
+
to: target.credentials.type === "oauth" ? target.credentials.accountId : "(api-key)",
|
|
1691
|
+
profileId: target.id,
|
|
1692
|
+
provider: target.provider
|
|
1693
|
+
},
|
|
1694
|
+
"Switched active profile"
|
|
1695
|
+
);
|
|
1696
|
+
return {
|
|
1697
|
+
success: true,
|
|
1698
|
+
from: currentAccountId ?? void 0,
|
|
1699
|
+
to: target.id
|
|
1700
|
+
};
|
|
1701
|
+
};
|
|
1702
|
+
var switchToNextProfile = async (options) => {
|
|
1703
|
+
const profPath = options.profilesPath ?? PROFILES_PATH;
|
|
1704
|
+
const log = options.logger?.child({ component: "profile-switcher" });
|
|
1705
|
+
const store = await loadProfiles(profPath);
|
|
1706
|
+
const excludeSet = new Set(options.excludeProfileIds ?? []);
|
|
1707
|
+
if (options.currentProfileId) {
|
|
1708
|
+
excludeSet.add(options.currentProfileId);
|
|
1709
|
+
}
|
|
1710
|
+
const candidates = Object.values(store).filter((p) => p.provider === options.provider && !excludeSet.has(p.id)).sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
|
1711
|
+
if (candidates.length === 0) {
|
|
1712
|
+
log?.warn({ provider: options.provider, excluded: [...excludeSet] }, "No available profiles to switch to");
|
|
1713
|
+
return {
|
|
1714
|
+
success: false,
|
|
1715
|
+
to: options.currentProfileId ?? "none",
|
|
1716
|
+
reason: "no_candidates"
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const next = candidates[0];
|
|
1720
|
+
return switchProfile({
|
|
1721
|
+
targetProfileId: next.id,
|
|
1722
|
+
profilesPath: profPath,
|
|
1723
|
+
authJsonPath: options.authJsonPath,
|
|
1724
|
+
logger: options.logger
|
|
1725
|
+
});
|
|
1726
|
+
};
|
|
1727
|
+
var { schema: z4 } = tool;
|
|
1555
1728
|
var createProfileTools = (options) => ({
|
|
1556
1729
|
profilesList: tool({
|
|
1557
1730
|
description: "List all saved auth profiles with provider, type, and creation date.",
|
|
@@ -1570,7 +1743,7 @@ var createProfileTools = (options) => ({
|
|
|
1570
1743
|
}),
|
|
1571
1744
|
profilesRemove: tool({
|
|
1572
1745
|
description: "Remove a saved auth profile by ID.",
|
|
1573
|
-
args: { id:
|
|
1746
|
+
args: { id: z4.string().min(1) },
|
|
1574
1747
|
async execute(args) {
|
|
1575
1748
|
const store = await loadProfiles(options?.profilesPath);
|
|
1576
1749
|
const { store: newStore, removed } = removeProfile(store, args.id);
|
|
@@ -1587,4 +1760,4 @@ var createProfileTools = (options) => ({
|
|
|
1587
1760
|
})
|
|
1588
1761
|
});
|
|
1589
1762
|
|
|
1590
|
-
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, REDACT_PATHS, SwitcherConfigSchema, TARGET_STATES, TargetConfigSchema, TargetRegistry, addProfile, applyConfigDiff, checkHardRejects, computeBackoffMs, computeConfigDiff, computeCooldownMs, computeScore, createAdmissionController, createAuditLogger, createAuthWatcher, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createFailoverOrchestrator, createLogSubscriber, createProfileTools, createRegistry, createRequestLogger, createRequestTraceBuffer, createRetryPolicy, createRoutingEventBus, disableTarget, discoverTargets, discoverTargetsFromProfiles, drainTarget, generateCorrelationId, getExclusionReason, getTargetStateTransition, inspectRequest, isRetryable, listProfiles, listTargets, loadProfiles, nextProfileId, normalizeLatency, pauseTarget, reloadConfig, removeProfile, resumeTarget, saveProfiles, selectTarget, updateHealthScore, updateLatencyEma, validateConfig };
|
|
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, REDACT_PATHS, SwitcherConfigSchema, TARGET_STATES, TargetConfigSchema, TargetRegistry, addProfile, applyConfigDiff, checkHardRejects, computeBackoffMs, computeConfigDiff, computeCooldownMs, computeScore, createAdmissionController, createAuditLogger, createAuthWatcher, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createFailoverOrchestrator, createLogSubscriber, createOperatorTools, createProfileTools, createRegistry, createRequestLogger, createRequestTraceBuffer, createRetryPolicy, createRoutingEventBus, disableTarget, discoverTargets, discoverTargetsFromProfiles, drainTarget, generateCorrelationId, getExclusionReason, getTargetStateTransition, inspectRequest, isRetryable, listProfiles, listTargets, loadProfiles, nextProfileId, normalizeLatency, pauseTarget, reloadConfig, removeProfile, resumeTarget, saveProfiles, selectTarget, switchProfile, switchToNextProfile, updateHealthScore, updateLatencyEma, validateConfig };
|