@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/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { createRequestLogger, generateCorrelationId
|
|
2
|
-
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 } from './chunk-
|
|
1
|
+
import { createRequestLogger, generateCorrelationId } from './chunk-XXH633FY.js';
|
|
2
|
+
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 } from './chunk-XXH633FY.js';
|
|
3
3
|
import { timingSafeEqual } from 'crypto';
|
|
4
|
-
import { tool } from '@opencode-ai/plugin/tool';
|
|
5
4
|
|
|
6
5
|
// src/mode/detection.ts
|
|
7
6
|
var detectDeploymentMode = (hint) => {
|
|
@@ -703,7 +702,12 @@ var createExecutionOrchestrator = (deps) => ({
|
|
|
703
702
|
outcome = "success";
|
|
704
703
|
finalTarget = failoverResult.target_id;
|
|
705
704
|
const attemptValue = failoverResult.value;
|
|
706
|
-
const continuationMode = stitcher.segmentCount() === 0 ? "same_target_resume" :
|
|
705
|
+
const continuationMode = stitcher.segmentCount() === 0 ? "same_target_resume" : determineContinuationMode(
|
|
706
|
+
stitcher.assemble().segments[stitcher.segmentCount() - 1].source_target_id,
|
|
707
|
+
failoverResult.target_id,
|
|
708
|
+
true
|
|
709
|
+
// same model assumption in plugin-only mode
|
|
710
|
+
);
|
|
707
711
|
stitcher.addSegment(attemptValue.buffer, {
|
|
708
712
|
request_id: requestId,
|
|
709
713
|
segment_id: stitcher.segmentCount(),
|
|
@@ -883,77 +887,5 @@ var validateBearerToken = (authHeader, expectedToken) => {
|
|
|
883
887
|
}
|
|
884
888
|
return { authorized: true };
|
|
885
889
|
};
|
|
886
|
-
var { schema: z } = tool;
|
|
887
|
-
var createOperatorTools = (deps) => ({
|
|
888
|
-
listTargets: tool({
|
|
889
|
-
description: "List all routing targets with health scores, states, and circuit breaker status.",
|
|
890
|
-
args: {},
|
|
891
|
-
async execute() {
|
|
892
|
-
deps.logger.info({ op: "listTargets" }, "operator: listTargets");
|
|
893
|
-
const result = listTargets(deps);
|
|
894
|
-
return JSON.stringify(result, null, 2);
|
|
895
|
-
}
|
|
896
|
-
}),
|
|
897
|
-
pauseTarget: tool({
|
|
898
|
-
description: "Pause a target, preventing new requests from being routed to it.",
|
|
899
|
-
args: { target_id: z.string().min(1) },
|
|
900
|
-
async execute(args) {
|
|
901
|
-
deps.logger.info({ op: "pauseTarget", target_id: args.target_id }, "operator: pauseTarget");
|
|
902
|
-
const result = pauseTarget(deps, args.target_id);
|
|
903
|
-
return JSON.stringify(result, null, 2);
|
|
904
|
-
}
|
|
905
|
-
}),
|
|
906
|
-
resumeTarget: tool({
|
|
907
|
-
description: "Resume a previously paused or disabled target, allowing new requests.",
|
|
908
|
-
args: { target_id: z.string().min(1) },
|
|
909
|
-
async execute(args) {
|
|
910
|
-
deps.logger.info({ op: "resumeTarget", target_id: args.target_id }, "operator: resumeTarget");
|
|
911
|
-
const result = resumeTarget(deps, args.target_id);
|
|
912
|
-
return JSON.stringify(result, null, 2);
|
|
913
|
-
}
|
|
914
|
-
}),
|
|
915
|
-
drainTarget: tool({
|
|
916
|
-
description: "Drain a target, allowing in-flight requests to complete but preventing new ones.",
|
|
917
|
-
args: { target_id: z.string().min(1) },
|
|
918
|
-
async execute(args) {
|
|
919
|
-
deps.logger.info({ op: "drainTarget", target_id: args.target_id }, "operator: drainTarget");
|
|
920
|
-
const result = drainTarget(deps, args.target_id);
|
|
921
|
-
return JSON.stringify(result, null, 2);
|
|
922
|
-
}
|
|
923
|
-
}),
|
|
924
|
-
disableTarget: tool({
|
|
925
|
-
description: "Disable a target entirely, removing it from routing.",
|
|
926
|
-
args: { target_id: z.string().min(1) },
|
|
927
|
-
async execute(args) {
|
|
928
|
-
deps.logger.info(
|
|
929
|
-
{ op: "disableTarget", target_id: args.target_id },
|
|
930
|
-
"operator: disableTarget"
|
|
931
|
-
);
|
|
932
|
-
const result = disableTarget(deps, args.target_id);
|
|
933
|
-
return JSON.stringify(result, null, 2);
|
|
934
|
-
}
|
|
935
|
-
}),
|
|
936
|
-
inspectRequest: tool({
|
|
937
|
-
description: "Inspect a request trace by ID, showing attempts, segments, and outcome.",
|
|
938
|
-
args: { request_id: z.string().min(1) },
|
|
939
|
-
async execute(args) {
|
|
940
|
-
deps.logger.info(
|
|
941
|
-
{ op: "inspectRequest", request_id: args.request_id },
|
|
942
|
-
"operator: inspectRequest"
|
|
943
|
-
);
|
|
944
|
-
const result = inspectRequest(deps, args.request_id);
|
|
945
|
-
return JSON.stringify(result, null, 2);
|
|
946
|
-
}
|
|
947
|
-
}),
|
|
948
|
-
reloadConfig: tool({
|
|
949
|
-
description: "Reload routing configuration with diff-apply. Validates new config before applying.",
|
|
950
|
-
args: { config: z.record(z.string(), z.unknown()) },
|
|
951
|
-
async execute(args) {
|
|
952
|
-
deps.logger.info({ op: "reloadConfig" }, "operator: reloadConfig");
|
|
953
|
-
const result = reloadConfig(deps, args.config);
|
|
954
|
-
return JSON.stringify(result, null, 2);
|
|
955
|
-
}
|
|
956
|
-
})
|
|
957
|
-
});
|
|
958
890
|
|
|
959
|
-
export { HEURISTIC_PATTERNS, PROVIDER_PATTERNS, TEMPORAL_QUOTA_PATTERN, classify, createAuditCollector, createExecutionOrchestrator, createModeAdapter,
|
|
891
|
+
export { HEURISTIC_PATTERNS, PROVIDER_PATTERNS, TEMPORAL_QUOTA_PATTERN, classify, createAuditCollector, createExecutionOrchestrator, createModeAdapter, createStreamBuffer, createStreamStitcher, detectDeploymentMode, determineContinuationMode, directSignalFromResponse, extractRetryAfterMs, getModeCapabilities, getSignalFidelity, heuristicSignalFromEvent, validateBearerToken };
|
package/dist/plugin.cjs
CHANGED
|
@@ -2,25 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
|
-
var
|
|
5
|
+
var chunkH72U2MNG_cjs = require('./chunk-H72U2MNG.cjs');
|
|
6
|
+
var promises = require('fs/promises');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var os = require('os');
|
|
9
|
+
|
|
10
|
+
var STATE_DIR = path.join(os.homedir(), ".local", "share", "o-switcher");
|
|
11
|
+
var STATE_FILE = path.join(STATE_DIR, "tui-state.json");
|
|
12
|
+
var STATE_TMP = path.join(STATE_DIR, "tui-state.json.tmp");
|
|
13
|
+
var writeStateAtomic = async (state) => {
|
|
14
|
+
await promises.mkdir(path.dirname(STATE_FILE), { recursive: true });
|
|
15
|
+
const json = JSON.stringify(state);
|
|
16
|
+
await promises.writeFile(STATE_TMP, json, "utf8");
|
|
17
|
+
await promises.rename(STATE_TMP, STATE_FILE);
|
|
18
|
+
};
|
|
19
|
+
var createStateWriter = (debounceMs = 500) => {
|
|
20
|
+
let pending;
|
|
21
|
+
let timer;
|
|
22
|
+
let writePromise = Promise.resolve();
|
|
23
|
+
const doWrite = () => {
|
|
24
|
+
if (!pending) return;
|
|
25
|
+
const snapshot = pending;
|
|
26
|
+
pending = void 0;
|
|
27
|
+
writePromise = writeStateAtomic(snapshot).catch(() => void 0);
|
|
28
|
+
};
|
|
29
|
+
const write = (state) => {
|
|
30
|
+
pending = state;
|
|
31
|
+
if (timer) clearTimeout(timer);
|
|
32
|
+
timer = setTimeout(doWrite, debounceMs);
|
|
33
|
+
};
|
|
34
|
+
const flush = async () => {
|
|
35
|
+
if (timer) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
timer = void 0;
|
|
38
|
+
}
|
|
39
|
+
doWrite();
|
|
40
|
+
await writePromise;
|
|
41
|
+
};
|
|
42
|
+
return { write, flush };
|
|
43
|
+
};
|
|
6
44
|
|
|
7
45
|
// src/plugin.ts
|
|
8
46
|
var initializeSwitcher = (rawConfig) => {
|
|
9
|
-
const config =
|
|
10
|
-
const registry =
|
|
11
|
-
const logger =
|
|
12
|
-
const eventBus =
|
|
47
|
+
const config = chunkH72U2MNG_cjs.validateConfig(rawConfig);
|
|
48
|
+
const registry = chunkH72U2MNG_cjs.createRegistry(config);
|
|
49
|
+
const logger = chunkH72U2MNG_cjs.createAuditLogger();
|
|
50
|
+
const eventBus = chunkH72U2MNG_cjs.createRoutingEventBus();
|
|
13
51
|
const circuitBreakers = /* @__PURE__ */ new Map();
|
|
14
52
|
for (const target of registry.getAllTargets()) {
|
|
15
53
|
circuitBreakers.set(
|
|
16
54
|
target.target_id,
|
|
17
|
-
|
|
55
|
+
chunkH72U2MNG_cjs.createCircuitBreaker(target.target_id, config.circuit_breaker, eventBus)
|
|
18
56
|
);
|
|
19
57
|
}
|
|
20
|
-
const concurrency =
|
|
21
|
-
const cooldownManager =
|
|
22
|
-
const traceBuffer =
|
|
23
|
-
|
|
58
|
+
const concurrency = chunkH72U2MNG_cjs.createConcurrencyTracker(config.concurrency_limit);
|
|
59
|
+
const cooldownManager = chunkH72U2MNG_cjs.createCooldownManager(registry, eventBus);
|
|
60
|
+
const traceBuffer = chunkH72U2MNG_cjs.createRequestTraceBuffer(100);
|
|
61
|
+
chunkH72U2MNG_cjs.createLogSubscriber(eventBus, logger);
|
|
24
62
|
const configRef = {
|
|
25
63
|
current: () => config,
|
|
26
64
|
swap: () => {
|
|
@@ -35,10 +73,52 @@ var initializeSwitcher = (rawConfig) => {
|
|
|
35
73
|
};
|
|
36
74
|
return { config, registry, logger, circuitBreakers, concurrency, cooldownManager, operatorDeps };
|
|
37
75
|
};
|
|
76
|
+
var snapshotState = (state) => {
|
|
77
|
+
if (!state.registry) return void 0;
|
|
78
|
+
const allTargets = state.registry.getAllTargets();
|
|
79
|
+
const activeTarget = allTargets.find((t) => t.state === "Active" && t.enabled);
|
|
80
|
+
const targets = allTargets.map((t) => ({
|
|
81
|
+
target_id: t.target_id,
|
|
82
|
+
provider_id: t.provider_id,
|
|
83
|
+
profile: t.profile,
|
|
84
|
+
state: t.state,
|
|
85
|
+
health_score: t.health_score,
|
|
86
|
+
latency_ema_ms: t.latency_ema_ms,
|
|
87
|
+
enabled: t.enabled
|
|
88
|
+
}));
|
|
89
|
+
return {
|
|
90
|
+
version: 1,
|
|
91
|
+
updated_at: Date.now(),
|
|
92
|
+
active_target_id: activeTarget?.target_id,
|
|
93
|
+
targets
|
|
94
|
+
};
|
|
95
|
+
};
|
|
38
96
|
var server = async (_input) => {
|
|
39
|
-
const logger =
|
|
97
|
+
const logger = chunkH72U2MNG_cjs.createAuditLogger({ level: "info" });
|
|
40
98
|
logger.info("O-Switcher plugin initializing");
|
|
41
99
|
const state = {};
|
|
100
|
+
const stateWriter = createStateWriter();
|
|
101
|
+
const publishTuiState = () => {
|
|
102
|
+
const snapshot = snapshotState(state);
|
|
103
|
+
if (snapshot) stateWriter.write(snapshot);
|
|
104
|
+
};
|
|
105
|
+
const lazyOperatorDeps = {
|
|
106
|
+
get registry() {
|
|
107
|
+
return state.operatorDeps.registry;
|
|
108
|
+
},
|
|
109
|
+
get circuitBreakers() {
|
|
110
|
+
return state.operatorDeps.circuitBreakers;
|
|
111
|
+
},
|
|
112
|
+
get configRef() {
|
|
113
|
+
return state.operatorDeps.configRef;
|
|
114
|
+
},
|
|
115
|
+
get logger() {
|
|
116
|
+
return state.operatorDeps.logger;
|
|
117
|
+
},
|
|
118
|
+
get traceBuffer() {
|
|
119
|
+
return state.operatorDeps.traceBuffer;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
42
122
|
const hooks = {
|
|
43
123
|
/**
|
|
44
124
|
* Config hook: initialize O-Switcher when OpenCode config is loaded.
|
|
@@ -57,17 +137,17 @@ var server = async (_input) => {
|
|
|
57
137
|
rawConfig["targets"] && Array.isArray(rawConfig["targets"]) && rawConfig["targets"].length > 0
|
|
58
138
|
);
|
|
59
139
|
if (!hasExplicitTargets) {
|
|
60
|
-
const profileStore = await
|
|
140
|
+
const profileStore = await chunkH72U2MNG_cjs.loadProfiles().catch(() => ({}));
|
|
61
141
|
const profileKeys = Object.keys(profileStore);
|
|
62
142
|
if (profileKeys.length > 0) {
|
|
63
|
-
const profileTargets =
|
|
143
|
+
const profileTargets = chunkH72U2MNG_cjs.discoverTargetsFromProfiles(profileStore);
|
|
64
144
|
rawConfig["targets"] = profileTargets;
|
|
65
145
|
logger.info(
|
|
66
146
|
{ discovered: profileTargets.length, profiles: profileKeys },
|
|
67
147
|
"Auto-discovered targets from O-Switcher profiles"
|
|
68
148
|
);
|
|
69
149
|
} else if (providerConfig && typeof providerConfig === "object") {
|
|
70
|
-
const discoveredTargets =
|
|
150
|
+
const discoveredTargets = chunkH72U2MNG_cjs.discoverTargets(providerConfig);
|
|
71
151
|
if (discoveredTargets.length === 0) {
|
|
72
152
|
logger.warn("No providers found \u2014 running in passthrough mode");
|
|
73
153
|
return;
|
|
@@ -86,7 +166,8 @@ var server = async (_input) => {
|
|
|
86
166
|
const initialized = initializeSwitcher(rawConfig);
|
|
87
167
|
Object.assign(state, initialized);
|
|
88
168
|
logger.info({ targets: state.registry?.getAllTargets().length }, "O-Switcher initialized");
|
|
89
|
-
|
|
169
|
+
publishTuiState();
|
|
170
|
+
state.authWatcher = chunkH72U2MNG_cjs.createAuthWatcher({ logger });
|
|
90
171
|
await state.authWatcher.start();
|
|
91
172
|
logger.info("Auth watcher started");
|
|
92
173
|
} catch (err) {
|
|
@@ -101,7 +182,7 @@ var server = async (_input) => {
|
|
|
101
182
|
*/
|
|
102
183
|
async "chat.params"(input, output) {
|
|
103
184
|
if (!state.registry) return;
|
|
104
|
-
const requestId =
|
|
185
|
+
const requestId = chunkH72U2MNG_cjs.generateCorrelationId();
|
|
105
186
|
const targets = state.registry.getAllTargets();
|
|
106
187
|
const providerId = input.provider?.info?.id ?? input.provider?.info?.name ?? input.model?.providerID ?? void 0;
|
|
107
188
|
if (!providerId) return;
|
|
@@ -157,16 +238,36 @@ var server = async (_input) => {
|
|
|
157
238
|
}
|
|
158
239
|
state.registry.recordObservation(target.target_id, 0);
|
|
159
240
|
state.circuitBreakers?.get(target.target_id)?.recordFailure();
|
|
241
|
+
publishTuiState();
|
|
160
242
|
state.logger?.info(
|
|
161
243
|
{ target_id: target.target_id, event_type: event.type },
|
|
162
244
|
"Recorded failure from session event"
|
|
163
245
|
);
|
|
246
|
+
if (target.health_score < 0.3) {
|
|
247
|
+
const unhealthyIds = matchingTargets.filter((t) => t.health_score < 0.3).map((t) => t.target_id);
|
|
248
|
+
chunkH72U2MNG_cjs.switchToNextProfile({
|
|
249
|
+
provider: providerId,
|
|
250
|
+
currentProfileId: target.target_id,
|
|
251
|
+
excludeProfileIds: unhealthyIds,
|
|
252
|
+
logger: state.logger
|
|
253
|
+
}).then((result) => {
|
|
254
|
+
if (result.success) {
|
|
255
|
+
state.logger?.info(
|
|
256
|
+
{ from: result.from, to: result.to, provider: providerId },
|
|
257
|
+
"Auto-switched to next profile after health drop"
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}).catch((err) => {
|
|
261
|
+
state.logger?.warn({ err }, "Failed to auto-switch profile");
|
|
262
|
+
});
|
|
263
|
+
}
|
|
164
264
|
}
|
|
165
265
|
}
|
|
166
266
|
}
|
|
167
267
|
},
|
|
168
268
|
tool: {
|
|
169
|
-
...
|
|
269
|
+
...chunkH72U2MNG_cjs.createProfileTools(),
|
|
270
|
+
...chunkH72U2MNG_cjs.createOperatorTools(lazyOperatorDeps)
|
|
170
271
|
}
|
|
171
272
|
};
|
|
172
273
|
return hooks;
|
package/dist/plugin.d.cts
CHANGED
|
@@ -10,7 +10,7 @@ export { PluginInput } from '@opencode-ai/plugin';
|
|
|
10
10
|
* - tool definitions: 7 operator commands (list, pause, resume, drain, disable, inspect, reload)
|
|
11
11
|
*
|
|
12
12
|
* Install via opencode.json:
|
|
13
|
-
* "plugin": ["@
|
|
13
|
+
* "plugin": ["@albinocrabs/o-switcher@latest"]
|
|
14
14
|
*
|
|
15
15
|
* Or local dev:
|
|
16
16
|
* "plugin": ["/path/to/o-switcher"]
|
package/dist/plugin.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { PluginInput } from '@opencode-ai/plugin';
|
|
|
10
10
|
* - tool definitions: 7 operator commands (list, pause, resume, drain, disable, inspect, reload)
|
|
11
11
|
*
|
|
12
12
|
* Install via opencode.json:
|
|
13
|
-
* "plugin": ["@
|
|
13
|
+
* "plugin": ["@albinocrabs/o-switcher@latest"]
|
|
14
14
|
*
|
|
15
15
|
* Or local dev:
|
|
16
16
|
* "plugin": ["/path/to/o-switcher"]
|
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
|
-
import { createAuditLogger, createProfileTools, generateCorrelationId, loadProfiles, discoverTargetsFromProfiles, discoverTargets, createAuthWatcher, validateConfig, createRegistry, createRoutingEventBus, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createRequestTraceBuffer, createLogSubscriber } from './chunk-
|
|
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
|
+
};
|
|
2
40
|
|
|
3
41
|
// src/plugin.ts
|
|
4
42
|
var initializeSwitcher = (rawConfig) => {
|
|
@@ -31,10 +69,52 @@ var initializeSwitcher = (rawConfig) => {
|
|
|
31
69
|
};
|
|
32
70
|
return { config, registry, logger, circuitBreakers, concurrency, cooldownManager, operatorDeps };
|
|
33
71
|
};
|
|
72
|
+
var snapshotState = (state) => {
|
|
73
|
+
if (!state.registry) return void 0;
|
|
74
|
+
const allTargets = state.registry.getAllTargets();
|
|
75
|
+
const activeTarget = allTargets.find((t) => t.state === "Active" && t.enabled);
|
|
76
|
+
const targets = allTargets.map((t) => ({
|
|
77
|
+
target_id: t.target_id,
|
|
78
|
+
provider_id: t.provider_id,
|
|
79
|
+
profile: t.profile,
|
|
80
|
+
state: t.state,
|
|
81
|
+
health_score: t.health_score,
|
|
82
|
+
latency_ema_ms: t.latency_ema_ms,
|
|
83
|
+
enabled: t.enabled
|
|
84
|
+
}));
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
updated_at: Date.now(),
|
|
88
|
+
active_target_id: activeTarget?.target_id,
|
|
89
|
+
targets
|
|
90
|
+
};
|
|
91
|
+
};
|
|
34
92
|
var server = async (_input) => {
|
|
35
93
|
const logger = createAuditLogger({ level: "info" });
|
|
36
94
|
logger.info("O-Switcher plugin initializing");
|
|
37
95
|
const state = {};
|
|
96
|
+
const stateWriter = createStateWriter();
|
|
97
|
+
const publishTuiState = () => {
|
|
98
|
+
const snapshot = snapshotState(state);
|
|
99
|
+
if (snapshot) stateWriter.write(snapshot);
|
|
100
|
+
};
|
|
101
|
+
const lazyOperatorDeps = {
|
|
102
|
+
get registry() {
|
|
103
|
+
return state.operatorDeps.registry;
|
|
104
|
+
},
|
|
105
|
+
get circuitBreakers() {
|
|
106
|
+
return state.operatorDeps.circuitBreakers;
|
|
107
|
+
},
|
|
108
|
+
get configRef() {
|
|
109
|
+
return state.operatorDeps.configRef;
|
|
110
|
+
},
|
|
111
|
+
get logger() {
|
|
112
|
+
return state.operatorDeps.logger;
|
|
113
|
+
},
|
|
114
|
+
get traceBuffer() {
|
|
115
|
+
return state.operatorDeps.traceBuffer;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
38
118
|
const hooks = {
|
|
39
119
|
/**
|
|
40
120
|
* Config hook: initialize O-Switcher when OpenCode config is loaded.
|
|
@@ -82,6 +162,7 @@ var server = async (_input) => {
|
|
|
82
162
|
const initialized = initializeSwitcher(rawConfig);
|
|
83
163
|
Object.assign(state, initialized);
|
|
84
164
|
logger.info({ targets: state.registry?.getAllTargets().length }, "O-Switcher initialized");
|
|
165
|
+
publishTuiState();
|
|
85
166
|
state.authWatcher = createAuthWatcher({ logger });
|
|
86
167
|
await state.authWatcher.start();
|
|
87
168
|
logger.info("Auth watcher started");
|
|
@@ -153,16 +234,36 @@ var server = async (_input) => {
|
|
|
153
234
|
}
|
|
154
235
|
state.registry.recordObservation(target.target_id, 0);
|
|
155
236
|
state.circuitBreakers?.get(target.target_id)?.recordFailure();
|
|
237
|
+
publishTuiState();
|
|
156
238
|
state.logger?.info(
|
|
157
239
|
{ target_id: target.target_id, event_type: event.type },
|
|
158
240
|
"Recorded failure from session event"
|
|
159
241
|
);
|
|
242
|
+
if (target.health_score < 0.3) {
|
|
243
|
+
const unhealthyIds = matchingTargets.filter((t) => t.health_score < 0.3).map((t) => t.target_id);
|
|
244
|
+
switchToNextProfile({
|
|
245
|
+
provider: providerId,
|
|
246
|
+
currentProfileId: target.target_id,
|
|
247
|
+
excludeProfileIds: unhealthyIds,
|
|
248
|
+
logger: state.logger
|
|
249
|
+
}).then((result) => {
|
|
250
|
+
if (result.success) {
|
|
251
|
+
state.logger?.info(
|
|
252
|
+
{ from: result.from, to: result.to, provider: providerId },
|
|
253
|
+
"Auto-switched to next profile after health drop"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}).catch((err) => {
|
|
257
|
+
state.logger?.warn({ err }, "Failed to auto-switch profile");
|
|
258
|
+
});
|
|
259
|
+
}
|
|
160
260
|
}
|
|
161
261
|
}
|
|
162
262
|
}
|
|
163
263
|
},
|
|
164
264
|
tool: {
|
|
165
|
-
...createProfileTools()
|
|
265
|
+
...createProfileTools(),
|
|
266
|
+
...createOperatorTools(lazyOperatorDeps)
|
|
166
267
|
}
|
|
167
268
|
};
|
|
168
269
|
return hooks;
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@albinocrabs/o-switcher",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Seamless OpenRouter profile rotation for OpenCode — buy multiple subscriptions, use as one pool",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/apolenkov/o-switcher.git"
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
],
|
|
21
21
|
"files": [
|
|
22
22
|
"dist",
|
|
23
|
+
"src/tui.tsx",
|
|
24
|
+
"src/state-bridge.ts",
|
|
25
|
+
"src/registry/types.ts",
|
|
23
26
|
"README.md",
|
|
24
27
|
"LICENSE",
|
|
25
28
|
"CHANGELOG.md"
|
|
@@ -47,7 +50,8 @@
|
|
|
47
50
|
"types": "./dist/index.d.cts",
|
|
48
51
|
"default": "./dist/index.cjs"
|
|
49
52
|
}
|
|
50
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"./tui": "./src/tui.tsx"
|
|
51
55
|
},
|
|
52
56
|
"scripts": {
|
|
53
57
|
"build": "tsup",
|
|
@@ -56,7 +60,7 @@
|
|
|
56
60
|
"typecheck": "tsc --noEmit",
|
|
57
61
|
"lint": "eslint src/",
|
|
58
62
|
"lint:fix": "eslint src/ --fix",
|
|
59
|
-
"lint:pkg": "publint && attw --pack . --ignore-rules no-resolution",
|
|
63
|
+
"lint:pkg": "publint && attw --pack . --ignore-rules no-resolution --ignore-rules cjs-resolves-to-esm",
|
|
60
64
|
"format:check": "prettier --check \"src/**/*.ts\" \"*.config.*\" \"*.json\" \".prettierrc\"",
|
|
61
65
|
"format": "prettier --write \"src/**/*.ts\" \"*.config.*\" \"*.json\" \".prettierrc\"",
|
|
62
66
|
"test:coverage": "vitest run --coverage",
|
|
@@ -78,8 +82,10 @@
|
|
|
78
82
|
"@changesets/cli": "^2.30.0",
|
|
79
83
|
"@eslint/js": "^10.0.1",
|
|
80
84
|
"@opencode-ai/plugin": "^1.4.3",
|
|
85
|
+
"@opentui/core": "^0.1.97",
|
|
86
|
+
"@opentui/solid": "^0.1.97",
|
|
81
87
|
"@tsconfig/node20": "^20.1.9",
|
|
82
|
-
"@types/node": "^
|
|
88
|
+
"@types/node": "^25",
|
|
83
89
|
"@vitest/coverage-v8": "^4.1.4",
|
|
84
90
|
"eslint": "^10.2.0",
|
|
85
91
|
"pino-pretty": "^13",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target registry types.
|
|
3
|
+
*
|
|
4
|
+
* Defines the TargetEntry interface with all FOUN-02 fields,
|
|
5
|
+
* the TargetState enum, and RegistrySnapshot for read-only access.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: No credential fields (api_key, token, secret, password).
|
|
8
|
+
* provider_id is a pointer to OpenCode's auth store (D-13, SECU-01, SECU-02).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* All valid states for a target in the registry.
|
|
13
|
+
*
|
|
14
|
+
* - Active: healthy and available for routing
|
|
15
|
+
* - CoolingDown: temporarily unavailable after transient failure
|
|
16
|
+
* - ReauthRequired: authentication failure, awaiting credential refresh
|
|
17
|
+
* - PolicyBlocked: blocked by policy (403), no retry
|
|
18
|
+
* - CircuitOpen: circuit breaker tripped, no requests allowed
|
|
19
|
+
* - CircuitHalfOpen: circuit breaker probing with limited requests
|
|
20
|
+
* - Draining: operator-initiated drain, no new requests
|
|
21
|
+
* - Disabled: operator-disabled or config-disabled
|
|
22
|
+
*/
|
|
23
|
+
export const TARGET_STATES = [
|
|
24
|
+
'Active',
|
|
25
|
+
'CoolingDown',
|
|
26
|
+
'ReauthRequired',
|
|
27
|
+
'PolicyBlocked',
|
|
28
|
+
'CircuitOpen',
|
|
29
|
+
'CircuitHalfOpen',
|
|
30
|
+
'Draining',
|
|
31
|
+
'Disabled',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/** Target state type. */
|
|
35
|
+
export type TargetState = (typeof TARGET_STATES)[number];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A single target entry in the registry.
|
|
39
|
+
*
|
|
40
|
+
* Contains all fields per FOUN-02: target_id, provider_id, endpoint_id,
|
|
41
|
+
* capabilities, enabled, state, health_score, cooldown_until, latency_ema_ms,
|
|
42
|
+
* failure_score, operator_priority, policy_tags.
|
|
43
|
+
*
|
|
44
|
+
* NO credential fields -- provider_id maps to OpenCode's credential store.
|
|
45
|
+
*/
|
|
46
|
+
export interface TargetEntry {
|
|
47
|
+
readonly target_id: string;
|
|
48
|
+
readonly provider_id: string;
|
|
49
|
+
readonly profile: string | undefined;
|
|
50
|
+
readonly endpoint_id: string | undefined;
|
|
51
|
+
readonly capabilities: readonly string[];
|
|
52
|
+
readonly enabled: boolean;
|
|
53
|
+
readonly state: TargetState;
|
|
54
|
+
readonly health_score: number;
|
|
55
|
+
readonly cooldown_until: number | null;
|
|
56
|
+
readonly latency_ema_ms: number;
|
|
57
|
+
readonly failure_score: number;
|
|
58
|
+
readonly operator_priority: number;
|
|
59
|
+
readonly policy_tags: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read-only snapshot of the entire registry. */
|
|
63
|
+
export interface RegistrySnapshot {
|
|
64
|
+
readonly targets: ReadonlyArray<Readonly<TargetEntry>>;
|
|
65
|
+
}
|