forked from boos/BoosAPI
feat: surface oauth quota details
This commit is contained in:
@@ -68,6 +68,9 @@ describe('backupService', () => {
|
||||
username: 'roundtrip-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: 'api-token',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'roundtrip-account-key',
|
||||
oauthProjectId: 'roundtrip-project-id',
|
||||
balance: 12.3,
|
||||
balanceUsed: 4.5,
|
||||
quota: 99.9,
|
||||
@@ -141,6 +144,9 @@ describe('backupService', () => {
|
||||
|
||||
expect(restoredAccount?.isPinned).toBe(true);
|
||||
expect(restoredAccount?.sortOrder).toBe(7);
|
||||
expect(restoredAccount?.oauthProvider).toBe('codex');
|
||||
expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key');
|
||||
expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id');
|
||||
|
||||
expect(restoredRoute?.displayName).toBe('gpt-route');
|
||||
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
|
||||
@@ -203,4 +209,80 @@ describe('backupService', () => {
|
||||
expect(accounts[0].username).toBe('legacy-user');
|
||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('backfills oauth columns from extraConfig when importing older backups', async () => {
|
||||
const payload = {
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'codex-site',
|
||||
url: 'https://codex.example.com',
|
||||
platform: 'chatgpt-account',
|
||||
proxyUrl: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
apiKey: null,
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
externalCheckinUrl: null,
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1,
|
||||
customHeaders: null,
|
||||
},
|
||||
],
|
||||
accounts: [
|
||||
{
|
||||
id: 10,
|
||||
siteId: 1,
|
||||
username: 'oauth-user',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
quota: 0,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
checkinEnabled: true,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
accountId: 'oauth-user@example.com',
|
||||
accountKey: 'oauth-user@example.com',
|
||||
projectId: 'oauth-project-id',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
},
|
||||
}),
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
},
|
||||
],
|
||||
accountTokens: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const result = await backupService.importBackup(payload);
|
||||
|
||||
expect(result.allImported).toBe(true);
|
||||
expect(result.sections.accounts).toBe(true);
|
||||
|
||||
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get();
|
||||
|
||||
expect(restoredAccount?.oauthProvider).toBe('gemini-cli');
|
||||
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
|
||||
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||
|
||||
const BACKUP_VERSION = '2.0';
|
||||
|
||||
@@ -104,6 +105,18 @@ function normalizeLegacyQuota(raw: unknown): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
|
||||
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
|
||||
const oauthProvider = row.oauthProvider || oauth?.provider || null;
|
||||
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
|
||||
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
|
||||
return {
|
||||
oauthProvider,
|
||||
oauthAccountKey,
|
||||
oauthProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacyPlatform(raw: string): string {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value) return 'new-api';
|
||||
@@ -494,12 +507,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
||||
}
|
||||
|
||||
for (const row of section.accounts) {
|
||||
const oauthColumns = resolveImportedOauthColumns(row);
|
||||
await tx.insert(schema.accounts).values({
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
username: row.username,
|
||||
accessToken: row.accessToken,
|
||||
apiToken: row.apiToken,
|
||||
oauthProvider: oauthColumns.oauthProvider,
|
||||
oauthAccountKey: oauthColumns.oauthAccountKey,
|
||||
oauthProjectId: oauthColumns.oauthProjectId,
|
||||
balance: row.balance,
|
||||
balanceUsed: row.balanceUsed,
|
||||
quota: row.quota,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { schema } from '../../db/index.js';
|
||||
import type { OauthQuotaSnapshot } from './quotaTypes.js';
|
||||
|
||||
type ParsedOauthInfo = {
|
||||
provider?: unknown;
|
||||
@@ -11,6 +12,7 @@ type ParsedOauthInfo = {
|
||||
refreshToken?: unknown;
|
||||
idToken?: unknown;
|
||||
providerData?: unknown;
|
||||
quota?: unknown;
|
||||
modelDiscoveryStatus?: unknown;
|
||||
lastModelSyncAt?: unknown;
|
||||
lastModelSyncError?: unknown;
|
||||
@@ -34,6 +36,7 @@ export type OauthInfo = {
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
quota?: OauthQuotaSnapshot;
|
||||
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
|
||||
lastModelSyncAt?: string;
|
||||
lastModelSyncError?: string;
|
||||
@@ -88,6 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as OauthQuotaSnapshot;
|
||||
}
|
||||
|
||||
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -113,6 +121,7 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI
|
||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
||||
idToken: asTrimmedString(parsed.idToken),
|
||||
providerData: asRecord(parsed.providerData),
|
||||
quota: asQuotaSnapshot(parsed.quota),
|
||||
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
|
||||
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
|
||||
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './providers.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||
|
||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||
const MANUAL_CALLBACK_DELAY_MS = 15_000;
|
||||
@@ -503,6 +504,7 @@ export async function listOauthConnections(options: {
|
||||
projectId: oauth.projectId,
|
||||
modelCount: models.length,
|
||||
modelsPreview: models.slice(0, 10),
|
||||
quota: buildQuotaSnapshotFromOauthInfo(oauth),
|
||||
status,
|
||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||
@@ -535,6 +537,11 @@ export async function deleteOauthConnection(accountId: number) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function refreshOauthConnectionQuota(accountId: number) {
|
||||
const quota = await refreshOauthQuotaSnapshot(accountId);
|
||||
return { success: true, quota };
|
||||
}
|
||||
|
||||
export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
|
||||
Reference in New Issue
Block a user