o-switcher 0.1.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.
@@ -0,0 +1,316 @@
1
+ # Getting Started
2
+
3
+ ## Installation
4
+
5
+ ### As OpenCode Plugin (recommended)
6
+
7
+ Add to your `opencode.json`:
8
+
9
+ ```json
10
+ {
11
+ "plugin": [
12
+ "@apolenkov/o-switcher@latest"
13
+ ]
14
+ }
15
+ ```
16
+
17
+ ### Local Development
18
+
19
+ Clone and link locally:
20
+
21
+ ```bash
22
+ git clone https://github.com/apolenkov/o-switcher.git
23
+ cd o-switcher
24
+ npm install
25
+ npm run build
26
+ ```
27
+
28
+ In your project's `opencode.json`:
29
+
30
+ ```json
31
+ {
32
+ "plugin": [
33
+ "/path/to/o-switcher"
34
+ ]
35
+ }
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ O-Switcher auto-discovers all configured providers from your OpenCode config. No target configuration needed.
41
+
42
+ ### Minimal Config (zero targets)
43
+
44
+ Just add the plugin. O-Switcher reads your providers and creates targets automatically:
45
+
46
+ ```json
47
+ {
48
+ "plugin": ["@apolenkov/o-switcher@latest"]
49
+ }
50
+ ```
51
+
52
+ ### Auto-Discovery
53
+
54
+ When O-Switcher starts, it reads the `provider` section of your OpenCode config. Each provider entry becomes a target automatically with these defaults:
55
+
56
+ - `capabilities`: `["chat"]`
57
+ - `enabled`: `true`
58
+ - `operator_priority`: `0` (all targets start equal)
59
+ - `policy_tags`: `[]`
60
+
61
+ The routing engine differentiates via health scores over time. If one provider starts returning errors, traffic shifts to healthier providers.
62
+
63
+ **Optional retry and timeout:**
64
+
65
+ ```json
66
+ {
67
+ "plugin": ["@apolenkov/o-switcher@latest"],
68
+ "switcher": {
69
+ "retry": 3,
70
+ "timeout": 30000
71
+ }
72
+ }
73
+ ```
74
+
75
+ - `retry` — max retry attempts per request (default: 3)
76
+ - `timeout` — max expected latency in ms (default: 30000)
77
+
78
+ ### Multiple API Keys for One Provider
79
+
80
+ Register the same provider multiple times with different API keys in your `opencode.json`. O-Switcher treats each as a separate target and distributes load across them:
81
+
82
+ ```json
83
+ {
84
+ "provider": {
85
+ "openai-work": { "api": "openai", "apiKey": "sk-work-key-111" },
86
+ "openai-personal": { "api": "openai", "apiKey": "sk-personal-222" },
87
+ "openai-backup": { "api": "openai", "apiKey": "sk-backup-333" }
88
+ },
89
+ "plugin": ["@apolenkov/o-switcher@latest"]
90
+ }
91
+ ```
92
+
93
+ When one key hits rate limits, O-Switcher automatically switches to the next key — same model, different credentials. This is the primary use case for running multiple targets.
94
+
95
+ **Managing keys via CLI:**
96
+
97
+ ```bash
98
+ opencode providers list # see all configured providers and credentials
99
+ opencode providers login # add a new provider/key
100
+ opencode providers logout # remove a provider/key
101
+ ```
102
+
103
+ ### Manual Target Configuration (Advanced)
104
+
105
+ For full control, specify targets explicitly. When `targets` is present, auto-discovery is disabled:
106
+
107
+ ```json
108
+ {
109
+ "plugin": ["@apolenkov/o-switcher@latest"],
110
+ "switcher": {
111
+ "targets": [
112
+ {
113
+ "target_id": "anthropic-main",
114
+ "provider_id": "anthropic",
115
+ "capabilities": ["chat"],
116
+ "enabled": true,
117
+ "operator_priority": 1,
118
+ "policy_tags": []
119
+ },
120
+ {
121
+ "target_id": "openai-backup",
122
+ "provider_id": "openai",
123
+ "capabilities": ["chat"],
124
+ "enabled": true,
125
+ "operator_priority": 0,
126
+ "policy_tags": []
127
+ }
128
+ ]
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### Full Config (all options with defaults shown)
134
+
135
+ ```json
136
+ {
137
+ "switcher": {
138
+ "targets": [
139
+ {
140
+ "target_id": "anthropic-main",
141
+ "provider_id": "anthropic",
142
+ "capabilities": ["chat"],
143
+ "enabled": true,
144
+ "operator_priority": 1,
145
+ "policy_tags": ["primary"],
146
+ "concurrency_limit": 5,
147
+ "circuit_breaker": {
148
+ "failure_threshold": 5,
149
+ "failure_rate_threshold": 0.5,
150
+ "sliding_window_size": 10,
151
+ "half_open_after_ms": 30000,
152
+ "half_open_max_probes": 1,
153
+ "success_threshold": 2
154
+ }
155
+ },
156
+ {
157
+ "target_id": "openai-backup",
158
+ "provider_id": "openai",
159
+ "capabilities": ["chat"],
160
+ "enabled": true,
161
+ "operator_priority": 0,
162
+ "policy_tags": ["backup"]
163
+ }
164
+ ],
165
+ "retry_budget": 3,
166
+ "failover_budget": 2,
167
+ "backoff": {
168
+ "base_ms": 1000,
169
+ "multiplier": 2,
170
+ "max_ms": 30000,
171
+ "jitter": "full"
172
+ },
173
+ "routing_weights": {
174
+ "health": 1.0,
175
+ "latency": 0.5,
176
+ "failure": 0.8,
177
+ "priority": 0.3
178
+ },
179
+ "queue_limit": 100,
180
+ "concurrency_limit": 10,
181
+ "backpressure_threshold": 50,
182
+ "circuit_breaker": {
183
+ "failure_threshold": 5,
184
+ "failure_rate_threshold": 0.5,
185
+ "sliding_window_size": 10,
186
+ "half_open_after_ms": 30000,
187
+ "half_open_max_probes": 1,
188
+ "success_threshold": 2
189
+ },
190
+ "max_expected_latency_ms": 30000,
191
+ "deployment_mode_hint": "auto"
192
+ }
193
+ }
194
+ ```
195
+
196
+ ## What Happens When You Start
197
+
198
+ 1. **OpenCode loads the plugin** from `opencode.json`
199
+ 2. **Config hook fires** — O-Switcher reads the `switcher` config, validates via Zod
200
+ 3. **Registry initialized** — each target gets health score 1.0, circuit breaker Closed
201
+ 4. **Hooks active:**
202
+ - `chat.params` — monitors target health, adjusts params for degraded targets
203
+ - `event` — watches `session.error` events, updates health scores
204
+
205
+ ## How Routing Works
206
+
207
+ ```
208
+ Request arrives
209
+
210
+ Admission Control: is system overloaded?
211
+ ↓ admitted
212
+ Policy Engine: which target is healthiest?
213
+ ↓ target selected (score = health - latency - failures + priority)
214
+ Execute request on target
215
+
216
+ Success? → done, record health +1
217
+ ↓ failure
218
+ Classify error:
219
+ • RateLimited → cooldown + retry (up to 3 times)
220
+ • TransientServerFailure → retry with backoff
221
+ • ModelUnavailable → immediately try next target
222
+ • AuthFailure → abort, mark target as ReauthRequired
223
+ ↓ retry budget exhausted
224
+ Failover to next target (up to 2 failovers)
225
+ ↓ all targets exhausted
226
+ Return error to user
227
+ ```
228
+
229
+ ## Deployment Modes
230
+
231
+ O-Switcher detects how it's running and adjusts capabilities:
232
+
233
+ | Mode | What it means | Failover capability |
234
+ |------|--------------|---------------------|
235
+ | **plugin-only** | Installed as OpenCode plugin | Same-target parameter adjustment only. Cannot switch providers. |
236
+ | **server-companion** | Running as HTTP sidecar server | Full cross-provider failover with direct HTTP status codes |
237
+ | **SDK-control** | Embedded via OpenCode SDK | Full cross-provider failover with direct HTTP status codes |
238
+
239
+ **Most users will use plugin-only mode.** This is the default when you add O-Switcher to `opencode.json`.
240
+
241
+ ## Operator Commands
242
+
243
+ Once running, you have 7 operator commands available as OpenCode tools:
244
+
245
+ | Command | What it does |
246
+ |---------|-------------|
247
+ | `listTargets` | Show all targets with health, state, circuit breaker |
248
+ | `pauseTarget` | Stop routing to a target (can resume later) |
249
+ | `resumeTarget` | Resume a paused target |
250
+ | `drainTarget` | Let in-flight requests finish, reject new ones |
251
+ | `disableTarget` | Fully disable a target |
252
+ | `inspectRequest` | Show full trace for a request by ID |
253
+ | `reloadConfig` | Hot-reload config without restart |
254
+
255
+ ## Error Classes
256
+
257
+ O-Switcher classifies every provider error into one of 10 classes:
258
+
259
+ | Error | What it means | What O-Switcher does |
260
+ |-------|--------------|---------------------|
261
+ | **RateLimited** | Provider says "too many requests" | Cooldown with backoff, respect Retry-After |
262
+ | **QuotaExhausted** | Billing/quota limit reached | Disable target, no retry |
263
+ | **AuthFailure** | Invalid credentials (401) | One recovery attempt, then ReauthRequired |
264
+ | **PermissionFailure** | Forbidden (403) | PolicyBlocked, no retry |
265
+ | **PolicyFailure** | Content policy violation | PolicyBlocked, no retry |
266
+ | **RegionRestriction** | Region not supported | PolicyBlocked, no retry |
267
+ | **ModelUnavailable** | Model overloaded/not found | Immediately failover to next target |
268
+ | **TransientServerFailure** | Server error (500/502/503) | Retry with backoff |
269
+ | **TransportFailure** | Network error, timeout | Retry with backoff |
270
+ | **InterruptedExecution** | Stream cut mid-output | Save confirmed chunks, resume on next target |
271
+
272
+ ## Troubleshooting
273
+
274
+ ### Plugin not loading
275
+
276
+ Check that `opencode.json` has the correct plugin path:
277
+
278
+ ```bash
279
+ # npm package
280
+ "plugin": ["@apolenkov/o-switcher@latest"]
281
+
282
+ # local path (must be absolute or relative to project root)
283
+ "plugin": ["/absolute/path/to/o-switcher"]
284
+ ```
285
+
286
+ ### Config validation error on start
287
+
288
+ O-Switcher validates config strictly. If using manual targets, check:
289
+ - `targets` array is not empty (if provided)
290
+ - Each target has `target_id` and `provider_id`
291
+ - `target_id` values are unique
292
+
293
+ If not using manual targets, O-Switcher auto-discovers from your OpenCode providers. If no providers are configured, it runs in passthrough mode (no routing).
294
+
295
+ The error message will show exactly which field is invalid.
296
+
297
+ ### All targets showing CircuitOpen
298
+
299
+ This means too many failures. Check:
300
+ - Are your API keys valid?
301
+ - Are the providers actually reachable?
302
+ - Use `listTargets` tool to see health scores and states
303
+ - Use `resumeTarget` to manually reset a circuit breaker
304
+
305
+ ### Audit logs
306
+
307
+ O-Switcher logs all routing decisions to stdout as NDJSON (structured JSON, one line per event). Filter by `request_id` to trace a single request:
308
+
309
+ ```bash
310
+ opencode 2>&1 | grep '"request_id":"YOUR-ID"'
311
+ ```
312
+
313
+ ## Next Steps
314
+
315
+ - [Architecture diagrams](architecture.md) — C4 diagrams and sequence flows
316
+ - [Contributing](../CONTRIBUTING.md) — how to develop and contribute
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "o-switcher",
3
+ "version": "0.1.0",
4
+ "description": "Routing and execution resilience layer for OpenCode",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/apolenkov/o-switcher.git"
10
+ },
11
+ "keywords": [
12
+ "opencode",
13
+ "llm",
14
+ "routing",
15
+ "resilience",
16
+ "circuit-breaker",
17
+ "failover",
18
+ "retry"
19
+ ],
20
+ "main": "dist/plugin.js",
21
+ "module": "dist/plugin.js",
22
+ "types": "dist/plugin.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "import": "./dist/plugin.js",
26
+ "require": "./dist/plugin.cjs",
27
+ "types": "./dist/plugin.d.ts"
28
+ },
29
+ "./api": {
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs",
32
+ "types": "./dist/index.d.ts"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "cockatiel": "^3.2.1",
43
+ "eventemitter3": "^5.0.4",
44
+ "p-queue": "^9.1.2",
45
+ "pino": "^10.3.1",
46
+ "zod": "^4.3.6"
47
+ },
48
+ "devDependencies": {
49
+ "@opencode-ai/plugin": "^1.4.3",
50
+ "@tsconfig/node20": "^20.1.9",
51
+ "@types/node": "^22",
52
+ "pino-pretty": "^13",
53
+ "tsup": "^8.5.1",
54
+ "typescript": "^5.4.0",
55
+ "vitest": "^4.1.4"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ }
60
+ }