@thelioo/opencode-balancer 0.1.8 → 0.2.1
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/INSTALL.txt +53 -25
- package/README.md +95 -51
- package/dist/core/accounts.ts +404 -0
- package/dist/core/database.ts +67 -0
- package/dist/core/events.ts +75 -0
- package/dist/core/native-auth-suppression.ts +36 -0
- package/dist/core/native-connect.ts +31 -0
- package/dist/core/path.ts +34 -0
- package/dist/core/pending.ts +351 -0
- package/dist/core/priority.ts +193 -0
- package/dist/core/schema.ts +439 -0
- package/dist/core/time.ts +3 -0
- package/dist/core/types.ts +72 -0
- package/dist/core/usage/index.ts +23 -0
- package/dist/core/usage/providers/copilot.ts +243 -0
- package/dist/core/usage/providers/openai.ts +179 -0
- package/dist/core/usage/redact.ts +80 -0
- package/dist/core/usage/store.ts +66 -0
- package/dist/core/usage/types.ts +24 -0
- package/dist/index.js +173 -4
- package/dist/index.js.map +1 -1
- package/dist/server/auth-watcher.ts +318 -0
- package/dist/server/commands.ts +58 -0
- package/dist/server/fetch-patch.ts +162 -0
- package/dist/server/index.ts +134 -0
- package/dist/server/native.ts +49 -0
- package/dist/server/request-balancer.ts +67 -0
- package/dist/tui/actions.ts +176 -112
- package/dist/tui/balancer-bar-sync.ts +55 -45
- package/dist/tui/components/alias-dialog.tsx +71 -56
- package/dist/tui/components/dashboard.tsx +530 -358
- package/dist/tui/components/priority-screen.tsx +389 -267
- package/dist/tui/components/provider-model-dialog.tsx +71 -64
- package/dist/tui/components/rename-dialog.tsx +35 -28
- package/dist/tui/components/sidebar.tsx +103 -79
- package/dist/tui/components/status-indicator.tsx +78 -59
- package/dist/tui/components/usage-bar.tsx +18 -7
- package/dist/tui/components/usage-display.tsx +32 -16
- package/dist/tui/connect.ts +104 -73
- package/dist/tui/dashboard-keys.ts +53 -41
- package/dist/tui/native-model-apply.ts +45 -36
- package/dist/tui/priority-keys.ts +44 -36
- package/dist/tui/provider-models.ts +32 -25
- package/dist/tui/responsive.ts +10 -7
- package/dist/tui/selected-account-bar-sync.ts +23 -23
- package/dist/tui/selection-colors.ts +38 -30
- package/dist/tui/state.ts +61 -44
- package/dist/tui/status-format.ts +24 -20
- package/dist/tui/tui.js +165 -153
- package/dist/tui/tui.js.map +1 -1
- package/dist/tui/tui.tsx +194 -144
- package/dist/tui/usage-auto-refresh.ts +52 -45
- package/dist/tui/usage-format.ts +9 -9
- package/package.json +61 -52
- package/dist/core/accounts.d.ts +0 -14
- package/dist/core/accounts.js +0 -260
- package/dist/core/accounts.js.map +0 -1
- package/dist/core/database.d.ts +0 -4
- package/dist/core/database.js +0 -69
- package/dist/core/database.js.map +0 -1
- package/dist/core/events.d.ts +0 -18
- package/dist/core/events.js +0 -39
- package/dist/core/events.js.map +0 -1
- package/dist/core/native-auth-suppression.d.ts +0 -3
- package/dist/core/native-auth-suppression.js +0 -19
- package/dist/core/native-auth-suppression.js.map +0 -1
- package/dist/core/native-connect.d.ts +0 -4
- package/dist/core/native-connect.js +0 -19
- package/dist/core/native-connect.js.map +0 -1
- package/dist/core/path.d.ts +0 -4
- package/dist/core/path.js +0 -26
- package/dist/core/path.js.map +0 -1
- package/dist/core/pending.d.ts +0 -9
- package/dist/core/pending.js +0 -237
- package/dist/core/pending.js.map +0 -1
- package/dist/core/priority.d.ts +0 -20
- package/dist/core/priority.js +0 -120
- package/dist/core/priority.js.map +0 -1
- package/dist/core/schema.d.ts +0 -2
- package/dist/core/schema.js +0 -265
- package/dist/core/schema.js.map +0 -1
- package/dist/core/time.d.ts +0 -1
- package/dist/core/time.js +0 -4
- package/dist/core/time.js.map +0 -1
- package/dist/core/types.d.ts +0 -59
- package/dist/core/types.js +0 -2
- package/dist/core/types.js.map +0 -1
- package/dist/core/usage/index.d.ts +0 -4
- package/dist/core/usage/index.js +0 -16
- package/dist/core/usage/index.js.map +0 -1
- package/dist/core/usage/providers/copilot.d.ts +0 -2
- package/dist/core/usage/providers/copilot.js +0 -169
- package/dist/core/usage/providers/copilot.js.map +0 -1
- package/dist/core/usage/providers/openai.d.ts +0 -2
- package/dist/core/usage/providers/openai.js +0 -133
- package/dist/core/usage/providers/openai.js.map +0 -1
- package/dist/core/usage/redact.d.ts +0 -3
- package/dist/core/usage/redact.js +0 -67
- package/dist/core/usage/redact.js.map +0 -1
- package/dist/core/usage/store.d.ts +0 -4
- package/dist/core/usage/store.js +0 -31
- package/dist/core/usage/store.js.map +0 -1
- package/dist/core/usage/types.d.ts +0 -21
- package/dist/core/usage/types.js +0 -2
- package/dist/core/usage/types.js.map +0 -1
- package/dist/index.d.ts +0 -5
- package/dist/server/auth-watcher.d.ts +0 -32
- package/dist/server/auth-watcher.js +0 -227
- package/dist/server/auth-watcher.js.map +0 -1
- package/dist/server/commands.d.ts +0 -2
- package/dist/server/commands.js +0 -46
- package/dist/server/commands.js.map +0 -1
- package/dist/server/fetch-patch.d.ts +0 -3
- package/dist/server/fetch-patch.js +0 -118
- package/dist/server/fetch-patch.js.map +0 -1
- package/dist/server/index.d.ts +0 -8
- package/dist/server/index.js +0 -94
- package/dist/server/index.js.map +0 -1
- package/dist/server/native.d.ts +0 -6
- package/dist/server/native.js +0 -35
- package/dist/server/native.js.map +0 -1
- package/dist/server/request-balancer.d.ts +0 -16
- package/dist/server/request-balancer.js +0 -43
- package/dist/server/request-balancer.js.map +0 -1
- package/dist/tui/actions.d.ts +0 -41
- package/dist/tui/actions.js +0 -92
- package/dist/tui/actions.js.map +0 -1
- package/dist/tui/balancer-bar-sync.d.ts +0 -19
- package/dist/tui/balancer-bar-sync.js +0 -45
- package/dist/tui/balancer-bar-sync.js.map +0 -1
- package/dist/tui/components/alias-dialog.d.ts +0 -4
- package/dist/tui/components/dashboard.d.ts +0 -12
- package/dist/tui/components/priority-screen.d.ts +0 -9
- package/dist/tui/components/provider-model-dialog.d.ts +0 -14
- package/dist/tui/components/rename-dialog.d.ts +0 -4
- package/dist/tui/components/sidebar.d.ts +0 -10
- package/dist/tui/components/status-indicator.d.ts +0 -9
- package/dist/tui/components/usage-bar.d.ts +0 -8
- package/dist/tui/components/usage-display.d.ts +0 -10
- package/dist/tui/connect.d.ts +0 -30
- package/dist/tui/connect.js +0 -75
- package/dist/tui/connect.js.map +0 -1
- package/dist/tui/dashboard-keys.d.ts +0 -45
- package/dist/tui/dashboard-keys.js +0 -44
- package/dist/tui/dashboard-keys.js.map +0 -1
- package/dist/tui/native-model-apply.d.ts +0 -21
- package/dist/tui/native-model-apply.js +0 -53
- package/dist/tui/native-model-apply.js.map +0 -1
- package/dist/tui/priority-keys.d.ts +0 -40
- package/dist/tui/priority-keys.js +0 -38
- package/dist/tui/priority-keys.js.map +0 -1
- package/dist/tui/provider-models.d.ts +0 -19
- package/dist/tui/provider-models.js +0 -17
- package/dist/tui/provider-models.js.map +0 -1
- package/dist/tui/responsive.d.ts +0 -9
- package/dist/tui/responsive.js +0 -13
- package/dist/tui/responsive.js.map +0 -1
- package/dist/tui/selected-account-bar-sync.d.ts +0 -10
- package/dist/tui/selected-account-bar-sync.js +0 -26
- package/dist/tui/selected-account-bar-sync.js.map +0 -1
- package/dist/tui/selection-colors.d.ts +0 -10
- package/dist/tui/selection-colors.js +0 -38
- package/dist/tui/selection-colors.js.map +0 -1
- package/dist/tui/state.d.ts +0 -14
- package/dist/tui/state.js +0 -46
- package/dist/tui/state.js.map +0 -1
- package/dist/tui/status-format.d.ts +0 -15
- package/dist/tui/status-format.js +0 -17
- package/dist/tui/status-format.js.map +0 -1
- package/dist/tui/tui.d.ts +0 -7
- package/dist/tui/usage-auto-refresh.d.ts +0 -16
- package/dist/tui/usage-auto-refresh.js +0 -46
- package/dist/tui/usage-auto-refresh.js.map +0 -1
- package/dist/tui/usage-format.d.ts +0 -2
- package/dist/tui/usage-format.js +0 -17
- package/dist/tui/usage-format.js.map +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
getActiveAccount,
|
|
5
|
+
listAccounts,
|
|
6
|
+
updateAccountAuth,
|
|
7
|
+
} from "../core/accounts";
|
|
8
|
+
import { isNativeAuthCaptureSuppressed as isDbNativeAuthCaptureSuppressed } from "../core/native-auth-suppression";
|
|
9
|
+
import { nativeAuthPath } from "../core/path";
|
|
10
|
+
import { createPendingConnection } from "../core/pending";
|
|
11
|
+
import type { AuthInfo } from "../core/types";
|
|
12
|
+
import {
|
|
13
|
+
showToast as defaultShowToast,
|
|
14
|
+
isNativeAuthCaptureSuppressed,
|
|
15
|
+
} from "./native";
|
|
16
|
+
|
|
17
|
+
let started = false;
|
|
18
|
+
|
|
19
|
+
type NativeAuthReadResult =
|
|
20
|
+
| { ok: true; auth: Record<string, AuthInfo> }
|
|
21
|
+
| { ok: false };
|
|
22
|
+
|
|
23
|
+
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
24
|
+
|
|
25
|
+
type AuthWatcherOptions = {
|
|
26
|
+
db: Database;
|
|
27
|
+
client: any;
|
|
28
|
+
initialSnapshot?: Record<string, AuthInfo>;
|
|
29
|
+
readAuth: () => NativeAuthReadResult;
|
|
30
|
+
showToast?: (
|
|
31
|
+
client: any,
|
|
32
|
+
message: string,
|
|
33
|
+
variant: ToastVariant,
|
|
34
|
+
) => Promise<void>;
|
|
35
|
+
createPending?: (
|
|
36
|
+
db: Database,
|
|
37
|
+
providerID: string,
|
|
38
|
+
auth: AuthInfo,
|
|
39
|
+
source: "auth-file",
|
|
40
|
+
) => unknown;
|
|
41
|
+
isSuppressed?: (providerID: string) => boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stringMetadata(value: unknown) {
|
|
49
|
+
if (value === undefined) return { metadata: undefined, ok: true as const };
|
|
50
|
+
if (!isRecord(value)) return { ok: false as const };
|
|
51
|
+
|
|
52
|
+
const metadata: Record<string, string> = {};
|
|
53
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
54
|
+
if (typeof entry !== "string") return { ok: false as const };
|
|
55
|
+
metadata[key] = entry;
|
|
56
|
+
}
|
|
57
|
+
return { metadata, ok: true as const };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attachMetadata<T extends AuthInfo>(
|
|
61
|
+
auth: T,
|
|
62
|
+
value: unknown,
|
|
63
|
+
): T | undefined {
|
|
64
|
+
const result = stringMetadata(value);
|
|
65
|
+
if (!result.ok) return undefined;
|
|
66
|
+
if (result.metadata) auth.metadata = result.metadata;
|
|
67
|
+
return auth;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseAuthInfo(value: unknown): AuthInfo | undefined {
|
|
71
|
+
if (!isRecord(value) || typeof value.type !== "string") return undefined;
|
|
72
|
+
|
|
73
|
+
if (value.type === "api" && typeof value.key === "string") {
|
|
74
|
+
return attachMetadata({ key: value.key, type: "api" }, value.metadata);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
value.type === "oauth" &&
|
|
79
|
+
typeof value.refresh === "string" &&
|
|
80
|
+
typeof value.access === "string" &&
|
|
81
|
+
typeof value.expires === "number" &&
|
|
82
|
+
Number.isFinite(value.expires)
|
|
83
|
+
) {
|
|
84
|
+
const auth: AuthInfo = {
|
|
85
|
+
access: value.access,
|
|
86
|
+
expires: value.expires,
|
|
87
|
+
refresh: value.refresh,
|
|
88
|
+
type: "oauth",
|
|
89
|
+
};
|
|
90
|
+
if (typeof value.accountId === "string") auth.accountId = value.accountId;
|
|
91
|
+
if (typeof value.enterpriseUrl === "string")
|
|
92
|
+
auth.enterpriseUrl = value.enterpriseUrl;
|
|
93
|
+
return attachMetadata(auth, value.metadata);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
value.type === "wellknown" &&
|
|
98
|
+
typeof value.key === "string" &&
|
|
99
|
+
typeof value.token === "string"
|
|
100
|
+
) {
|
|
101
|
+
return attachMetadata(
|
|
102
|
+
{ key: value.key, token: value.token, type: "wellknown" },
|
|
103
|
+
value.metadata,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseNativeAuthContent(content: string): NativeAuthReadResult {
|
|
111
|
+
let raw: unknown;
|
|
112
|
+
try {
|
|
113
|
+
raw = JSON.parse(content);
|
|
114
|
+
} catch {
|
|
115
|
+
return { ok: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!isRecord(raw)) return { ok: false };
|
|
119
|
+
|
|
120
|
+
const authByProvider: Record<string, AuthInfo> = {};
|
|
121
|
+
for (const [providerID, auth] of Object.entries(raw)) {
|
|
122
|
+
const parsed = parseAuthInfo(auth);
|
|
123
|
+
if (!parsed) return { ok: false };
|
|
124
|
+
authByProvider[providerID] = parsed;
|
|
125
|
+
}
|
|
126
|
+
return { auth: authByProvider, ok: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function readNativeAuth(): NativeAuthReadResult {
|
|
130
|
+
const authContent = Bun.env.OPENCODE_AUTH_CONTENT;
|
|
131
|
+
if (authContent !== undefined) return parseNativeAuthContent(authContent);
|
|
132
|
+
|
|
133
|
+
const path = nativeAuthPath();
|
|
134
|
+
if (!existsSync(path)) return { auth: {}, ok: true };
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return parseNativeAuthContent(readFileSync(path, "utf8"));
|
|
138
|
+
} catch {
|
|
139
|
+
return { ok: false };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function authChanged(previous: AuthInfo | undefined, current: AuthInfo) {
|
|
144
|
+
return stableStringify(previous ?? null) !== stableStringify(current);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stableStringify(value: unknown): string {
|
|
148
|
+
return JSON.stringify(sortKeys(value));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sortKeys(value: unknown): unknown {
|
|
152
|
+
if (!isRecord(value)) return value;
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
Object.entries(value)
|
|
155
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
156
|
+
.map(([key, entry]) => [key, sortKeys(entry)]),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
161
|
+
const payload = token.split(".")[1];
|
|
162
|
+
if (!payload) return undefined;
|
|
163
|
+
try {
|
|
164
|
+
const decoded = JSON.parse(
|
|
165
|
+
Buffer.from(payload, "base64url").toString("utf8"),
|
|
166
|
+
) as unknown;
|
|
167
|
+
return isRecord(decoded) ? decoded : undefined;
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function oauthAccountID(auth: Extract<AuthInfo, { type: "oauth" }>) {
|
|
174
|
+
if (auth.accountId) return auth.accountId;
|
|
175
|
+
const claim = decodeJwtPayload(auth.access)?.["https://api.openai.com/auth"];
|
|
176
|
+
if (!isRecord(claim)) return undefined;
|
|
177
|
+
const accountID = claim.chatgpt_account_id;
|
|
178
|
+
return typeof accountID === "string" && accountID.length > 0
|
|
179
|
+
? accountID
|
|
180
|
+
: undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function sameSavedAuth(saved: AuthInfo, current: AuthInfo) {
|
|
184
|
+
if (saved.type !== current.type) return false;
|
|
185
|
+
if (saved.type === "api" && current.type === "api")
|
|
186
|
+
return saved.key === current.key;
|
|
187
|
+
if (saved.type === "wellknown" && current.type === "wellknown")
|
|
188
|
+
return saved.key === current.key && saved.token === current.token;
|
|
189
|
+
if (saved.type === "oauth" && current.type === "oauth") {
|
|
190
|
+
const savedAccountID = oauthAccountID(saved);
|
|
191
|
+
const currentAccountID = oauthAccountID(current);
|
|
192
|
+
if (savedAccountID && currentAccountID)
|
|
193
|
+
return savedAccountID === currentAccountID;
|
|
194
|
+
return saved.refresh === current.refresh;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saveKnownNativeAuth(db: Database, providerID: string, auth: AuthInfo) {
|
|
200
|
+
const account = listAccounts(db, providerID).find((candidate) =>
|
|
201
|
+
sameSavedAuth(candidate.auth, auth),
|
|
202
|
+
);
|
|
203
|
+
if (!account) return false;
|
|
204
|
+
updateAccountAuth(db, providerID, account.alias, auth);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function captureUnknownNativeAuth(
|
|
209
|
+
db: Database,
|
|
210
|
+
providerID: string,
|
|
211
|
+
auth: AuthInfo,
|
|
212
|
+
) {
|
|
213
|
+
if (saveKnownNativeAuth(db, providerID, auth)) return false;
|
|
214
|
+
createPendingConnection(db, providerID, auth, "auth-file");
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function initialAuthSnapshot(
|
|
219
|
+
db: Database,
|
|
220
|
+
authByProvider: Record<string, AuthInfo>,
|
|
221
|
+
) {
|
|
222
|
+
const snapshot: Record<string, AuthInfo> = {};
|
|
223
|
+
for (const [providerID, auth] of Object.entries(authByProvider)) {
|
|
224
|
+
if (
|
|
225
|
+
listAccounts(db, providerID).some((account) =>
|
|
226
|
+
sameSavedAuth(account.auth, auth),
|
|
227
|
+
)
|
|
228
|
+
) {
|
|
229
|
+
snapshot[providerID] = auth;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return snapshot;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createAuthWatcher(options: AuthWatcherOptions) {
|
|
236
|
+
const lastAuthSnapshot = options.initialSnapshot ?? {};
|
|
237
|
+
let inFlight = false;
|
|
238
|
+
const notify = options.showToast ?? defaultShowToast;
|
|
239
|
+
const createPending = options.createPending ?? createPendingConnection;
|
|
240
|
+
const isSuppressed =
|
|
241
|
+
options.isSuppressed ??
|
|
242
|
+
((providerID: string) => {
|
|
243
|
+
return (
|
|
244
|
+
isNativeAuthCaptureSuppressed(providerID) ||
|
|
245
|
+
isDbNativeAuthCaptureSuppressed(options.db, providerID)
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
async poll() {
|
|
251
|
+
if (inFlight) return;
|
|
252
|
+
inFlight = true;
|
|
253
|
+
try {
|
|
254
|
+
const result = options.readAuth();
|
|
255
|
+
if (!result.ok) return;
|
|
256
|
+
|
|
257
|
+
const next = result.auth;
|
|
258
|
+
const changed: Array<[string, AuthInfo]> = [];
|
|
259
|
+
for (const [providerID, auth] of Object.entries(next)) {
|
|
260
|
+
if (isSuppressed(providerID)) {
|
|
261
|
+
const active = getActiveAccount(options.db, providerID);
|
|
262
|
+
if (active && sameSavedAuth(active.auth, auth))
|
|
263
|
+
updateAccountAuth(options.db, providerID, active.alias, auth);
|
|
264
|
+
lastAuthSnapshot[providerID] = auth;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (saveKnownNativeAuth(options.db, providerID, auth)) {
|
|
268
|
+
lastAuthSnapshot[providerID] = auth;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!authChanged(lastAuthSnapshot[providerID], auth)) continue;
|
|
272
|
+
changed.push([providerID, auth]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [providerID, auth] of changed) {
|
|
276
|
+
createPending(options.db, providerID, auth, "auth-file");
|
|
277
|
+
lastAuthSnapshot[providerID] = auth;
|
|
278
|
+
await notify(
|
|
279
|
+
options.client,
|
|
280
|
+
`Balancer detected a ${providerID} connection. Save an alias from the TUI sidebar.`,
|
|
281
|
+
"info",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const providerID of Object.keys(lastAuthSnapshot)) {
|
|
286
|
+
if (!(providerID in next)) delete lastAuthSnapshot[providerID];
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
inFlight = false;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function startAuthWatcher(client: any, db: Database) {
|
|
296
|
+
if (started) return;
|
|
297
|
+
started = true;
|
|
298
|
+
const initial = readNativeAuth();
|
|
299
|
+
const watcher = createAuthWatcher({
|
|
300
|
+
client,
|
|
301
|
+
db,
|
|
302
|
+
initialSnapshot: initial.ok ? initialAuthSnapshot(db, initial.auth) : {},
|
|
303
|
+
readAuth: readNativeAuth,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const timer = setInterval(() => void watcher.poll(), 1_000);
|
|
307
|
+
(timer as { unref?: () => void }).unref?.();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const __testParseNativeAuthContent = parseNativeAuthContent;
|
|
311
|
+
|
|
312
|
+
export const __testInitialAuthSnapshot = initialAuthSnapshot;
|
|
313
|
+
|
|
314
|
+
export function __testCreateAuthWatcher(
|
|
315
|
+
options: Omit<AuthWatcherOptions, "client"> & { client?: any },
|
|
316
|
+
) {
|
|
317
|
+
return createAuthWatcher({ ...options, client: options.client ?? {} });
|
|
318
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import {
|
|
3
|
+
getActiveAccount,
|
|
4
|
+
listAccounts,
|
|
5
|
+
setActiveAccount,
|
|
6
|
+
} from "../core/accounts";
|
|
7
|
+
import { listPendingConnections } from "../core/pending";
|
|
8
|
+
|
|
9
|
+
function parseArgs(input: string) {
|
|
10
|
+
return (
|
|
11
|
+
input.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((x) => x.replace(/^"|"$/g, "")) ??
|
|
12
|
+
[]
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runFallbackBalancerCommand(db: Database, raw: string) {
|
|
17
|
+
const [action = "help", ...args] = parseArgs(raw);
|
|
18
|
+
|
|
19
|
+
if (["help", "--help", "-h"].includes(action)) {
|
|
20
|
+
return [
|
|
21
|
+
"Balancer is TUI-first. Use fallback commands only for compatibility or troubleshooting.",
|
|
22
|
+
"Fallback commands: list, status, use <provider> <alias>, active <provider>.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (action === "list") {
|
|
27
|
+
const accounts = listAccounts(db);
|
|
28
|
+
if (accounts.length === 0) return "No accounts saved.";
|
|
29
|
+
return accounts
|
|
30
|
+
.map((account) => `${account.providerID}/${account.alias}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (action === "status") {
|
|
35
|
+
return `accounts=${listAccounts(db).length} pending=${listPendingConnections(db).length}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (action === "use") {
|
|
39
|
+
const [providerID, alias] = args;
|
|
40
|
+
if (!providerID || !alias) return "Usage: /balancer use <provider> <alias>";
|
|
41
|
+
try {
|
|
42
|
+
const account = setActiveAccount(db, providerID, alias);
|
|
43
|
+
return `Active account changed to ${providerID}/${account.alias}`;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return error instanceof Error ? error.message : "Account not found.";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (action === "active") {
|
|
50
|
+
const [providerID] = args;
|
|
51
|
+
if (!providerID) return "Usage: /balancer active <provider>";
|
|
52
|
+
const account = getActiveAccount(db, providerID);
|
|
53
|
+
if (!account) return "No active account for provider.";
|
|
54
|
+
return `${providerID}/${account.alias}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `Unknown command: ${action}. Use /balancer help.`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { setActiveAccount } from "../core/accounts";
|
|
3
|
+
import { getBalancingEnabled } from "../core/priority";
|
|
4
|
+
import type { Account } from "../core/types";
|
|
5
|
+
import { setNativeAuth, showToast } from "./native";
|
|
6
|
+
import {
|
|
7
|
+
chooseFailoverAccount,
|
|
8
|
+
INTERNAL_REQUEST_HEADER,
|
|
9
|
+
markRateLimited,
|
|
10
|
+
RETRYABLE_STATUS,
|
|
11
|
+
takePendingRequest,
|
|
12
|
+
} from "./request-balancer";
|
|
13
|
+
|
|
14
|
+
let fetchPatched = false;
|
|
15
|
+
|
|
16
|
+
function headerEntries(headers: Headers) {
|
|
17
|
+
return Array.from(headers.entries()).map(
|
|
18
|
+
([key, value]) => [key.toLowerCase(), value] as const,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyAuthToHeaders(
|
|
23
|
+
headers: Headers,
|
|
24
|
+
account: Account,
|
|
25
|
+
options: { preserveExistingOAuth?: boolean } = {},
|
|
26
|
+
) {
|
|
27
|
+
const auth = account.auth;
|
|
28
|
+
const entries = headerEntries(headers);
|
|
29
|
+
|
|
30
|
+
if (auth.type === "oauth") {
|
|
31
|
+
if (options.preserveExistingOAuth && headers.has("authorization")) return;
|
|
32
|
+
headers.set("authorization", `Bearer ${auth.access}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (auth.type === "wellknown") {
|
|
37
|
+
if (headers.has("authorization")) {
|
|
38
|
+
headers.set("authorization", `Bearer ${auth.token}`);
|
|
39
|
+
} else {
|
|
40
|
+
headers.set(auth.key, auth.token);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const authHeaderNames = [
|
|
46
|
+
"authorization",
|
|
47
|
+
"x-api-key",
|
|
48
|
+
"api-key",
|
|
49
|
+
"x-goog-api-key",
|
|
50
|
+
"x-stainless-api-key",
|
|
51
|
+
"anthropic-api-key",
|
|
52
|
+
"cohere-api-key",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
let changed = false;
|
|
56
|
+
for (const name of authHeaderNames) {
|
|
57
|
+
const current = entries.find(([key]) => key === name)?.[1];
|
|
58
|
+
if (!current) continue;
|
|
59
|
+
headers.set(
|
|
60
|
+
name,
|
|
61
|
+
name === "authorization" && current.toLowerCase().startsWith("bearer ")
|
|
62
|
+
? `Bearer ${auth.key}`
|
|
63
|
+
: auth.key,
|
|
64
|
+
);
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!changed) headers.set("authorization", `Bearer ${auth.key}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function __testResetFetchPatch() {
|
|
72
|
+
fetchPatched = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function headersFrom(input: RequestInfo | URL, init?: RequestInit) {
|
|
76
|
+
if (init?.headers) return new Headers(init.headers);
|
|
77
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
78
|
+
return new Headers(input.headers);
|
|
79
|
+
}
|
|
80
|
+
return new Headers();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cloneRequestInput(
|
|
84
|
+
input: RequestInfo | URL,
|
|
85
|
+
init: RequestInit | undefined,
|
|
86
|
+
headers: Headers,
|
|
87
|
+
): [RequestInfo | URL, RequestInit | undefined] {
|
|
88
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
89
|
+
return [new Request(input, { ...init, headers }), undefined];
|
|
90
|
+
}
|
|
91
|
+
return [input, { ...init, headers }];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function retryAfterMs(response: Response) {
|
|
95
|
+
const retryAfter = response.headers.get("retry-after");
|
|
96
|
+
if (!retryAfter) return 60_000;
|
|
97
|
+
|
|
98
|
+
const seconds = Number(retryAfter);
|
|
99
|
+
if (Number.isFinite(seconds)) return Math.max(1_000, seconds * 1000);
|
|
100
|
+
|
|
101
|
+
const date = Date.parse(retryAfter);
|
|
102
|
+
if (Number.isFinite(date)) return Math.max(1_000, date - Date.now());
|
|
103
|
+
|
|
104
|
+
return 60_000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function installFetchPatch(db: Database, client: any) {
|
|
108
|
+
if (fetchPatched) return;
|
|
109
|
+
fetchPatched = true;
|
|
110
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
111
|
+
|
|
112
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
113
|
+
const headers = headersFrom(input, init);
|
|
114
|
+
const requestID = headers.get(INTERNAL_REQUEST_HEADER);
|
|
115
|
+
if (!requestID) return originalFetch(input, init);
|
|
116
|
+
|
|
117
|
+
const pending = takePendingRequest(requestID);
|
|
118
|
+
headers.delete(INTERNAL_REQUEST_HEADER);
|
|
119
|
+
if (!pending?.account) {
|
|
120
|
+
const [nextInput, nextInit] = cloneRequestInput(input, init, headers);
|
|
121
|
+
return originalFetch(nextInput, nextInit);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let account = pending.account;
|
|
125
|
+
const maxAttempts = 3;
|
|
126
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
127
|
+
const attemptHeaders = new Headers(headers);
|
|
128
|
+
applyAuthToHeaders(attemptHeaders, account, {
|
|
129
|
+
preserveExistingOAuth: attempt === 0,
|
|
130
|
+
});
|
|
131
|
+
const [nextInput, nextInit] = cloneRequestInput(
|
|
132
|
+
input,
|
|
133
|
+
init,
|
|
134
|
+
attemptHeaders,
|
|
135
|
+
);
|
|
136
|
+
const response = await originalFetch(nextInput, nextInit);
|
|
137
|
+
if (!RETRYABLE_STATUS.has(response.status)) return response;
|
|
138
|
+
|
|
139
|
+
markRateLimited(
|
|
140
|
+
db,
|
|
141
|
+
account.providerID,
|
|
142
|
+
account.alias,
|
|
143
|
+
retryAfterMs(response),
|
|
144
|
+
);
|
|
145
|
+
if (!getBalancingEnabled(db)) return response;
|
|
146
|
+
if (attempt === maxAttempts - 1) return response;
|
|
147
|
+
|
|
148
|
+
const next = chooseFailoverAccount(db, account.providerID, account.alias);
|
|
149
|
+
if (!next) return response;
|
|
150
|
+
|
|
151
|
+
account = setActiveAccount(db, next.providerID, next.alias);
|
|
152
|
+
await setNativeAuth(client, account.providerID, account.auth, db);
|
|
153
|
+
await showToast(
|
|
154
|
+
client,
|
|
155
|
+
`Balancer: ${pending.providerID}/${pending.account.alias} is rate limited. Switching to ${account.alias}.`,
|
|
156
|
+
"warning",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error("Balancer retry loop exited unexpectedly");
|
|
161
|
+
}) as typeof globalThis.fetch;
|
|
162
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import {
|
|
3
|
+
type Config,
|
|
4
|
+
type Hooks,
|
|
5
|
+
type Plugin,
|
|
6
|
+
tool,
|
|
7
|
+
} from "@opencode-ai/plugin";
|
|
8
|
+
import {
|
|
9
|
+
getActiveAccount,
|
|
10
|
+
getSelectedAccount,
|
|
11
|
+
getSelectedModel,
|
|
12
|
+
setActiveAccount,
|
|
13
|
+
} from "../core/accounts";
|
|
14
|
+
import { openBalancerDatabase } from "../core/database";
|
|
15
|
+
import { storePath } from "../core/path";
|
|
16
|
+
import { getBalancingEnabled, resolveActiveSelection } from "../core/priority";
|
|
17
|
+
import { migrate } from "../core/schema";
|
|
18
|
+
import { runFallbackBalancerCommand } from "./commands";
|
|
19
|
+
import { installFetchPatch } from "./fetch-patch";
|
|
20
|
+
import { setNativeAuth, showToast } from "./native";
|
|
21
|
+
import {
|
|
22
|
+
BALANCER_METADATA_KEY,
|
|
23
|
+
INTERNAL_REQUEST_HEADER,
|
|
24
|
+
setPendingRequest,
|
|
25
|
+
} from "./request-balancer";
|
|
26
|
+
|
|
27
|
+
export function configureFallbackCommand(cfg: Config) {
|
|
28
|
+
if (!cfg.command?.balancer) return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runSafeFallbackBalancerCommand(db: Database, raw: string) {
|
|
32
|
+
try {
|
|
33
|
+
return runFallbackBalancerCommand(db, raw);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return error instanceof Error ? error.message : "Balancer command failed.";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createServerHooks({
|
|
40
|
+
db,
|
|
41
|
+
client,
|
|
42
|
+
}: {
|
|
43
|
+
db: Database;
|
|
44
|
+
client: any;
|
|
45
|
+
}): Hooks {
|
|
46
|
+
return {
|
|
47
|
+
"chat.headers": async (input, output) => {
|
|
48
|
+
const providerID = input.model.providerID;
|
|
49
|
+
const account = getActiveAccount(db, providerID);
|
|
50
|
+
if (!account) return;
|
|
51
|
+
|
|
52
|
+
if (account.auth.type === "oauth") {
|
|
53
|
+
await setNativeAuth(client, providerID, account.auth, db);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const requestID = crypto.randomUUID();
|
|
57
|
+
setPendingRequest(requestID, { account, providerID });
|
|
58
|
+
output.headers[INTERNAL_REQUEST_HEADER] = requestID;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
"chat.message": async (_input, output) => {
|
|
62
|
+
// Balancing on: the priority list decides the provider/model for every
|
|
63
|
+
// message (recomputed per message -> failover and recovery for free).
|
|
64
|
+
if (getBalancingEnabled(db)) {
|
|
65
|
+
const selection = resolveActiveSelection(
|
|
66
|
+
db,
|
|
67
|
+
undefined,
|
|
68
|
+
output.message.model?.providerID,
|
|
69
|
+
);
|
|
70
|
+
if (!selection) return;
|
|
71
|
+
setActiveAccount(db, selection.providerID, selection.account.alias);
|
|
72
|
+
output.message.model = {
|
|
73
|
+
modelID: selection.modelID,
|
|
74
|
+
providerID: selection.providerID,
|
|
75
|
+
};
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Balancing off: keep opencode's native choice; only fill when missing.
|
|
80
|
+
if (output.message.model?.providerID && output.message.model?.modelID)
|
|
81
|
+
return;
|
|
82
|
+
|
|
83
|
+
const selected = getSelectedAccount(db);
|
|
84
|
+
if (!selected) return;
|
|
85
|
+
const model = getSelectedModel(db, selected.providerID);
|
|
86
|
+
if (!model) return;
|
|
87
|
+
|
|
88
|
+
output.message.model = {
|
|
89
|
+
modelID: model.modelID,
|
|
90
|
+
providerID: model.providerID,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
"command.execute.before": async (input, output) => {
|
|
95
|
+
if (input.command !== "balancer") return;
|
|
96
|
+
const result = runSafeFallbackBalancerCommand(db, input.arguments);
|
|
97
|
+
output.parts.length = 0;
|
|
98
|
+
await showToast(client, result.split("\n")[0] ?? result, "info");
|
|
99
|
+
throw new Error(`[balancer]\n${result}`);
|
|
100
|
+
},
|
|
101
|
+
config: async (cfg) => {
|
|
102
|
+
configureFallbackCommand(cfg);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
106
|
+
output.messages = output.messages.filter((message) => {
|
|
107
|
+
return !message.parts.some((part: any) => {
|
|
108
|
+
return part?.metadata?.[BALANCER_METADATA_KEY] === true;
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
tool: {
|
|
114
|
+
balancer_command: tool({
|
|
115
|
+
args: {
|
|
116
|
+
command: tool.schema
|
|
117
|
+
.string()
|
|
118
|
+
.describe("Command arguments for /balancer."),
|
|
119
|
+
},
|
|
120
|
+
description: "Run fallback account balancer commands.",
|
|
121
|
+
execute: async (args) =>
|
|
122
|
+
runSafeFallbackBalancerCommand(db, args.command),
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const serverPlugin = (async ({ client }) => {
|
|
129
|
+
const db = openBalancerDatabase(storePath());
|
|
130
|
+
migrate(db);
|
|
131
|
+
installFetchPatch(db, client);
|
|
132
|
+
|
|
133
|
+
return createServerHooks({ client, db });
|
|
134
|
+
}) satisfies Plugin;
|