1
0
forked from boos/BoosAPI

feat: refine OAuth proxy controls and quota UI (#433)

* feat: refine oauth management proxy controls

* fix: preserve oauth refresh state and tighten oauth rate limiting

* fix: satisfy codeql oauth rate limiting

* fix: address oauth review comments

* fix: move oauth sensitive limits into prehandlers

* fix: cap oauth quota refresh batch size
This commit is contained in:
Cita
2026-04-06 08:50:19 +08:00
committed by GitHub
parent 287c5aafd6
commit 3edeb6e245
26 changed files with 5407 additions and 694 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<typeof authChangePayloadSchema>;
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
export type OauthImportPayload = z.output<typeof oauthImportPayloadSchema>;
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
export type OauthQuotaBatchRefreshPayload = z.output<typeof oauthQuotaBatchRefreshPayloadSchema>;
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
@@ -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);
@@ -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);
@@ -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,
@@ -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,
@@ -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);
@@ -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,
+635
View File
@@ -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<string, unknown>) {
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
}
function buildCodexQuotaProbeResponse(input: {
status?: number;
headers?: Record<string, string>;
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<T>() {
let resolve!: (value: T | PromiseLike<T>) => 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<string, unknown> | 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<ReturnType<typeof buildCodexQuotaProbeResponse>>();
const secondProbe = createDeferred<ReturnType<typeof buildCodexQuotaProbeResponse>>();
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,
+73 -1
View File
@@ -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] },
@@ -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: '',
+17
View File
@@ -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<AccountCredentialMode>([
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);
+4 -4
View File
@@ -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<string | null> {
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);
+3 -3
View File
@@ -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<string | null> {
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));
+32 -21
View File
@@ -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<symbol, ModelDiscoveryAccountRow | undefined>)[REFRESHED_OAUTH_ACCOUNT]
|| null
);
}
function looksLikeHtmlJsonParseError(message: string): boolean {
const lowered = String(message || '').trim().toLowerCase();
@@ -339,23 +362,11 @@ async function retryOauthModelDiscoveryWithRefresh<T>(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})`;
+453
View File
@@ -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<string, unknown>;
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<number, {
fingerprint: string;
recordedAtMs: number;
}>();
const pendingQuotaHeaderSnapshotKeys = new Set<string>();
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<CodexQuotaHeaderSnapshot, 'capturedAt'>,
value: number | undefined,
) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return;
snapshot[field] = value as CodexQuotaHeaderSnapshot[keyof Omit<CodexQuotaHeaderSnapshot, 'capturedAt'>];
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<string, unknown>;
@@ -132,6 +350,68 @@ function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | unde
};
}
function buildQuotaErrorSnapshot(input: {
oauth: Pick<OauthInfo, 'provider' | 'planType' | 'idToken' | 'quota'>;
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<OauthInfo, 'provider' | 'planType' | 'idToken' | 'quota'>,
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<OauthInfo, 'planType' | 'idToken' | 'quota'>): 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<OauthQuotaSnapshot | null> {
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<string, unknown> {
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<string, string> {
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<OauthQuotaSnapshot> {
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<ReturnType<typeof fetch>>;
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 } };
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<OauthQuotaSnapshot> {
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<Oaut
if (!oauth) {
throw new Error('account is not managed by oauth');
}
if (oauth.provider === 'codex') {
const syncedAt = new Date().toISOString();
try {
const snapshot = await probeCodexQuotaSnapshot({
account,
oauth,
syncedAt,
});
return persistQuotaSnapshot(accountId, snapshot);
} catch (error) {
const message = error instanceof Error
? (error.message || error.name)
: String(error || 'codex quota probe failed');
return persistQuotaSnapshot(accountId, buildQuotaErrorSnapshot({
oauth,
message,
syncedAt,
}));
}
}
const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth);
const snapshot: OauthQuotaSnapshot = {
...baseSnapshot,
+2 -2
View File
@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { getOAuthProviderDefinition } from './providers.js';
import { getProxyUrlFromExtraConfig } from '../accountExtraConfig.js';
import { resolveProxyUrlFromExtraConfig } from '../accountExtraConfig.js';
import { resolveSiteProxyUrlByRequestUrl } from '../siteProxy.js';
export async function resolveOauthProviderProxyUrl(provider: string): Promise<string | null> {
@@ -14,7 +14,7 @@ export async function resolveOauthAccountProxyUrl(input: {
siteId?: number | null;
extraConfig?: string | null;
}): Promise<string | null> {
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({
+402 -25
View File
@@ -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<typeof listOauthProviders>[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<string, unknown> {
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<string, unknown> | 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<string, unknown>,
): {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
idToken?: string;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
providerData?: Record<string, unknown>;
} {
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<string, unknown>
: 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<string, unknown>
: 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<string, unknown>;
};
name: string;
} {
const rawType = asNonEmptyString(payload.type);
const payloadRecord = payload as Record<string, unknown>;
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<string, unknown>);
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<number> {
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<T, TResult>(
items: readonly T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<TResult[]> {
if (items.length === 0) return [];
const results = new Array<TResult>(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<typeof buildQuotaSnapshotFromOauthInfo>;
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<ReturnType<typeof upsertOauthAccount>> | 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,
});
}
@@ -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);
}
+2 -2
View File
@@ -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);
+3 -2
View File
@@ -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<OAuthStartResponse>,
@@ -850,7 +851,7 @@ export const api = {
method: 'POST',
body: JSON.stringify({ accountIds }),
}) as Promise<OAuthQuotaBatchRefreshResponse>,
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<OAuthStartResponse>,
+862 -5
View File
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -3,11 +3,15 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('OAuthManagement mobile layout', () => {
it('allows narrow screens to wrap provider and connection action rows', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/OAuthManagement.tsx'), 'utf8');
it('uses class-based mobile wrappers for trigger rows and connection actions', () => {
const pageSource = readFileSync(resolve(process.cwd(), 'src/web/pages/OAuthManagement.tsx'), 'utf8');
const cssSource = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(source).toContain("flexWrap: 'wrap'");
expect(source).toContain("justifyContent: 'space-between'");
expect(source).toContain("minWidth: 0");
expect(pageSource).toContain('className="mobile-filter-row oauth-mobile-trigger-row"');
expect(pageSource).toContain('className="mobile-card-actions oauth-mobile-actions"');
expect(pageSource).toContain('className="oauth-row-actions"');
expect(cssSource).toContain('.oauth-row-actions');
expect(cssSource).toContain('.oauth-mobile-trigger-row');
expect(cssSource).toContain('.oauth-toolbar-actions');
});
});
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import CenteredModal from '../../components/CenteredModal.js';
export type OAuthModelItem = {
name: string;
latencyMs: number | null;
disabled: boolean;
isManual?: boolean;
};
type OAuthModelsModalProps = {
open: boolean;
title: string;
siteName?: string | null;
loading: boolean;
refreshing: boolean;
models: OAuthModelItem[];
totalCount: number;
disabledCount: number;
onClose: () => void;
onRefresh: () => Promise<void> | void;
};
export default function OAuthModelsModal({
open,
title,
siteName,
loading,
refreshing,
models,
totalCount,
disabledCount,
onClose,
onRefresh,
}: OAuthModelsModalProps) {
const enabledCount = Math.max(0, totalCount - disabledCount);
return (
<CenteredModal
open={open}
onClose={onClose}
title={title}
maxWidth={620}
footer={(
<>
<button type="button" className="btn btn-ghost" onClick={onClose}>
关闭
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void onRefresh()}
disabled={loading || refreshing}
>
{refreshing ? <><span className="spinner spinner-sm" />刷新中...</> : '刷新模型'}
</button>
</>
)}
>
{loading ? (
<div className="oauth-models-empty">
<span className="spinner" />
<span className="oauth-models-empty-copy">正在加载模型列表...</span>
</div>
) : (
<div className="oauth-models-layout">
<div className="oauth-models-summary">
<div className="oauth-models-summary-title">
{siteName ? `${siteName} · 共 ${totalCount} 个模型` : `共 ${totalCount} 个模型`}
</div>
<div className="oauth-models-summary-copy">
已启用 {enabledCount} 个,已禁用 {disabledCount} 个。点击“刷新模型”可重新拉取当前账号支持的模型列表。
</div>
</div>
{models.length === 0 ? (
<div className="oauth-models-empty">
<div className="oauth-models-empty-title">暂无模型</div>
<div className="oauth-models-empty-copy">当前账号还没有同步到可用模型,可点击右下角“刷新模型”重试。</div>
</div>
) : (
<div className="oauth-models-list">
{models.map((model) => (
<div key={model.name} className={`oauth-models-item ${model.disabled ? 'is-disabled' : ''}`.trim()}>
<div className="oauth-models-item-main">
<div className="oauth-models-item-name">{model.name}</div>
<div className="oauth-models-item-meta">
{model.latencyMs != null ? <span>{model.latencyMs}ms</span> : null}
{model.isManual ? <span className="badge badge-info oauth-models-badge">手动</span> : null}
{model.disabled ? <span className="badge badge-warning oauth-models-badge">禁用</span> : null}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</CenteredModal>
);
}