opencode-auth-load-balancer 0.6.0 → 0.7.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,62 @@
1
+ import { z } from 'zod';
2
+ /** Display and management API; its status output never contains credentials. */
3
+ export declare const loadBalancerRpc: {
4
+ readonly id: 'auth-load-balancer';
5
+ readonly methods: {
6
+ readonly status: {
7
+ readonly input: z.ZodObject<{
8
+ sessionID: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ readonly output: z.ZodArray<z.ZodObject<{
11
+ providerID: z.ZodString;
12
+ accounts: z.ZodArray<z.ZodObject<{
13
+ id: z.ZodString;
14
+ label: z.ZodString;
15
+ weeklyUtil: z.ZodNullable<z.ZodNumber>;
16
+ hourlyUtil: z.ZodNullable<z.ZodNumber>;
17
+ weeklyResetAt: z.ZodNumber;
18
+ available: z.ZodBoolean;
19
+ cooldownUntil: z.ZodNumber;
20
+ modelCooldownsUntil: z.ZodRecord<z.ZodString, z.ZodNumber>;
21
+ disabledReason: z.ZodNullable<z.ZodString>;
22
+ login: z.ZodString;
23
+ warnings: z.ZodArray<z.ZodString>;
24
+ lostNotes: z.ZodArray<z.ZodString>;
25
+ current: z.ZodBoolean;
26
+ rank: z.ZodNumber;
27
+ }, z.core.$strip>>;
28
+ }, z.core.$strip>>;
29
+ };
30
+ readonly manage: {
31
+ readonly input: z.ZodObject<{
32
+ account: z.ZodString;
33
+ action: z.ZodEnum<{
34
+ "clear-relogin": "clear-relogin";
35
+ delete: "delete";
36
+ disable: "disable";
37
+ enable: "enable";
38
+ relogin: "relogin";
39
+ rename: "rename";
40
+ }>;
41
+ name: z.ZodOptional<z.ZodString>;
42
+ }, z.core.$strip>;
43
+ readonly output: z.ZodString;
44
+ };
45
+ };
46
+ readonly events: {
47
+ readonly toast: {
48
+ readonly schema: z.ZodObject<{
49
+ title: z.ZodOptional<z.ZodString>;
50
+ message: z.ZodString;
51
+ variant: z.ZodEnum<{
52
+ error: "error";
53
+ info: "info";
54
+ success: "success";
55
+ warning: "warning";
56
+ }>;
57
+ duration: z.ZodOptional<z.ZodNumber>;
58
+ }, z.core.$strip>;
59
+ };
60
+ };
61
+ };
62
+ export type ProviderStatus = z.infer<typeof loadBalancerRpc.methods.status.output>[number];
package/dist/v2.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from '@opencode/plugin';
2
+ export declare function setupV2(ctx: Plugin.Context): Promise<Plugin.Cleanup>;
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // OpenCode V2 resolves local plugin directories through their root index file.
2
+ export { default } from './dist/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts and Kimi Code subscriptions, weighted primarily by weekly usage with drain-before-reset scheduling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,16 +20,25 @@
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
22
  "default": "./dist/index.js"
23
- }
23
+ },
24
+ "./v1": {
25
+ "types": "./dist/v1.d.ts",
26
+ "default": "./dist/v1.js"
27
+ },
28
+ "./server": "./dist/index.js",
29
+ "./tui": "./tui.ts",
30
+ "./tui-v1": "./tui/auth-load-balancer-tui.ts"
24
31
  },
25
32
  "files": [
33
+ "index.js",
34
+ "tui.ts",
26
35
  "dist",
27
36
  "tui"
28
37
  ],
29
38
  "scripts": {
30
- "sync:tui": "bun -e \"await Bun.write('tui/auth-load-balancer-scoring.ts', Bun.file('src/scheduler/score-core.ts'))\"",
31
- "build": "bun run sync:tui && bun build ./src/index.ts --outdir dist --target node --format esm && tsc -p tsconfig.build.json",
32
- "dev": "bun build ./src/index.ts --outdir dist --target node --format esm --watch",
39
+ "sync:tui": "bun -e \"await Bun.write('tui/auth-load-balancer-scoring.ts', Bun.file('src/scheduler/score-core.ts')); await Bun.write('tui/auth-load-balancer-rpc.ts', Bun.file('src/v2-rpc.ts'))\"",
40
+ "build": "bun run sync:tui && bun build ./src/index.ts ./src/v1.ts --outdir dist --target node --format esm --external @opencode/plugin --external effect --external 'effect/*' && tsc -p tsconfig.build.json",
41
+ "dev": "bun build ./src/index.ts ./src/v1.ts --outdir dist --target node --format esm --external @opencode/plugin --external effect --external 'effect/*' --watch",
33
42
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json --noEmit",
34
43
  "typecheck:tui": "tsc -p tsconfig.tui.json --noEmit",
35
44
  "lint": "oxlint",
@@ -41,12 +50,35 @@
41
50
  "prepare": "husky"
42
51
  },
43
52
  "peerDependencies": {
44
- "@opencode-ai/plugin": ">=1.18.26"
53
+ "@opencode-ai/plugin": ">=1.18.26 <2",
54
+ "@opencode/plugin": "^2.0.27",
55
+ "@opentui/core": ">=0.5.17",
56
+ "@opentui/solid": ">=0.5.17",
57
+ "solid-js": ">=1.9.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@opencode-ai/plugin": {
61
+ "optional": true
62
+ },
63
+ "@opencode/plugin": {
64
+ "optional": true
65
+ },
66
+ "@opentui/core": {
67
+ "optional": true
68
+ },
69
+ "@opentui/solid": {
70
+ "optional": true
71
+ },
72
+ "solid-js": {
73
+ "optional": true
74
+ }
45
75
  },
46
76
  "devDependencies": {
47
77
  "@opencode-ai/plugin": "^1.18.26",
48
- "@opentui/core": "0.5.10",
49
- "@opentui/solid": "0.5.10",
78
+ "@opencode/plugin": "2.0.27",
79
+ "@opencode/theme": "2.0.27",
80
+ "@opentui/core": "0.5.17",
81
+ "@opentui/solid": "0.5.17",
50
82
  "@types/bun": "^1.4.0",
51
83
  "@types/node": "^26.4.1",
52
84
  "eslint-plugin-devup": "^2.1.0",
@@ -73,5 +105,11 @@
73
105
  },
74
106
  "publishConfig": {
75
107
  "access": "public"
108
+ },
109
+ "dependencies": {
110
+ "@ai-sdk/anthropic": "3.0.129",
111
+ "@ai-sdk/openai": "3.0.125",
112
+ "@ai-sdk/openai-compatible": "2.0.81",
113
+ "zod": "^4.3"
76
114
  }
77
115
  }
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod'
2
+
3
+ const accountStatus = z.object({
4
+ id: z.string(),
5
+ label: z.string(),
6
+ weeklyUtil: z.number().nullable(),
7
+ hourlyUtil: z.number().nullable(),
8
+ weeklyResetAt: z.number(),
9
+ available: z.boolean(),
10
+ cooldownUntil: z.number(),
11
+ modelCooldownsUntil: z.record(z.string(), z.number()),
12
+ disabledReason: z.string().nullable(),
13
+ login: z.string(),
14
+ warnings: z.array(z.string()),
15
+ lostNotes: z.array(z.string()),
16
+ current: z.boolean(),
17
+ rank: z.number(),
18
+ })
19
+
20
+ /** Display and management API; its status output never contains credentials. */
21
+ export const loadBalancerRpc = {
22
+ id: 'auth-load-balancer',
23
+ methods: {
24
+ status: {
25
+ input: z.object({ sessionID: z.string().optional() }),
26
+ output: z.array(
27
+ z.object({ providerID: z.string(), accounts: z.array(accountStatus) }),
28
+ ),
29
+ },
30
+ manage: {
31
+ input: z.object({
32
+ account: z.string(),
33
+ action: z.enum([
34
+ 'rename',
35
+ 'disable',
36
+ 'enable',
37
+ 'delete',
38
+ 'relogin',
39
+ 'clear-relogin',
40
+ ]),
41
+ name: z.string().optional(),
42
+ }),
43
+ output: z.string(),
44
+ },
45
+ },
46
+ events: {
47
+ toast: {
48
+ schema: z.object({
49
+ title: z.string().optional(),
50
+ message: z.string(),
51
+ variant: z.enum(['info', 'success', 'warning', 'error']),
52
+ duration: z.number().optional(),
53
+ }),
54
+ },
55
+ },
56
+ } as const
57
+
58
+ export type ProviderStatus = z.infer<
59
+ typeof loadBalancerRpc.methods.status.output
60
+ >[number]
@@ -0,0 +1,10 @@
1
+ import type { Plugin } from '@opencode/plugin/tui'
2
+
3
+ export default {
4
+ id: 'auth-load-balancer-tui',
5
+ async setup(ctx: Plugin.Context) {
6
+ // The host installs its Solid transform before evaluating this view.
7
+ const { registerV2Slots } = await import('./auth-load-balancer-v2.view.tsx')
8
+ return registerV2Slots(ctx)
9
+ },
10
+ }
@@ -0,0 +1,242 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { Plugin } from '@opencode/plugin/tui'
3
+ import { createSignal, For, onCleanup, Show } from 'solid-js'
4
+
5
+ import { loadBalancerRpc, type ProviderStatus } from './auth-load-balancer-rpc'
6
+
7
+ const names: Record<string, string> = {
8
+ anthropic: 'Claude',
9
+ openai: 'Codex',
10
+ 'kimi-code-plan-cn': 'Kimi',
11
+ 'kimi-code-plan-global': 'Kimi (kimi.ai)',
12
+ }
13
+ const pct = (value: number | null) =>
14
+ value === null ? '?' : `${Math.round(value * 100)}%`
15
+
16
+ function StatusView(props: {
17
+ ctx: Plugin.Context
18
+ sessionID?: string
19
+ compact?: boolean
20
+ }) {
21
+ const rpc = props.ctx.client.rpc(loadBalancerRpc)
22
+ const [providers, setProviders] = createSignal<ProviderStatus[]>([])
23
+ const [error, setError] = createSignal('')
24
+ let disposed = false
25
+ const [refreshing, setRefreshing] = createSignal(false)
26
+ const refresh = async () => {
27
+ if (refreshing()) return
28
+ setRefreshing(true)
29
+ try {
30
+ const result = await rpc.status(
31
+ { sessionID: props.sessionID },
32
+ { location: props.ctx.location },
33
+ )
34
+ if (!disposed) {
35
+ setProviders(result)
36
+ setError('')
37
+ }
38
+ } catch {
39
+ if (!disposed) setError('Auth Load Balancer unavailable')
40
+ } finally {
41
+ setRefreshing(false)
42
+ }
43
+ }
44
+ const timer = setInterval(() => void refresh(), 3000)
45
+ onCleanup(() => {
46
+ disposed = true
47
+ clearInterval(timer)
48
+ })
49
+ void refresh()
50
+
51
+ const login = async (
52
+ provider: ProviderStatus,
53
+ account: ProviderStatus['accounts'][number],
54
+ ) => {
55
+ const result = await props.ctx.client.integration.get({
56
+ integrationID: provider.providerID,
57
+ location: props.ctx.location,
58
+ })
59
+ const methods = result.data.methods.flatMap((method) =>
60
+ method.type === 'oauth' && method.id.startsWith('auth-lb-')
61
+ ? [method]
62
+ : [],
63
+ )
64
+ const methodID = await props.ctx.ui.dialog.select({
65
+ title: `Sign in to ${names[provider.providerID] ?? provider.providerID}`,
66
+ options: methods.map((method) => ({
67
+ title: method.label,
68
+ value: method.id,
69
+ })),
70
+ })
71
+ if (!methodID) return
72
+ await rpc.manage(
73
+ { account: account.id, action: 'relogin' },
74
+ { location: props.ctx.location },
75
+ )
76
+ let attemptID: string | undefined
77
+ try {
78
+ const { data } = await props.ctx.client.integration.oauth.connect({
79
+ integrationID: provider.providerID,
80
+ methodID,
81
+ location: props.ctx.location,
82
+ })
83
+ attemptID = data.attemptID
84
+ const code =
85
+ data.mode === 'code'
86
+ ? await props.ctx.ui.dialog.prompt({
87
+ title: 'Authorize account',
88
+ description: `${data.url}\n${data.instructions}`,
89
+ })
90
+ : await props.ctx.ui.dialog.confirm({
91
+ title: 'Authorize account',
92
+ message: `${data.url}\n${data.instructions}\nContinue after approving in your browser.`,
93
+ })
94
+ if (code === undefined || code === false) return
95
+ await props.ctx.client.integration.oauth.complete({
96
+ integrationID: provider.providerID,
97
+ attemptID,
98
+ ...(typeof code === 'string' ? { code } : {}),
99
+ location: props.ctx.location,
100
+ })
101
+ attemptID = undefined
102
+ props.ctx.ui.toast.show({
103
+ message: 'Account added to load balancer',
104
+ variant: 'success',
105
+ })
106
+ } finally {
107
+ if (attemptID)
108
+ await props.ctx.client.integration.oauth
109
+ .cancel({
110
+ integrationID: provider.providerID,
111
+ attemptID,
112
+ location: props.ctx.location,
113
+ })
114
+ .catch(() => {})
115
+ await rpc
116
+ .manage(
117
+ { account: account.id, action: 'clear-relogin' },
118
+ { location: props.ctx.location },
119
+ )
120
+ .catch(() => {})
121
+ }
122
+ }
123
+ const manage = async (
124
+ provider: ProviderStatus,
125
+ account: ProviderStatus['accounts'][number],
126
+ ) => {
127
+ try {
128
+ const disabled = account.disabledReason === 'manually disabled'
129
+ const action = await props.ctx.ui.dialog.select({
130
+ title: account.label,
131
+ options: [
132
+ { title: 'Rename', value: 'rename' as const },
133
+ {
134
+ title: disabled ? 'Enable' : 'Disable',
135
+ value: disabled ? ('enable' as const) : ('disable' as const),
136
+ },
137
+ { title: 'Re-login / pair credential', value: 'relogin' as const },
138
+ { title: 'Delete', value: 'delete' as const },
139
+ ],
140
+ })
141
+ if (!action) return
142
+ if (action === 'relogin') {
143
+ await login(provider, account)
144
+ return
145
+ }
146
+ const name =
147
+ action === 'rename'
148
+ ? await props.ctx.ui.dialog.prompt({
149
+ title: 'Rename account',
150
+ value: account.label,
151
+ })
152
+ : undefined
153
+ if (action === 'rename' && name === undefined) return
154
+ if (
155
+ action === 'delete' &&
156
+ !(await props.ctx.ui.dialog.confirm({
157
+ title: 'Delete account',
158
+ message: `Remove ${account.label} from the pool?`,
159
+ }))
160
+ )
161
+ return
162
+ await rpc.manage(
163
+ { account: account.id, action, name },
164
+ { location: props.ctx.location },
165
+ )
166
+ } catch (cause) {
167
+ props.ctx.ui.toast.show({
168
+ message:
169
+ cause instanceof Error ? cause.message : 'Account update failed',
170
+ variant: 'error',
171
+ })
172
+ } finally {
173
+ await refresh()
174
+ }
175
+ }
176
+
177
+ return (
178
+ <box flexDirection={props.compact ? 'row' : 'column'} gap={1}>
179
+ <Show when={error()}>
180
+ <text fg={props.ctx.theme.text.muted}>{error()}</text>
181
+ </Show>
182
+ <For each={providers()}>
183
+ {(provider) => (
184
+ <box flexDirection="column">
185
+ <Show when={!props.compact}>
186
+ <text fg={props.ctx.theme.text.base}>
187
+ <b>{names[provider.providerID] ?? provider.providerID}</b>
188
+ </text>
189
+ </Show>
190
+ <For
191
+ each={
192
+ props.compact
193
+ ? provider.accounts.filter((account) => account.current)
194
+ : provider.accounts
195
+ }
196
+ >
197
+ {(account) => (
198
+ <box
199
+ flexDirection="column"
200
+ onMouseDown={() => void manage(provider, account)}
201
+ >
202
+ <text fg={props.ctx.theme.text.base} wrapMode="word">
203
+ {`${account.current ? '●' : `#${account.rank}`} ${props.compact ? `${names[provider.providerID] ?? provider.providerID}: ` : ''}${account.label} · weekly ${pct(account.weeklyUtil)} · 5h ${pct(account.hourlyUtil)}${account.disabledReason ? ' · disabled' : account.available ? '' : ' · waiting'}`}
204
+ </text>
205
+ <Show when={!props.compact && account.warnings.length > 0}>
206
+ <text fg={props.ctx.theme.text.muted}>
207
+ {account.warnings.join(' · ')}
208
+ </text>
209
+ </Show>
210
+ </box>
211
+ )}
212
+ </For>
213
+ </box>
214
+ )}
215
+ </For>
216
+ </box>
217
+ )
218
+ }
219
+
220
+ export function registerV2Slots(ctx: Plugin.Context): () => void {
221
+ const stopToast = ctx.client
222
+ .rpc(loadBalancerRpc)
223
+ .events.on('toast', (event) => {
224
+ if (event.location?.directory === ctx.location?.directory)
225
+ ctx.ui.toast.show(event.data)
226
+ })
227
+ const stopFooter = ctx.ui.slot({
228
+ after: 'prompt.footer',
229
+ render: (props) => (
230
+ <StatusView compact ctx={ctx} sessionID={props.sessionID} />
231
+ ),
232
+ })
233
+ const stopSidebar = ctx.ui.slot({
234
+ append: 'sidebar.content',
235
+ render: (props) => <StatusView ctx={ctx} sessionID={props.sessionID} />,
236
+ })
237
+ return () => {
238
+ stopToast()
239
+ stopFooter()
240
+ stopSidebar()
241
+ }
242
+ }
package/tui.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Local directory discovery and the published ./tui export share this entry.
2
+ export { default } from './tui/auth-load-balancer-v2'