@bitkyc08/opencodex 2.7.42 → 2.7.43
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/README.md +8 -1
- package/bin/ocx.mjs +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +49 -6
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +55 -16
- package/src/cli/status.ts +47 -13
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +15 -0
- package/src/codex/routing.ts +695 -106
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +566 -25
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +14 -4
- package/src/server/auth-cors.ts +110 -22
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +583 -152
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/oauth/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Exceptions:
|
|
11
11
|
* - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
|
|
12
12
|
* for Codex pool logins, which have their own ledger (codex-accounts.json).
|
|
13
|
-
* - Credentials without identity (no accountId/email
|
|
13
|
+
* - Credentials without identity (no accountId/email) replace the active slot
|
|
14
14
|
* instead of appending: their refresh tokens rotate, so a derived id would duplicate the
|
|
15
15
|
* same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor
|
|
16
16
|
* extracts JWT `sub` — both append distinct accounts under multiauth.
|
|
@@ -19,6 +19,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
19
19
|
import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
22
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
22
23
|
import { validateCopilotApiBaseUrl } from "./github-copilot";
|
|
23
24
|
import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
|
|
24
25
|
|
|
@@ -150,7 +151,7 @@ function sameFd(a: LockSnapshot,b: ReturnType<typeof fstatSync>): boolean { retu
|
|
|
150
151
|
export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise<OAuthFileLockGuard> } {
|
|
151
152
|
const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8"));
|
|
152
153
|
if(wait<0||stale<=0||min<0||max<min) throw new OAuthFileLockError("Invalid OAuth file-lock timing options");
|
|
153
|
-
return { async acquire() { hardenConfigDir(); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
|
|
154
|
+
return { async acquire() { hardenConfigDir(); recordOwnedConfigPath(getConfigDir(),options.path); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
|
|
154
155
|
try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } };
|
|
155
156
|
}
|
|
156
157
|
/** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */
|
|
@@ -206,6 +207,28 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
|
|
|
206
207
|
const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl);
|
|
207
208
|
if (validated) normalized.apiBaseUrl = validated;
|
|
208
209
|
}
|
|
210
|
+
if (candidate.kiro && typeof candidate.kiro === "object") {
|
|
211
|
+
const kiro = candidate.kiro;
|
|
212
|
+
const clean = (value: unknown, max: number): string | undefined => {
|
|
213
|
+
if (typeof value !== "string") return undefined;
|
|
214
|
+
const trimmed = value.trim();
|
|
215
|
+
return trimmed && trimmed.length <= max && !/[\x00-\x1f\x7f]/.test(trimmed) ? trimmed : undefined;
|
|
216
|
+
};
|
|
217
|
+
const profileArn = clean(kiro.profileArn, 1024);
|
|
218
|
+
const ssoRegion = clean(kiro.ssoRegion, 64);
|
|
219
|
+
const apiRegion = clean(kiro.apiRegion, 64);
|
|
220
|
+
const clientId = clean(kiro.clientId, 4096);
|
|
221
|
+
const clientSecret = clean(kiro.clientSecret, 4096);
|
|
222
|
+
if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) {
|
|
223
|
+
normalized.kiro = {
|
|
224
|
+
...(profileArn ? { profileArn } : {}),
|
|
225
|
+
...(ssoRegion ? { ssoRegion } : {}),
|
|
226
|
+
...(apiRegion ? { apiRegion } : {}),
|
|
227
|
+
...(clientId ? { clientId } : {}),
|
|
228
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
209
232
|
return normalized;
|
|
210
233
|
}
|
|
211
234
|
|
|
@@ -293,7 +316,11 @@ export function getCredential(provider: string): OAuthCredentials | null {
|
|
|
293
316
|
* (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the
|
|
294
317
|
* active slot / whole set instead.
|
|
295
318
|
*/
|
|
296
|
-
export async function saveCredential(
|
|
319
|
+
export async function saveCredential(
|
|
320
|
+
provider: string,
|
|
321
|
+
cred: OAuthCredentials,
|
|
322
|
+
opts: { preserveIdentityless?: boolean } = {},
|
|
323
|
+
): Promise<void> {
|
|
297
324
|
const safe = normalizeCredential(cred);
|
|
298
325
|
if (!safe) return;
|
|
299
326
|
await mutateStore(store => {
|
|
@@ -317,7 +344,7 @@ export async function saveCredential(provider: string, cred: OAuthCredentials):
|
|
|
317
344
|
// active identity-less row in place prevents a stale duplicate that stays selectable
|
|
318
345
|
// and would re-refresh into a second row with the same identity.
|
|
319
346
|
const active = set.accounts.find(a => a.id === set.activeAccountId);
|
|
320
|
-
if (active && active.credential.accountId === undefined && active.credential.email === undefined) {
|
|
347
|
+
if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
|
|
321
348
|
active.credential = safe;
|
|
322
349
|
delete active.needsReauth;
|
|
323
350
|
return;
|
|
@@ -418,6 +445,29 @@ export async function removeAccount(provider: string, accountId: string): Promis
|
|
|
418
445
|
});
|
|
419
446
|
}
|
|
420
447
|
|
|
448
|
+
/** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */
|
|
449
|
+
export async function replaceProviderAccountSet(
|
|
450
|
+
provider: string,
|
|
451
|
+
set: ProviderAccountSet | null,
|
|
452
|
+
): Promise<void> {
|
|
453
|
+
await mutateStore(store => {
|
|
454
|
+
if (!set || set.accounts.length === 0) {
|
|
455
|
+
delete store[provider];
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
store[provider] = {
|
|
459
|
+
activeAccountId: set.activeAccountId,
|
|
460
|
+
accounts: set.accounts.map(account => ({
|
|
461
|
+
id: account.id,
|
|
462
|
+
credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) },
|
|
463
|
+
...(account.alias ? { alias: account.alias } : {}),
|
|
464
|
+
...(account.needsReauth ? { needsReauth: true } : {}),
|
|
465
|
+
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
|
466
|
+
})),
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
421
471
|
export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise<void> {
|
|
422
472
|
await mutateStore(store => {
|
|
423
473
|
const account = store[provider]?.accounts.find(a => a.id === accountId);
|
package/src/oauth/types.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */
|
|
2
2
|
export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual";
|
|
3
3
|
|
|
4
|
+
/** Account-scoped Kiro data required for refresh and request routing. */
|
|
5
|
+
export interface KiroOAuthMetadata {
|
|
6
|
+
profileArn?: string;
|
|
7
|
+
ssoRegion?: string;
|
|
8
|
+
apiRegion?: string;
|
|
9
|
+
clientId?: string;
|
|
10
|
+
clientSecret?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export type OAuthCredentials = {
|
|
5
14
|
refresh: string;
|
|
6
15
|
access: string;
|
|
@@ -15,6 +24,8 @@ export type OAuthCredentials = {
|
|
|
15
24
|
* Never reuse for Antigravity projectId; validated on write and again at request time.
|
|
16
25
|
*/
|
|
17
26
|
apiBaseUrl?: string;
|
|
27
|
+
/** Never returned by management APIs; persisted only inside the protected auth-store boundary. */
|
|
28
|
+
kiro?: KiroOAuthMetadata;
|
|
18
29
|
};
|
|
19
30
|
|
|
20
31
|
/** One logged-in account inside a provider's account set (multiauth). */
|
|
@@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices";
|
|
|
2
2
|
import { providerConfigSeed } from "./derive";
|
|
3
3
|
import { rewriteProviderReferences } from "./provider-id-rewrite";
|
|
4
4
|
import { PROVIDER_REGISTRY } from "./registry";
|
|
5
|
+
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
5
6
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
6
7
|
|
|
7
8
|
const BEIJING_ID = "alibaba-token-plan";
|
|
@@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig {
|
|
|
91
92
|
* those contracts across. That override fix shipped once, was backed out twice,
|
|
92
93
|
* and was refused again when PR #459 closed.
|
|
93
94
|
*
|
|
94
|
-
* Refuses to act when the destination
|
|
95
|
-
*
|
|
96
|
-
* user decision, not a
|
|
95
|
+
* Refuses to act when the destination is reserved by an account namespace or
|
|
96
|
+
* already holds a value — a provider row or a key the reference rewrite would
|
|
97
|
+
* land on — because choosing which setting wins is a user decision, not a
|
|
98
|
+
* migration's.
|
|
97
99
|
*/
|
|
98
100
|
export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection {
|
|
99
101
|
const beijing = config.providers[BEIJING_ID];
|
|
@@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM
|
|
|
101
103
|
if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) {
|
|
102
104
|
return { config, changed: false, warnings: [] };
|
|
103
105
|
}
|
|
106
|
+
if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) {
|
|
107
|
+
return {
|
|
108
|
+
config,
|
|
109
|
+
changed: false,
|
|
110
|
+
warnings: [
|
|
111
|
+
`provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved `
|
|
112
|
+
+ `by a configured Codex account namespace. Nothing was changed. Rename the account `
|
|
113
|
+
+ `selector or move the provider manually, then restart.`,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
104
117
|
if (config.providers[INTL_ID]) {
|
|
105
118
|
return {
|
|
106
119
|
config,
|
|
@@ -13,6 +13,7 @@ const ANTIGRAVITY_WIRE_MODELS = [
|
|
|
13
13
|
"gemini-3.6-flash-high",
|
|
14
14
|
"gemini-3.1-pro-low",
|
|
15
15
|
"gemini-pro-agent",
|
|
16
|
+
"gemini-3.1-flash-image",
|
|
16
17
|
"claude-sonnet-4-6",
|
|
17
18
|
"claude-opus-4-6-thinking",
|
|
18
19
|
"gpt-oss-120b-medium",
|
|
@@ -85,6 +86,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
|
|
|
85
86
|
export const ANTIGRAVITY_MODELS = [
|
|
86
87
|
"gemini-3.6-flash",
|
|
87
88
|
"gemini-3.1-pro",
|
|
89
|
+
"gemini-3.1-flash-image",
|
|
88
90
|
"claude-sonnet-4-6",
|
|
89
91
|
"claude-opus-4-6-thinking",
|
|
90
92
|
"gpt-oss-120b-medium",
|
|
@@ -97,6 +99,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
|
97
99
|
"gemini-3.6-flash-high": 1_048_576,
|
|
98
100
|
"gemini-3.1-pro-low": 1_048_576,
|
|
99
101
|
"gemini-pro-agent": 1_048_576,
|
|
102
|
+
"gemini-3.1-flash-image": 1_048_576,
|
|
100
103
|
"claude-sonnet-4-6": 200_000,
|
|
101
104
|
"claude-opus-4-6-thinking": 1_000_000,
|
|
102
105
|
"gpt-oss-120b-medium": 131_072,
|
|
@@ -30,7 +30,7 @@ export function maskApiKey(value: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Content-derived id: re-adding the same key upserts instead of duplicating. */
|
|
33
|
-
function
|
|
33
|
+
export function apiKeyPoolEntryId(key: string): string {
|
|
34
34
|
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,11 +39,18 @@ export function isKeyAuthProvider(provider: OcxProviderConfig): boolean {
|
|
|
39
39
|
return provider.authMode !== "oauth" && provider.authMode !== "forward";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Trim and reject blank / CRLF-bearing secrets. Shared by pool writes and OAuth upsert. */
|
|
43
|
+
export function sanitizeApiKeyValue(value: unknown): string | undefined {
|
|
44
|
+
if (typeof value !== "string") return undefined;
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
return trimmed && !/[\r\n]/.test(trimmed) ? trimmed : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
/** Seed the pool from a legacy bare `apiKey`, and keep `apiKey` mirrored to the active entry. */
|
|
43
50
|
function ensurePool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
|
|
44
51
|
if (!provider.apiKeyPool) provider.apiKeyPool = [];
|
|
45
52
|
if (provider.apiKeyPool.length === 0 && provider.apiKey) {
|
|
46
|
-
provider.apiKeyPool.push({ id:
|
|
53
|
+
provider.apiKeyPool.push({ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey });
|
|
47
54
|
}
|
|
48
55
|
return provider.apiKeyPool;
|
|
49
56
|
}
|
|
@@ -75,11 +82,11 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
|
|
|
75
82
|
export function addProviderApiKey(config: OcxConfig, name: string, key: string, label?: string): { id: string } | { error: string } {
|
|
76
83
|
const provider = config.providers[name];
|
|
77
84
|
if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" };
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
85
|
+
if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
|
|
86
|
+
const trimmed = sanitizeApiKeyValue(key);
|
|
87
|
+
if (!trimmed) return { error: "key must not include line breaks" };
|
|
81
88
|
const pool = ensurePool(provider);
|
|
82
|
-
const id =
|
|
89
|
+
const id = apiKeyPoolEntryId(trimmed);
|
|
83
90
|
const existing = pool.find(e => e.id === id);
|
|
84
91
|
if (existing) {
|
|
85
92
|
if (label?.trim()) existing.label = label.trim();
|
package/src/providers/derive.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface DerivedKeyLoginProvider {
|
|
|
5
5
|
label: string;
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
adapter: string;
|
|
8
|
+
apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
|
|
8
9
|
dashboardUrl: string;
|
|
9
10
|
models?: string[];
|
|
10
11
|
liveModels?: boolean;
|
|
@@ -103,6 +104,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
103
104
|
return {
|
|
104
105
|
adapter: entry.adapter,
|
|
105
106
|
baseUrl: entry.baseUrl,
|
|
107
|
+
...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
|
|
106
108
|
authMode: entry.authKind === "local" ? undefined : entry.authKind,
|
|
107
109
|
...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}),
|
|
108
110
|
...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
|
|
@@ -129,6 +131,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
129
131
|
...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}),
|
|
130
132
|
...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
|
|
131
133
|
...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
|
|
134
|
+
...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
|
|
132
135
|
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
|
|
133
136
|
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
|
|
134
137
|
...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
|
|
@@ -150,6 +153,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
|
|
|
150
153
|
label: entry.label,
|
|
151
154
|
baseUrl: entry.baseUrl,
|
|
152
155
|
adapter: entry.adapter,
|
|
156
|
+
...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
|
|
153
157
|
dashboardUrl: entry.dashboardUrl,
|
|
154
158
|
...(entry.models ? { models: [...entry.models] } : {}),
|
|
155
159
|
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
|
|
@@ -221,6 +225,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
221
225
|
const entry = PROVIDER_REGISTRY.find(row => row.id === name);
|
|
222
226
|
if (!entry) return;
|
|
223
227
|
const seed = providerConfigSeed(entry);
|
|
228
|
+
if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
|
|
224
229
|
if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
|
|
225
230
|
// Fill mode only when absent: an explicit persisted `direct` must never be overwritten.
|
|
226
231
|
if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode;
|
|
@@ -242,6 +247,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
242
247
|
if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels];
|
|
243
248
|
if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
|
|
244
249
|
if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
|
|
250
|
+
if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey;
|
|
245
251
|
if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
|
|
246
252
|
if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
|
|
247
253
|
if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { saveConfigPreservingClaudeCode } from "../config";
|
|
12
12
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
13
|
-
import { resolveProviderTransport } from "./xai-transport";
|
|
13
|
+
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
14
14
|
|
|
15
15
|
// ---- cooldown state (in-memory, same as codex/routing.ts) ----
|
|
16
16
|
|
|
@@ -69,6 +69,11 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
|
|
|
69
69
|
*
|
|
70
70
|
* @returns A new OcxProviderConfig with the swapped key (and mutated config on disk),
|
|
71
71
|
* or `null` when no alternative key is available (all in cooldown or pool < 2).
|
|
72
|
+
*
|
|
73
|
+
* The returned object is a snapshot of the PERSISTED config — it carries none of the
|
|
74
|
+
* registry backfills `routedProviderConfig` merges in at request time. Request paths must
|
|
75
|
+
* not assign it to an active route wholesale; use `rotateProviderTransportOn429`, which
|
|
76
|
+
* takes only the swapped key and keeps the routed provider intact.
|
|
72
77
|
*/
|
|
73
78
|
export function rotateKeyOn429(
|
|
74
79
|
config: OcxConfig,
|
|
@@ -133,12 +138,23 @@ interface RotateProviderTransportOptions {
|
|
|
133
138
|
promptCacheKey?: string;
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
|
|
143
|
+
*
|
|
144
|
+
* `routedProvider` is the request's active provider (the `routedProviderConfig` output the
|
|
145
|
+
* route was built with). The result inherits it and swaps ONLY the API key: the persisted
|
|
146
|
+
* config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
|
|
147
|
+
* provider from that snapshot would silently drop every field the registry merged in at
|
|
148
|
+
* routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
|
|
149
|
+
* metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
|
|
150
|
+
* path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
|
|
151
|
+
*/
|
|
137
152
|
export function rotateProviderTransportOn429(
|
|
138
153
|
config: OcxConfig,
|
|
139
154
|
providerName: string,
|
|
155
|
+
routedProvider: OcxProviderTransport,
|
|
140
156
|
options: RotateProviderTransportOptions = {},
|
|
141
|
-
):
|
|
157
|
+
): OcxProviderTransport | null {
|
|
142
158
|
const rotated = rotateKeyOn429(
|
|
143
159
|
config,
|
|
144
160
|
providerName,
|
|
@@ -147,7 +163,11 @@ export function rotateProviderTransportOn429(
|
|
|
147
163
|
options.attemptedKey,
|
|
148
164
|
);
|
|
149
165
|
return rotated
|
|
150
|
-
? resolveProviderTransport(
|
|
166
|
+
? resolveProviderTransport(
|
|
167
|
+
providerName,
|
|
168
|
+
{ ...routedProvider, apiKey: rotated.apiKey },
|
|
169
|
+
options.promptCacheKey,
|
|
170
|
+
)
|
|
151
171
|
: null;
|
|
152
172
|
}
|
|
153
173
|
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
|
|
2
2
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
3
3
|
import { resolveEnvValue } from "../config";
|
|
4
|
-
import { getValidAccessToken } from "../oauth";
|
|
5
|
-
import { getCredential } from "../oauth/store";
|
|
4
|
+
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
5
|
+
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
6
6
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
7
7
|
import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
|
|
8
8
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
9
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
10
10
|
|
|
11
|
+
/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
|
|
12
|
+
const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
13
|
+
|
|
11
14
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
12
15
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
13
16
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
@@ -188,43 +191,244 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number
|
|
|
188
191
|
return { percent, resetAt };
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
|
|
195
|
+
const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
|
|
196
|
+
|
|
197
|
+
async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
198
|
+
const joinable = anthropicUsageInflight.get(accessToken);
|
|
199
|
+
if (joinable) return joinable;
|
|
200
|
+
|
|
201
|
+
const probe = (async (): Promise<ProviderQuota | null> => {
|
|
202
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
203
|
+
headers: {
|
|
204
|
+
Accept: "application/json, text/plain, */*",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
"User-Agent": "claude-cli/2.1.63 (external, cli)",
|
|
207
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
208
|
+
Authorization: `Bearer ${accessToken}`,
|
|
209
|
+
},
|
|
210
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) return null;
|
|
213
|
+
const body = asRecord(await response.json().catch(() => null));
|
|
214
|
+
if (!body) return null;
|
|
215
|
+
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
216
|
+
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
217
|
+
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
218
|
+
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
219
|
+
const customWindows: ProviderQuotaWindow[] = [];
|
|
220
|
+
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
221
|
+
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
222
|
+
const quota: ProviderQuota = {
|
|
223
|
+
// Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
|
|
224
|
+
// rows: report it in the canonical fields so the dashboard renders it with the standard
|
|
225
|
+
// "5-hour limit" label and ordering instead of as a generic extra window.
|
|
226
|
+
...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
|
|
227
|
+
...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
228
|
+
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
229
|
+
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
230
|
+
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
231
|
+
updatedAt: Date.now(),
|
|
232
|
+
};
|
|
233
|
+
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
234
|
+
return hasQuotaRows(quota) ? quota : null;
|
|
235
|
+
})().finally(() => {
|
|
236
|
+
if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
|
|
237
|
+
});
|
|
238
|
+
anthropicUsageInflight.set(accessToken, probe);
|
|
239
|
+
return probe;
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
243
|
+
// Capture the account we intend to probe before awaiting — a mid-flight active
|
|
244
|
+
// switch must not seed the wrong account's cache with this response.
|
|
245
|
+
const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
|
|
192
246
|
let accessToken: string;
|
|
193
247
|
try {
|
|
194
248
|
accessToken = await getValidAccessToken("anthropic");
|
|
195
249
|
} catch {
|
|
196
250
|
return null;
|
|
197
251
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
if (!response.ok) return null;
|
|
209
|
-
const body = asRecord(await response.json().catch(() => null));
|
|
210
|
-
if (!body) return null;
|
|
211
|
-
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
212
|
-
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
213
|
-
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
214
|
-
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
215
|
-
const customWindows: ProviderQuotaWindow[] = [];
|
|
216
|
-
if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) });
|
|
217
|
-
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
218
|
-
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
219
|
-
const quota: ProviderQuota = {
|
|
220
|
-
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
221
|
-
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
222
|
-
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
223
|
-
updatedAt: Date.now(),
|
|
224
|
-
};
|
|
252
|
+
const quota = await fetchAnthropicUsageQuota(accessToken);
|
|
253
|
+
if (!quota) return null;
|
|
254
|
+
// Share the active-account probe with the per-account cache so Providers-page
|
|
255
|
+
// loads do not double-hit Anthropic's rate-limited usage endpoint.
|
|
256
|
+
if (probedAccountId) {
|
|
257
|
+
const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
|
|
258
|
+
if (stillOwnsToken) {
|
|
259
|
+
accountQuotaCache.set(accountCacheKey("anthropic", probedAccountId), { ts: Date.now(), quota });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
225
262
|
return report(provider, "anthropic:oauth-usage", quota);
|
|
226
263
|
}
|
|
227
264
|
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Per-account quota (multiauth)
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its
|
|
271
|
+
* own bearer token — the active-account selection and the local usage log are irrelevant here.
|
|
272
|
+
* Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a
|
|
273
|
+
* per-account TTL so N accounts cost at most N upstream calls per window.
|
|
274
|
+
*
|
|
275
|
+
* The TTL is deliberately longer than the provider-level one: this path multiplies by account
|
|
276
|
+
* count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing).
|
|
277
|
+
*/
|
|
278
|
+
const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
|
|
279
|
+
type AccountQuotaCacheEntry = {
|
|
280
|
+
ts: number;
|
|
281
|
+
quota: ProviderQuota | null;
|
|
282
|
+
/** Last probe failed (429 / network / expired login); still may hold last-good quota. */
|
|
283
|
+
unavailable?: true;
|
|
284
|
+
};
|
|
285
|
+
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
286
|
+
const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
|
|
287
|
+
|
|
288
|
+
export interface ProviderAccountQuota {
|
|
289
|
+
accountId: string;
|
|
290
|
+
quota: ProviderQuota | null;
|
|
291
|
+
/** Set when the probe could not reach upstream (expired login, 429, network). */
|
|
292
|
+
unavailable?: true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
|
|
296
|
+
export function supportsPerAccountQuota(provider: string): boolean {
|
|
297
|
+
return provider === "anthropic";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function accountCacheKey(provider: string, accountId: string): string {
|
|
301
|
+
return `${provider}\u0000${accountId}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Synchronous last-good per-account quota read for routing. Never probes the network.
|
|
306
|
+
* Returns null when nothing is cached (or the cached row has no bars).
|
|
307
|
+
*/
|
|
308
|
+
export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
|
|
309
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
|
|
310
|
+
return entry?.quota ?? null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Test-only: seed or clear the per-account quota cache without probing upstream. */
|
|
314
|
+
export function setCachedProviderAccountQuotaForTests(
|
|
315
|
+
provider: string,
|
|
316
|
+
accountId: string,
|
|
317
|
+
quota: ProviderQuota | null,
|
|
318
|
+
): void {
|
|
319
|
+
const key = accountCacheKey(provider, accountId);
|
|
320
|
+
if (quota === null) {
|
|
321
|
+
accountQuotaCache.delete(key);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Drop cached per-account rows (all, or just one provider's). */
|
|
328
|
+
export function clearAccountQuotaCache(provider?: string): void {
|
|
329
|
+
if (!provider) {
|
|
330
|
+
accountQuotaCache.clear();
|
|
331
|
+
accountQuotaInflight.clear();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const prefix = `${provider}\u0000`;
|
|
335
|
+
for (const key of [...accountQuotaCache.keys()]) {
|
|
336
|
+
if (key.startsWith(prefix)) accountQuotaCache.delete(key);
|
|
337
|
+
}
|
|
338
|
+
// Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
|
|
339
|
+
for (const key of [...accountQuotaInflight.keys()]) {
|
|
340
|
+
if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a bearer for quota probing without silently adopting a newer global
|
|
346
|
+
* Claude CLI credential into a background multiauth slot.
|
|
347
|
+
*
|
|
348
|
+
* - Fresh stored access → use as-is (no refresh).
|
|
349
|
+
* - Active account with expired access → normal refresh path.
|
|
350
|
+
* - Background `local-cli` with expired access → fail closed (unavailable):
|
|
351
|
+
* `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity.
|
|
352
|
+
* - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh;
|
|
353
|
+
* Anthropic's lock only adopts disk credentials for `local-cli` rows.
|
|
354
|
+
*/
|
|
355
|
+
async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
|
|
356
|
+
const stored = getAccountCredential(provider, accountId);
|
|
357
|
+
if (!stored) throw new Error("account credential missing");
|
|
358
|
+
if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
|
|
359
|
+
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
360
|
+
if (activeId !== accountId && stored.source === "local-cli") {
|
|
361
|
+
throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe");
|
|
362
|
+
}
|
|
363
|
+
return getValidAccessTokenForAccount(provider, accountId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function fetchAccountQuota(
|
|
367
|
+
provider: string,
|
|
368
|
+
accountId: string,
|
|
369
|
+
forceRefresh: boolean,
|
|
370
|
+
): Promise<AccountQuotaCacheEntry> {
|
|
371
|
+
const key = accountCacheKey(provider, accountId);
|
|
372
|
+
const cached = accountQuotaCache.get(key);
|
|
373
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached;
|
|
374
|
+
const joinable = accountQuotaInflight.get(key);
|
|
375
|
+
if (joinable) return joinable;
|
|
376
|
+
|
|
377
|
+
const probe = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
378
|
+
try {
|
|
379
|
+
const token = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
380
|
+
const quota = await fetchAnthropicUsageQuota(token);
|
|
381
|
+
if (!quota) {
|
|
382
|
+
// Preserve last-good bars and mark unavailable; advance TTL so failures
|
|
383
|
+
// negative-cache instead of re-probing on every GUI poll.
|
|
384
|
+
const entry: AccountQuotaCacheEntry = {
|
|
385
|
+
ts: Date.now(),
|
|
386
|
+
quota: cached?.quota ?? null,
|
|
387
|
+
unavailable: true,
|
|
388
|
+
};
|
|
389
|
+
accountQuotaCache.set(key, entry);
|
|
390
|
+
return entry;
|
|
391
|
+
}
|
|
392
|
+
const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota };
|
|
393
|
+
accountQuotaCache.set(key, entry);
|
|
394
|
+
return entry;
|
|
395
|
+
} catch {
|
|
396
|
+
const entry: AccountQuotaCacheEntry = {
|
|
397
|
+
ts: Date.now(),
|
|
398
|
+
quota: cached?.quota ?? null,
|
|
399
|
+
unavailable: true,
|
|
400
|
+
};
|
|
401
|
+
accountQuotaCache.set(key, entry);
|
|
402
|
+
return entry;
|
|
403
|
+
}
|
|
404
|
+
})().finally(() => {
|
|
405
|
+
if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
|
|
406
|
+
});
|
|
407
|
+
accountQuotaInflight.set(key, probe);
|
|
408
|
+
return probe;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
|
|
413
|
+
* single failing account never blocks the others.
|
|
414
|
+
*/
|
|
415
|
+
export async function fetchProviderAccountQuotas(
|
|
416
|
+
provider: string,
|
|
417
|
+
forceRefresh = false,
|
|
418
|
+
): Promise<ProviderAccountQuota[]> {
|
|
419
|
+
if (!supportsPerAccountQuota(provider)) return [];
|
|
420
|
+
const set = getAccountSet(provider);
|
|
421
|
+
if (!set) return [];
|
|
422
|
+
return await Promise.all(set.accounts.map(async account => {
|
|
423
|
+
const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
|
|
424
|
+
return {
|
|
425
|
+
accountId: account.id,
|
|
426
|
+
quota: entry.quota,
|
|
427
|
+
...(entry.unavailable ? { unavailable: true as const } : {}),
|
|
428
|
+
};
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
228
432
|
function normalizedBaseUrl(value: string): string | null {
|
|
229
433
|
try {
|
|
230
434
|
const url = new URL(value);
|