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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+1799
-497
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user