diff --git a/package-lock.json b/package-lock.json index 6d7a4d6..ff4655f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pg": "^8.16.3", + "rate-limiter-flexible": "^11.0.0", "socks": "^2.8.7", "undici": "^6.24.1", "ws": "^8.20.0", @@ -12600,6 +12601,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rate-limiter-flexible": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.0.0.tgz", + "integrity": "sha512-UhN3xVeU6Az3y6hAuxMUwFsKcKD1HffhMGK0MknbSxH9vkwslS/p19ovCvJqIVT97pE778nKu2sUgYAcxj4dmQ==", + "license": "ISC" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index fc7c091..8805903 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pg": "^8.16.3", + "rate-limiter-flexible": "^11.0.0", "socks": "^2.8.7", "undici": "^6.24.1", "ws": "^8.20.0", diff --git a/src/server/contracts/supportRoutePayloads.ts b/src/server/contracts/supportRoutePayloads.ts index 36fa60b..71dde12 100644 --- a/src/server/contracts/supportRoutePayloads.ts +++ b/src/server/contracts/supportRoutePayloads.ts @@ -15,6 +15,7 @@ const oauthStartPayloadSchema = z.object({ accountId: z.number().int().positive().optional(), projectId: z.string().optional(), proxyUrl: z.union([z.string(), z.null()]).optional(), + useSystemProxy: z.boolean().optional(), }).passthrough(); const oauthManualCallbackPayloadSchema = z.object({ @@ -23,6 +24,15 @@ const oauthManualCallbackPayloadSchema = z.object({ const oauthConnectionRebindPayloadSchema = z.object({ proxyUrl: z.union([z.string(), z.null()]).optional(), + useSystemProxy: z.boolean().optional(), +}).passthrough(); + +const oauthQuotaBatchRefreshPayloadSchema = z.object({ + accountIds: z.array(z.number().int().positive()).optional(), +}).passthrough(); + +const oauthImportPayloadSchema = z.object({ + data: z.unknown().optional(), }).passthrough(); const updateCenterConfigPayloadSchema = z.object({ @@ -51,7 +61,9 @@ const updateCenterRollbackPayloadSchema = z.object({ export type AuthChangePayload = z.output; export type MonitorConfigPayload = z.output; export type OauthConnectionRebindPayload = z.output; +export type OauthImportPayload = z.output; export type OauthManualCallbackPayload = z.output; +export type OauthQuotaBatchRefreshPayload = z.output; export type OauthStartPayload = z.output; export type UpdateCenterConfigPayload = z.output; export type UpdateCenterDeployPayload = z.output; @@ -85,9 +97,18 @@ function formatSupportRoutePayloadError(error: z.ZodError): string { if (firstPath === 'proxyUrl') { return 'Invalid proxyUrl. Expected string or null.'; } + if (firstPath === 'useSystemProxy') { + return 'Invalid useSystemProxy. Expected boolean.'; + } + if (firstPath === 'accountIds') { + return 'Invalid accountIds. Expected positive number array.'; + } if (firstPath === 'callbackUrl') { return 'Invalid callbackUrl. Expected string.'; } + if (firstPath === 'data') { + return 'Invalid data. Expected object.'; + } if (firstPath === 'enabled') { return 'Invalid enabled. Expected boolean.'; } @@ -175,6 +196,16 @@ export function parseOauthConnectionRebindPayload(input: unknown): return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input); } +export function parseOauthQuotaBatchRefreshPayload(input: unknown): +{ success: true; data: OauthQuotaBatchRefreshPayload } | { success: false; error: string } { + return parseSupportRoutePayload(oauthQuotaBatchRefreshPayloadSchema, input); +} + +export function parseOauthImportPayload(input: unknown): +{ success: true; data: OauthImportPayload } | { success: false; error: string } { + return parseSupportRoutePayload(oauthImportPayloadSchema, input); +} + export function parseUpdateCenterConfigPayload(input: unknown): { success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } { return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input); diff --git a/src/server/middleware/requestRateLimit.ts b/src/server/middleware/requestRateLimit.ts index e87b2b9..b0b64e8 100644 --- a/src/server/middleware/requestRateLimit.ts +++ b/src/server/middleware/requestRateLimit.ts @@ -54,7 +54,6 @@ export function resetRequestRateLimitStore(): void { export function createRateLimitGuard(options: RateLimitOptions) { const message = options.message || DEFAULT_MESSAGE; - return async function rateLimitGuard(request: FastifyRequest, reply: FastifyReply) { const nowMs = Date.now(); pruneExpiredEntries(nowMs); diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 76e27fd..49a32db 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -559,6 +559,7 @@ export async function handleChatSurfaceRequest( modelName, parsedUsage, upstreamUsagePresent, + upstreamHeaders: upstream.headers, requestStartedAtMs: startTime, isStream: true, firstByteLatencyMs, @@ -922,6 +923,7 @@ export async function handleChatSurfaceRequest( modelName, parsedUsage, upstreamUsagePresent, + upstreamHeaders: upstream.headers, requestStartedAtMs: startTime, isStream: false, firstByteLatencyMs, diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 93559ae..67087cc 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -771,6 +771,7 @@ export async function handleOpenAiResponsesSurfaceRequest( modelName, parsedUsage, upstreamUsagePresent, + upstreamHeaders: upstream.headers, requestStartedAtMs: startTime, isStream: true, firstByteLatencyMs, @@ -1221,6 +1222,7 @@ export async function handleOpenAiResponsesSurfaceRequest( modelName, parsedUsage, upstreamUsagePresent, + upstreamHeaders: upstream.headers, requestStartedAtMs: startTime, isStream: false, firstByteLatencyMs, diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts index e5be236..b2c2ba3 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.test.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -16,6 +16,7 @@ const reportProxyAllFailedMock = vi.fn(); const reportTokenExpiredMock = vi.fn(); const isTokenExpiredErrorMock = vi.fn(); const shouldRetryProxyRequestMock = vi.fn(); +const recordOauthQuotaHeadersSnapshotMock = vi.fn(); const recordOauthQuotaResetHintMock = vi.fn(); const recordSuccessMock = vi.fn(); const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(); @@ -89,6 +90,7 @@ vi.mock('../../services/proxyRetryPolicy.js', () => ({ })); vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaHeadersSnapshot: (...args: unknown[]) => recordOauthQuotaHeadersSnapshotMock(...args), recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), })); @@ -126,6 +128,7 @@ describe('selectSurfaceChannelForAttempt', () => { reportTokenExpiredMock.mockReset(); isTokenExpiredErrorMock.mockReset(); shouldRetryProxyRequestMock.mockReset(); + recordOauthQuotaHeadersSnapshotMock.mockReset(); recordOauthQuotaResetHintMock.mockReset(); recordSuccessMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockReset(); @@ -1076,6 +1079,59 @@ describe('selectSurfaceChannelForAttempt', () => { })); }); + it('captures codex quota headers from successful upstream responses as best-effort bookkeeping', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({ + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + recoveredFromSelfLog: false, + estimatedCostFromQuota: 0, + selfLogBillingMeta: null, + usageSource: 'upstream', + }); + resolveProxyLogBillingMock.mockResolvedValue({ + estimatedCost: 0.12, + billingDetails: null, + }); + recordOauthQuotaHeadersSnapshotMock.mockResolvedValue(null); + const logSuccess = vi.fn().mockResolvedValue(undefined); + + const { recordSurfaceSuccess } = await import('./sharedSurface.js'); + await recordSurfaceSuccess({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + requestStartedAtMs: 1000, + latencyMs: 250, + retryCount: 0, + upstreamPath: '/v1/responses', + upstreamHeaders: new Headers({ + 'x-codex-primary-used-percent': '61', + 'x-codex-secondary-used-percent': '13', + }), + logSuccess, + }); + + await vi.waitFor(() => { + expect(recordOauthQuotaHeadersSnapshotMock).toHaveBeenCalledWith({ + accountId: 33, + headers: expect.any(Headers), + }); + }); + }); + it('treats success metrics as best-effort when requested', async () => { resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed')); const logSuccess = vi.fn().mockResolvedValue(undefined); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index f062b06..583067b 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -14,7 +14,7 @@ import { insertProxyLog } from '../../services/proxyLogStore.js'; import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js'; import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js'; import { buildUpstreamUrl } from '../orchestration/upstreamRequest.js'; -import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { recordOauthQuotaHeadersSnapshot, recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { proxyChannelCoordinator } from '../../services/proxyChannelCoordinator.js'; import { readRuntimeResponseText } from '../executors/types.js'; @@ -331,6 +331,7 @@ export async function recordSurfaceSuccess(input: { modelName: string; parsedUsage: SurfaceUsageSummary; upstreamUsagePresent?: boolean; + upstreamHeaders?: { get(name: string): string | null } | null; requestStartedAtMs: number; isStream?: boolean | null; firstByteLatencyMs?: number | null; @@ -451,6 +452,15 @@ export async function recordSurfaceSuccess(input: { upstreamPath: input.upstreamPath, }); + if (input.upstreamHeaders) { + void recordOauthQuotaHeadersSnapshot({ + accountId: input.selected.account.id, + headers: input.upstreamHeaders, + }).catch((error) => { + console.warn('[proxy/shared] failed to record oauth quota headers', error); + }); + } + return { resolvedUsage, estimatedCost, diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 65d06a4..fe13227 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { config } from '../../config.js'; const fetchMock = vi.fn(); const undiciAgentCtorMock = vi.fn(); @@ -31,6 +32,23 @@ function buildJwt(payload: Record) { return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; } +function buildCodexQuotaProbeResponse(input: { + status?: number; + headers?: Record; + text?: string; +}) { + const status = input.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(input.headers || {}), + text: async () => input.text || '', + body: { + cancel: async () => undefined, + }, + }; +} + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -65,6 +83,9 @@ describe('oauth routes', { timeout: 15_000 }, () => { fetchMock.mockReset(); undiciAgentCtorMock.mockReset(); undiciProxyAgentCtorMock.mockReset(); + config.systemProxyUrl = ''; + const { resetRequestRateLimitStore } = await import('../../middleware/requestRateLimit.js'); + resetRequestRateLimitStore(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenModelAvailability).run(); @@ -168,6 +189,18 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(invalidRebindResponse.json()).toMatchObject({ message: 'Invalid proxyUrl. Expected string or null.', }); + + const invalidUseSystemProxyResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + payload: { + useSystemProxy: 'yes', + }, + }); + expect(invalidUseSystemProxyResponse.statusCode).toBe(400); + expect(invalidUseSystemProxyResponse.json()).toMatchObject({ + message: 'Invalid useSystemProxy. Expected boolean.', + }); }); it('starts an antigravity oauth session and returns provider metadata', async () => { @@ -637,6 +670,77 @@ describe('oauth routes', { timeout: 15_000 }, () => { })); }); + it('includes dispatcher for codex token exchange when oauth start explicitly requests the system proxy', async () => { + const jwt = buildJwt({ + email: 'codex-system-proxy@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-system-proxy', + chatgpt_plan_type: 'plus', + }, + }); + config.systemProxyUrl = 'http://127.0.0.1:7890'; + + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token-system', + refresh_token: 'oauth-refresh-token-system', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: 'gpt-5.4' }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + payload: { + useSystemProxy: true, + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-system-proxy`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + + const codexTokenCall = fetchMock.mock.calls.find((call) => String(call[0] || '') === 'https://auth.openai.com/oauth/token'); + const codexTokenFetchInit = codexTokenCall?.[1] as Record | undefined; + expect(codexTokenFetchInit).toEqual(expect.objectContaining({ + dispatcher: expect.anything(), + })); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ + useSystemProxy: true, + oauth: { + email: 'codex-system-proxy@example.com', + }, + }); + }); + it('falls back to the site proxy during rebind exchange when clearing an account proxy override', async () => { const originalJwt = buildJwt({ email: 'codex-clear@example.com', @@ -1717,6 +1821,17 @@ describe('oauth routes', { timeout: 15_000 }, () => { }), }).returning().get(); + fetchMock.mockResolvedValueOnce(buildCodexQuotaProbeResponse({ + headers: { + 'x-codex-primary-used-percent': '62', + 'x-codex-primary-reset-after-seconds': '604800', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-secondary-used-percent': '14', + 'x-codex-secondary-reset-after-seconds': '7200', + 'x-codex-secondary-window-minutes': '300', + }, + })); + const codexRefresh = await app.inject({ method: 'POST', url: `/api/oauth/connections/${codexAccount.id}/quota/refresh`, @@ -1726,13 +1841,40 @@ describe('oauth routes', { timeout: 15_000 }, () => { success: true, quota: expect.objectContaining({ status: 'supported', + providerMessage: 'codex usage windows inferred from rate limit response headers', subscription: expect.objectContaining({ planType: 'plus', activeStart: '2026-03-01T00:00:00.000Z', activeUntil: '2026-04-01T00:00:00.000Z', }), + windows: { + fiveHour: expect.objectContaining({ + supported: true, + used: 14, + limit: 100, + remaining: 86, + }), + sevenDay: expect.objectContaining({ + supported: true, + used: 62, + limit: 100, + remaining: 38, + }), + }, }), }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/codex/responses', + expect.objectContaining({ + method: 'POST', + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-access-token', + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }), + }), + ); const antigravityRefresh = await app.inject({ method: 'POST', @@ -1748,6 +1890,499 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); }); + it('supports batch quota refresh for oauth connections', async () => { + const codexSite = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const firstAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-a@example.com', + accessToken: 'oauth-access-token-a', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-a', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-a', + email: 'codex-a@example.com', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-a@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-a', + chatgpt_plan_type: 'plus', + }, + }), + }, + }), + }).returning().get(); + + const secondAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-b@example.com', + accessToken: 'oauth-access-token-b', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-b', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-b', + email: 'codex-b@example.com', + planType: 'team', + idToken: buildJwt({ + email: 'codex-b@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-b', + chatgpt_plan_type: 'team', + }, + }), + }, + }), + }).returning().get(); + + fetchMock + .mockResolvedValueOnce(buildCodexQuotaProbeResponse({ + headers: { + 'x-codex-primary-used-percent': '41', + 'x-codex-primary-reset-after-seconds': '86400', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-secondary-used-percent': '9', + 'x-codex-secondary-reset-after-seconds': '1800', + 'x-codex-secondary-window-minutes': '300', + }, + })) + .mockResolvedValueOnce(buildCodexQuotaProbeResponse({ + headers: { + 'x-codex-primary-used-percent': '88', + 'x-codex-primary-reset-after-seconds': '43200', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-secondary-used-percent': '27', + 'x-codex-secondary-reset-after-seconds': '900', + 'x-codex-secondary-window-minutes': '300', + }, + })); + + const batchRefresh = await app.inject({ + method: 'POST', + url: '/api/oauth/connections/quota/refresh-batch', + payload: { + accountIds: [firstAccount.id, secondAccount.id], + }, + }); + + expect(batchRefresh.statusCode).toBe(200); + expect(batchRefresh.json()).toMatchObject({ + success: true, + refreshed: 2, + failed: 0, + items: expect.arrayContaining([ + expect.objectContaining({ + accountId: firstAccount.id, + success: true, + quota: expect.objectContaining({ + status: 'supported', + windows: expect.objectContaining({ + fiveHour: expect.objectContaining({ + supported: true, + used: 9, + limit: 100, + }), + sevenDay: expect.objectContaining({ + supported: true, + used: 41, + limit: 100, + }), + }), + }), + }), + expect.objectContaining({ + accountId: secondAccount.id, + success: true, + quota: expect.objectContaining({ + status: 'supported', + windows: expect.objectContaining({ + fiveHour: expect.objectContaining({ + supported: true, + used: 27, + limit: 100, + }), + sevenDay: expect.objectContaining({ + supported: true, + used: 88, + limit: 100, + }), + }), + }), + }), + ]), + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + signal: expect.any(AbortSignal), + }); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + signal: expect.any(AbortSignal), + }); + }); + + it('rejects oversized oauth quota refresh batches before dispatching upstream work', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/connections/quota/refresh-batch', + payload: { + accountIds: Array.from({ length: 101 }, (_, index) => index + 1), + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'accountIds must contain at most 100 items', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('runs batch quota refresh probes with bounded concurrency instead of fully serializing them', async () => { + const codexSite = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const firstAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-concurrency-a@example.com', + accessToken: 'oauth-access-token-a', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-concurrency-account-a', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-concurrency-account-a', + email: 'codex-concurrency-a@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const secondAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-concurrency-b@example.com', + accessToken: 'oauth-access-token-b', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-concurrency-account-b', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-concurrency-account-b', + email: 'codex-concurrency-b@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const firstProbe = createDeferred>(); + const secondProbe = createDeferred>(); + fetchMock + .mockImplementationOnce(() => firstProbe.promise) + .mockImplementationOnce(() => secondProbe.promise); + + const batchRefreshPromise = app.inject({ + method: 'POST', + url: '/api/oauth/connections/quota/refresh-batch', + payload: { + accountIds: [firstAccount.id, secondAccount.id], + }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + firstProbe.resolve(buildCodexQuotaProbeResponse({ + headers: { + 'x-codex-primary-used-percent': '51', + 'x-codex-primary-reset-after-seconds': '3600', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-secondary-used-percent': '11', + 'x-codex-secondary-reset-after-seconds': '900', + 'x-codex-secondary-window-minutes': '300', + }, + })); + secondProbe.resolve(buildCodexQuotaProbeResponse({ + headers: { + 'x-codex-primary-used-percent': '61', + 'x-codex-primary-reset-after-seconds': '7200', + 'x-codex-primary-window-minutes': '10080', + 'x-codex-secondary-used-percent': '19', + 'x-codex-secondary-reset-after-seconds': '1200', + 'x-codex-secondary-window-minutes': '300', + }, + })); + + const batchRefresh = await batchRefreshPromise; + expect(batchRefresh.statusCode).toBe(200); + expect(batchRefresh.json()).toMatchObject({ + success: true, + refreshed: 2, + failed: 0, + }); + }); + + it('imports a native codex oauth json object', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.4' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'codex', + access_token: 'imported-access-token', + refresh_token: 'imported-refresh-token', + expired: '2026-04-12T11:26:13+08:00', + last_refresh: '2026-04-02T11:26:14+08:00', + id_token: buildJwt({ + email: 'imported-codex@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-imported-123', + chatgpt_plan_type: 'plus', + }, + }), + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + imported: 1, + skipped: 0, + failed: 0, + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + username: 'imported-codex@example.com', + accessToken: 'imported-access-token', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-imported-123', + status: 'active', + }); + + const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { + credentialMode?: string; + oauth?: { + provider?: string; + refreshToken?: string; + email?: string; + planType?: string; + tokenExpiresAt?: number; + }; + }; + expect(parsedExtra.credentialMode).toBe('session'); + expect(parsedExtra.oauth).toMatchObject({ + refreshToken: 'imported-refresh-token', + email: 'imported-codex@example.com', + planType: 'plus', + }); + expect(parsedExtra.oauth?.tokenExpiresAt).toBe(Date.parse('2026-04-12T11:26:13+08:00')); + expect(fetchMock).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/codex/models?client_version=1.0.0', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer imported-access-token', + Originator: 'codex_cli_rs', + }), + }), + ); + const modelRows = await db.select().from(schema.modelAvailability).all(); + expect(modelRows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + accountId: accounts[0]?.id, + modelName: 'gpt-5.4', + available: true, + }), + ])); + }); + + it('prefers explicit identity fields from native oauth json and preserves disabled status', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'codex', + access_token: 'imported-access-token', + refresh_token: 'imported-refresh-token', + account_id: 'explicit-account-id', + email: 'explicit-user@example.com', + disabled: true, + id_token: buildJwt({ + email: 'claim-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'claim-account-id', + chatgpt_plan_type: 'plus', + }, + }), + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + imported: 1, + skipped: 0, + failed: 0, + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + username: 'explicit-user@example.com', + accessToken: 'imported-access-token', + oauthProvider: 'codex', + oauthAccountKey: 'explicit-account-id', + status: 'disabled', + }); + }); + + it('rejects a native oauth json object without access_token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'codex', + refresh_token: 'imported-refresh-token', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'oauth credentials missing access_token', + }); + }); + + it('rejects a native oauth json object with malformed expired', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'codex', + access_token: 'imported-access-token', + expired: 'not-a-date', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'invalid oauth expired timestamp', + }); + }); + + it('rejects legacy sub2api oauth envelopes', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'sub2api-data', + version: 1, + accounts: [], + proxies: [], + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'native oauth json expected; sub2api envelopes are no longer supported', + }); + }); + + it('rejects oauth import arrays', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: [ + { + type: 'codex', + access_token: 'imported-access-token', + }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'data must be a native oauth json object', + }); + }); + + it('returns 500 and rolls back a newly imported oauth account when initial model discovery fails', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + text: async () => 'unavailable', + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/import', + payload: { + data: { + type: 'codex', + access_token: 'broken-imported-access-token', + refresh_token: 'broken-imported-refresh-token', + id_token: buildJwt({ + email: 'broken-import@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'broken-import-account', + chatgpt_plan_type: 'plus', + }, + }), + }, + }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json()).toMatchObject({ + message: 'Codex 模型获取失败(HTTP 503: unavailable)', + }); + expect(await db.select().from(schema.accounts).all()).toHaveLength(0); + expect(await db.select().from(schema.modelAvailability).all()).toHaveLength(0); + }); + it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => { const buildTokenExchange = (accountId: string) => ({ ok: true, diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts index 2c6943a..85b2862 100644 --- a/src/server/routes/api/oauth.ts +++ b/src/server/routes/api/oauth.ts @@ -1,11 +1,15 @@ -import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; import { deleteOauthConnection, + importOauthConnectionsFromNativeJson, getOauthSessionStatus, handleOauthCallback, listOauthConnections, listOauthProviders, + OauthImportValidationError, + refreshOauthConnectionQuotaBatch, refreshOauthConnectionQuota, startOauthProviderFlow, startOauthRebindFlow, @@ -14,7 +18,9 @@ import { import { parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { parseOauthConnectionRebindPayload, + parseOauthImportPayload, parseOauthManualCallbackPayload, + parseOauthQuotaBatchRefreshPayload, parseOauthStartPayload, } from '../../contracts/supportRoutePayloads.js'; @@ -54,6 +60,24 @@ const limitOauthConnectionMutate = createRateLimitGuard({ windowMs: 60_000, }); +const oauthSensitiveRouteLimiter = new RateLimiterMemory({ + keyPrefix: 'oauth-connection-sensitive', + points: 20, + duration: 60, +}); +const MAX_OAUTH_QUOTA_BATCH_SIZE = 100; + +async function limitOauthSensitiveRoute(request: FastifyRequest, reply: FastifyReply) { + try { + await oauthSensitiveRouteLimiter.consume(request.ip); + } catch (error) { + const retryState = error instanceof RateLimiterRes ? error : null; + const retryAfterSec = Math.max(1, Math.ceil((retryState?.msBeforeNext ?? 60_000) / 1000)); + reply.code(429).header('retry-after', String(retryAfterSec)) + .send({ message: '请求过于频繁,请稍后再试' }); + } +} + const limitOauthCallback = createRateLimitGuard({ bucket: 'oauth-callback', max: 30, @@ -147,6 +171,7 @@ export async function oauthRoutes(app: FastifyInstance) { rebindAccountId: rebindAccountId ?? undefined, projectId: projectId ?? undefined, proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined, + useSystemProxy: body.useSystemProxy, requestOrigin: resolveRequestOrigin(request), }); } catch (error: any) { @@ -248,6 +273,7 @@ export async function oauthRoutes(app: FastifyInstance) { { requestOrigin: resolveRequestOrigin(request), proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined, + useSystemProxy: parsedBody.data.useSystemProxy, }, ); } catch (error: any) { @@ -288,6 +314,52 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); + app.post<{ Body: unknown }>( + '/api/oauth/connections/quota/refresh-batch', + { preHandler: [limitOauthConnectionMutate, limitOauthSensitiveRoute] }, + async (request, reply) => { + const parsedBody = parseOauthQuotaBatchRefreshPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + const accountIds = Array.isArray(parsedBody.data.accountIds) ? parsedBody.data.accountIds : []; + if (accountIds.length === 0) { + return reply.code(400).send({ message: 'accountIds is required' }); + } + if (accountIds.length > MAX_OAUTH_QUOTA_BATCH_SIZE) { + return reply.code(400).send({ + message: `accountIds must contain at most ${MAX_OAUTH_QUOTA_BATCH_SIZE} items`, + }); + } + return refreshOauthConnectionQuotaBatch(accountIds); + }, + ); + + app.post<{ Body: unknown }>( + '/api/oauth/import', + { preHandler: [limitOauthConnectionMutate, limitOauthSensitiveRoute] }, + async (request, reply) => { + const parsedBody = parseOauthImportPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + const data = parsedBody.data.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return reply.code(400).send({ message: 'data must be a native oauth json object' }); + } + try { + return await importOauthConnectionsFromNativeJson(data); + } catch (error: any) { + const message = error?.message || 'oauth import failed'; + if (error instanceof OauthImportValidationError) { + return reply.code(400).send({ message }); + } + request.log.error({ err: error }, 'oauth import failed'); + return reply.code(500).send({ message }); + } + }, + ); + app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>( '/api/oauth/callback/:provider', { preHandler: [limitOauthCallback] }, diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index bb0f8dc..bb7600d 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -5,6 +5,8 @@ import { hasOauthProvider, getPlatformUserIdFromExtraConfig, getProxyUrlFromExtraConfig, + getUseSystemProxyFromExtraConfig, + resolveProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, getSub2ApiSubscriptionFromExtraConfig, guessPlatformUserIdFromUsername, @@ -14,6 +16,7 @@ import { requiresManagedAccountTokens, supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; +import { config } from '../config.js'; describe('accountExtraConfig', () => { it('reads platformUserId from extra config when present', () => { @@ -110,6 +113,31 @@ describe('accountExtraConfig', () => { expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull(); }); + it('reads useSystemProxy from extra config', () => { + expect(getUseSystemProxyFromExtraConfig(JSON.stringify({ useSystemProxy: true }))).toBe(true); + expect(getUseSystemProxyFromExtraConfig(JSON.stringify({ useSystemProxy: false }))).toBe(false); + expect(getUseSystemProxyFromExtraConfig(JSON.stringify({}))).toBe(false); + }); + + it('resolves account proxy url from custom override before system proxy', () => { + const previousSystemProxyUrl = config.systemProxyUrl; + try { + config.systemProxyUrl = 'http://127.0.0.1:7890'; + expect(resolveProxyUrlFromExtraConfig(JSON.stringify({ + proxyUrl: 'http://account-proxy:8080', + useSystemProxy: true, + }))).toBe('http://account-proxy:8080'); + expect(resolveProxyUrlFromExtraConfig(JSON.stringify({ + useSystemProxy: true, + }))).toBe('http://127.0.0.1:7890'); + expect(resolveProxyUrlFromExtraConfig(JSON.stringify({ + useSystemProxy: false, + }))).toBeNull(); + } finally { + config.systemProxyUrl = previousSystemProxyUrl; + } + }); + it('treats auto-mode api token connections as direct-account routable', () => { expect(supportsDirectAccountRoutingConnection({ accessToken: '', diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 5dc4976..2d0d9b4 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -1,3 +1,4 @@ +import { config } from '../config.js'; import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js'; type AutoReloginConfig = { @@ -29,6 +30,7 @@ const VALID_CREDENTIAL_MODES = new Set([ type AccountExtraConfig = { platformUserId?: unknown; credentialMode?: unknown; + useSystemProxy?: unknown; oauth?: { provider?: unknown; [key: string]: unknown; @@ -136,6 +138,21 @@ export function getProxyUrlFromExtraConfig(extraConfig?: ExtraConfigInput): stri return normalizeNonEmptyString(parsed.proxyUrl) ?? null; } +export function getUseSystemProxyFromExtraConfig(extraConfig?: ExtraConfigInput): boolean { + const parsed = parseExtraConfig(extraConfig); + return parsed.useSystemProxy === true; +} + +export function resolveProxyUrlFromExtraConfig( + extraConfig?: ExtraConfigInput, + systemProxyUrl = config.systemProxyUrl, +): string | null { + const explicitProxyUrl = getProxyUrlFromExtraConfig(extraConfig); + if (explicitProxyUrl) return explicitProxyUrl; + if (!getUseSystemProxyFromExtraConfig(extraConfig)) return null; + return normalizeNonEmptyString(systemProxyUrl) ?? null; +} + export function getPlatformUserIdFromExtraConfig(extraConfig?: ExtraConfigInput): number | undefined { const parsed = parseExtraConfig(extraConfig); return normalizeUserId(parsed.platformUserId); diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index 5540a12..eb703c1 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -7,9 +7,9 @@ import { buildStoredSub2ApiSubscriptionSummary, getAutoReloginConfig, getCredentialModeFromExtraConfig, - getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, + resolveProxyUrlFromExtraConfig, resolvePlatformUserId, } from './accountExtraConfig.js'; import { decryptAccountPassword } from './accountCredentialService.js'; @@ -201,7 +201,7 @@ async function refreshSub2ApiManagedSession(params: { method: 'POST', headers, body: JSON.stringify({ refresh_token: refreshToken }), - }, getProxyUrlFromExtraConfig(params.account.extraConfig))); + }, resolveProxyUrlFromExtraConfig(params.account.extraConfig))); payload = await response.json().catch(() => null); } catch (err: any) { throw new Error(err?.message || 'sub2api token refresh request failed'); @@ -319,7 +319,7 @@ async function tryAutoRelogin(account: any, site: any): Promise { if (!password) return null; const loginResult = await withAccountProxyOverride( - getProxyUrlFromExtraConfig(account.extraConfig), + resolveProxyUrlFromExtraConfig(account.extraConfig), () => adapter.login(site.url, relogin.username, password), ); if (!loginResult.success || !loginResult.accessToken) return null; @@ -382,7 +382,7 @@ export async function refreshBalance(accountId: number) { let activeExtraConfig = account.extraConfig; let balanceInfo: BalanceInfo | null = null; - const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + const accountProxyUrl = resolveProxyUrlFromExtraConfig(account.extraConfig); if (isSub2ApiPlatform(site.platform)) { const managedAuth = getSub2ApiAuthFromExtraConfig(activeExtraConfig); diff --git a/src/server/services/checkinService.ts b/src/server/services/checkinService.ts index 46fd1cd..06107ef 100644 --- a/src/server/services/checkinService.ts +++ b/src/server/services/checkinService.ts @@ -8,10 +8,10 @@ import { refreshBalance } from './balanceService.js'; import { parseCheckinRewardAmount } from './checkinRewardParser.js'; import { getAutoReloginConfig, - getProxyUrlFromExtraConfig, getPlatformUserIdFromExtraConfig, guessPlatformUserIdFromUsername, mergeAccountExtraConfig, + resolveProxyUrlFromExtraConfig, resolvePlatformUserId, } from './accountExtraConfig.js'; import { decryptAccountPassword } from './accountCredentialService.js'; @@ -103,7 +103,7 @@ async function tryAutoRelogin(account: any, site: any): Promise { if (!password) return null; const result = await withAccountProxyOverride( - getProxyUrlFromExtraConfig(account.extraConfig), + resolveProxyUrlFromExtraConfig(account.extraConfig), () => adapter.login(site.url, relogin.username, password), ); if (!result.success || !result.accessToken) return null; @@ -177,7 +177,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: : guessPlatformUserIdFromUsername(account.username); const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); - const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + const accountProxyUrl = resolveProxyUrlFromExtraConfig(account.extraConfig); let activeAccessToken = account.accessToken; let result = await withAccountProxyOverride(accountProxyUrl, () => adapter.checkin(site.url, activeAccessToken, platformUserId)); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index a68407d..de2e5b0 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -11,8 +11,8 @@ import { } from './accountTokenService.js'; import { getCredentialModeFromExtraConfig, - getProxyUrlFromExtraConfig, mergeAccountExtraConfig, + resolveProxyUrlFromExtraConfig, requiresManagedAccountTokens, resolvePlatformUserId, supportsDirectAccountRoutingConnection, @@ -109,9 +109,32 @@ export type ModelRefreshResult = | ModelRefreshSuccessResult; type ModelDiscoveryAccountRow = typeof schema.accounts.$inferSelect; -type ModelDiscoveryRetryError = { - refreshedAccount?: ModelDiscoveryAccountRow; -}; +const REFRESHED_OAUTH_ACCOUNT = Symbol('refreshedOauthAccount'); + +function throwWithRefreshedOauthAccount(error: unknown, account: ModelDiscoveryAccountRow): never { + if (error && typeof error === 'object') { + Object.defineProperty(error, REFRESHED_OAUTH_ACCOUNT, { + value: account, + configurable: true, + }); + throw error; + } + + const wrapped = new Error(String(error || 'oauth model discovery failed')); + Object.defineProperty(wrapped, REFRESHED_OAUTH_ACCOUNT, { + value: account, + configurable: true, + }); + throw wrapped; +} + +function getRefreshedOauthAccountFromError(error: unknown): ModelDiscoveryAccountRow | null { + if (!error || typeof error !== 'object') return null; + return ( + (error as Record)[REFRESHED_OAUTH_ACCOUNT] + || null + ); +} function looksLikeHtmlJsonParseError(message: string): boolean { const lowered = String(message || '').trim().toLowerCase(); @@ -339,23 +362,11 @@ async function retryOauthModelDiscoveryWithRefresh(input: { account: discoveryAccount, }; } catch (retryError) { - if (retryError && typeof retryError === 'object') { - (retryError as ModelDiscoveryRetryError).refreshedAccount = discoveryAccount; - throw retryError; - } - const wrappedError = new Error(String(retryError || 'model discovery retry failed')) as Error & ModelDiscoveryRetryError; - wrappedError.refreshedAccount = discoveryAccount; - throw wrappedError; + throwWithRefreshedOauthAccount(retryError, discoveryAccount); } } } -function extractRefreshedDiscoveryAccount(error: unknown): ModelDiscoveryAccountRow | null { - if (!error || typeof error !== 'object') return null; - const refreshedAccount = (error as ModelDiscoveryRetryError).refreshedAccount; - return refreshedAccount ?? null; -} - export async function refreshModelsForAccount( accountId: number, options?: { allowInactive?: boolean }, @@ -373,7 +384,7 @@ export async function refreshModelsForAccount( const site = row.sites; const oauth = getOauthInfoFromAccount(account); const adapter = getAdapter(site.platform); - const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + const accountProxyUrl = resolveProxyUrlFromExtraConfig(account.extraConfig); const restoreAvailabilityOnFailure = options?.allowInactive === true; const previousAccountTokens = restoreAvailabilityOnFailure @@ -507,7 +518,7 @@ export async function refreshModelsForAccount( discoveredApiToken: false, }); } catch (err) { - discoveryAccount = extractRefreshedDiscoveryAccount(err) ?? discoveryAccount; + discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed'; const errorCode = classifyModelDiscoveryError(rawMessage); const errorMessage = `Codex 模型获取失败(${rawMessage})`; @@ -587,7 +598,7 @@ export async function refreshModelsForAccount( discoveredApiToken: false, }); } catch (err) { - discoveryAccount = extractRefreshedDiscoveryAccount(err) ?? discoveryAccount; + discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; const rawMessage = (err as { message?: string })?.message || 'claude oauth model discovery failed'; const errorCode = classifyModelDiscoveryError(rawMessage); const errorMessage = `Claude OAuth 模型获取失败(${rawMessage})`; @@ -761,7 +772,7 @@ export async function refreshModelsForAccount( discoveredApiToken: false, }); } catch (err) { - discoveryAccount = extractRefreshedDiscoveryAccount(err) ?? discoveryAccount; + discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed'; const errorCode = classifyModelDiscoveryError(rawMessage); const errorMessage = `Antigravity 模型获取失败(${rawMessage})`; diff --git a/src/server/services/oauth/quota.ts b/src/server/services/oauth/quota.ts index 3a79dcb..36d99c8 100644 --- a/src/server/services/oauth/quota.ts +++ b/src/server/services/oauth/quota.ts @@ -1,11 +1,16 @@ +import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; +import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { runWithSiteApiEndpointPool } from '../siteApiEndpointService.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount, type OauthInfo, } from './oauthAccount.js'; +import { resolveOauthAccountProxyUrl } from './requestProxy.js'; import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js'; type CodexJwtClaims = { @@ -16,12 +21,86 @@ type CodexJwtClaims = { }; }; +type HeaderSource = { + get(name: string): string | null; +} | Record; + +type CodexQuotaHeaderSnapshot = { + primaryUsedPercent?: number; + primaryResetAfterSeconds?: number; + primaryWindowMinutes?: number; + secondaryUsedPercent?: number; + secondaryResetAfterSeconds?: number; + secondaryWindowMinutes?: number; + capturedAt: string; +}; + +type NormalizedCodexQuotaHeaders = { + fiveHour?: { + usedPercent?: number; + resetAfterSeconds?: number; + windowMinutes?: number; + }; + sevenDay?: { + usedPercent?: number; + resetAfterSeconds?: number; + windowMinutes?: number; + }; +}; + +const CODEX_QUOTA_PROBE_MODEL = 'gpt-5.1-codex'; +const CODEX_QUOTA_PROBE_VERSION = '0.101.0'; +const CODEX_QUOTA_PROBE_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +const CODEX_QUOTA_PROBE_BETA = 'responses-2025-03-11'; +const CODEX_QUOTA_PROBE_INSTRUCTIONS = 'You are a helpful assistant.'; +const CODEX_QUOTA_PROBE_TIMEOUT_MS = 10_000; +const QUOTA_HEADER_SNAPSHOT_DEDUPE_WINDOW_MS = 30_000; +const recentQuotaHeaderSnapshotByAccount = new Map(); +const pendingQuotaHeaderSnapshotKeys = new Set(); + function asTrimmedString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed || undefined; } +function getHeaderValue(headers: HeaderSource, key: string): string | undefined { + if (typeof (headers as { get?: unknown }).get === 'function') { + const value = (headers as { get(name: string): string | null }).get(key); + return asTrimmedString(value); + } + + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + if (typeof candidateValue === 'string') return asTrimmedString(candidateValue); + if (Array.isArray(candidateValue)) { + for (const item of candidateValue) { + const normalized = asTrimmedString(item); + if (normalized) return normalized; + } + } + } + + return undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'string') return undefined; + const parsed = Number.parseFloat(value.trim()); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function asFiniteInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); + if (typeof value !== 'string') return undefined; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + function asIsoDateTime(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); @@ -66,6 +145,145 @@ function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot }; } +function roundPercent(value: number): number { + return Math.round(value * 100) / 100; +} + +function addSecondsToIso(baseIso: string, seconds?: number): string | undefined { + if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return undefined; + const parsed = Date.parse(baseIso); + if (Number.isNaN(parsed)) return undefined; + const clampedSeconds = Math.max(0, Math.trunc(seconds)); + return new Date(parsed + clampedSeconds * 1000).toISOString(); +} + +function parseCodexQuotaHeaders( + headers: HeaderSource, + capturedAt = new Date().toISOString(), +): CodexQuotaHeaderSnapshot | null { + const snapshot: CodexQuotaHeaderSnapshot = { capturedAt }; + let hasAnyValue = false; + + const assignField = ( + field: keyof Omit, + value: number | undefined, + ) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return; + snapshot[field] = value as CodexQuotaHeaderSnapshot[keyof Omit]; + hasAnyValue = true; + }; + + assignField('primaryUsedPercent', asFiniteNumber(getHeaderValue(headers, 'x-codex-primary-used-percent'))); + assignField('primaryResetAfterSeconds', asFiniteInteger(getHeaderValue(headers, 'x-codex-primary-reset-after-seconds'))); + assignField('primaryWindowMinutes', asFiniteInteger(getHeaderValue(headers, 'x-codex-primary-window-minutes'))); + assignField('secondaryUsedPercent', asFiniteNumber(getHeaderValue(headers, 'x-codex-secondary-used-percent'))); + assignField('secondaryResetAfterSeconds', asFiniteInteger(getHeaderValue(headers, 'x-codex-secondary-reset-after-seconds'))); + assignField('secondaryWindowMinutes', asFiniteInteger(getHeaderValue(headers, 'x-codex-secondary-window-minutes'))); + + return hasAnyValue ? snapshot : null; +} + +function normalizeCodexQuotaHeaders(snapshot: CodexQuotaHeaderSnapshot): NormalizedCodexQuotaHeaders | null { + const primaryWindow = snapshot.primaryWindowMinutes; + const secondaryWindow = snapshot.secondaryWindowMinutes; + const hasPrimaryWindow = typeof primaryWindow === 'number' && Number.isFinite(primaryWindow); + const hasSecondaryWindow = typeof secondaryWindow === 'number' && Number.isFinite(secondaryWindow); + + let fiveHourSource: 'primary' | 'secondary' | null = null; + let sevenDaySource: 'primary' | 'secondary' | null = null; + + if (hasPrimaryWindow && hasSecondaryWindow) { + if ((primaryWindow || 0) < (secondaryWindow || 0)) { + fiveHourSource = 'primary'; + sevenDaySource = 'secondary'; + } else { + fiveHourSource = 'secondary'; + sevenDaySource = 'primary'; + } + } else if (hasPrimaryWindow) { + if ((primaryWindow || 0) <= 360) { + fiveHourSource = 'primary'; + } else { + sevenDaySource = 'primary'; + } + } else if (hasSecondaryWindow) { + if ((secondaryWindow || 0) <= 360) { + fiveHourSource = 'secondary'; + } else { + sevenDaySource = 'secondary'; + } + } else { + sevenDaySource = 'primary'; + fiveHourSource = 'secondary'; + } + + const pickSource = (source: 'primary' | 'secondary' | null) => { + if (!source) return undefined; + if (source === 'primary') { + return { + usedPercent: snapshot.primaryUsedPercent, + resetAfterSeconds: snapshot.primaryResetAfterSeconds, + windowMinutes: snapshot.primaryWindowMinutes, + }; + } + return { + usedPercent: snapshot.secondaryUsedPercent, + resetAfterSeconds: snapshot.secondaryResetAfterSeconds, + windowMinutes: snapshot.secondaryWindowMinutes, + }; + }; + + const normalized: NormalizedCodexQuotaHeaders = { + fiveHour: pickSource(fiveHourSource), + sevenDay: pickSource(sevenDaySource), + }; + + const hasData = !!( + normalized.fiveHour?.usedPercent !== undefined + || normalized.fiveHour?.resetAfterSeconds !== undefined + || normalized.sevenDay?.usedPercent !== undefined + || normalized.sevenDay?.resetAfterSeconds !== undefined + ); + return hasData ? normalized : null; +} + +function buildCodexQuotaHeadersFingerprint(headers: HeaderSource): string | null { + const parsed = parseCodexQuotaHeaders(headers, 'fingerprint'); + if (!parsed) return null; + const { capturedAt: _capturedAt, ...stableFields } = parsed; + return JSON.stringify(stableFields); +} + +function buildCodexWindowFromNormalized(input: { + usedPercent?: number; + resetAfterSeconds?: number; + windowMinutes?: number; + capturedAt: string; +}): OauthQuotaWindowSnapshot | null { + const usedPercent = typeof input.usedPercent === 'number' && Number.isFinite(input.usedPercent) + ? roundPercent(input.usedPercent) + : undefined; + const resetAt = addSecondsToIso(input.capturedAt, input.resetAfterSeconds); + if (usedPercent === undefined && !resetAt) { + return null; + } + + return { + supported: true, + ...(usedPercent !== undefined + ? { + used: usedPercent, + limit: 100, + remaining: roundPercent(Math.max(0, 100 - usedPercent)), + } + : {}), + ...(resetAt ? { resetAt } : {}), + message: typeof input.windowMinutes === 'number' && Number.isFinite(input.windowMinutes) + ? `codex ${Math.max(0, Math.trunc(input.windowMinutes))}m window inferred from rate limit headers` + : 'codex window inferred from rate limit headers', + }; +} + function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const raw = value as Record; @@ -132,6 +350,68 @@ function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | unde }; } +function buildQuotaErrorSnapshot(input: { + oauth: Pick; + message: string; + syncedAt: string; + lastLimitResetAt?: string; +}): OauthQuotaSnapshot { + const baseSnapshot = buildQuotaSnapshotFromOauthInfo(input.oauth); + return { + ...baseSnapshot, + status: 'error', + lastSyncAt: input.syncedAt, + lastError: input.message, + providerMessage: input.message, + ...(input.lastLimitResetAt + ? { lastLimitResetAt: input.lastLimitResetAt } + : (baseSnapshot.lastLimitResetAt ? { lastLimitResetAt: baseSnapshot.lastLimitResetAt } : {})), + }; +} + +export function buildCodexQuotaSnapshotFromHeaders( + oauth: Pick, + headers: HeaderSource, + capturedAt = new Date().toISOString(), +): OauthQuotaSnapshot | null { + if (oauth.provider !== 'codex') return null; + const parsedHeaders = parseCodexQuotaHeaders(headers, capturedAt); + if (!parsedHeaders) return null; + const normalizedHeaders = normalizeCodexQuotaHeaders(parsedHeaders); + if (!normalizedHeaders) return null; + + const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth); + const fiveHour = normalizedHeaders.fiveHour + ? buildCodexWindowFromNormalized({ + ...normalizedHeaders.fiveHour, + capturedAt: parsedHeaders.capturedAt, + }) + : null; + const sevenDay = normalizedHeaders.sevenDay + ? buildCodexWindowFromNormalized({ + ...normalizedHeaders.sevenDay, + capturedAt: parsedHeaders.capturedAt, + }) + : null; + if (!fiveHour && !sevenDay) return null; + + const lastLimitResetAt = fiveHour?.resetAt || sevenDay?.resetAt || baseSnapshot.lastLimitResetAt; + + return { + ...baseSnapshot, + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: parsedHeaders.capturedAt, + lastError: undefined, + providerMessage: 'codex usage windows inferred from rate limit response headers', + windows: { + fiveHour: fiveHour || baseSnapshot.windows.fiveHour, + sevenDay: sevenDay || baseSnapshot.windows.sevenDay, + }, + ...(lastLimitResetAt ? { lastLimitResetAt } : {}), + }; +} + function buildStoredCodexSnapshot(oauth: Pick): OauthQuotaSnapshot { const claims = parseCodexJwtClaims(oauth.idToken); const authClaims = claims?.['https://api.openai.com/auth']; @@ -212,6 +492,159 @@ async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnaps return snapshot; } +export async function recordOauthQuotaHeadersSnapshot(input: { + accountId: number; + headers: HeaderSource; +}): Promise { + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get(); + if (!account) return null; + const oauth = getOauthInfoFromAccount(account); + if (!oauth || oauth.provider !== 'codex') return null; + + const fingerprint = buildCodexQuotaHeadersFingerprint(input.headers); + if (!fingerprint) return null; + const nowMs = Date.now(); + const lastRecorded = recentQuotaHeaderSnapshotByAccount.get(input.accountId); + if ( + lastRecorded + && lastRecorded.fingerprint === fingerprint + && nowMs - lastRecorded.recordedAtMs < QUOTA_HEADER_SNAPSHOT_DEDUPE_WINDOW_MS + ) { + return buildQuotaSnapshotFromOauthInfo(oauth); + } + const pendingKey = `${input.accountId}:${fingerprint}`; + if (pendingQuotaHeaderSnapshotKeys.has(pendingKey)) { + return buildQuotaSnapshotFromOauthInfo(oauth); + } + + const snapshot = buildCodexQuotaSnapshotFromHeaders(oauth, input.headers); + if (!snapshot) return null; + pendingQuotaHeaderSnapshotKeys.add(pendingKey); + try { + const persisted = await persistQuotaSnapshot(input.accountId, snapshot); + recentQuotaHeaderSnapshotByAccount.set(input.accountId, { + fingerprint, + recordedAtMs: nowMs, + }); + return persisted; + } finally { + pendingQuotaHeaderSnapshotKeys.delete(pendingKey); + } +} + +function buildCodexQuotaProbePayload(): Record { + return { + model: CODEX_QUOTA_PROBE_MODEL, + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: 'hi', + }, + ], + }, + ], + stream: true, + store: false, + instructions: CODEX_QUOTA_PROBE_INSTRUCTIONS, + }; +} + +function buildCodexQuotaProbeUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/responses`; +} + +function buildCodexQuotaProbeHeaders(input: { + accessToken: string; + accountId?: string; +}): Record { + return { + Authorization: `Bearer ${input.accessToken.trim()}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + Connection: 'Keep-Alive', + Originator: 'codex_cli_rs', + Version: CODEX_QUOTA_PROBE_VERSION, + 'User-Agent': CODEX_QUOTA_PROBE_USER_AGENT, + 'OpenAI-Beta': CODEX_QUOTA_PROBE_BETA, + Session_id: randomUUID(), + ...(input.accountId ? { 'Chatgpt-Account-Id': input.accountId } : {}), + }; +} + +async function probeCodexQuotaSnapshot(input: { + account: typeof schema.accounts.$inferSelect; + oauth: OauthInfo; + syncedAt: string; +}): Promise { + const site = await db.select().from(schema.sites).where(eq(schema.sites.id, input.account.siteId)).get(); + if (!site) { + throw new Error('oauth site not found'); + } + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('codex oauth access token missing'); + } + const proxyUrl = await resolveOauthAccountProxyUrl({ + siteId: input.account.siteId, + extraConfig: input.account.extraConfig, + }); + const requestBody = JSON.stringify(buildCodexQuotaProbePayload()); + + return runWithSiteApiEndpointPool(site, async (target) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CODEX_QUOTA_PROBE_TIMEOUT_MS); + let response: Awaited>; + try { + response = await fetch( + buildCodexQuotaProbeUrl(target.baseUrl), + withExplicitProxyRequestInit(proxyUrl, { + method: 'POST', + headers: buildCodexQuotaProbeHeaders({ + accessToken, + accountId: input.oauth.accountId || input.oauth.accountKey, + }), + body: requestBody, + signal: controller.signal, + }), + ); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`codex quota probe timeout (${Math.round(CODEX_QUOTA_PROBE_TIMEOUT_MS / 1000)}s)`); + } + throw error; + } finally { + clearTimeout(timeout); + } + + const snapshot = buildCodexQuotaSnapshotFromHeaders(input.oauth, response.headers, input.syncedAt); + if (snapshot) { + const responseBody = response as { body?: { cancel?: () => Promise | void } }; + void Promise.resolve(responseBody.body?.cancel?.()).catch(() => {}); + return snapshot; + } + + const errorText = await response.text().catch(() => ''); + if (!response.ok) { + const resetHint = parseCodexQuotaResetHint(response.status, errorText, Date.now()); + return buildQuotaErrorSnapshot({ + oauth: input.oauth, + message: errorText || `codex quota probe failed with status ${response.status}`, + syncedAt: input.syncedAt, + ...(resetHint ? { lastLimitResetAt: resetHint.resetAt } : {}), + }); + } + + return buildQuotaErrorSnapshot({ + oauth: input.oauth, + message: 'codex quota probe response did not expose x-codex rate limit headers', + syncedAt: input.syncedAt, + }); + }); +} + export async function refreshOauthQuotaSnapshot(accountId: number): Promise { const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); if (!account) { @@ -221,6 +654,26 @@ export async function refreshOauthQuotaSnapshot(accountId: number): Promise { @@ -14,7 +14,7 @@ export async function resolveOauthAccountProxyUrl(input: { siteId?: number | null; extraConfig?: string | null; }): Promise { - const accountProxyUrl = getProxyUrlFromExtraConfig(input.extraConfig); + const accountProxyUrl = resolveProxyUrlFromExtraConfig(input.extraConfig); if (accountProxyUrl) return accountProxyUrl; if (!input.siteId || input.siteId <= 0) return null; const site = await db.select({ diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index 918a9b9..e1ba271 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -1,7 +1,12 @@ import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { insertAndGetById } from '../../db/insertHelpers.js'; -import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { + getProxyUrlFromExtraConfig, + getUseSystemProxyFromExtraConfig, + mergeAccountExtraConfig, + resolveProxyUrlFromExtraConfig, +} from '../accountExtraConfig.js'; import { refreshModelsForAccount } from '../modelService.js'; import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js'; import { @@ -14,6 +19,7 @@ import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js'; import { getOAuthProviderDefinition, listOAuthProviderDefinitions, + type OAuthProviderId, type OAuthProviderDefinition, } from './providers.js'; import { ensureOauthProviderSite } from './oauthSiteRegistry.js'; @@ -36,6 +42,7 @@ import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './qu type OAuthProviderMetadata = ReturnType[number]; const MANUAL_CALLBACK_DELAY_MS = 15_000; +const OAUTH_QUOTA_BATCH_REFRESH_CONCURRENCY = 4; type OauthProviderHeaderAccountInput = OauthIdentityCarrierLike & { extraConfig?: OauthExtraConfigInput; }; @@ -49,6 +56,30 @@ type OAuthStartInstructions = { sshTunnelKeyCommand?: string; }; +type ImportedNativeOauthJson = { + type?: unknown; + access_token?: unknown; + refresh_token?: unknown; + id_token?: unknown; + email?: unknown; + account_id?: unknown; + account_key?: unknown; + expired?: unknown; + disabled?: unknown; + last_refresh?: unknown; +}; + +export class OauthImportValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'OauthImportValidationError'; + } +} + +function throwOauthImportValidationError(message: string): never { + throw new OauthImportValidationError(message); +} + function isLoopbackHost(hostname: string): boolean { const normalized = hostname.trim().toLowerCase(); return normalized === 'localhost' @@ -128,6 +159,193 @@ function asNonEmptyString(value: unknown): string | undefined { return trimmed || undefined; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +function decodeJwtPayload(token?: string): Record | null { + const raw = asNonEmptyString(token); + if (!raw) return null; + const parts = raw.split('.'); + if (parts.length !== 3) return null; + try { + const parsed = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function mapImportedOauthProvider(platform: string): OAuthProviderId | null { + const normalized = platform.trim().toLowerCase(); + if (normalized === 'codex') return 'codex'; + if (normalized === 'claude') return 'claude'; + if (normalized === 'gemini-cli') return 'gemini-cli'; + if (normalized === 'antigravity') return 'antigravity'; + if (normalized === 'openai') return 'codex'; + if (normalized === 'anthropic' || normalized === 'claude') return 'claude'; + if (normalized === 'gemini' || normalized === 'gemini-cli') return 'gemini-cli'; + if (normalized === 'antigravity') return 'antigravity'; + return null; +} + +function resolveImportedOauthIdentity( + provider: OAuthProviderId, + credentials: Record, +): { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + idToken?: string; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + projectId?: string; + providerData?: Record; +} { + const idToken = asNonEmptyString(credentials.id_token); + const claims = decodeJwtPayload(idToken); + const openAiAuth = isRecord(claims?.['https://api.openai.com/auth']) + ? claims?.['https://api.openai.com/auth'] as Record + : null; + const accessToken = asNonEmptyString(credentials.access_token) + || asNonEmptyString(credentials.session_token); + if (!accessToken) { + throwOauthImportValidationError('oauth credentials missing access_token/session_token'); + } + + const email = asNonEmptyString(credentials.email) + || asNonEmptyString(claims?.email); + const accountKey = asNonEmptyString(credentials.chatgpt_account_id) + || asNonEmptyString(credentials.account_key) + || asNonEmptyString(credentials.account_id) + || asNonEmptyString(openAiAuth?.chatgpt_account_id) + || email; + const planType = asNonEmptyString(credentials.plan_type) + || asNonEmptyString(openAiAuth?.chatgpt_plan_type); + const tokenExpiresAt = asPositiveInteger(credentials.expires_at) + || asPositiveInteger(credentials.token_expires_at); + const providerData = isRecord(credentials.provider_data) + ? credentials.provider_data as Record + : undefined; + const projectId = asNonEmptyString(credentials.project_id) + || asNonEmptyString(credentials.cloudaicompanionProject); + + return { + accessToken, + ...(asNonEmptyString(credentials.refresh_token) ? { refreshToken: asNonEmptyString(credentials.refresh_token) } : {}), + ...(tokenExpiresAt ? { tokenExpiresAt } : {}), + ...(idToken ? { idToken } : {}), + ...(email ? { email } : {}), + ...(accountKey ? { accountKey, accountId: accountKey } : {}), + ...(planType ? { planType } : {}), + ...(projectId ? { projectId } : {}), + ...(providerData ? { providerData } : {}), + }; +} + +function parseImportedOauthExpiry(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value !== 'string') { + throwOauthImportValidationError('invalid oauth expired timestamp'); + } + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (/^\d+$/.test(trimmed)) { + const parsedNumeric = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsedNumeric) && parsedNumeric > 0) { + return parsedNumeric; + } + } + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) { + throwOauthImportValidationError('invalid oauth expired timestamp'); + } + return parsed; +} + +function resolveImportedNativeOauthIdentity( + payload: ImportedNativeOauthJson, +): { + provider: OAuthProviderId; + disabled: boolean; + exchange: { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + idToken?: string; + providerData?: Record; + }; + name: string; +} { + const rawType = asNonEmptyString(payload.type); + const payloadRecord = payload as Record; + if (rawType === 'sub2api-data' || rawType === 'sub2api-bundle' || Array.isArray(payloadRecord.accounts)) { + throwOauthImportValidationError('native oauth json expected; sub2api envelopes are no longer supported'); + } + if ('accounts' in payloadRecord || 'proxies' in payloadRecord || 'version' in payloadRecord || 'exported_at' in payloadRecord) { + throwOauthImportValidationError('native oauth json expected; sub2api envelopes are no longer supported'); + } + const provider = rawType ? mapImportedOauthProvider(rawType) : null; + if (!provider) { + throwOauthImportValidationError(`unsupported oauth import type: ${rawType || 'unknown'}`); + } + + const accessToken = asNonEmptyString(payload.access_token); + if (!accessToken) { + throwOauthImportValidationError('oauth credentials missing access_token'); + } + + const derived = resolveImportedOauthIdentity(provider, payload as Record); + const explicitEmail = asNonEmptyString(payload.email); + const explicitAccountId = asNonEmptyString(payload.account_id); + const explicitAccountKey = asNonEmptyString(payload.account_key); + const tokenExpiresAt = parseImportedOauthExpiry(payload.expired); + + const exchange = { + ...derived, + accessToken, + ...(asNonEmptyString(payload.refresh_token) ? { refreshToken: asNonEmptyString(payload.refresh_token) } : {}), + ...(tokenExpiresAt ? { tokenExpiresAt } : {}), + ...(explicitEmail ? { email: explicitEmail } : {}), + ...(explicitAccountId ? { accountId: explicitAccountId } : {}), + ...(explicitAccountKey ? { accountKey: explicitAccountKey } : {}), + }; + + if (!exchange.accountKey && exchange.accountId) { + exchange.accountKey = exchange.accountId; + } + if (!exchange.accountId && exchange.accountKey) { + exchange.accountId = exchange.accountKey; + } + + return { + provider, + disabled: payload.disabled === true, + exchange, + name: explicitEmail || explicitAccountKey || explicitAccountId || derived.email || derived.accountKey || derived.accountId || provider, + }; +} + function buildUsername(input: { email?: string; accountKey?: string; @@ -143,6 +361,55 @@ async function getNextAccountSortOrder(): Promise { return (row?.maxSortOrder ?? -1) + 1; } +async function revertPersistedOauthAccount(input: { + accountId: number; + created: boolean; + previousAccount: typeof schema.accounts.$inferSelect | null; +}) { + if (input.created) { + await db.delete(schema.accounts).where(eq(schema.accounts.id, input.accountId)).run(); + return; + } + + if (!input.previousAccount) return; + await db.update(schema.accounts).set({ + siteId: input.previousAccount.siteId, + username: input.previousAccount.username, + accessToken: input.previousAccount.accessToken, + apiToken: input.previousAccount.apiToken, + checkinEnabled: input.previousAccount.checkinEnabled, + status: input.previousAccount.status, + oauthProvider: input.previousAccount.oauthProvider, + oauthAccountKey: input.previousAccount.oauthAccountKey, + oauthProjectId: input.previousAccount.oauthProjectId, + extraConfig: input.previousAccount.extraConfig, + updatedAt: input.previousAccount.updatedAt, + }).where(eq(schema.accounts.id, input.previousAccount.id)).run(); +} + +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + mapper: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) return []; + + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(Math.trunc(concurrency), items.length)); + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= items.length) return; + results[currentIndex] = await mapper(items[currentIndex]!, currentIndex); + } + })); + + return results; +} + async function ensureOauthSite(definition: OAuthProviderDefinition) { return ensureOauthProviderSite(definition); } @@ -202,6 +469,8 @@ async function upsertOauthAccount(input: { }; rebindAccountId?: number; proxyUrl?: string | null; + useSystemProxy?: boolean; + persistedStatus?: 'active' | 'disabled'; }) { const site = await ensureOauthSite(input.definition); const existing = await findExistingOauthAccount({ @@ -231,6 +500,7 @@ async function upsertOauthAccount(input: { const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, { credentialMode: 'session', ...(input.proxyUrl !== undefined ? { proxyUrl: input.proxyUrl } : {}), + ...(input.useSystemProxy !== undefined ? { useSystemProxy: input.useSystemProxy } : {}), oauth: buildStoredOauthState(oauth), }); @@ -241,7 +511,7 @@ async function upsertOauthAccount(input: { accessToken: input.exchange.accessToken, apiToken: null, checkinEnabled: false, - status: 'disabled', + status: input.persistedStatus ?? 'disabled', oauthProvider: input.definition.metadata.provider, oauthAccountKey: oauth.accountKey || oauth.accountId || null, oauthProjectId: oauth.projectId || null, @@ -265,7 +535,7 @@ async function upsertOauthAccount(input: { accessToken: input.exchange.accessToken, apiToken: null, checkinEnabled: false, - status: 'active', + status: input.persistedStatus ?? 'active', oauthProvider: input.definition.metadata.provider, oauthAccountKey: oauth.accountKey || oauth.accountId || null, oauthProjectId: oauth.projectId || null, @@ -294,6 +564,7 @@ export async function startOauthProviderFlow(input: { rebindAccountId?: number; projectId?: string; proxyUrl?: string | null; + useSystemProxy?: boolean; requestOrigin?: string; }) { const definition = getOAuthProviderDefinition(input.provider); @@ -311,6 +582,7 @@ export async function startOauthProviderFlow(input: { rebindAccountId: input.rebindAccountId, projectId: input.projectId, proxyUrl: input.proxyUrl, + useSystemProxy: input.useSystemProxy, }); return { provider: input.provider, @@ -366,9 +638,11 @@ export async function handleOauthCallback(input: { } try { - const resolvedProxyUrl = session.proxyUrl == null - ? await resolveOauthProviderProxyUrl(input.provider) - : session.proxyUrl; + const resolvedProxyUrl = session.proxyUrl + ? session.proxyUrl + : session.useSystemProxy + ? resolveProxyUrlFromExtraConfig({ useSystemProxy: true }) + : await resolveOauthProviderProxyUrl(input.provider); const exchange = await definition.exchangeAuthorizationCode({ code, state: input.state, @@ -382,6 +656,7 @@ export async function handleOauthCallback(input: { exchange, rebindAccountId: session.rebindAccountId, proxyUrl: session.proxyUrl, + useSystemProxy: session.useSystemProxy, }); if (!account) { markOauthSessionError(input.state, 'failed to persist oauth account'); @@ -393,23 +668,11 @@ export async function handleOauthCallback(input: { previousAccount ? { allowInactive: true } : undefined, ); if (refreshResult.status !== 'success') { - if (created) { - await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); - } else if (previousAccount) { - await db.update(schema.accounts).set({ - siteId: previousAccount.siteId, - username: previousAccount.username, - accessToken: previousAccount.accessToken, - apiToken: previousAccount.apiToken, - checkinEnabled: previousAccount.checkinEnabled, - status: previousAccount.status, - oauthProvider: previousAccount.oauthProvider, - oauthAccountKey: previousAccount.oauthAccountKey, - oauthProjectId: previousAccount.oauthProjectId, - extraConfig: previousAccount.extraConfig, - updatedAt: previousAccount.updatedAt, - }).where(eq(schema.accounts.id, previousAccount.id)).run(); - } + await revertPersistedOauthAccount({ + accountId: account.id, + created, + previousAccount, + }); await routeRefreshWorkflow.rebuildRoutesOnly(); const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; markOauthSessionError(input.state, errorMessage); @@ -549,6 +812,7 @@ export async function listOauthConnections(options: { lastModelSyncAt: oauth.lastModelSyncAt, lastModelSyncError: oauth.lastModelSyncError, proxyUrl: getProxyUrlFromExtraConfig(row.accounts.extraConfig), + useSystemProxy: getUseSystemProxyFromExtraConfig(row.accounts.extraConfig), site: { id: row.sites.id, name: row.sites.name, @@ -582,11 +846,121 @@ export async function refreshOauthConnectionQuota(accountId: number) { return { success: true, quota }; } +export async function refreshOauthConnectionQuotaBatch(accountIds: number[]) { + const uniqueIds = Array.from(new Set(accountIds.filter((id) => Number.isFinite(id) && id > 0))); + const items = await mapWithConcurrency(uniqueIds, OAUTH_QUOTA_BATCH_REFRESH_CONCURRENCY, async (accountId) => { + try { + const { quota } = await refreshOauthConnectionQuota(accountId); + return { + accountId, + success: true, + quota, + }; + } catch (error: any) { + return { + accountId, + success: false, + error: error?.message || 'oauth quota refresh failed', + }; + } + }) satisfies Array<{ + accountId: number; + success: boolean; + quota?: ReturnType; + error?: string; + }>; + + const refreshed = items.filter((item) => item.success).length; + const failed = items.length - refreshed; + return { + success: failed === 0, + refreshed, + failed, + items, + }; +} + +export async function importOauthConnectionsFromNativeJson(data: unknown) { + if (!isRecord(data)) { + throwOauthImportValidationError('data must be a native oauth json object'); + } + + const payload = data as ImportedNativeOauthJson; + + const items: Array<{ + name: string; + status: 'imported' | 'skipped' | 'failed'; + accountId?: number; + provider?: string; + message?: string; + }> = []; + const changedAccountIds: number[] = []; + let resolvedProvider: OAuthProviderId | null = null; + let persistedAccount: Awaited> | null = null; + try { + const resolved = resolveImportedNativeOauthIdentity(payload); + resolvedProvider = resolved.provider; + const definition = getOAuthProviderDefinition(resolved.provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${resolved.provider}`); + } + const persisted = await upsertOauthAccount({ + definition, + exchange: resolved.exchange, + persistedStatus: resolved.disabled ? 'disabled' : 'active', + }); + persistedAccount = persisted; + changedAccountIds.push(persisted.account.id); + items.push({ + name: resolved.name, + status: 'imported', + provider: resolved.provider, + accountId: persisted.account.id, + }); + } catch (error: any) { + items.push({ + name: asNonEmptyString(payload.email) || asNonEmptyString(payload.account_key) || asNonEmptyString(payload.account_id) || asNonEmptyString(payload.type) || 'unknown', + status: 'failed', + provider: asNonEmptyString(payload.type) || undefined, + message: error?.message || 'oauth import failed', + }); + throw error; + } + + if (changedAccountIds.length > 0) { + const importedAccountId = changedAccountIds[0]!; + if (persistedAccount && (persistedAccount.account.status || 'active') === 'active') { + const refreshResult = await refreshModelsForAccount( + importedAccountId, + persistedAccount.previousAccount ? { allowInactive: true } : undefined, + ); + if (refreshResult.status !== 'success') { + await revertPersistedOauthAccount({ + accountId: importedAccountId, + created: persistedAccount.created, + previousAccount: persistedAccount.previousAccount, + }); + await routeRefreshWorkflow.rebuildRoutesOnly(); + throw new Error(refreshResult.errorMessage || `${resolvedProvider || 'oauth'} model discovery failed`); + } + } + await routeRefreshWorkflow.rebuildRoutesOnly(); + } + + return { + success: true, + imported: 1, + skipped: 0, + failed: 0, + items, + }; +} + export async function startOauthRebindFlow( accountId: number, - options?: { requestOrigin?: string; proxyUrl?: string | null }, + options?: { requestOrigin?: string; proxyUrl?: string | null; useSystemProxy?: boolean }, ) { - const { requestOrigin, proxyUrl } = options ?? {}; + const { requestOrigin, proxyUrl, useSystemProxy } = options ?? {}; const account = await db.select().from(schema.accounts) .where(eq(schema.accounts.id, accountId)) .get(); @@ -604,6 +978,9 @@ export async function startOauthRebindFlow( proxyUrl: proxyUrl !== undefined ? proxyUrl : (getProxyUrlFromExtraConfig(account.extraConfig) ?? undefined), + useSystemProxy: useSystemProxy !== undefined + ? useSystemProxy + : (getUseSystemProxyFromExtraConfig(account.extraConfig) || undefined), requestOrigin, }); } diff --git a/src/server/services/oauth/sessionStore.ts b/src/server/services/oauth/sessionStore.ts index 65d1a2e..d148c3d 100644 --- a/src/server/services/oauth/sessionStore.ts +++ b/src/server/services/oauth/sessionStore.ts @@ -17,6 +17,7 @@ export type OAuthSessionRecord = { rebindAccountId?: number; projectId?: string; proxyUrl?: string | null; + useSystemProxy?: boolean; }; export interface OAuthSessionStore { @@ -26,6 +27,7 @@ export interface OAuthSessionStore { rebindAccountId?: number; projectId?: string; proxyUrl?: string | null; + useSystemProxy?: boolean; }): OAuthSessionRecord; get(state: string): OAuthSessionRecord | null; markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null; @@ -68,6 +70,7 @@ class MemoryOAuthSessionStore implements OAuthSessionStore { rebindAccountId?: number; projectId?: string; proxyUrl?: string | null; + useSystemProxy?: boolean; }): OAuthSessionRecord { this.pruneExpiredSessions(); const state = toBase64Url(randomBytes(24)); @@ -86,6 +89,7 @@ class MemoryOAuthSessionStore implements OAuthSessionStore { rebindAccountId: input.rebindAccountId, projectId: input.projectId, proxyUrl: input.proxyUrl, + useSystemProxy: input.useSystemProxy, }; this.sessions.set(state, record); return record; @@ -137,6 +141,7 @@ export function createOauthSession(input: { rebindAccountId?: number; projectId?: string; proxyUrl?: string | null; + useSystemProxy?: boolean; }): OAuthSessionRecord { return oauthSessionStore.create(input); } diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index f819894..efbbbab 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -9,7 +9,7 @@ import { SocksClient } from 'socks'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; import { Agent as UndiciAgent, ProxyAgent } from 'undici'; import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js'; -import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; +import { resolveProxyUrlFromExtraConfig } from './accountExtraConfig.js'; import { stripTrailingSlashes } from './urlNormalization.js'; const SITE_PROXY_CACHE_TTL_MS = 3_000; @@ -481,7 +481,7 @@ export function resolveChannelProxyUrl( accountExtraConfig?: string | null, ): string | null { if (accountExtraConfig) { - const normalized = normalizeSiteProxyUrl(getProxyUrlFromExtraConfig(accountExtraConfig)); + const normalized = normalizeSiteProxyUrl(resolveProxyUrlFromExtraConfig(accountExtraConfig)); if (normalized) return normalized; } return resolveProxyUrlForSite(site); diff --git a/src/web/api.ts b/src/web/api.ts index afce9fb..3c45fc5 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -650,6 +650,7 @@ export type OAuthConnectionInfo = { lastModelSyncAt?: string | null; lastModelSyncError?: string | null; proxyUrl?: string | null; + useSystemProxy?: boolean; site?: { id: number; name: string; url: string; platform: string } | null; }; @@ -831,7 +832,7 @@ export const api = { // OAuth getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>, - startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string; proxyUrl?: string | null }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { + startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string; proxyUrl?: string | null; useSystemProxy?: boolean }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { method: 'POST', body: JSON.stringify(data || {}), }) as Promise, @@ -850,7 +851,7 @@ export const api = { method: 'POST', body: JSON.stringify({ accountIds }), }) as Promise, - rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, { + rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null; useSystemProxy?: boolean }) => request(`/api/oauth/connections/${accountId}/rebind`, { method: 'POST', body: JSON.stringify(data || {}), }) as Promise, diff --git a/src/web/index.css b/src/web/index.css index a21b61d..ebbc7ec 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1031,7 +1031,7 @@ body { font-weight: 600; color: var(--color-text-primary); line-height: 1.3; - word-break: break-word; + overflow-wrap: break-word; } .mobile-card-subtitle { @@ -1135,7 +1135,7 @@ body { font-weight: 600; color: var(--color-text-primary); line-height: 1.3; - word-break: break-word; + overflow-wrap: break-word; } /* ===== Mobile Drawer ===== */ @@ -1803,7 +1803,7 @@ body { font-size: 13px; color: var(--color-text-secondary); line-height: 1.7; - word-break: break-word; + overflow-wrap: break-word; } .announcement-rich-content > :first-child { @@ -2015,7 +2015,7 @@ body { .token-table th, .token-table td { white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } .token-table-actions-col { @@ -2054,7 +2054,7 @@ body { .accounts-table th, .accounts-table td { white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } .accounts-actions-col { @@ -2846,6 +2846,832 @@ textarea { justify-content: flex-end; } +.oauth-toolbar-card { + padding: 16px; + margin-bottom: 16px; +} + +.oauth-toolbar { + margin-bottom: 0; + display: grid; + gap: 12px; +} + +.oauth-toolbar-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.oauth-toolbar-filters, +.oauth-toolbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} + +.oauth-toolbar-filters { + flex: 1; +} + +.oauth-toolbar-actions { + justify-content: flex-end; +} + +.oauth-filter-slot { + min-width: 160px; +} + +.oauth-filter-slot-wide { + min-width: 180px; +} + +.oauth-filter-slot-search { + flex: 1; + min-width: 260px; + max-width: 420px; +} + +.oauth-toolbar-meta, +.oauth-summary-note, +.oauth-page-message-text, +.oauth-mobile-section-label, +.oauth-workbench-meta, +.oauth-field-label, +.oauth-form-note, +.oauth-guide-copy, +.oauth-import-copy { + font-size: 12px; + color: var(--color-text-muted); + line-height: 1.6; +} + +.oauth-toolbar-meta { + min-width: 84px; +} + +.oauth-column-menu-anchor { + position: relative; +} + +.oauth-outline-button { + border: 1px solid var(--color-border); +} + +.oauth-column-menu { + position: absolute; + right: 0; + top: calc(100% + 8px); + z-index: 10; + width: 220px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg); + box-shadow: var(--shadow-lg); + padding: 10px; + display: grid; + gap: 6px; +} + +.oauth-column-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--color-text-primary); +} + +.oauth-page-message { + padding: 14px; + margin-bottom: 16px; +} + +.oauth-workbench-card { + padding: 16px; +} + +.oauth-workbench-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.oauth-workbench-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-table { + width: 100%; +} + +.oauth-table th, +.oauth-table td { + white-space: normal; + word-break: normal; + overflow-wrap: break-word; +} + +.oauth-table th { + padding: 8px 10px; + font-size: 12px; +} + +.oauth-table td { + padding: 8px 10px; + vertical-align: top; + line-height: 1.3; +} + +.oauth-table-checkbox-col { + width: 36px; +} + +.oauth-col-identity { + width: 28%; + min-width: 240px; +} + +.oauth-col-site { + width: 18%; + min-width: 150px; +} + +.oauth-col-status { + width: 20%; + min-width: 220px; +} + +.oauth-col-quota { + width: 20%; + min-width: 190px; +} + +.oauth-col-proxy { + width: 14%; + min-width: 126px; +} + +.oauth-table-actions-col { + width: 1%; + min-width: 180px; + text-align: right; +} + +.oauth-actions-cell { + min-width: 180px; + white-space: normal !important; +} + +.oauth-row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 2px 4px; + width: 100%; +} + +.oauth-row-actions .btn-link { + white-space: nowrap; + padding: 2px 4px; + font-size: 11px; +} + +.oauth-account-trigger { + padding: 0; + justify-content: flex-start; + text-align: left; + line-height: 1.2; + max-width: 100%; +} + +.oauth-inline-trigger { + justify-content: flex-start; + padding: 0; + font-size: 11px; + line-height: 1.2; +} + +.oauth-cell-stack { + display: grid; + gap: 2px; + min-width: 0; +} + +.oauth-cell-inline { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.oauth-cell-primary { + font-size: 12.5px; + font-weight: 600; + color: var(--color-text-primary); + line-height: 1.3; +} + +.oauth-site-name { + overflow-wrap: break-word; +} + +.oauth-cell-secondary { + font-size: 11.5px; + color: var(--color-text-muted); + line-height: 1.35; + overflow-wrap: anywhere; +} + +.oauth-cell-tertiary { + font-size: 11px; + color: var(--color-text-secondary); + line-height: 1.3; + overflow-wrap: anywhere; +} + +.oauth-status-stack { + display: grid; + gap: 6px; + min-width: 0; +} + +.oauth-status-line { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; +} + +.oauth-status-item { + display: grid; + gap: 2px; + min-width: 0; +} + +.oauth-status-label { + flex: 0 0 auto; + font-size: 10.5px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.oauth-status-value, +.oauth-identity-primary, +.oauth-identity-secondary, +.oauth-identity-key { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.oauth-status-detail { + font-size: 11px; + color: var(--color-text-muted); + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; +} + +.oauth-badge { + font-size: 11px; +} + +.oauth-quota-stack { + display: grid; + gap: 4px; + min-width: 180px; +} + +.oauth-quota-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.oauth-window-row { + display: block; +} + +.oauth-window-row-header { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.oauth-window-pill { + min-width: 34px; + padding: 2px 6px; + border-radius: 999px; + text-align: center; + font-size: 10px; + font-weight: 700; + background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg)); + color: var(--color-text-primary); +} + +.oauth-window-meter { + width: 60px; + height: 5px; + border-radius: 999px; + background: var(--color-border-light); + overflow: hidden; +} + +.oauth-window-meter-fill { + height: 100%; + background: var(--color-border); +} + +.oauth-window-value { + font-size: 11px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-window-reset, +.oauth-window-summary { + font-size: 11px; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +} + +.oauth-mobile-list { + display: grid; + gap: 12px; + margin-top: 4px; +} + +.oauth-mobile-trigger-row { + gap: 8px; +} + +.oauth-mobile-filter-panel { + gap: 12px; +} + +.oauth-mobile-actions { + margin-top: 8px; +} + +.oauth-mobile-section { + display: grid; + gap: 6px; +} + +.oauth-empty-state { + margin-top: 4px; +} + +.oauth-drawer-backdrop { + justify-content: flex-end; + align-items: stretch; + padding: 0; +} + +.oauth-drawer-content { + width: min(92vw, 560px); + max-width: 560px; + height: 100vh; + max-height: 100vh; + border-radius: 0; + animation: drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.oauth-drawer-content.is-closing { + animation: drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both; +} + +.oauth-drawer-header { + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border-light); +} + +.oauth-drawer-close { + flex: 0 0 auto; +} + +.oauth-drawer-body { + padding-top: 0; +} + +.oauth-drawer-layout { + display: grid; + gap: 16px; +} + +.oauth-drawer-panel { + padding: 16px; +} + +.oauth-drawer-section { + display: grid; + gap: 12px; +} + +.oauth-form-field { + display: grid; + gap: 6px; +} + +.oauth-input, +.oauth-textarea { + width: 100%; + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text-primary); + font-size: 13px; + line-height: 1.5; +} + +.oauth-input:disabled, +.oauth-textarea:disabled { + background: var(--color-bg-card); + opacity: 0.7; +} + +.oauth-textarea { + resize: vertical; +} + +.oauth-mono { + font-family: var(--font-mono); +} + +.oauth-toggle-group { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.oauth-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg); + font-size: 13px; + color: var(--color-text-primary); +} + +.oauth-panel-title { + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-guide-grid { + display: grid; + gap: 12px; +} + +.oauth-guide-highlight { + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card)); +} + +.oauth-guide-card { + display: grid; + gap: 10px; + padding: 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg); +} + +.oauth-guide-title { + margin-bottom: 4px; + font-size: 13px; + font-weight: 700; + color: var(--color-text-primary); +} + +.oauth-guide-copy { + color: var(--color-text-secondary); +} + +.oauth-guide-block-list { + display: grid; + gap: 8px; +} + +.oauth-guide-block-label { + font-size: 12px; + color: var(--color-text-muted); +} + +.oauth-guide-block-label-spaced { + margin-bottom: 6px; +} + +.oauth-code-block { + display: block; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg)); + border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border)); + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; +} + +.oauth-inline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.oauth-import-result { + padding: 14px; +} + +.oauth-import-preview { + display: grid; + gap: 10px; + padding: 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg); +} + +.oauth-import-preview.is-valid { + border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border-light)); + background: color-mix(in srgb, var(--color-success) 8%, var(--color-bg)); +} + +.oauth-import-preview.is-invalid { + border-color: color-mix(in srgb, var(--color-danger) 24%, var(--color-border-light)); + background: color-mix(in srgb, var(--color-danger) 6%, var(--color-bg)); +} + +.oauth-import-preview-title { + font-size: 13px; + font-weight: 700; + color: var(--color-text-primary); +} + +.oauth-import-preview-summary, +.oauth-import-preview-note { + font-size: 12px; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.oauth-import-preview-list { + display: grid; + gap: 8px; +} + +.oauth-import-preview-item { + display: grid; + gap: 6px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-light); + background: var(--color-bg-card); +} + +.oauth-import-preview-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.oauth-import-preview-item-name { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); + overflow-wrap: break-word; +} + +.oauth-import-preview-badge { + flex-shrink: 0; +} + +.oauth-import-preview-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); +} + +.oauth-import-preview-error { + font-size: 12px; + line-height: 1.5; + color: var(--color-danger); +} + +.oauth-import-picker { + display: grid; + gap: 8px; + padding: 16px; + border: 2px dashed var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.oauth-import-picker.is-dragover { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg)); +} + +.oauth-import-picker-copy { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-import-picker-hint { + font-size: 12px; + color: var(--color-text-muted); +} + +.oauth-import-file-list { + display: grid; + gap: 6px; +} + +.oauth-import-file-item { + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--color-bg-card); + border: 1px solid var(--color-border-light); +} + +.oauth-import-file-name { + display: block; + font-size: 12px; + color: var(--color-text-secondary); + overflow-wrap: break-word; +} + +.oauth-import-result-title { + margin-bottom: 8px; + font-size: 13px; + font-weight: 700; + color: var(--color-text-primary); +} + +.oauth-models-layout { + display: grid; + gap: 12px; +} + +.oauth-models-summary { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg); +} + +.oauth-models-summary-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-models-summary-copy { + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.oauth-models-list { + display: grid; + gap: 0; + max-height: 320px; + overflow: auto; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg); +} + +.oauth-models-item { + display: grid; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-border-light); +} + +.oauth-models-item:last-child { + border-bottom: none; +} + +.oauth-models-item.is-disabled { + background: color-mix(in srgb, var(--color-warning) 6%, var(--color-bg)); +} + +.oauth-models-item-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.oauth-models-item-name { + font-size: 12px; + font-family: var(--font-mono); + color: var(--color-text-primary); + word-break: break-all; +} + +.oauth-models-item-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 11px; + color: var(--color-text-muted); +} + +.oauth-models-badge { + font-size: 10px; +} + +.oauth-models-empty { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + min-height: 140px; + text-align: center; + color: var(--color-text-muted); +} + +.oauth-models-empty-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.oauth-models-empty-copy { + font-size: 12px; + line-height: 1.5; +} + +.oauth-import-copy-summary { + margin-bottom: 8px; +} + +.oauth-import-result-list { + display: grid; + gap: 6px; + max-height: 220px; + overflow: auto; +} + +.oauth-import-result-item { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + background: var(--color-bg); +} + +.oauth-import-result-file { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); + overflow-wrap: break-word; +} + +.oauth-import-result-metrics { + font-size: 12px; + color: var(--color-text-secondary); +} + /* ===== Chart Placeholder ===== */ .chart-container { background: var(--color-bg-card); @@ -4880,6 +5706,11 @@ textarea { font-size: 11px; } + .oauth-row-actions .btn-link { + padding: 3px 6px; + font-size: 11px; + } + .sites-actions-col { min-width: 165px; } @@ -4928,6 +5759,32 @@ textarea { font-size: 18px; } + .oauth-toolbar-card, + .oauth-workbench-card, + .oauth-drawer-panel { + padding: 16px; + } + + .oauth-filter-slot, + .oauth-filter-slot-wide, + .oauth-filter-slot-search { + min-width: 100%; + max-width: none; + } + + .oauth-toolbar-actions { + width: 100%; + justify-content: flex-start; + } + + .oauth-column-menu { + width: min(220px, calc(100vw - 32px)); + } + + .oauth-quota-stack { + min-width: 0; + } + /* --- Topbar --- */ .topbar { padding: 0 12px; diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index e04ec63..b5b76dc 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -12,8 +12,12 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() getOAuthSession: vi.fn(), submitOAuthManualCallback: vi.fn(), refreshOAuthConnectionQuota: vi.fn(), + refreshOAuthConnectionQuotaBatch: vi.fn(), rebindOAuthConnection: vi.fn(), deleteOAuthConnection: vi.fn(), + importOAuthConnections: vi.fn(), + getAccountModels: vi.fn(), + checkModels: vi.fn(), }, openMock: vi.fn(), focusMock: vi.fn(), @@ -40,6 +44,41 @@ async function flushMicrotasks() { }); } +function findButton(root: WebTestRenderer, label: string) { + return root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes(label) + )); +} + +function findOauthSettingInput(root: WebTestRenderer, key: string) { + return root.root.find((node) => ( + node.type === 'input' + && node.props['data-oauth-setting'] === key + )); +} + +function findAllByClassName(root: WebTestRenderer, className: string) { + return root.root.findAll((node) => ( + typeof node.props?.className === 'string' + && node.props.className.split(' ').includes(className) + )); +} + +function findHeaders(root: WebTestRenderer, label: string) { + return root.root.findAll((node) => node.type === 'th' && collectText(node) === label); +} + +async function clickButton(root: WebTestRenderer, label: string) { + const button = findButton(root, label); + await act(async () => { + await button.props.onClick(); + }); + await flushMicrotasks(); + return button; +} + describe('OAuthManagement page', () => { beforeEach(() => { vi.useFakeTimers(); @@ -135,6 +174,657 @@ describe('OAuthManagement page', () => { } }); + it('renders the oauth workbench toolbar and supports batch quota refresh', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + siteId: 2, + provider: 'codex', + email: 'codex-user@example.com', + accountKey: 'chatgpt-account-123', + planType: 'plus', + modelCount: 3, + modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], + status: 'healthy', + routeChannelCount: 2, + site: { + id: 2, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }) + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + siteId: 2, + provider: 'codex', + email: 'codex-user@example.com', + accountKey: 'chatgpt-account-123', + planType: 'plus', + modelCount: 3, + modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], + status: 'healthy', + routeChannelCount: 2, + site: { + id: 2, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.refreshOAuthConnectionQuotaBatch.mockResolvedValue({ + success: true, + refreshed: 1, + failed: 0, + items: [ + { + accountId: 7, + success: true, + quota: { + status: 'supported', + source: 'reverse_engineered', + windows: { + fiveHour: { supported: false }, + sevenDay: { supported: false }, + }, + }, + }, + ], + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root.root); + expect(text).toContain('新建 OAuth 连接'); + expect(text).toContain('导入 JSON'); + expect(text).toContain('自动刷新'); + expect(findAllByClassName(root, 'oauth-toolbar-meta')).toHaveLength(0); + + const selectAll = root.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-testid'] === 'oauth-select-all' + )); + + await act(async () => { + selectAll.props.onChange({ target: { checked: true } }); + }); + + const batchRefreshButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('批量刷新额度') + )); + + await act(async () => { + await batchRefreshButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.refreshOAuthConnectionQuotaBatch).toHaveBeenCalledWith([7]); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + } finally { + root?.unmount(); + } + }); + + it('opens a models modal from the model count trigger and loads the full model list', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 52, + siteId: 44, + provider: 'codex', + username: 'Juricek Team A', + email: 'juricek.chen@gmail.com', + accountKey: 'chatgpt-account-123', + planType: 'team', + modelCount: 12, + modelsPreview: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex'], + status: 'healthy', + routeChannelCount: 12, + site: { + id: 44, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.getAccountModels.mockResolvedValue({ + siteId: 44, + siteName: 'ChatGPT Codex OAuth', + totalCount: 4, + disabledCount: 1, + models: [ + { name: 'gpt-5.4', latencyMs: 120, disabled: false }, + { name: 'gpt-5.4-mini', latencyMs: 90, disabled: false }, + { name: 'gpt-5.3-codex', latencyMs: 110, disabled: false }, + { name: 'gpt-5.2-codex', latencyMs: null, disabled: true, isManual: true }, + ], + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root, 'Juricek Team A'); + + await vi.waitFor(async () => { + await flushMicrotasks(); + const text = collectText(root.root); + expect(text).toContain('模型列表 · Juricek Team A'); + expect(text).toContain('ChatGPT Codex OAuth · 共 4 个模型'); + expect(text).toContain('gpt-5.2-codex'); + expect(text).toContain('禁用'); + }); + + expect(apiMock.getAccountModels).toHaveBeenCalledWith(52); + } finally { + root?.unmount(); + } + }); + + it('renders supported quota windows without duplicating raw counters beside the percent', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 91, + provider: 'codex', + email: 'quota-user@example.com', + planType: 'team', + modelCount: 12, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + windows: { + fiveHour: { + supported: true, + used: 100, + limit: 100, + resetAt: '2999-03-18T01:28:00.000Z', + }, + sevenDay: { + supported: true, + used: 81, + limit: 100, + resetAt: '2999-03-22T17:00:00.000Z', + }, + }, + }, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + const text = collectText(root.root); + expect(text).toContain('100%'); + expect(text).toContain('81%'); + expect(text).not.toContain('100 / 100'); + expect(text).not.toContain('81 / 100'); + expect(findHeaders(root, '模型 / 路由')).toHaveLength(0); + expect(findHeaders(root, '同步')).toHaveLength(0); + }); + } finally { + root?.unmount(); + } + }); + + it('previews native oauth json in the workbench modal and closes after adding', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 }) + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'imported@example.com', + accountKey: 'chatgpt-account-123', + planType: 'plus', + modelCount: 0, + modelsPreview: [], + status: 'healthy', + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.importOAuthConnections.mockResolvedValue({ + success: true, + imported: 1, + skipped: 0, + failed: 0, + items: [ + { + name: 'Imported Codex OAuth', + status: 'imported', + accountId: 7, + provider: 'codex', + }, + ], + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root, '导入 JSON'); + + const textarea = root.root.find((node) => ( + node.type === 'textarea' + && typeof node.props.placeholder === 'string' + && node.props.placeholder.includes('"access_token"') + )); + + expect(collectText(root.root)).not.toContain('sub2api'); + + await act(async () => { + textarea.props.onChange({ + target: { + value: JSON.stringify({ + type: 'codex', + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + }), + }, + }); + }); + + expect(collectText(root.root)).toContain('识别结果'); + expect(collectText(root.root)).toContain('结构有效'); + expect(collectText(root.root)).toContain('Codex'); + + await clickButton(root, '添加'); + + expect(apiMock.importOAuthConnections).toHaveBeenCalledWith({ + type: 'codex', + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + await flushMicrotasks(); + expect(collectText(root.root)).toContain('已添加 1 个 OAuth 连接'); + expect(collectText(root.root)).not.toContain('导入 OAuth 连接 JSON'); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + } finally { + root?.unmount(); + } + }); + + it('supports selecting multiple json files and imports them sequentially', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 }) + .mockResolvedValueOnce({ + items: [ + { + accountId: 9, + provider: 'codex', + email: 'imported@example.com', + accountKey: 'chatgpt-account-456', + planType: 'team', + modelCount: 0, + modelsPreview: [], + status: 'healthy', + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.importOAuthConnections + .mockResolvedValueOnce({ + success: true, + imported: 1, + skipped: 0, + failed: 0, + items: [ + { + name: 'Workspace A', + status: 'imported', + accountId: 7, + provider: 'codex', + }, + ], + }) + .mockResolvedValueOnce({ + success: true, + imported: 1, + skipped: 1, + failed: 0, + items: [ + { + name: 'Workspace B', + status: 'imported', + accountId: 9, + provider: 'codex', + }, + { + name: 'Skipped API Key', + status: 'skipped', + message: 'not oauth', + }, + ], + }); + + const fileA = { + name: 'workspace-a.json', + text: vi.fn().mockResolvedValue(JSON.stringify({ + type: 'codex', + access_token: 'oauth-access-token-a', + email: 'workspace-a@example.com', + })), + }; + const fileB = { + name: 'workspace-b.json', + text: vi.fn().mockResolvedValue(JSON.stringify({ + type: 'codex', + access_token: 'oauth-access-token-b', + email: 'workspace-b@example.com', + })), + }; + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root, '导入 JSON'); + + const fileInput = root.root.find((node) => ( + node.type === 'input' + && node.props.type === 'file' + && node.props['data-testid'] === 'oauth-import-file-input' + )); + + expect(fileInput.props.multiple).toBe(true); + + await act(async () => { + await fileInput.props.onChange({ + target: { + files: [fileA, fileB], + }, + }); + }); + await flushMicrotasks(); + + const importText = collectText(root.root); + expect(importText).toContain('workspace-a.json'); + expect(importText).toContain('workspace-b.json'); + expect(importText).toContain('已识别 2 份 JSON'); + expect(importText).toContain('结构有效'); + + await clickButton(root, '添加'); + + expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(2); + expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(1, { + type: 'codex', + access_token: 'oauth-access-token-a', + email: 'workspace-a@example.com', + }); + expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(2, { + type: 'codex', + access_token: 'oauth-access-token-b', + email: 'workspace-b@example.com', + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + await flushMicrotasks(); + expect(collectText(root.root)).toContain('已添加 2 个 OAuth 连接'); + expect(collectText(root.root)).not.toContain('导入 OAuth 连接 JSON'); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + } finally { + root?.unmount(); + } + }); + + it('shows native oauth guidance when import starts without sources', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root, '导入 JSON'); + await clickButton(root, '添加'); + + expect(collectText(root.root)).toContain('请先选择 JSON 文件或粘贴 OAuth 连接 JSON 内容'); + } finally { + root?.unmount(); + } + }); + + it('shows invalid preview feedback before adding oauth json', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root, '导入 JSON'); + + const textarea = root.root.find((node) => ( + node.type === 'textarea' + && typeof node.props.placeholder === 'string' + && node.props.placeholder.includes('"access_token"') + )); + + await act(async () => { + textarea.props.onChange({ + target: { + value: '{"type":"codex"}', + }, + }); + }); + + const addButton = findButton(root, '添加'); + expect(addButton.props.disabled).toBe(true); + expect(collectText(root.root)).toContain('结构无效'); + expect(collectText(root.root)).toContain('缺少 access_token'); + } finally { + root?.unmount(); + } + }); + it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ @@ -221,34 +911,21 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); - const proxyToggle = root!.root.find((node) => ( - node.type === 'input' - && node.props.type === 'checkbox' - && node.props['data-oauth-setting'] === 'use-proxy' - )); + await clickButton(root!, '新建 OAuth 连接'); + + const proxyToggle = findOauthSettingInput(root!, 'use-custom-proxy'); await act(async () => { proxyToggle.props.onChange({ target: { checked: true } }); }); - const proxyInput = root!.root.find((node) => ( - node.type === 'input' - && node.props['data-oauth-setting'] === 'proxy-url' - )); + const proxyInput = findOauthSettingInput(root!, 'proxy-url'); await act(async () => { proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } }); }); - const startButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('连接 Codex') - )); - - await act(async () => { - await startButton.props.onClick(); - }); + await clickButton(root!, '连接 Codex'); await vi.waitFor(async () => { await flushMicrotasks(); }); @@ -256,6 +933,7 @@ describe('OAuthManagement page', () => { expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined, proxyUrl: 'http://127.0.0.1:7890', + useSystemProxy: false, }); expect(openMock).toHaveBeenCalledWith( 'https://auth.openai.com/oauth/authorize?state=oauth-state-123', @@ -341,15 +1019,8 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); - const startButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('连接 Claude') - )); - - await act(async () => { - await startButton.props.onClick(); - }); + await clickButton(root!, '新建 OAuth 连接'); + await clickButton(root!, '连接 Claude'); await vi.waitFor(async () => { await flushMicrotasks(); }); @@ -436,13 +1107,11 @@ describe('OAuthManagement page', () => { const text = collectText(root!.root); expect(text).toContain('官方上游连接'); expect(text).toContain('CLI'); - expect(text).toContain('下游密钥'); + expect(text).toContain('API Key'); }); - const startButton = root!.root.find((node) => ( - node.type === 'button' - && collectText(node).includes('当前不可用') - )); + await clickButton(root!, '新建 OAuth 连接'); + const startButton = findButton(root!, '连接 Codex'); expect(startButton.props.disabled).toBe(true); expect(collectText(root!.root)).toContain('当前环境未启用'); } finally { @@ -563,20 +1232,12 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); - const startButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('连接 Gemini CLI') - )); - - await act(async () => { - await startButton.props.onClick(); - }); + await clickButton(root!, '新建 OAuth 连接'); + await clickButton(root!, '连接 Gemini CLI'); await vi.waitFor(async () => { await flushMicrotasks(); }); - expect(promptMock).toHaveBeenCalled(); expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined }); expect(openMock).toHaveBeenCalledWith( 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123', @@ -655,15 +1316,8 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); - const rebindButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('重新授权') - )); - - await act(async () => { - await rebindButton.props.onClick(); - }); + await clickButton(root!, '重新授权'); + await clickButton(root!, '重新授权 Gemini CLI'); await vi.waitFor(async () => { await flushMicrotasks(); }); @@ -743,26 +1397,87 @@ describe('OAuthManagement page', () => { }); await flushMicrotasks(); - const rebindButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('重新授权') - )); - - await act(async () => { - await rebindButton.props.onClick(); - }); + await clickButton(root!, '重新授权'); + await clickButton(root!, '重新授权 Gemini CLI'); await flushMicrotasks(); expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, { proxyUrl: 'http://127.0.0.1:7890', + useSystemProxy: false, }); } finally { root?.unmount(); } }); - it('can clear the stored account proxy during rebind', async () => { + it('opens oauth proxy settings from the dedicated proxy action', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'gemini-cli', + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 11, + provider: 'gemini-cli', + email: 'gemini@example.com', + projectId: 'project-demo', + modelCount: 5, + modelsPreview: ['gemini-2.5-pro'], + status: 'healthy', + proxyUrl: 'http://127.0.0.1:7890', + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root!, '代理设置'); + + expect(collectText(root!.root)).toContain('已打开 OAuth 代理设置'); + expect(collectText(root!.root)).toContain('代理设置 · gemini@example.com'); + + const proxyToggle = findOauthSettingInput(root!, 'use-custom-proxy'); + const proxyInput = findOauthSettingInput(root!, 'proxy-url'); + + expect(findOauthSettingInput(root!, 'use-system-proxy').props.checked).toBe(false); + + expect(proxyToggle).toBeTruthy(); + expect(proxyToggle.props.checked).toBe(true); + expect(proxyInput).toBeTruthy(); + expect(proxyInput.props.disabled).toBe(false); + expect(proxyInput.props.value).toBe('http://127.0.0.1:7890'); + } finally { + root?.unmount(); + } + }); + + it('can clear the stored account proxy by turning off both proxy options', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ { @@ -825,29 +1540,86 @@ describe('OAuthManagement page', () => { }); await flushMicrotasks(); - const clearProxyToggle = root!.root.find((node) => ( - node.type === 'input' - && node.props.type === 'checkbox' - && node.props['data-oauth-setting'] === 'clear-proxy' - )); + await clickButton(root!, '代理设置'); + const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy'); await act(async () => { - clearProxyToggle.props.onChange({ target: { checked: true } }); + customProxyToggle.props.onChange({ target: { checked: false } }); }); - const rebindButton = root!.root.find((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && collectText(node).includes('重新授权') - )); - - await act(async () => { - await rebindButton.props.onClick(); - }); + await clickButton(root!, '保存代理并重新授权'); await flushMicrotasks(); expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, { proxyUrl: null, + useSystemProxy: false, + }); + } finally { + root?.unmount(); + } + }); + + it('starts oauth with the configured system proxy when selected', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'ChatGPT Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + apiMock.startOAuthProvider.mockResolvedValue({ + provider: 'codex', + state: 'oauth-state-system-proxy', + authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-system-proxy', + instructions: { + redirectUri: 'http://localhost:1455/auth/callback', + callbackPort: 1455, + callbackPath: '/auth/callback', + manualCallbackDelayMs: 15000, + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'codex', + state: 'oauth-state-system-proxy', + status: 'pending', + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await clickButton(root!, '新建 OAuth 连接'); + const systemProxyToggle = findOauthSettingInput(root!, 'use-system-proxy'); + + await act(async () => { + systemProxyToggle.props.onChange({ target: { checked: true } }); + }); + + await clickButton(root!, '连接 ChatGPT Codex'); + await flushMicrotasks(); + + expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { + projectId: undefined, + proxyUrl: null, + useSystemProxy: true, }); } finally { root?.unmount(); @@ -929,20 +1701,10 @@ describe('OAuthManagement page', () => { }); await flushMicrotasks(); - const proxyToggle = root.root.find((node) => ( - node.type === 'input' - && node.props.type === 'checkbox' - && node.props['data-oauth-setting'] === 'use-proxy' - )); - const proxyInput = root.root.find((node) => ( - node.type === 'input' - && node.props['data-oauth-setting'] === 'proxy-url' - )); - const startButtons = root.root.findAll((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && String(node.props.children).startsWith('连接 ') - )); + await clickButton(root, '新建 OAuth 连接'); + + const proxyToggle = findOauthSettingInput(root, 'use-custom-proxy'); + const proxyInput = findOauthSettingInput(root, 'proxy-url'); await act(async () => { proxyToggle.props.onChange({ target: { checked: true } }); @@ -950,43 +1712,22 @@ describe('OAuthManagement page', () => { await act(async () => { proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } }); }); - await act(async () => { - await startButtons[0].props.onClick(); - }); + await clickButton(root, '连接 ChatGPT Codex'); await flushMicrotasks(); expect(apiMock.startOAuthProvider).toHaveBeenNthCalledWith(1, 'codex', { projectId: undefined, proxyUrl: 'http://127.0.0.1:7890', + useSystemProxy: false, }); - const resetProxyToggle = root.root.find((node) => ( - node.type === 'input' - && node.props.type === 'checkbox' - && node.props['data-oauth-setting'] === 'use-proxy' - )); - const resetProxyInput = root.root.find((node) => ( - node.type === 'input' - && node.props['data-oauth-setting'] === 'proxy-url' - )); + const resetProxyToggle = findOauthSettingInput(root, 'use-custom-proxy'); + const resetProxyInput = findOauthSettingInput(root, 'proxy-url'); + const resetSystemProxyToggle = findOauthSettingInput(root, 'use-system-proxy'); expect(resetProxyToggle.props.checked).toBe(false); expect(resetProxyInput.props.value).toBe(''); - - const refreshedStartButtons = root.root.findAll((node) => ( - node.type === 'button' - && typeof node.props.onClick === 'function' - && String(node.props.children).startsWith('连接 ') - )); - - await act(async () => { - await refreshedStartButtons[1].props.onClick(); - }); - await flushMicrotasks(); - - expect(apiMock.startOAuthProvider).toHaveBeenNthCalledWith(2, 'claude', { - projectId: undefined, - }); + expect(resetSystemProxyToggle.props.checked).toBe(false); } finally { root?.unmount(); } @@ -1020,8 +1761,9 @@ describe('OAuthManagement page', () => { modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'], status: 'abnormal', quota: { - status: 'supported', + status: 'error', source: 'reverse_engineered', + lastError: '{"detail":{"code":"deactivated_workspace"}}', subscription: { planType: 'team', activeStart: '2026-03-01T00:00:00.000Z', @@ -1067,14 +1809,13 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); const text = collectText(root!.root); expect(text).toContain('异常'); - expect(text).toContain('1 条路由'); expect(text).toContain('Codex 模型获取失败'); - expect(text).toContain('额度状态'); + expect(text).toContain('HTTP 403: forbidden'); expect(text).toContain('team'); - expect(text).toContain('2026-03-01T00:00:00.000Z'); - expect(text).toContain('2026-04-01T00:00:00.000Z'); - expect(text).toContain('2026-03-17T13:00:00.000Z'); - expect(text).toContain('官方未提供'); + expect(text).toContain('11 个模型'); + expect(text).toContain('deactivated_workspace'); + expect(text).not.toContain('当前 Codex OAuth 未暴露官方 5h 窗口'); + expect(text).not.toContain('当前 Codex OAuth 未暴露官方 7d 窗口'); expect(text).toContain('http://***@127.0.0.1:7890'); expect(text).not.toContain('oauth-user:secret'); }); @@ -1229,7 +1970,8 @@ describe('OAuthManagement page', () => { expect(apiMock.refreshOAuthConnectionQuota).toHaveBeenCalledWith(7); expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); expect(collectText(root!.root)).toContain('额度信息已刷新'); - expect(collectText(root!.root)).toContain('2026-03-18T01:00:00.000Z'); + expect(collectText(root!.root)).not.toContain('当前 Codex OAuth 未暴露官方 5h 窗口'); + expect(collectText(root!.root)).not.toContain('当前 Codex OAuth 未暴露官方 7d 窗口'); } finally { root?.unmount(); } diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 211f680..1d8a871 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -1,4 +1,24 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type DragEvent, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation } from 'react-router-dom'; +import CenteredModal from '../components/CenteredModal.js'; +import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; +import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; +import ModernSelect from '../components/ModernSelect.js'; +import { useToast } from '../components/Toast.js'; +import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { useIsMobile } from '../components/useIsMobile.js'; +import OAuthModelsModal, { type OAuthModelItem } from './oauth/OAuthModelsModal.js'; import { api, type OAuthConnectionInfo, @@ -9,7 +29,8 @@ import { } from '../api.js'; const POLL_INTERVAL_MS = 1500; -const CONNECTION_PAGE_LIMIT = 100; +const CONNECTION_PAGE_LIMIT = 200; +const AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30] as const; type ActiveSession = { provider: string; @@ -18,6 +39,69 @@ type ActiveSession = { instructions: OAuthStartInstructions; }; +type DrawerIntent = + | { mode: 'create'; provider?: string } + | { mode: 'rebind'; account: OAuthConnectionInfo } + | { mode: 'proxy'; account: OAuthConnectionInfo }; + +type ColumnKey = 'identity' | 'site' | 'status' | 'quota' | 'proxy'; + +type OAuthImportFileLike = { + name?: string; + text?: () => Promise; +}; + +type OAuthImportDraft = { + sourceName: string; + rawText: string; + error?: string; +}; + +type OAuthImportSource = { + sourceName: string; + rawText: string; + error?: string; +}; + +type OAuthImportPreview = { + sourceName: string; + valid: boolean; + providerLabel?: string; + email?: string; + accountKey?: string; + expiresLabel?: string; + disabled?: boolean; + error?: string; + parsedData?: Record; +}; + +type OAuthImportPreviewSummary = { + totalCount: number; + validCount: number; + invalidCount: number; + canImport: boolean; + items: OAuthImportPreview[]; +}; + +type OAuthModelsModalState = { + open: boolean; + loading: boolean; + refreshing: boolean; + connection: OAuthConnectionInfo | null; + models: OAuthModelItem[]; + totalCount: number; + disabledCount: number; + siteName: string; +}; + +const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [ + { key: 'identity', label: '账号 / Provider' }, + { key: 'site', label: '站点' }, + { key: 'status', label: '运行状态' }, + { key: 'quota', label: 'Usage / Quota' }, + { key: 'proxy', label: '代理 / 项目' }, +]; + function openOAuthPopup(provider: string, authorizationUrl: string) { if (typeof window === 'undefined' || typeof window.open !== 'function') return; const popup = window.open( @@ -37,12 +121,251 @@ function openOAuthPopup(provider: string, authorizationUrl: string) { } } +function asTrimmedString(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeOauthMessage(value: string | null | undefined): string { + const text = asTrimmedString(value); + if (!text) return ''; + + return text + .replace(/codex usage windows inferred from rate limit response headers/ig, '额度窗口已从响应头推断') + .replace(/official 5h quota window is not exposed by current codex oauth artifacts/ig, '当前 Codex OAuth 未暴露官方 5h 窗口') + .replace(/official 7d quota window is not exposed by current codex oauth artifacts/ig, '当前 Codex OAuth 未暴露官方 7d 窗口') + .replace(/official 5h quota window is unavailable for this provider/ig, '当前 Provider 不提供官方 5h 窗口') + .replace(/official 7d quota window is unavailable for this provider/ig, '当前 Provider 不提供官方 7d 窗口') + .replace(/\bfetch failed\b/ig, '网络请求失败'); +} + +function listImportFiles(files: ArrayLike | null | undefined): OAuthImportFileLike[] { + return files ? Array.from(files) : []; +} + +async function readOauthImportDrafts( + files: ArrayLike | null | undefined, +): Promise { + const nextFiles = listImportFiles(files); + return Promise.all(nextFiles.map(async (file, index) => { + const sourceName = asTrimmedString(file.name) || `oauth-import-${index + 1}.json`; + if (typeof file.text !== 'function') { + return { + sourceName, + rawText: '', + error: '当前浏览器不支持读取该文件', + }; + } + try { + return { + sourceName, + rawText: await file.text(), + }; + } catch (error: any) { + return { + sourceName, + rawText: '', + error: error?.message || '读取文件失败', + }; + } + })); +} + +function decodeJwtPayload(token?: string): Record | null { + if (typeof token !== 'string' || !token.trim()) return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + const raw = (parts[1] || '').replace(/-/g, '+').replace(/_/g, '/'); + const padded = raw + '='.repeat((4 - (raw.length % 4 || 4)) % 4); + try { + const decoded = typeof window !== 'undefined' && typeof window.atob === 'function' + ? window.atob(padded) + : ''; + const parsed = JSON.parse(decoded) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function resolveImportProviderLabel(value: string): string | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'codex' || normalized === 'openai') return 'Codex'; + if (normalized === 'claude' || normalized === 'anthropic') return 'Claude'; + if (normalized === 'gemini-cli' || normalized === 'gemini') return 'Gemini CLI'; + if (normalized === 'antigravity') return 'Antigravity'; + return null; +} + +function resolveImportPreviewExpiryLabel(value: unknown): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + const parsed = typeof value === 'number' + ? value + : (typeof value === 'string' && /^\d+$/.test(value.trim())) + ? Number.parseInt(value.trim(), 10) + : Date.parse(typeof value === 'string' ? value : ''); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error('expired 时间格式无效'); + } + return new Date(parsed).toLocaleString(); +} + +function parseOauthImportPreview(source: OAuthImportSource): OAuthImportPreview { + if (source.error) { + return { + sourceName: source.sourceName, + valid: false, + error: source.error, + }; + } + + const raw = source.rawText.trim(); + if (!raw) { + return { + sourceName: source.sourceName, + valid: false, + error: 'JSON 内容为空', + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { + sourceName: source.sourceName, + valid: false, + error: 'JSON 解析失败', + }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + sourceName: source.sourceName, + valid: false, + error: '需要单个 OAuth JSON 对象', + }; + } + + const payload = parsed as Record; + const type = asTrimmedString(typeof payload.type === 'string' ? payload.type : ''); + if ( + type === 'sub2api-data' + || type === 'sub2api-bundle' + || 'accounts' in payload + || 'proxies' in payload + || 'version' in payload + || 'exported_at' in payload + ) { + return { + sourceName: source.sourceName, + valid: false, + error: '这是旧的 sub2api 导出格式', + }; + } + + const providerLabel = type ? resolveImportProviderLabel(type) : null; + if (!providerLabel) { + return { + sourceName: source.sourceName, + valid: false, + error: `不支持的 OAuth 类型:${type || '未知'}`, + }; + } + + if (!asTrimmedString(typeof payload.access_token === 'string' ? payload.access_token : '')) { + return { + sourceName: source.sourceName, + valid: false, + error: '缺少 access_token', + }; + } + + try { + const claims = decodeJwtPayload(typeof payload.id_token === 'string' ? payload.id_token : undefined); + const authClaims = claims?.['https://api.openai.com/auth']; + const authRecord = authClaims && typeof authClaims === 'object' && !Array.isArray(authClaims) + ? authClaims as Record + : null; + return { + sourceName: source.sourceName, + valid: true, + providerLabel, + email: asTrimmedString(typeof payload.email === 'string' ? payload.email : '') + || asTrimmedString(typeof claims?.email === 'string' ? claims.email : ''), + accountKey: asTrimmedString(typeof payload.account_key === 'string' ? payload.account_key : '') + || asTrimmedString(typeof payload.account_id === 'string' ? payload.account_id : '') + || asTrimmedString(typeof authRecord?.chatgpt_account_id === 'string' ? authRecord.chatgpt_account_id : ''), + expiresLabel: resolveImportPreviewExpiryLabel(payload.expired), + disabled: payload.disabled === true, + parsedData: payload, + }; + } catch (error: any) { + return { + sourceName: source.sourceName, + valid: false, + error: error?.message || 'JSON 结构无效', + }; + } +} + +function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string { + return asTrimmedString(connection.username) + || asTrimmedString(connection.email) + || asTrimmedString(connection.accountKey) + || asTrimmedString(connection.provider) + || 'OAuth 连接'; +} + +function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string { + return asTrimmedString(connection.email); +} + function resolveConnectionStatusLabel(status?: string): string { return status === 'abnormal' ? '异常' : '正常'; } -function asTrimmedString(value: string | null | undefined): string { - return typeof value === 'string' ? value.trim() : ''; +function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string { + if (status === 'unsupported') return '不支持'; + if (status === 'error') return '获取失败'; + return '支持'; +} + +function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string { + return source === 'official' ? '官方' : '响应头推断'; +} + +function resolveModelSyncStatusText(connection: OAuthConnectionInfo): string { + const failureText = normalizeOauthMessage(connection.lastModelSyncError || ''); + if (failureText) return '获取失败'; + return connection.lastModelSyncAt ? '同步正常' : '未同步'; +} + +function resolveQuotaSyncStatusText(quota?: OAuthQuotaInfo | null): string { + if (!quota) return '未刷新额度'; + if (quota.status === 'error') { + return '获取失败'; + } + if (quota.status === 'unsupported') { + return '不支持'; + } + return quota.lastSyncAt ? '同步正常' : '未刷新'; +} + +function resolveModelSyncDetail(connection: OAuthConnectionInfo): string { + return normalizeOauthMessage(connection.lastModelSyncError || ''); +} + +function resolveQuotaSyncDetail(quota?: OAuthQuotaInfo | null): string { + if (!quota) return ''; + if (quota.status === 'error') { + return normalizeOauthMessage(quota.lastError || quota.providerMessage || '额度刷新失败'); + } + if (quota.status === 'unsupported') { + return normalizeOauthMessage(quota.providerMessage || '当前连接暂不支持额度窗口'); + } + return ''; } function redactProxyUrl(value: string | null | undefined): string { @@ -63,143 +386,328 @@ function redactProxyUrl(value: string | null | undefined): string { } } -function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string { - if (loading) return '启动中...'; - if (!provider.enabled) return '当前不可用'; - return `连接 ${provider.label}`; +function compactAccountKey(value?: string | null): string { + const text = asTrimmedString(value || ''); + if (!text || text.length <= 24) return text; + return `${text.slice(0, 8)}...${text.slice(-6)}`; } -function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string { - return asTrimmedString(connection.username) - || asTrimmedString(connection.email) - || asTrimmedString(connection.provider) - || 'OAuth 连接'; +function formatResetLabel(value?: string | null): string { + const text = asTrimmedString(value || ''); + if (!text) return ''; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return text; + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return '现在'; + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + if (diffHours >= 24) { + const days = Math.floor(diffHours / 24); + return `${days}d ${diffHours % 24}h`; + } + if (diffHours > 0) return `${diffHours}h ${diffMinutes}m`; + return `${Math.max(1, diffMinutes)}m`; } -function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string { - const email = asTrimmedString(connection.email); - if (!email) return ''; - return email; -} - -function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string { - if (status === 'unsupported') return '不支持'; - if (status === 'error') return '获取失败'; - return '支持'; -} - -function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string { - return source === 'official' ? '官方' : '本地信号'; +function resolveQuotaWindowPercent(window?: OAuthQuotaWindowInfo | null): number | null { + if (!window?.supported) return null; + if (typeof window.used === 'number' && typeof window.limit === 'number' && window.limit > 0) { + return Math.max(0, Math.min(100, Math.round((window.used / window.limit) * 100))); + } + if (typeof window.remaining === 'number' && typeof window.limit === 'number' && window.limit > 0) { + return Math.max(0, Math.min(100, Math.round(((window.limit - window.remaining) / window.limit) * 100))); + } + return null; } function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string { - if (!window || !window.supported) return '官方未提供'; - - const parts: string[] = []; - if (typeof window.remaining === 'number') { - parts.push(`剩余 ${window.remaining}`); - } + if (!window || !window.supported) return ''; if (typeof window.used === 'number' && typeof window.limit === 'number') { - parts.push(`已用 ${window.used} / ${window.limit}`); - } else if (typeof window.limit === 'number') { - parts.push(`总量 ${window.limit}`); + return `${window.used} / ${window.limit}`; } - if (window.resetAt) { - parts.push(`重置 ${window.resetAt}`); + if (typeof window.remaining === 'number' && typeof window.limit === 'number') { + return `剩余 ${window.remaining} / ${window.limit}`; } + if (typeof window.limit === 'number') return `总量 ${window.limit}`; + return window.message || '官方未提供'; +} - return parts.length > 0 ? parts.join(' · ') : '官方未提供'; +function resolveProxyProjectSummary(connection: OAuthConnectionInfo): string { + const parts = [ + asTrimmedString(connection.planType || ''), + connection.projectId ? `Project ${connection.projectId}` : '', + ].filter(Boolean); + return parts.join(' · ') || '--'; +} + +function resolveProxyDisplayText(connection: OAuthConnectionInfo): string { + if (connection.useSystemProxy) return '系统级代理'; + if (connection.proxyUrl) return redactProxyUrl(connection.proxyUrl); + return '未设置代理'; +} + +function hasOauthProxySelection(connection: OAuthConnectionInfo): boolean { + return !!connection.useSystemProxy || !!asTrimmedString(connection.proxyUrl); } function renderCodeBlock(value: string) { return ( - - {value} - + {value} ); } function renderGuideCard(title: string, description: string, children?: ReactNode) { return ( -
+
-
{title}
-
{description}
+
{title}
+
{description}
{children}
); } +function QuotaWindowRow({ + label, + window, +}: { + label: string; + window?: OAuthQuotaWindowInfo | null; +}) { + const percent = resolveQuotaWindowPercent(window); + const summary = resolveQuotaWindowSummary(window); + const tone = percent != null && percent >= 90 + ? 'var(--color-danger)' + : percent != null && percent >= 70 + ? 'var(--color-warning)' + : 'var(--color-primary)'; + + return ( +
+
+ {label} +
+
+
+ {percent == null ? 'N/A' : `${percent}%`} + {summary && percent == null ? {summary} : null} + {window?.resetAt ? ( + 重置 {formatResetLabel(window.resetAt)} + ) : null} +
+
+ ); +} + +function SideDrawer({ + open, + onClose, + title, + children, +}: { + open: boolean; + onClose: () => void; + title: ReactNode; + children: ReactNode; +}) { + const presence = useAnimatedVisibility(open, 220); + + useEffect(() => { + if (!open || typeof document === 'undefined') return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + if (!presence.shouldRender) return null; + + const panel = ( +
+
event.stopPropagation()} + > +
+
{title}
+ +
+
+ {children} +
+
+
+ ); + + const portalTarget = typeof document !== 'undefined' ? document.body : null; + return portalTarget ? createPortal(panel, portalTarget) : panel; +} + export default function OAuthManagement() { + const location = useLocation(); + const isMobile = useIsMobile(); + const toast = useToast(); + const createIntentHandledRef = useRef(false); + const modelsModalRequestSeqRef = useRef(0); const [providers, setProviders] = useState([]); const [connections, setConnections] = useState([]); const [loaded, setLoaded] = useState(false); - const [activeSession, setActiveSession] = useState(null); const [sessionMessage, setSessionMessage] = useState(''); const [actionLoadingKey, setActionLoadingKey] = useState(''); + const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); + const [showMobileFilters, setShowMobileFilters] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [providerFilter, setProviderFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [siteFilter, setSiteFilter] = useState(''); + const [visibleColumns, setVisibleColumns] = useState>({ + identity: true, + site: true, + status: true, + quota: true, + proxy: true, + }); + const [showColumnMenu, setShowColumnMenu] = useState(false); + const [autoRefreshSeconds, setAutoRefreshSeconds] = useState(0); + const [autoRefreshCountdown, setAutoRefreshCountdown] = useState(0); + const [importOpen, setImportOpen] = useState(false); + const importFileInputRef = useRef(null); + const [importJsonText, setImportJsonText] = useState(''); + const [importDrafts, setImportDrafts] = useState([]); + const [importDragOver, setImportDragOver] = useState(false); + const [importing, setImporting] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerIntent, setDrawerIntent] = useState({ mode: 'create' }); + const [selectedProviderKey, setSelectedProviderKey] = useState(''); + const [drawerProjectId, setDrawerProjectId] = useState(''); + const [activeSession, setActiveSession] = useState(null); const [manualCallbackVisible, setManualCallbackVisible] = useState(false); const [manualCallbackUrl, setManualCallbackUrl] = useState(''); const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false); - const [oauthProxyEnabled, setOauthProxyEnabled] = useState(false); - const [oauthProxyClearEnabled, setOauthProxyClearEnabled] = useState(false); + const [oauthCustomProxyEnabled, setOauthCustomProxyEnabled] = useState(false); + const [oauthSystemProxyEnabled, setOauthSystemProxyEnabled] = useState(false); const [oauthProxyUrl, setOauthProxyUrl] = useState(''); + const [modelsModal, setModelsModal] = useState({ + open: false, + loading: false, + refreshing: false, + connection: null, + models: [], + totalCount: 0, + disabledCount: 0, + siteName: '', + }); - const resetOauthProxySettings = () => { - setOauthProxyEnabled(false); - setOauthProxyClearEnabled(false); + const resetOauthProxySettings = useCallback(() => { + setOauthCustomProxyEnabled(false); + setOauthSystemProxyEnabled(false); setOauthProxyUrl(''); - }; + }, []); - const loadConnections = async () => { + const resetImportState = useCallback(() => { + setImportJsonText(''); + setImportDrafts([]); + setImportDragOver(false); + }, []); + + const closeImportModal = useCallback(() => { + setImportOpen(false); + resetImportState(); + }, [resetImportState]); + + const openImportModal = useCallback(() => { + resetImportState(); + setImportOpen(true); + }, [resetImportState]); + + const loadConnections = useCallback(async () => { const response = await api.getOAuthConnections({ limit: CONNECTION_PAGE_LIMIT, offset: 0, }); - setConnections(Array.isArray(response?.items) ? response.items : []); - }; + const nextItems = Array.isArray(response?.items) ? response.items : []; + setConnections(nextItems); + setSelectedConnectionIds((current) => current.filter((id) => nextItems.some((item) => item.accountId === id))); + return nextItems; + }, []); - const load = async () => { + const load = useCallback(async () => { try { const [providersResponse] = await Promise.all([ api.getOAuthProviders(), loadConnections(), ]); - setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []); - } catch (error) { + const nextProviders = Array.isArray(providersResponse?.providers) ? providersResponse.providers : []; + setProviders(nextProviders); + setSelectedProviderKey((current) => current || nextProviders[0]?.provider || ''); + } catch (error: any) { console.error('failed to load oauth management data', error); - setSessionMessage('OAuth 管理数据加载失败'); + setSessionMessage(error?.message || 'OAuth 管理数据加载失败'); } finally { setLoaded(true); } - }; + }, [loadConnections]); useEffect(() => { - load(); - }, []); + void load(); + }, [load]); useEffect(() => { - if (!activeSession) return; + if (autoRefreshSeconds <= 0) { + setAutoRefreshCountdown(0); + return undefined; + } + + setAutoRefreshCountdown(autoRefreshSeconds); + const timer = setInterval(() => { + setAutoRefreshCountdown((current) => { + if (current <= 1) { + void loadConnections().catch((error: any) => { + setSessionMessage(error?.message || 'OAuth 连接列表刷新失败'); + }); + return autoRefreshSeconds; + } + return current - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [autoRefreshSeconds, loadConnections]); + + useEffect(() => { + if (!loaded || providers.length === 0 || createIntentHandledRef.current) return; + const params = new URLSearchParams(location.search); + if (params.get('create') !== '1') return; + + createIntentHandledRef.current = true; + const provider = asTrimmedString(params.get('provider')) || providers[0]?.provider || ''; + setDrawerIntent({ mode: 'create', provider }); + setSelectedProviderKey(provider); + setDrawerProjectId(''); + setDrawerOpen(true); + setSessionMessage('从建站流程跳转到 OAuth 管理,请在这里完成授权。'); + }, [loaded, location.search, providers]); + + useEffect(() => { + if (!activeSession) return undefined; let cancelled = false; let timer: ReturnType | null = null; @@ -231,19 +739,19 @@ export default function OAuthManagement() { } }; - poll(); + void poll(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; - }, [activeSession]); + }, [activeSession, loadConnections]); useEffect(() => { if (!activeSession) { setManualCallbackVisible(false); setManualCallbackUrl(''); setManualCallbackSubmitting(false); - return; + return undefined; } setManualCallbackVisible(false); @@ -257,51 +765,149 @@ export default function OAuthManagement() { return () => clearTimeout(timer); }, [activeSession]); - const handleStart = async ( - provider: OAuthProviderInfo, - options?: { - accountId?: number; - fallbackProxyUrl?: string | null; - }, - ) => { + const providerOptions = useMemo( + () => providers.map((provider) => ({ + value: provider.provider, + label: provider.label, + description: `${provider.platform}${provider.requiresProjectId ? ' · 可选 Project ID' : ''}${!provider.enabled ? ' · 当前环境未启用' : ''}`, + })), + [providers], + ); + + const selectedProvider = useMemo( + () => providers.find((provider) => provider.provider === selectedProviderKey) || null, + [providers, selectedProviderKey], + ); + + const siteOptions = useMemo(() => { + const seen = new Map(); + connections.forEach((connection) => { + const id = String(connection.site?.id || ''); + const label = asTrimmedString(connection.site?.name) || asTrimmedString(connection.site?.url); + if (id && label && !seen.has(id)) { + seen.set(id, label); + } + }); + return Array.from(seen.entries()).map(([value, label]) => ({ value, label })); + }, [connections]); + + const filteredConnections = useMemo(() => { + const search = searchQuery.trim().toLowerCase(); + return connections.filter((connection) => { + if (providerFilter && connection.provider !== providerFilter) return false; + if (statusFilter && connection.status !== statusFilter) return false; + if (siteFilter && String(connection.site?.id || '') !== siteFilter) return false; + if (!search) return true; + const haystack = [ + resolveConnectionPrimaryTitle(connection), + resolveConnectionEmailLabel(connection), + connection.provider, + connection.site?.name, + connection.site?.url, + connection.accountKey, + connection.projectId, + connection.modelsPreview.join(' '), + ].join(' ').toLowerCase(); + return haystack.includes(search); + }); + }, [connections, providerFilter, searchQuery, siteFilter, statusFilter]); + + const allVisibleSelected = filteredConnections.length > 0 + && filteredConnections.every((connection) => selectedConnectionIds.includes(connection.accountId)); + + const openCreateDrawer = (provider?: string) => { + setDrawerIntent({ mode: 'create', provider }); + setSelectedProviderKey(provider || providers[0]?.provider || ''); + setDrawerProjectId(''); + resetOauthProxySettings(); + setDrawerOpen(true); + setShowColumnMenu(false); + }; + + const openRebindDrawer = (connection: OAuthConnectionInfo) => { + setDrawerIntent({ mode: 'rebind', account: connection }); + setSelectedProviderKey(connection.provider); + setDrawerProjectId(connection.projectId || ''); + resetOauthProxySettings(); + setDrawerOpen(true); + setShowColumnMenu(false); + }; + + const openProxySettingsDrawer = (connection: OAuthConnectionInfo) => { + setDrawerIntent({ mode: 'proxy', account: connection }); + setSelectedProviderKey(connection.provider); + setDrawerProjectId(connection.projectId || ''); + setOauthSystemProxyEnabled(connection.useSystemProxy === true); + setOauthCustomProxyEnabled(connection.useSystemProxy !== true && !!asTrimmedString(connection.proxyUrl)); + setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl)); + setDrawerOpen(true); + setShowColumnMenu(false); + setSessionMessage('已打开 OAuth 代理设置,修改后点击“保存代理并重新授权”。'); + }; + + const handleStart = async () => { + const provider = selectedProvider + || (drawerIntent.mode === 'create' + ? providers.find((item) => item.provider === drawerIntent.provider) + : null) + || providers[0] + || null; + if (!provider) { + setSessionMessage('当前没有可用的 OAuth Provider。'); + return; + } if (!provider.enabled) { setSessionMessage(`${provider.label} 当前环境未启用`); return; } - const accountId = options?.accountId; + + const rebindAccount = drawerIntent.mode === 'create' ? null : drawerIntent.account; + const accountId = rebindAccount?.accountId; const customProxyUrl = asTrimmedString(oauthProxyUrl); - if (oauthProxyEnabled && !customProxyUrl) { + if (oauthCustomProxyEnabled && !customProxyUrl) { setSessionMessage('已开启代理,请先输入完整代理地址'); return; } + let resolvedProxyUrl: string | null | undefined; - if (oauthProxyEnabled) { + let resolvedUseSystemProxy: boolean | undefined; + if (oauthCustomProxyEnabled) { resolvedProxyUrl = customProxyUrl; - } else if (oauthProxyClearEnabled) { + resolvedUseSystemProxy = false; + } else if (oauthSystemProxyEnabled) { resolvedProxyUrl = null; - } else if (options?.fallbackProxyUrl !== undefined) { - resolvedProxyUrl = asTrimmedString(options.fallbackProxyUrl) || null; + resolvedUseSystemProxy = true; + } else if (drawerIntent.mode === 'proxy') { + resolvedProxyUrl = null; + resolvedUseSystemProxy = false; + } else if (rebindAccount?.useSystemProxy) { + resolvedProxyUrl = null; + resolvedUseSystemProxy = true; + } else if (rebindAccount?.proxyUrl !== undefined) { + resolvedProxyUrl = asTrimmedString(rebindAccount.proxyUrl) || null; + resolvedUseSystemProxy = false; } + const actionKey = `start:${provider.provider}:${accountId || 0}`; setActionLoadingKey(actionKey); try { - const shouldPromptForProjectId = provider.requiresProjectId && !accountId; - const projectId = shouldPromptForProjectId - ? (() => { - if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined; - const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)'); - return typeof value === 'string' && value.trim() ? value.trim() : undefined; - })() + const projectId = drawerIntent.mode === 'create' && provider.requiresProjectId + ? (asTrimmedString(drawerProjectId) || undefined) : undefined; const started = accountId ? await api.rebindOAuthConnection( accountId, - resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}, + { + ...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}), + ...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}), + }, ) : await api.startOAuthProvider(provider.provider, { projectId, ...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}), + ...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}), }); + setSessionMessage('等待授权完成'); setActiveSession({ provider: started.provider, @@ -347,6 +953,7 @@ export default function OAuthManagement() { await api.deleteOAuthConnection(accountId); setSessionMessage('连接已删除'); await loadConnections(); + setSelectedConnectionIds((current) => current.filter((id) => id !== accountId)); } catch (error: any) { setSessionMessage(error?.message || '删除 OAuth 连接失败'); } finally { @@ -354,6 +961,26 @@ export default function OAuthManagement() { } }; + const handleDeleteSelected = async () => { + if (selectedConnectionIds.length === 0) return; + if (typeof window !== 'undefined' && typeof window.confirm === 'function') { + const confirmed = window.confirm(`确定要删除选中的 ${selectedConnectionIds.length} 个 OAuth 连接吗?`); + if (!confirmed) return; + } + setActionLoadingKey('delete:selected'); + try { + const results = await Promise.allSettled(selectedConnectionIds.map((accountId) => api.deleteOAuthConnection(accountId))); + const failed = results.filter((item) => item.status === 'rejected').length; + await loadConnections(); + setSelectedConnectionIds([]); + setSessionMessage(failed > 0 + ? `批量删除完成,${failed} 个连接删除失败` + : `已删除 ${results.length} 个 OAuth 连接`); + } finally { + setActionLoadingKey(''); + } + }; + const handleRefreshQuota = async (accountId: number) => { const actionKey = `quota:${accountId}`; setActionLoadingKey(actionKey); @@ -368,406 +995,1081 @@ export default function OAuthManagement() { } }; - return ( -
-
-
-
OAuth 管理
-
- 统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。 -
-
-
+ const handleRefreshSelected = async () => { + if (selectedConnectionIds.length === 0) return; + setActionLoadingKey('quota:selected'); + try { + const result = await api.refreshOAuthConnectionQuotaBatch(selectedConnectionIds); + await loadConnections(); + setSessionMessage(result.failed > 0 + ? `批量刷新完成,成功 ${result.refreshed} 个,失败 ${result.failed} 个` + : `已批量刷新 ${result.refreshed} 个 OAuth 连接`); + } catch (error: any) { + setSessionMessage(error?.message || '批量刷新额度失败'); + } finally { + setActionLoadingKey(''); + } + }; -
-
授权设置
-
- 这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置。 -
-
- - -
-
-
代理地址
- setOauthProxyUrl(event.target.value)} - placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080" - disabled={!oauthProxyEnabled} - style={{ - width: '100%', - maxWidth: 460, - padding: '10px 14px', - borderRadius: 'var(--radius-sm)', - border: '1px solid var(--color-border)', - background: oauthProxyEnabled ? 'var(--color-bg)' : 'var(--color-bg-card)', - color: 'var(--color-text-primary)', - fontSize: 13, - opacity: oauthProxyEnabled ? 1 : 0.7, - }} - /> -
- 关闭时,新建连接会按站点/系统默认链路处理;重新授权已有账号时,会继续沿用该账号已有的代理配置。 - 勾选“清空已有账号代理”后,重新授权会显式移除当前账号上的代理覆盖。 -
-
-
+ const applyLoadedModelsModal = useCallback((connection: OAuthConnectionInfo, result: any) => { + const models = Array.isArray(result?.models) ? result.models as OAuthModelItem[] : []; + setModelsModal((current) => ({ + ...current, + open: true, + loading: false, + refreshing: false, + connection, + models, + totalCount: Number.isFinite(result?.totalCount) ? Number(result.totalCount) : models.length, + disabledCount: Number.isFinite(result?.disabledCount) + ? Number(result.disabledCount) + : models.filter((item) => item.disabled).length, + siteName: asTrimmedString(result?.siteName) || connection.site?.name || '', + })); + }, []); - {sessionMessage && ( -
-
{sessionMessage}
-
- )} + const loadModelsModal = useCallback(async ( + connection: OAuthConnectionInfo, + options: { + refreshUpstream?: boolean; + resetBeforeLoad?: boolean; + closeOnError?: boolean; + } = {}, + ) => { + const requestId = ++modelsModalRequestSeqRef.current; + setModelsModal((current) => ({ + ...current, + open: true, + connection, + loading: options.resetBeforeLoad ? true : current.loading, + refreshing: options.refreshUpstream ? true : current.refreshing, + ...(options.resetBeforeLoad + ? { models: [], totalCount: 0, disabledCount: 0, siteName: connection.site?.name || '' } + : {}), + })); - {activeSession && ( -
-
授权指引
-
-
-
固定回调地址
- {renderCodeBlock(activeSession.instructions.redirectUri)} + try { + if (options.refreshUpstream) { + await api.checkModels(connection.accountId); + } + const result = await api.getAccountModels(connection.accountId); + if (modelsModalRequestSeqRef.current !== requestId) return; + applyLoadedModelsModal(connection, result); + if (options.refreshUpstream) { + await loadConnections(); + setSessionMessage('模型列表已刷新'); + } + } catch (error: any) { + if (modelsModalRequestSeqRef.current !== requestId) return; + setSessionMessage(error?.message || '加载模型列表失败'); + setModelsModal((current) => ( + options.closeOnError + ? { + ...current, + open: false, + loading: false, + refreshing: false, + connection: null, + } + : { + ...current, + loading: false, + refreshing: false, + } + )); + } + }, [applyLoadedModelsModal, loadConnections]); + + const openModelsModal = useCallback(async (connection: OAuthConnectionInfo) => { + await loadModelsModal(connection, { + resetBeforeLoad: true, + closeOnError: true, + }); + }, [loadModelsModal]); + + const closeModelsModal = useCallback(() => { + modelsModalRequestSeqRef.current += 1; + setModelsModal((current) => ({ + ...current, + open: false, + loading: false, + refreshing: false, + connection: null, + })); + }, []); + + const handleImportFilesSelected = useCallback(async (files: ArrayLike | null | undefined) => { + const drafts = await readOauthImportDrafts(files); + setImportDrafts(drafts); + }, []); + + const handleImportFileChange = useCallback(async (event: ChangeEvent) => { + await handleImportFilesSelected(event.target.files); + event.target.value = ''; + }, [handleImportFilesSelected]); + + const handleImportDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + setImportDragOver(true); + }, []); + + const handleImportDragLeave = useCallback((event: DragEvent) => { + event.preventDefault(); + setImportDragOver(false); + }, []); + + const handleImportDrop = useCallback(async (event: DragEvent) => { + event.preventDefault(); + setImportDragOver(false); + await handleImportFilesSelected(event.dataTransfer?.files); + }, [handleImportFilesSelected]); + + const importSources = useMemo(() => { + const manualRaw = importJsonText.trim(); + return [ + ...importDrafts, + ...(manualRaw ? [{ sourceName: '手动粘贴 JSON', rawText: manualRaw }] : []), + ]; + }, [importDrafts, importJsonText]); + + const importPreviewSummary = useMemo(() => { + if (importSources.length <= 0) return null; + const items = importSources.map((source) => parseOauthImportPreview(source)); + const validCount = items.filter((item) => item.valid).length; + const invalidCount = items.length - validCount; + return { + totalCount: items.length, + validCount, + invalidCount, + canImport: validCount > 0 && invalidCount === 0, + items, + }; + }, [importSources]); + + const handleImport = async () => { + if (importSources.length <= 0) { + setSessionMessage('请先选择 JSON 文件或粘贴 OAuth 连接 JSON 内容'); + return; + } + if (!importPreviewSummary?.canImport) { + setSessionMessage('请先修正无效的 OAuth JSON'); + return; + } + + setImporting(true); + try { + let importedCount = 0; + + for (const item of importPreviewSummary.items) { + if (!item.valid || !item.parsedData) continue; + const result = await api.importOAuthConnections(item.parsedData); + importedCount += result.imported; + } + + await loadConnections(); + const successMessage = `已添加 ${importedCount} 个 OAuth 连接`; + toast.success(successMessage); + setSessionMessage(successMessage); + closeImportModal(); + } catch (error: any) { + const message = error?.message || '导入 OAuth JSON 失败'; + toast.error(message); + setSessionMessage(message); + } finally { + setImporting(false); + } + }; + + const toggleColumn = (key: ColumnKey) => { + setVisibleColumns((current) => ({ + ...current, + [key]: !current[key], + })); + }; + + const toggleSelectAllVisible = (checked: boolean) => { + if (!checked) { + setSelectedConnectionIds((current) => current.filter((id) => !filteredConnections.some((connection) => connection.accountId === id))); + return; + } + setSelectedConnectionIds((current) => Array.from(new Set([ + ...current, + ...filteredConnections.map((connection) => connection.accountId), + ]))); + }; + + const filterBar = ( +
+
+
+
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索账号 / 邮箱 / 站点 / 项目" + />
+
+ setProviderFilter(String(value || ''))} + options={[ + { value: '', label: '全部 Provider' }, + ...providerOptions, + ]} + placeholder="全部 Provider" + /> +
+
+ setStatusFilter(String(value || ''))} + options={[ + { value: '', label: '全部状态' }, + { value: 'healthy', label: '正常' }, + { value: 'abnormal', label: '异常' }, + ]} + placeholder="全部状态" + /> +
+
+ setSiteFilter(String(value || ''))} + options={[ + { value: '', label: '全部站点' }, + ...siteOptions, + ]} + placeholder="全部站点" + /> +
+
- {renderGuideCard( - '本地部署', - 'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。', -
- 如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。 -
, - )} - - {activeSession.instructions.sshTunnelCommand - ? renderGuideCard( - '云端部署', - 'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。', -
-
常规 SSH 隧道
- {renderCodeBlock(activeSession.instructions.sshTunnelCommand)} - {activeSession.instructions.sshTunnelKeyCommand && ( - <> -
SSH Key 隧道
- {renderCodeBlock(activeSession.instructions.sshTunnelKeyCommand)} - - )} -
, - ) - : renderGuideCard( - '云端部署', - '当前没有检测到远程主机地址。如果你实际是云端部署,请用能访问服务器 127.0.0.1 回调端口的 SSH 隧道方式完成授权。', - )} - - {renderGuideCard( - '手动回调', - `如果浏览器停在 localhost 错误页,复制浏览器地址栏里的完整 URL,等待 ${Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。`, - manualCallbackVisible ? ( -
-