@albinocrabs/o-switcher 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.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": ["@apolenkov/o-switcher@latest"]
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": ["@apolenkov/o-switcher@latest"]
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,20 +1,42 @@
1
- import {
2
- createAuditLogger,
3
- createAuthWatcher,
4
- createCircuitBreaker,
5
- createConcurrencyTracker,
6
- createCooldownManager,
7
- createLogSubscriber,
8
- createProfileTools,
9
- createRegistry,
10
- createRequestTraceBuffer,
11
- createRoutingEventBus,
12
- discoverTargets,
13
- discoverTargetsFromProfiles,
14
- generateCorrelationId,
15
- loadProfiles,
16
- validateConfig
17
- } from "./chunk-BTDKGS7P.js";
1
+ import { createAuditLogger, createOperatorTools, createProfileTools, generateCorrelationId, loadProfiles, discoverTargetsFromProfiles, discoverTargets, createAuthWatcher, validateConfig, createRegistry, createRoutingEventBus, createCircuitBreaker, createConcurrencyTracker, createCooldownManager, createRequestTraceBuffer, createLogSubscriber } from './chunk-7ITX5623.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
+ };
18
40
 
19
41
  // src/plugin.ts
20
42
  var initializeSwitcher = (rawConfig) => {
@@ -47,10 +69,52 @@ var initializeSwitcher = (rawConfig) => {
47
69
  };
48
70
  return { config, registry, logger, circuitBreakers, concurrency, cooldownManager, operatorDeps };
49
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
+ };
50
92
  var server = async (_input) => {
51
93
  const logger = createAuditLogger({ level: "info" });
52
94
  logger.info("O-Switcher plugin initializing");
53
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
+ };
54
118
  const hooks = {
55
119
  /**
56
120
  * Config hook: initialize O-Switcher when OpenCode config is loaded.
@@ -97,10 +161,8 @@ var server = async (_input) => {
97
161
  try {
98
162
  const initialized = initializeSwitcher(rawConfig);
99
163
  Object.assign(state, initialized);
100
- logger.info(
101
- { targets: state.registry?.getAllTargets().length },
102
- "O-Switcher initialized"
103
- );
164
+ logger.info({ targets: state.registry?.getAllTargets().length }, "O-Switcher initialized");
165
+ publishTuiState();
104
166
  state.authWatcher = createAuthWatcher({ logger });
105
167
  await state.authWatcher.start();
106
168
  logger.info("Auth watcher started");
@@ -120,9 +182,7 @@ var server = async (_input) => {
120
182
  const targets = state.registry.getAllTargets();
121
183
  const providerId = input.provider?.info?.id ?? input.provider?.info?.name ?? input.model?.providerID ?? void 0;
122
184
  if (!providerId) return;
123
- const matchingTargets = targets.filter(
124
- (t) => t.provider_id === providerId && t.enabled
125
- );
185
+ const matchingTargets = targets.filter((t) => t.provider_id === providerId && t.enabled);
126
186
  if (matchingTargets.length === 0) return;
127
187
  const activeTarget = matchingTargets.reduce(
128
188
  (best, t) => t.health_score > best.health_score ? t : best
@@ -135,7 +195,11 @@ var server = async (_input) => {
135
195
  output.maxOutputTokens = Math.min(output.maxOutputTokens, 4096);
136
196
  }
137
197
  state.logger?.info(
138
- { request_id: requestId, target_id: activeTarget.target_id, health: activeTarget.health_score },
198
+ {
199
+ request_id: requestId,
200
+ target_id: activeTarget.target_id,
201
+ health: activeTarget.health_score
202
+ },
139
203
  "Adjusted params for degraded target"
140
204
  );
141
205
  }
@@ -153,21 +217,24 @@ var server = async (_input) => {
153
217
  if (error && "providerID" in error) {
154
218
  const providerId = error.providerID;
155
219
  if (providerId) {
156
- const matchingTargets = state.registry.getAllTargets().filter(
157
- (t) => t.provider_id === providerId
158
- );
220
+ const matchingTargets = state.registry.getAllTargets().filter((t) => t.provider_id === providerId);
159
221
  if (matchingTargets.length === 0) return;
160
222
  const target = matchingTargets.length === 1 ? matchingTargets[0] : matchingTargets.reduce(
161
223
  (worst, t) => t.health_score < worst.health_score ? t : worst
162
224
  );
163
225
  if (matchingTargets.length > 1) {
164
226
  state.logger?.warn(
165
- { provider_id: providerId, attributed_target: target.target_id, profile_count: matchingTargets.length },
227
+ {
228
+ provider_id: providerId,
229
+ attributed_target: target.target_id,
230
+ profile_count: matchingTargets.length
231
+ },
166
232
  "Multiple profiles for provider \u2014 error attributed to worst-health target (plugin-only mode limitation)"
167
233
  );
168
234
  }
169
235
  state.registry.recordObservation(target.target_id, 0);
170
236
  state.circuitBreakers?.get(target.target_id)?.recordFailure();
237
+ publishTuiState();
171
238
  state.logger?.info(
172
239
  { target_id: target.target_id, event_type: event.type },
173
240
  "Recorded failure from session event"
@@ -177,7 +244,8 @@ var server = async (_input) => {
177
244
  }
178
245
  },
179
246
  tool: {
180
- ...createProfileTools()
247
+ ...createProfileTools(),
248
+ ...createOperatorTools(lazyOperatorDeps)
181
249
  }
182
250
  };
183
251
  return hooks;
@@ -187,8 +255,5 @@ var pluginModule = {
187
255
  server
188
256
  };
189
257
  var plugin_default = pluginModule;
190
- export {
191
- plugin_default as default,
192
- server
193
- };
194
- //# sourceMappingURL=plugin.js.map
258
+
259
+ export { plugin_default as default, server };
package/package.json CHANGED
@@ -1,42 +1,73 @@
1
1
  {
2
2
  "name": "@albinocrabs/o-switcher",
3
- "version": "0.1.0",
4
- "description": "Routing and execution resilience layer for OpenCode",
3
+ "version": "0.2.0",
4
+ "description": "Seamless OpenRouter profile rotation for OpenCode — buy multiple subscriptions, use as one pool",
5
5
  "type": "module",
6
- "license": "MIT",
6
+ "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/apolenkov/o-switcher.git"
10
10
  },
11
11
  "keywords": [
12
12
  "opencode",
13
+ "openrouter",
13
14
  "llm",
14
- "routing",
15
+ "profile-rotation",
16
+ "quota-pool",
15
17
  "resilience",
16
- "circuit-breaker",
17
18
  "failover",
18
19
  "retry"
19
20
  ],
21
+ "files": [
22
+ "dist",
23
+ "src/tui.tsx",
24
+ "src/state-bridge.ts",
25
+ "src/registry/types.ts",
26
+ "README.md",
27
+ "LICENSE",
28
+ "CHANGELOG.md"
29
+ ],
20
30
  "main": "dist/plugin.js",
21
31
  "module": "dist/plugin.js",
22
32
  "types": "dist/plugin.d.ts",
23
33
  "exports": {
24
34
  ".": {
25
- "import": "./dist/plugin.js",
26
- "require": "./dist/plugin.cjs",
27
- "types": "./dist/plugin.d.ts"
35
+ "import": {
36
+ "types": "./dist/plugin.d.ts",
37
+ "default": "./dist/plugin.js"
38
+ },
39
+ "require": {
40
+ "types": "./dist/plugin.d.cts",
41
+ "default": "./dist/plugin.cjs"
42
+ }
28
43
  },
29
44
  "./api": {
30
- "import": "./dist/index.js",
31
- "require": "./dist/index.cjs",
32
- "types": "./dist/index.d.ts"
33
- }
45
+ "import": {
46
+ "types": "./dist/index.d.ts",
47
+ "default": "./dist/index.js"
48
+ },
49
+ "require": {
50
+ "types": "./dist/index.d.cts",
51
+ "default": "./dist/index.cjs"
52
+ }
53
+ },
54
+ "./tui": "./src/tui.tsx"
34
55
  },
35
56
  "scripts": {
36
57
  "build": "tsup",
37
58
  "test": "vitest run",
38
59
  "test:watch": "vitest",
39
- "typecheck": "tsc --noEmit"
60
+ "typecheck": "tsc --noEmit",
61
+ "lint": "eslint src/",
62
+ "lint:fix": "eslint src/ --fix",
63
+ "lint:pkg": "publint && attw --pack . --ignore-rules no-resolution --ignore-rules cjs-resolves-to-esm",
64
+ "format:check": "prettier --check \"src/**/*.ts\" \"*.config.*\" \"*.json\" \".prettierrc\"",
65
+ "format": "prettier --write \"src/**/*.ts\" \"*.config.*\" \"*.json\" \".prettierrc\"",
66
+ "test:coverage": "vitest run --coverage",
67
+ "changeset": "changeset",
68
+ "version-packages": "changeset version",
69
+ "release": "npm run build && npm run lint:pkg && changeset publish",
70
+ "prepublishOnly": "npm run lint && npm run build && npm run lint:pkg"
40
71
  },
41
72
  "dependencies": {
42
73
  "cockatiel": "^3.2.1",
@@ -46,12 +77,23 @@
46
77
  "zod": "^4.3.6"
47
78
  },
48
79
  "devDependencies": {
80
+ "@arethetypeswrong/cli": "^0.18.2",
81
+ "@changesets/changelog-github": "^0.6.0",
82
+ "@changesets/cli": "^2.30.0",
83
+ "@eslint/js": "^10.0.1",
49
84
  "@opencode-ai/plugin": "^1.4.3",
85
+ "@opentui/core": "^0.1.97",
86
+ "@opentui/solid": "^0.1.97",
50
87
  "@tsconfig/node20": "^20.1.9",
51
- "@types/node": "^22",
88
+ "@types/node": "^25",
89
+ "@vitest/coverage-v8": "^4.1.4",
90
+ "eslint": "^10.2.0",
52
91
  "pino-pretty": "^13",
92
+ "prettier": "^3.8.2",
93
+ "publint": "^0.3.18",
53
94
  "tsup": "^8.5.1",
54
95
  "typescript": "^5.4.0",
96
+ "typescript-eslint": "^8.58.1",
55
97
  "vitest": "^4.1.4"
56
98
  },
57
99
  "engines": {
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * State bridge between server plugin and TUI plugin.
3
+ *
4
+ * Server and TUI plugins run in separate contexts (Node.js vs Bun/Solid).
5
+ * This module provides a file-based state bridge:
6
+ * - Server writes a JSON snapshot after each meaningful state change
7
+ * - TUI polls the file to render current state
8
+ *
9
+ * Writes are atomic (tmp → rename) and debounced to avoid thrashing.
10
+ * Reads tolerate missing/corrupt files gracefully.
11
+ */
12
+
13
+ import { writeFile, rename, mkdir } from 'node:fs/promises';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import type { TargetState } from './registry/types.js';
17
+
18
+ // ── Shared types ──────────────────────────────────────────────────
19
+
20
+ /** Compact target summary for TUI display. */
21
+ export interface TuiTargetSummary {
22
+ readonly target_id: string;
23
+ readonly provider_id: string;
24
+ readonly profile: string | undefined;
25
+ readonly state: TargetState;
26
+ readonly health_score: number;
27
+ readonly latency_ema_ms: number;
28
+ readonly enabled: boolean;
29
+ }
30
+
31
+ /** Root state file structure written by server plugin. */
32
+ export interface TuiStateFile {
33
+ readonly version: 1;
34
+ readonly updated_at: number;
35
+ readonly active_target_id: string | undefined;
36
+ readonly targets: readonly TuiTargetSummary[];
37
+ }
38
+
39
+ // ── File path ─────────────────────────────────────────────────────
40
+
41
+ const STATE_DIR = join(homedir(), '.local', 'share', 'o-switcher');
42
+ const STATE_FILE = join(STATE_DIR, 'tui-state.json');
43
+ const STATE_TMP = join(STATE_DIR, 'tui-state.json.tmp');
44
+
45
+ /** Resolved path to the TUI state file. Exposed for tests and TUI reader. */
46
+ export const TUI_STATE_PATH = STATE_FILE;
47
+
48
+ // ── Writer (server side) ──────────────────────────────────────────
49
+
50
+ /**
51
+ * Write state atomically: write to .tmp, then rename.
52
+ * Ensures TUI never reads a partial file.
53
+ */
54
+ const writeStateAtomic = async (state: TuiStateFile): Promise<void> => {
55
+ await mkdir(dirname(STATE_FILE), { recursive: true });
56
+ const json = JSON.stringify(state);
57
+ await writeFile(STATE_TMP, json, 'utf8');
58
+ await rename(STATE_TMP, STATE_FILE);
59
+ };
60
+
61
+ /**
62
+ * Create a debounced state writer.
63
+ *
64
+ * Collapses rapid state changes into a single file write.
65
+ * Returns a `write(state)` function and a `flush()` for shutdown.
66
+ */
67
+ export const createStateWriter = (debounceMs = 500): {
68
+ write: (state: TuiStateFile) => void;
69
+ flush: () => Promise<void>;
70
+ } => {
71
+ let pending: TuiStateFile | undefined;
72
+ let timer: ReturnType<typeof setTimeout> | undefined;
73
+ let writePromise: Promise<void> = Promise.resolve();
74
+
75
+ const doWrite = (): void => {
76
+ if (!pending) return;
77
+ const snapshot = pending;
78
+ pending = undefined;
79
+ writePromise = writeStateAtomic(snapshot).catch(() => undefined);
80
+ };
81
+
82
+ const write = (state: TuiStateFile): void => {
83
+ pending = state;
84
+ if (timer) clearTimeout(timer);
85
+ timer = setTimeout(doWrite, debounceMs);
86
+ };
87
+
88
+ const flush = async (): Promise<void> => {
89
+ if (timer) {
90
+ clearTimeout(timer);
91
+ timer = undefined;
92
+ }
93
+ doWrite();
94
+ await writePromise;
95
+ };
96
+
97
+ return { write, flush };
98
+ };
99
+
100
+ // ── Reader (TUI side) ─────────────────────────────────────────────
101
+
102
+ /**
103
+ * Read the TUI state file. Returns undefined if missing or corrupt.
104
+ *
105
+ * Uses dynamic import for readFile to avoid bundling it into the server
106
+ * plugin (where only the writer is needed). This eliminates tree-shaking
107
+ * warnings from tsup.
108
+ */
109
+ export const readTuiState = async (): Promise<TuiStateFile | undefined> => {
110
+ try {
111
+ const { readFile } = await import('node:fs/promises');
112
+ const raw = await readFile(STATE_FILE, 'utf8');
113
+ const parsed = JSON.parse(raw) as TuiStateFile;
114
+ if (parsed.version !== 1) return undefined;
115
+ return parsed;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ };