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.
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/chunk-BTDKGS7P.js +1777 -0
- package/dist/chunk-BTDKGS7P.js.map +1 -0
- package/dist/index.cjs +2582 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2020 -0
- package/dist/index.d.ts +2020 -0
- package/dist/index.js +832 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.cjs +1177 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.d.cts +22 -0
- package/dist/plugin.d.ts +22 -0
- package/dist/plugin.js +194 -0
- package/dist/plugin.js.map +1 -0
- package/docs/api-reference.md +286 -0
- package/docs/architecture.md +511 -0
- package/docs/examples.md +190 -0
- package/docs/getting-started.md +316 -0
- package/package.json +60 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# O-Switcher Architecture
|
|
2
|
+
|
|
3
|
+
## C4 Level 1: System Context
|
|
4
|
+
|
|
5
|
+
Who uses O-Switcher and what it connects to.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
C4Context
|
|
9
|
+
title O-Switcher — System Context
|
|
10
|
+
|
|
11
|
+
Person(developer, "Developer", "Uses OpenCode for AI-assisted coding")
|
|
12
|
+
|
|
13
|
+
System(opencode, "OpenCode Runtime", "AI coding assistant with plugin system")
|
|
14
|
+
System(oswitcher, "O-Switcher", "Routing and execution resilience layer. Routes LLM requests to healthy targets with retry, failover, and circuit breaking")
|
|
15
|
+
|
|
16
|
+
System_Ext(anthropic, "Anthropic API", "Claude models")
|
|
17
|
+
System_Ext(openai, "OpenAI API", "GPT models")
|
|
18
|
+
System_Ext(google, "Google AI API", "Gemini models")
|
|
19
|
+
System_Ext(bedrock, "AWS Bedrock", "Multi-provider gateway")
|
|
20
|
+
|
|
21
|
+
Rel(developer, opencode, "Writes code with", "CLI / IDE")
|
|
22
|
+
Rel(opencode, oswitcher, "Routes LLM requests through", "Plugin hooks / SDK")
|
|
23
|
+
Rel(oswitcher, anthropic, "Sends requests", "HTTPS")
|
|
24
|
+
Rel(oswitcher, openai, "Sends requests", "HTTPS")
|
|
25
|
+
Rel(oswitcher, google, "Sends requests", "HTTPS")
|
|
26
|
+
Rel(oswitcher, bedrock, "Sends requests", "HTTPS")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## C4 Level 2: Container
|
|
30
|
+
|
|
31
|
+
Internal modules of O-Switcher.
|
|
32
|
+
|
|
33
|
+
```mermaid
|
|
34
|
+
C4Container
|
|
35
|
+
title O-Switcher — Containers
|
|
36
|
+
|
|
37
|
+
Person(developer, "Developer")
|
|
38
|
+
System_Ext(opencode, "OpenCode Runtime")
|
|
39
|
+
|
|
40
|
+
Container_Boundary(oswitcher, "O-Switcher") {
|
|
41
|
+
Container(plugin, "Plugin Entry", "TypeScript", "chat.params + event hooks. Entry point for OpenCode plugin system")
|
|
42
|
+
Container(config, "Config & Registry", "Zod 4", "Config validation, target registry with health scores and state")
|
|
43
|
+
Container(errors, "Error Classifier", "TypeScript", "10 error classes, dual-mode: direct HTTP + heuristic events")
|
|
44
|
+
Container(routing, "Routing Engine", "cockatiel + p-queue", "Policy engine, circuit breaker, admission control, failover orchestrator")
|
|
45
|
+
Container(execution, "Execution Layer", "TypeScript", "Mode adapters, stream stitcher, audit collector")
|
|
46
|
+
Container(operator, "Operator Surface", "TypeScript", "7 runtime commands, config reload, server auth")
|
|
47
|
+
Container(audit, "Audit Trail", "pino", "Structured NDJSON logs with request correlation and credential redaction")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
System_Ext(providers, "LLM Providers", "Anthropic, OpenAI, Google, Bedrock")
|
|
51
|
+
|
|
52
|
+
Rel(developer, opencode, "Uses")
|
|
53
|
+
Rel(opencode, plugin, "Loads plugin", "chat.params / event hooks")
|
|
54
|
+
Rel(plugin, config, "Reads config")
|
|
55
|
+
Rel(plugin, routing, "Routes requests")
|
|
56
|
+
Rel(routing, errors, "Classifies failures")
|
|
57
|
+
Rel(routing, execution, "Dispatches to adapter")
|
|
58
|
+
Rel(execution, providers, "HTTP/SSE requests")
|
|
59
|
+
Rel(execution, audit, "Emits audit events")
|
|
60
|
+
Rel(operator, config, "Reloads config")
|
|
61
|
+
Rel(operator, routing, "Pauses/drains targets")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## C4 Level 3: Components — Routing Engine
|
|
65
|
+
|
|
66
|
+
The core decision-making layer.
|
|
67
|
+
|
|
68
|
+
```mermaid
|
|
69
|
+
C4Component
|
|
70
|
+
title O-Switcher — Routing Engine Components
|
|
71
|
+
|
|
72
|
+
Container_Boundary(routing, "Routing Engine") {
|
|
73
|
+
Component(policy, "Policy Engine", "selectTarget()", "Filter-then-score: 7 exclusion types, weighted scoring, deterministic tie-breaking")
|
|
74
|
+
Component(circuit, "Circuit Breaker", "cockatiel wrapper", "Dual-trigger hysteresis: consecutive failures + sliding window rate. Per-target FSM: Closed → Open → HalfOpen")
|
|
75
|
+
Component(admission, "Admission Controller", "p-queue", "3-layer: hard reject → backpressure detection → concurrency gating")
|
|
76
|
+
Component(failover, "Failover Orchestrator", "nested loops", "Outer: failover across targets (budget=2). Inner: retry per target (budget=3). Max 6 attempts")
|
|
77
|
+
Component(cooldown, "Cooldown Manager", "adaptive", "Retry-After priority, error-class-specific duration, 10-25% jitter")
|
|
78
|
+
Component(concurrency, "Concurrency Tracker", "Map-based", "Per-target in-flight count with acquire/release/headroom")
|
|
79
|
+
Component(events, "Event Bus", "eventemitter3", "Typed events: circuit_state_change, target_excluded, health_updated, admission_decision, cooldown_set/expired")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Container_Ext(registry, "Target Registry")
|
|
83
|
+
Container_Ext(classifier, "Error Classifier")
|
|
84
|
+
Container_Ext(executor, "Execution Layer")
|
|
85
|
+
|
|
86
|
+
Rel(admission, policy, "Selects target via")
|
|
87
|
+
Rel(failover, policy, "Re-selects on failover")
|
|
88
|
+
Rel(failover, circuit, "Records success/failure")
|
|
89
|
+
Rel(failover, cooldown, "Sets cooldown on RateLimited")
|
|
90
|
+
Rel(policy, registry, "Reads target state")
|
|
91
|
+
Rel(policy, circuit, "Checks allowRequest()")
|
|
92
|
+
Rel(circuit, events, "Emits state changes")
|
|
93
|
+
Rel(admission, events, "Emits admission decisions")
|
|
94
|
+
Rel(cooldown, events, "Emits cooldown set/expired")
|
|
95
|
+
Rel(failover, classifier, "Classifies errors")
|
|
96
|
+
Rel(failover, executor, "Calls AttemptFn")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## C4 Level 3: Components — Execution Layer
|
|
100
|
+
|
|
101
|
+
Stream handling and audit.
|
|
102
|
+
|
|
103
|
+
```mermaid
|
|
104
|
+
C4Component
|
|
105
|
+
title O-Switcher — Execution Layer Components
|
|
106
|
+
|
|
107
|
+
Container_Boundary(execution, "Execution Layer") {
|
|
108
|
+
Component(orchestrator, "Execution Orchestrator", "createExecutionOrchestrator()", "Wires adapter + failover + stitcher + audit. Produces ExecutionResult with provenance")
|
|
109
|
+
Component(adapters, "Mode Adapters", "plugin / server / SDK", "Three implementations of ModeAdapter interface. Plugin: heuristic detection. Server/SDK: direct HTTP")
|
|
110
|
+
Component(factory, "Adapter Factory", "createAdapterFactory()", "Selects adapter by DeploymentMode detected at startup")
|
|
111
|
+
Component(buffer, "Stream Buffer", "createStreamBuffer()", "Append-only chunk accumulator. Tracks confirmed boundary for resume")
|
|
112
|
+
Component(stitcher, "Stream Stitcher", "createStreamStitcher()", "Multi-segment assembly with provenance metadata and continuation boundaries")
|
|
113
|
+
Component(collector, "Audit Collector", "createAuditCollector()", "Accumulates attempts + segments, flushes to pino NDJSON on request completion")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Container_Ext(failover, "Failover Orchestrator")
|
|
117
|
+
Container_Ext(providers, "LLM Providers")
|
|
118
|
+
Container_Ext(pino, "Pino Logger")
|
|
119
|
+
|
|
120
|
+
Rel(orchestrator, factory, "Gets adapter for mode")
|
|
121
|
+
Rel(orchestrator, failover, "Runs retry-failover loop")
|
|
122
|
+
Rel(orchestrator, stitcher, "Assembles segments")
|
|
123
|
+
Rel(orchestrator, collector, "Records audit trail")
|
|
124
|
+
Rel(adapters, providers, "HTTP/SSE requests")
|
|
125
|
+
Rel(adapters, buffer, "Appends chunks")
|
|
126
|
+
Rel(collector, pino, "Flushes NDJSON")
|
|
127
|
+
Rel(stitcher, buffer, "Reads confirmed chunks")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Sequence: Happy Path (Success on First Target)
|
|
131
|
+
|
|
132
|
+
```mermaid
|
|
133
|
+
sequenceDiagram
|
|
134
|
+
participant OC as OpenCode
|
|
135
|
+
participant PL as Plugin (chat.params)
|
|
136
|
+
participant AD as Admission Controller
|
|
137
|
+
participant PE as Policy Engine
|
|
138
|
+
participant CB as Circuit Breaker
|
|
139
|
+
participant EX as Executor
|
|
140
|
+
participant PR as LLM Provider
|
|
141
|
+
participant AU as Audit Collector
|
|
142
|
+
|
|
143
|
+
OC->>PL: chat.params hook (model, provider, message)
|
|
144
|
+
PL->>AD: admit(request_id, context)
|
|
145
|
+
AD->>AD: checkHardRejects() → pass
|
|
146
|
+
AD->>AD: backpressure check → ok
|
|
147
|
+
AD-->>PL: admitted
|
|
148
|
+
|
|
149
|
+
PL->>PE: selectTarget(snapshot, capabilities)
|
|
150
|
+
PE->>PE: filter: 7 exclusion checks
|
|
151
|
+
PE->>CB: allowRequest(target-1)?
|
|
152
|
+
CB-->>PE: true (Closed)
|
|
153
|
+
PE->>PE: score: w1*health - w2*latency - w3*failure + w4*priority
|
|
154
|
+
PE-->>PL: SelectionRecord {selected: target-1, score: 0.85}
|
|
155
|
+
|
|
156
|
+
PL->>EX: execute(target-1, request)
|
|
157
|
+
EX->>PR: HTTP POST /chat/completions
|
|
158
|
+
PR-->>EX: 200 OK (streaming chunks)
|
|
159
|
+
EX->>EX: StreamBuffer.append(chunks)
|
|
160
|
+
EX-->>PL: success {value, latency_ms: 1200}
|
|
161
|
+
|
|
162
|
+
PL->>CB: recordSuccess()
|
|
163
|
+
PL->>AU: recordAttempt(target-1, success)
|
|
164
|
+
AU->>AU: flush to pino NDJSON
|
|
165
|
+
|
|
166
|
+
PL-->>OC: output (params unchanged, target healthy)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Sequence: Retry + Failover
|
|
170
|
+
|
|
171
|
+
```mermaid
|
|
172
|
+
sequenceDiagram
|
|
173
|
+
participant OC as OpenCode
|
|
174
|
+
participant FO as Failover Orchestrator
|
|
175
|
+
participant PE as Policy Engine
|
|
176
|
+
participant CB as Circuit Breaker
|
|
177
|
+
participant EX as Executor
|
|
178
|
+
participant T1 as Target 1 (Anthropic)
|
|
179
|
+
participant T2 as Target 2 (OpenAI)
|
|
180
|
+
participant CD as Cooldown Manager
|
|
181
|
+
participant AU as Audit
|
|
182
|
+
|
|
183
|
+
OC->>FO: execute(request)
|
|
184
|
+
|
|
185
|
+
Note over FO: Outer loop: failover_no=0
|
|
186
|
+
|
|
187
|
+
FO->>PE: selectTarget(exclude: ∅)
|
|
188
|
+
PE-->>FO: target-1 (score: 0.85)
|
|
189
|
+
|
|
190
|
+
Note over FO: Inner loop: attempt 1/3
|
|
191
|
+
|
|
192
|
+
FO->>EX: attemptFn(target-1)
|
|
193
|
+
EX->>T1: POST /chat
|
|
194
|
+
T1-->>EX: 429 Too Many Requests (Retry-After: 5s)
|
|
195
|
+
EX-->>FO: fail {error_class: RateLimited, retry_after_ms: 5000}
|
|
196
|
+
|
|
197
|
+
FO->>CB: recordFailure(target-1)
|
|
198
|
+
FO->>CD: setCooldown(target-1, 5500ms)
|
|
199
|
+
FO->>AU: recordAttempt(retry)
|
|
200
|
+
|
|
201
|
+
Note over FO: Inner loop: attempt 2/3 (after backoff)
|
|
202
|
+
|
|
203
|
+
FO->>EX: attemptFn(target-1)
|
|
204
|
+
EX->>T1: POST /chat
|
|
205
|
+
T1-->>EX: 429 Too Many Requests
|
|
206
|
+
EX-->>FO: fail {error_class: RateLimited}
|
|
207
|
+
|
|
208
|
+
FO->>CB: recordFailure(target-1)
|
|
209
|
+
FO->>AU: recordAttempt(retry)
|
|
210
|
+
|
|
211
|
+
Note over FO: Inner loop: attempt 3/3
|
|
212
|
+
|
|
213
|
+
FO->>EX: attemptFn(target-1)
|
|
214
|
+
EX->>T1: POST /chat
|
|
215
|
+
T1-->>EX: 429 Too Many Requests
|
|
216
|
+
EX-->>FO: fail {error_class: RateLimited}
|
|
217
|
+
|
|
218
|
+
FO->>CB: recordFailure(target-1)
|
|
219
|
+
FO->>AU: recordAttempt(failover)
|
|
220
|
+
|
|
221
|
+
Note over FO: Retry budget exhausted → FAILOVER<br/>Outer loop: failover_no=1
|
|
222
|
+
|
|
223
|
+
FO->>PE: selectTarget(exclude: {target-1})
|
|
224
|
+
PE-->>FO: target-2 (score: 0.72)
|
|
225
|
+
|
|
226
|
+
Note over FO: Inner loop: attempt 1/3
|
|
227
|
+
|
|
228
|
+
FO->>EX: attemptFn(target-2)
|
|
229
|
+
EX->>T2: POST /chat
|
|
230
|
+
T2-->>EX: 200 OK (streaming)
|
|
231
|
+
EX-->>FO: success {value, latency_ms: 800}
|
|
232
|
+
|
|
233
|
+
FO->>CB: recordSuccess(target-2)
|
|
234
|
+
FO->>AU: recordAttempt(success)
|
|
235
|
+
FO-->>OC: FailoverResult {outcome: success, target: target-2, retries: 3, failovers: 1}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Sequence: Stream Interruption + Resume
|
|
239
|
+
|
|
240
|
+
```mermaid
|
|
241
|
+
sequenceDiagram
|
|
242
|
+
participant FO as Failover Orchestrator
|
|
243
|
+
participant EX as Executor
|
|
244
|
+
participant BF as Stream Buffer
|
|
245
|
+
participant T1 as Target 1
|
|
246
|
+
participant T2 as Target 2
|
|
247
|
+
participant ST as Stream Stitcher
|
|
248
|
+
participant AU as Audit
|
|
249
|
+
|
|
250
|
+
FO->>EX: attemptFn(target-1)
|
|
251
|
+
EX->>T1: POST /chat (streaming)
|
|
252
|
+
|
|
253
|
+
T1-->>EX: chunk 1: "Here is the"
|
|
254
|
+
EX->>BF: append(chunk 1) ✓ confirmed
|
|
255
|
+
T1-->>EX: chunk 2: " implementation"
|
|
256
|
+
EX->>BF: append(chunk 2) ✓ confirmed
|
|
257
|
+
T1-->>EX: chunk 3: " of the"
|
|
258
|
+
EX->>BF: append(chunk 3) ✓ confirmed
|
|
259
|
+
T1--xEX: CONNECTION RESET (stream interrupted)
|
|
260
|
+
|
|
261
|
+
EX-->>FO: fail {error_class: InterruptedExecution}
|
|
262
|
+
|
|
263
|
+
Note over BF: confirmed: "Here is the implementation of the"<br/>confirmedCharCount: 34
|
|
264
|
+
|
|
265
|
+
FO->>ST: addSegment(segment_id=0, buffer, target-1, "same_target_resume")
|
|
266
|
+
FO->>AU: recordAttempt(failover, InterruptedExecution)
|
|
267
|
+
|
|
268
|
+
Note over FO: FAILOVER → target-2
|
|
269
|
+
|
|
270
|
+
FO->>EX: attemptFn(target-2)
|
|
271
|
+
EX->>T2: POST /chat (with context: continue from "...of the")
|
|
272
|
+
T2-->>EX: chunk 4: " sorting algorithm"
|
|
273
|
+
EX->>BF: append(chunk 4) ✓ confirmed
|
|
274
|
+
T2-->>EX: chunk 5: " using quicksort..."
|
|
275
|
+
EX->>BF: append(chunk 5) ✓ confirmed
|
|
276
|
+
T2-->>EX: [DONE]
|
|
277
|
+
EX-->>FO: success
|
|
278
|
+
|
|
279
|
+
FO->>ST: addSegment(segment_id=1, buffer, target-2, "cross_model_continuation")
|
|
280
|
+
|
|
281
|
+
ST->>ST: assemble()
|
|
282
|
+
Note over ST: StitchedOutput:<br/>text: "Here is the implementation of the[→target-2] sorting algorithm using quicksort..."<br/>segments: [{target-1, chars 0-34}, {target-2, chars 34-72}]<br/>continuation_boundaries: [{offset: 34, non_deterministic: true}]
|
|
283
|
+
|
|
284
|
+
FO->>AU: flush(request_id, 2 segments, 2 targets)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Sequence: Circuit Breaker State Transitions
|
|
288
|
+
|
|
289
|
+
```mermaid
|
|
290
|
+
sequenceDiagram
|
|
291
|
+
participant RQ as Requests
|
|
292
|
+
participant CB as Circuit Breaker
|
|
293
|
+
participant DB as DualBreaker
|
|
294
|
+
participant EB as Event Bus
|
|
295
|
+
|
|
296
|
+
Note over CB: State: CLOSED (Active)
|
|
297
|
+
|
|
298
|
+
RQ->>CB: recordFailure()
|
|
299
|
+
CB->>DB: failure(Closed) → false (1/5 consecutive)
|
|
300
|
+
RQ->>CB: recordFailure()
|
|
301
|
+
CB->>DB: failure(Closed) → false (2/5)
|
|
302
|
+
RQ->>CB: recordFailure()
|
|
303
|
+
CB->>DB: failure(Closed) → false (3/5)
|
|
304
|
+
RQ->>CB: recordFailure()
|
|
305
|
+
CB->>DB: failure(Closed) → false (4/5)
|
|
306
|
+
RQ->>CB: recordFailure()
|
|
307
|
+
CB->>DB: failure(Closed) → TRUE (5/5 consecutive threshold!)
|
|
308
|
+
|
|
309
|
+
CB->>EB: emit circuit_state_change {from: Active, to: CircuitOpen}
|
|
310
|
+
|
|
311
|
+
Note over CB: State: OPEN (CircuitOpen)<br/>Rejects all requests for 30s
|
|
312
|
+
|
|
313
|
+
RQ->>CB: allowRequest() → false ❌
|
|
314
|
+
RQ->>CB: allowRequest() → false ❌
|
|
315
|
+
|
|
316
|
+
Note over CB: 30 seconds pass (half_open_after_ms)
|
|
317
|
+
|
|
318
|
+
Note over CB: State: HALF-OPEN (CircuitHalfOpen)<br/>Allows 1 probe request
|
|
319
|
+
|
|
320
|
+
RQ->>CB: allowRequest() → true ✓ (probe)
|
|
321
|
+
RQ->>CB: recordSuccess()
|
|
322
|
+
CB->>DB: success(HalfOpen)
|
|
323
|
+
|
|
324
|
+
CB->>EB: emit circuit_state_change {from: CircuitHalfOpen, to: Active}
|
|
325
|
+
|
|
326
|
+
Note over CB: State: CLOSED (Active)<br/>Normal routing resumed
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Sequence: Admission Control Flow
|
|
330
|
+
|
|
331
|
+
```mermaid
|
|
332
|
+
sequenceDiagram
|
|
333
|
+
participant RQ as Request
|
|
334
|
+
participant AC as Admission Controller
|
|
335
|
+
participant HR as Hard Reject Layer
|
|
336
|
+
participant BP as Backpressure Layer
|
|
337
|
+
participant PQ as p-queue Layer
|
|
338
|
+
|
|
339
|
+
RQ->>AC: admit(request_id, context)
|
|
340
|
+
|
|
341
|
+
AC->>HR: checkHardRejects(context)
|
|
342
|
+
|
|
343
|
+
alt No eligible targets
|
|
344
|
+
HR-->>AC: REJECTED "no eligible targets"
|
|
345
|
+
AC-->>RQ: ❌ rejected
|
|
346
|
+
else Operator paused
|
|
347
|
+
HR-->>AC: REJECTED "operator pause active"
|
|
348
|
+
AC-->>RQ: ❌ rejected
|
|
349
|
+
else Budgets exhausted
|
|
350
|
+
HR-->>AC: REJECTED "retry/failover budget exhausted"
|
|
351
|
+
AC-->>RQ: ❌ rejected
|
|
352
|
+
else All checks pass
|
|
353
|
+
HR-->>AC: null (no hard reject)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
AC->>BP: check queue.size > backpressure_threshold?
|
|
357
|
+
|
|
358
|
+
alt Queue depth > threshold
|
|
359
|
+
BP-->>AC: DEGRADED "backpressure: N pending > threshold"
|
|
360
|
+
AC-->>RQ: ⚠️ degraded (still executes, but signals pressure)
|
|
361
|
+
else Queue normal
|
|
362
|
+
BP-->>AC: ok
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
AC->>PQ: check total capacity
|
|
366
|
+
|
|
367
|
+
alt Queue full (size >= queueLimit + concurrencyLimit)
|
|
368
|
+
PQ-->>AC: REJECTED "queue full"
|
|
369
|
+
AC-->>RQ: ❌ rejected
|
|
370
|
+
else Capacity available
|
|
371
|
+
PQ-->>AC: ADMITTED
|
|
372
|
+
AC-->>RQ: ✅ admitted
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Data Flow: Request Lifecycle
|
|
377
|
+
|
|
378
|
+
```mermaid
|
|
379
|
+
flowchart TD
|
|
380
|
+
A[OpenCode sends request] --> B{Admission Control}
|
|
381
|
+
B -->|rejected| Z[Return error to user]
|
|
382
|
+
B -->|degraded| C[Select Target with degraded flag]
|
|
383
|
+
B -->|admitted| C
|
|
384
|
+
|
|
385
|
+
C --> D[Policy Engine: filter + score]
|
|
386
|
+
D --> E{Eligible targets?}
|
|
387
|
+
E -->|none| Z
|
|
388
|
+
E -->|found| F[Best target selected]
|
|
389
|
+
|
|
390
|
+
F --> G[Execute via Mode Adapter]
|
|
391
|
+
G --> H{Result?}
|
|
392
|
+
|
|
393
|
+
H -->|success| I[Record success on circuit breaker]
|
|
394
|
+
I --> J[Update health score +1]
|
|
395
|
+
J --> K[Flush audit trail]
|
|
396
|
+
K --> L[Return result with provenance]
|
|
397
|
+
|
|
398
|
+
H -->|RateLimited| M[Set adaptive cooldown]
|
|
399
|
+
M --> N{Retry budget left?}
|
|
400
|
+
N -->|yes| O[Backoff + retry same target]
|
|
401
|
+
O --> G
|
|
402
|
+
N -->|no| P{Failover budget left?}
|
|
403
|
+
|
|
404
|
+
H -->|ModelUnavailable| P
|
|
405
|
+
|
|
406
|
+
H -->|TransientServerFailure| N
|
|
407
|
+
|
|
408
|
+
H -->|AuthFailure / PermissionFailure| Q[Abort immediately]
|
|
409
|
+
Q --> R[Update target state]
|
|
410
|
+
R --> K
|
|
411
|
+
|
|
412
|
+
P -->|yes| S[Exclude failed target]
|
|
413
|
+
S --> D
|
|
414
|
+
P -->|no| T[All budgets exhausted]
|
|
415
|
+
T --> K
|
|
416
|
+
|
|
417
|
+
H -->|InterruptedExecution| U[Save confirmed chunks to buffer]
|
|
418
|
+
U --> V[Record segment provenance]
|
|
419
|
+
V --> P
|
|
420
|
+
|
|
421
|
+
style Z fill:#f66,color:#fff
|
|
422
|
+
style L fill:#6f6,color:#000
|
|
423
|
+
style Q fill:#f66,color:#fff
|
|
424
|
+
style T fill:#f96,color:#000
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Module Dependency Graph
|
|
428
|
+
|
|
429
|
+
```mermaid
|
|
430
|
+
graph TB
|
|
431
|
+
subgraph "Layer 4: Entry Points"
|
|
432
|
+
plugin[src/plugin.ts<br/>OpenCode Plugin Entry]
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
subgraph "Layer 3: Operator Surface"
|
|
436
|
+
commands[operator/commands.ts<br/>7 operator commands]
|
|
437
|
+
reload[operator/reload.ts<br/>Config reload diff-apply]
|
|
438
|
+
tools[operator/plugin-tools.ts<br/>Plugin tool wrappers]
|
|
439
|
+
auth[operator/server-auth.ts<br/>Bearer token auth]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
subgraph "Layer 2: Execution"
|
|
443
|
+
orchestrator[execution/orchestrator.ts<br/>Execution orchestrator]
|
|
444
|
+
adapters[execution/adapters/<br/>Plugin / Server / SDK]
|
|
445
|
+
stitcher[execution/stream-stitcher.ts<br/>Multi-segment assembly]
|
|
446
|
+
buffer[execution/stream-buffer.ts<br/>Chunk buffer]
|
|
447
|
+
collector[execution/audit-collector.ts<br/>Audit collector]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
subgraph "Layer 1: Routing (pure functions)"
|
|
451
|
+
policy[routing/policy-engine.ts<br/>Filter-then-score]
|
|
452
|
+
failover[routing/failover.ts<br/>Nested retry-failover]
|
|
453
|
+
admission[routing/admission.ts<br/>Layered admission]
|
|
454
|
+
cb[routing/circuit-breaker.ts<br/>Circuit breaker FSM]
|
|
455
|
+
cooldown[routing/cooldown.ts<br/>Adaptive cooldown]
|
|
456
|
+
concurrency[routing/concurrency-tracker.ts<br/>Per-target tracking]
|
|
457
|
+
events[routing/events.ts<br/>Typed event bus]
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
subgraph "Layer 0: Foundation"
|
|
461
|
+
config[config/schema.ts<br/>Zod validation]
|
|
462
|
+
registry[registry/registry.ts<br/>Target state]
|
|
463
|
+
errors[errors/taxonomy.ts<br/>10 error classes]
|
|
464
|
+
classifier[errors/classifier.ts<br/>Dual-mode classifier]
|
|
465
|
+
retry[retry/retry-policy.ts<br/>Bounded retry]
|
|
466
|
+
backoff[retry/backoff.ts<br/>Exponential backoff]
|
|
467
|
+
logger[audit/logger.ts<br/>Pino + redaction]
|
|
468
|
+
mode[mode/types.ts<br/>Deployment modes]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
plugin --> orchestrator
|
|
472
|
+
plugin --> commands
|
|
473
|
+
plugin --> config
|
|
474
|
+
|
|
475
|
+
commands --> registry
|
|
476
|
+
reload --> config
|
|
477
|
+
reload --> registry
|
|
478
|
+
tools --> commands
|
|
479
|
+
auth -.-> tools
|
|
480
|
+
|
|
481
|
+
orchestrator --> failover
|
|
482
|
+
orchestrator --> adapters
|
|
483
|
+
orchestrator --> stitcher
|
|
484
|
+
orchestrator --> collector
|
|
485
|
+
adapters --> buffer
|
|
486
|
+
|
|
487
|
+
failover --> policy
|
|
488
|
+
failover --> cb
|
|
489
|
+
failover --> cooldown
|
|
490
|
+
failover --> concurrency
|
|
491
|
+
admission --> policy
|
|
492
|
+
policy --> registry
|
|
493
|
+
policy --> cb
|
|
494
|
+
cb --> events
|
|
495
|
+
cooldown --> events
|
|
496
|
+
cooldown --> backoff
|
|
497
|
+
|
|
498
|
+
failover --> retry
|
|
499
|
+
failover --> errors
|
|
500
|
+
retry --> backoff
|
|
501
|
+
classifier --> errors
|
|
502
|
+
|
|
503
|
+
collector --> logger
|
|
504
|
+
orchestrator --> logger
|
|
505
|
+
|
|
506
|
+
style plugin fill:#4a9eff,color:#fff
|
|
507
|
+
style policy fill:#2ecc71,color:#fff
|
|
508
|
+
style failover fill:#2ecc71,color:#fff
|
|
509
|
+
style cb fill:#e74c3c,color:#fff
|
|
510
|
+
style registry fill:#f39c12,color:#fff
|
|
511
|
+
style logger fill:#9b59b6,color:#fff
|
package/docs/examples.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
## Basic: Just Add the Plugin
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"plugin": ["@apolenkov/o-switcher@latest"]
|
|
8
|
+
}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it. O-Switcher reads your OpenCode providers and handles failures automatically.
|
|
12
|
+
|
|
13
|
+
## Multiple API Keys (Rate Limit Distribution)
|
|
14
|
+
|
|
15
|
+
Register the same provider multiple times in `opencode.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"provider": {
|
|
20
|
+
"openai-work": {
|
|
21
|
+
"api": "openai",
|
|
22
|
+
"apiKey": "sk-proj-work-111..."
|
|
23
|
+
},
|
|
24
|
+
"openai-personal": {
|
|
25
|
+
"api": "openai",
|
|
26
|
+
"apiKey": "sk-proj-personal-222..."
|
|
27
|
+
},
|
|
28
|
+
"openai-backup": {
|
|
29
|
+
"api": "openai",
|
|
30
|
+
"apiKey": "sk-proj-backup-333..."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"plugin": ["@apolenkov/o-switcher@latest"]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
When `work` key hits rate limit → switches to `personal` → then `backup`.
|
|
38
|
+
|
|
39
|
+
## Multiple Providers (Cross-Provider Failover)
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"provider": {
|
|
44
|
+
"anthropic": {
|
|
45
|
+
"apiKey": "sk-ant-..."
|
|
46
|
+
},
|
|
47
|
+
"openai": {
|
|
48
|
+
"apiKey": "sk-proj-..."
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"plugin": ["@apolenkov/o-switcher@latest"]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If Anthropic is down → requests go to OpenAI. Note: in plugin-only mode, cross-provider failover is limited to parameter adjustment. Full cross-provider redirect requires server-companion mode.
|
|
56
|
+
|
|
57
|
+
## Custom Retry Settings
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"plugin": ["@apolenkov/o-switcher@latest"],
|
|
62
|
+
"switcher": {
|
|
63
|
+
"retry": 5,
|
|
64
|
+
"timeout": 60000
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
5 retry attempts, 60 second timeout.
|
|
70
|
+
|
|
71
|
+
## Accumulating Keys via CLI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# First login — O-Switcher saves the credential
|
|
75
|
+
opencode auth login openai
|
|
76
|
+
|
|
77
|
+
# Second login — O-Switcher saves the new one too
|
|
78
|
+
# (OpenCode overwrites, but O-Switcher keeps both)
|
|
79
|
+
opencode auth login openai
|
|
80
|
+
|
|
81
|
+
# Check your profiles
|
|
82
|
+
# In OpenCode, use the profiles-list tool
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Analyzing Logs
|
|
86
|
+
|
|
87
|
+
O-Switcher logs everything as NDJSON. Pipe through `jq`:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# How many failovers today?
|
|
91
|
+
opencode 2>&1 | jq 'select(.msg == "failover_start")' | wc -l
|
|
92
|
+
|
|
93
|
+
# Which profile gets rate limited most?
|
|
94
|
+
opencode 2>&1 | jq 'select(.event == "cooldown_set") | .target_id' | sort | uniq -c | sort -rn
|
|
95
|
+
|
|
96
|
+
# Average retries per request
|
|
97
|
+
opencode 2>&1 | jq 'select(.msg == "request_summary") | .total_retries' | awk '{s+=$1; n++} END {print s/n}'
|
|
98
|
+
|
|
99
|
+
# Circuit breaker flapping (opens and closes)
|
|
100
|
+
opencode 2>&1 | jq 'select(.event == "circuit_state_change") | {target: .target_id, from: .from, to: .to}'
|
|
101
|
+
|
|
102
|
+
# Requests that failed completely (no successful target)
|
|
103
|
+
opencode 2>&1 | jq 'select(.msg == "request_summary") | select(.outcome == "failure")'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Manual Target Control
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"plugin": ["@apolenkov/o-switcher@latest"],
|
|
111
|
+
"switcher": {
|
|
112
|
+
"targets": [
|
|
113
|
+
{
|
|
114
|
+
"target_id": "claude-primary",
|
|
115
|
+
"provider_id": "anthropic",
|
|
116
|
+
"capabilities": ["chat"],
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"operator_priority": 10,
|
|
119
|
+
"policy_tags": ["primary"]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"target_id": "gpt-backup",
|
|
123
|
+
"provider_id": "openai",
|
|
124
|
+
"capabilities": ["chat"],
|
|
125
|
+
"enabled": true,
|
|
126
|
+
"operator_priority": 1,
|
|
127
|
+
"policy_tags": ["backup"]
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"target_id": "gemini-fallback",
|
|
131
|
+
"provider_id": "google",
|
|
132
|
+
"capabilities": ["chat"],
|
|
133
|
+
"enabled": true,
|
|
134
|
+
"operator_priority": 0,
|
|
135
|
+
"policy_tags": ["fallback"]
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Priority: Claude (10) → GPT (1) → Gemini (0). Health scores adjust over time.
|
|
143
|
+
|
|
144
|
+
## Conservative Circuit Breaker
|
|
145
|
+
|
|
146
|
+
For critical workloads — slower to open, faster to recover:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"switcher": {
|
|
151
|
+
"circuit_breaker": {
|
|
152
|
+
"failure_threshold": 10,
|
|
153
|
+
"failure_rate_threshold": 0.7,
|
|
154
|
+
"sliding_window_size": 20,
|
|
155
|
+
"half_open_after_ms": 15000,
|
|
156
|
+
"success_threshold": 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Aggressive Circuit Breaker
|
|
163
|
+
|
|
164
|
+
For fast failover — quick to open on failures:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"switcher": {
|
|
169
|
+
"circuit_breaker": {
|
|
170
|
+
"failure_threshold": 2,
|
|
171
|
+
"failure_rate_threshold": 0.3,
|
|
172
|
+
"sliding_window_size": 5,
|
|
173
|
+
"half_open_after_ms": 5000,
|
|
174
|
+
"success_threshold": 1
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Local Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
git clone https://github.com/apolenkov/o-switcher.git
|
|
184
|
+
cd o-switcher
|
|
185
|
+
npm install
|
|
186
|
+
npm run build
|
|
187
|
+
|
|
188
|
+
# Use local path in your project's opencode.json:
|
|
189
|
+
# "plugin": ["/path/to/o-switcher"]
|
|
190
|
+
```
|