diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27aa4a4..0860aca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,14 +46,30 @@ jobs: if-no-files-found: error build-packages: - name: Build Desktop Package (${{ matrix.os }}) + name: Build Desktop Package (${{ matrix.platform }}) needs: verify - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + include: + - platform: linux + runner: ubuntu-latest + artifactLabel: linux-x64 + - platform: windows + runner: windows-latest + artifactLabel: windows-x64 + - platform: mac-arm64 + runner: macos-15 + artifactLabel: mac-arm64 + packageCommand: npm run package:desktop -- --mac --arm64 + expectedMacArch: arm64 + - platform: mac-x64 + runner: macos-15-intel + artifactLabel: mac-x64 + packageCommand: npm run package:desktop:mac:intel + expectedMacArch: x64 steps: - name: Checkout @@ -183,7 +199,7 @@ jobs: done - name: Build desktop artifacts (macOS) - if: runner.os == 'macOS' + if: startsWith(matrix.platform, 'mac-') shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -195,14 +211,15 @@ jobs: APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} + PACKAGE_COMMAND: ${{ matrix.packageCommand }} run: | set -euo pipefail if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi for attempt in 1 2; do - echo "Desktop build (host arch) attempt ${attempt}/2" - if npm run package:desktop; then + echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" + if eval "${PACKAGE_COMMAND}"; then break fi if [ "$attempt" -eq 2 ]; then @@ -210,25 +227,45 @@ jobs: fi sleep 90 done - if [ "${RUNNER_ARCH:-}" != "X64" ]; then - for attempt in 1 2; do - echo "Desktop build (mac intel x64) attempt ${attempt}/2" - if npm run package:desktop:mac:intel; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - else - echo "Runner is already X64, skip extra Intel build" + + - name: Verify packaged macOS architecture + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + expectedMacArch: ${{ matrix.expectedMacArch }} + run: | + set -euo pipefail + mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi') + if [ "${#app_binaries[@]}" -eq 0 ]; then + echo "No packaged macOS app binary found under release/" + exit 1 fi + for binary in "${app_binaries[@]}"; do + archs="$(lipo -archs "$binary" 2>/dev/null | xargs)" + echo "$binary => ${archs:-unknown}" + if [ -z "${archs}" ]; then + echo "Unable to detect architecture via lipo" + exit 1 + fi + + if [ "${expectedMacArch}" = "x64" ]; then + [ "${archs}" = "x86_64" ] || { + echo "Expected x86_64-only binary but got: ${archs}" + exit 1 + } + else + [ "${archs}" = "arm64" ] || { + echo "Expected arm64-only binary but got: ${archs}" + exit 1 + } + fi + done + - name: Upload desktop package uses: actions/upload-artifact@v7 with: - name: metapi-desktop-${{ github.run_id }}-${{ runner.os }}-${{ runner.arch }} + name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }} path: | release/*.exe release/*.dmg diff --git a/scripts/desktop/release.workflow.test.ts b/scripts/desktop/release.workflow.test.ts new file mode 100644 index 0000000..23b37c3 --- /dev/null +++ b/scripts/desktop/release.workflow.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('release workflow', () => { + it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { + const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(workflow).toContain('runner: macos-15-intel'); + expect(workflow).toContain('runner: macos-15'); + expect(workflow).toContain('expectedMacArch: x64'); + expect(workflow).toContain('expectedMacArch: arm64'); + expect(workflow).toContain('Verify packaged macOS architecture'); + expect(workflow).toContain('lipo -archs'); + }); +}); diff --git a/src/server/routes/api/accounts.add-background-task.test.ts b/src/server/routes/api/accounts.add-background-task.test.ts new file mode 100644 index 0000000..af304e3 --- /dev/null +++ b/src/server/routes/api/accounts.add-background-task.test.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const verifyTokenMock = vi.fn(); +const getApiTokensMock = vi.fn(); +const refreshBalanceMock = vi.fn(); +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); +const ensureDefaultTokenForAccountMock = vi.fn(); +const syncTokensFromUpstreamMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + verifyToken: (...args: unknown[]) => verifyTokenMock(...args), + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + }), +})); + +vi.mock('../../services/balanceService.js', () => ({ + refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +vi.mock('../../services/accountTokenService.js', () => ({ + ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args), + syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts background initialization', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let resetBackgroundTasks: (() => void) | null = null; + let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + verifyTokenMock.mockReset(); + getApiTokensMock.mockReset(); + refreshBalanceMock.mockReset(); + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + ensureDefaultTokenForAccountMock.mockReset(); + syncTokensFromUpstreamMock.mockReset(); + resetBackgroundTasks?.(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('returns immediately and queues background initialization when token sync is slow', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Session Site', + url: 'https://session.example.com', + platform: 'new-api', + }).returning().get(); + + verifyTokenMock.mockResolvedValue({ + tokenType: 'session', + userInfo: { username: 'demo-user' }, + apiToken: 'sk-demo', + }); + ensureDefaultTokenForAccountMock.mockResolvedValue(undefined); + refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 }); + refreshModelsForAccountMock.mockResolvedValue(undefined); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined); + syncTokensFromUpstreamMock.mockResolvedValue(undefined); + + let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null; + const pendingTokens = new Promise>((resolve) => { + releaseTokens = resolve; + }); + getApiTokensMock.mockReturnValue(pendingTokens); + + const responsePromise = app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'session-token', + }, + }); + + try { + const raceResult = await Promise.race([ + responsePromise.then(() => 'response'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)), + ]); + + expect(raceResult).toBe('response'); + + const response = await responsePromise; + expect(response.statusCode).toBe(200); + + const body = response.json() as { + id: number; + queued?: boolean; + jobId?: string; + usernameDetected?: boolean; + apiTokenFound?: boolean; + }; + + expect(body).toMatchObject({ + queued: true, + usernameDetected: true, + apiTokenFound: true, + }); + expect(body.jobId).toBeTruthy(); + + const insertedAccounts = await db.select().from(schema.accounts).all(); + expect(insertedAccounts).toHaveLength(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ + status: expect.stringMatching(/pending|running/), + }); + + releaseTokens?.([{ name: 'default', value: 'sk-demo' }]); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const task = getBackgroundTask?.(body.jobId!); + if (task?.status === 'succeeded') break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1); + expect(refreshBalanceMock).toHaveBeenCalledTimes(1); + expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' }); + } finally { + releaseTokens?.([]); + await responsePromise.catch(() => undefined); + } + }); +}); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 8b2ea35..689241e 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -49,6 +49,17 @@ type AccountCapabilities = { proxyOnly: boolean; }; +type AccountInitializationParams = { + accountId: number; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + tokenType: 'session' | 'apikey' | 'unknown'; + accessToken: string; + apiToken: string; + platformUserId?: number; + skipModelFetch?: boolean; +}; + type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; const limitAccountLogin = createRateLimitGuard({ @@ -123,6 +134,75 @@ function normalizePinnedFlag(input: unknown): boolean | null { return null; } +async function initializeAccountInBackground({ + accountId, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId, + skipModelFetch, +}: AccountInitializationParams) { + const summary = { + accountId, + syncedTokenCount: 0, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + }; + + if (tokenType === 'session' && apiToken) { + try { + await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' }); + } catch {} + } + + if (tokenType === 'session' && accessToken) { + try { + const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); + summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; + if (summary.syncedTokenCount > 0) { + await syncTokensFromUpstream(accountId, syncedTokens); + } + } catch {} + } + + if (tokenType === 'session') { + try { + await refreshBalance(accountId); + summary.refreshedBalance = true; + } catch {} + } + + if (skipModelFetch !== true) { + try { + await refreshModelsForAccount(accountId); + summary.refreshedModels = true; + await rebuildTokenRoutesFromAvailability(); + summary.rebuiltRoutes = true; + } catch {} + } + + return summary; +} + +function buildQueuedAccountInitializationMessage( + tokenType: 'session' | 'apikey' | 'unknown', + skipModelFetch?: boolean, +) { + if (tokenType === 'session' && skipModelFetch === true) { + return '账号已添加,后台正在同步令牌和余额信息。'; + } + if (tokenType === 'session') { + return '账号已添加,后台正在同步令牌、余额和模型信息。'; + } + if (skipModelFetch === true) { + return '已添加为 API Key 账号(可用于代理转发)。'; + } + return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; +} + function normalizeSortOrder(input: unknown): number | null { if (input === undefined || input === null || input === '') return null; const parsed = Number.parseInt(String(input), 10); @@ -1117,32 +1197,33 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建账号失败' }); } - if (tokenType === 'session' && apiToken) { - try { - await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' }); - } catch { } - } - - if (tokenType === 'session' && accessToken) { - try { - const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId); - if (syncedTokens.length > 0) { - await syncTokensFromUpstream(result.id, syncedTokens); - } - } catch { } - } - - // Try to refresh balance - if (tokenType === 'session') { - try { await refreshBalance(result.id); } catch { } - } - - // Try to refresh models only if not skipping. - if (body.skipModelFetch !== true) { - try { - await refreshModelsForAccount(result.id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; + let queuedTaskId: string | undefined; + let queuedMessage: string | undefined; + if (shouldQueueInitialization) { + const taskTitle = `初始化连接 #${result.id}`; + const { task } = startBackgroundTask( + { + type: 'account-init', + title: taskTitle, + dedupeKey: `account-init-${result.id}`, + notifyOnFailure: true, + successMessage: () => `${taskTitle}已完成`, + failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, + }, + async () => initializeAccountInBackground({ + accountId: result.id, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId: resolvedPlatformUserId, + skipModelFetch: body.skipModelFetch, + }), + ); + queuedTaskId = task.id; + queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); } const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); @@ -1158,6 +1239,9 @@ export async function accountsRoutes(app: FastifyInstance) { modelCount: verifiedModels.length, apiTokenFound: !!apiToken, usernameDetected: !!(!body.username && username), + queued: !!queuedTaskId, + jobId: queuedTaskId, + message: queuedMessage, }; }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 0b84a81..bb759db 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -16,12 +16,12 @@ import { saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); -} +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} async function getDefaultTokenId(accountId: number): Promise { const token = await db.select().from(schema.accountTokens) diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index f566a57..ed1e39b 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -3193,6 +3193,8 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(response.body).toContain('"finish_reason":"error"'); expect(response.body).toContain('[DONE]'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); }); it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => { diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index b7886ad..60ed69c 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -366,6 +366,7 @@ async function handleChatProxyRequest( const streamSession = openAiChatTransformer.proxyStream.createSession({ downstreamFormat, modelName, + successfulUpstreamPath, onParsedPayload: (payload) => { if (payload && typeof payload === 'object') { parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); @@ -389,9 +390,32 @@ async function handleChatProxyRequest( if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { fallbackData = unwrapGeminiCliPayload(fallbackData); } - streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); + const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -443,9 +467,32 @@ async function handleChatProxyRequest( const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader ? createGeminiCliStreamReader(upstreamReader) : upstreamReader; - await streamSession.run(reader, reply.raw); + const streamResult = await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index fc621b0..8ae89a3 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -185,6 +185,22 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + proxyUrl: 'https://proxy.internal/base', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); + }); + it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index 1ed1bc4..03e5e42 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -63,6 +63,12 @@ type ExecuteEndpointFlowInput = { onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; }; +function buildAbsoluteUrl(base: string, path: string): string { + const normalizedBase = base.replace(/\/+$/, ''); + const normalizedPath = path.replace(/^\/+/, ''); + return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase; +} + export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise { const endpointCount = input.endpointCandidates.length; if (endpointCount <= 0) { @@ -80,7 +86,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const request = input.buildRequest(endpoint, endpointIndex); - const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); + const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); + const targetUrl = input.proxyUrl + ? buildAbsoluteUrl(input.proxyUrl, request.path) + : defaultTarget; let response = input.dispatchRequest ? await input.dispatchRequest(request, targetUrl) diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index 483ce51..cc7d66a 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -16,10 +16,14 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => })); const refreshOauthAccessTokenSingleflightMock = vi.fn(); const recordOauthQuotaResetHintMock = vi.fn(); +const insertedProxyLogs: Record[] = []; const dbInsertMock = vi.fn((_arg?: any) => ({ - values: () => ({ - run: () => undefined, - }), + values: (values: Record) => { + insertedProxyLogs.push(values); + return { + run: () => undefined, + }; + }, })); vi.mock('undici', () => ({ @@ -117,6 +121,7 @@ describe('responses proxy codex oauth refresh', () => { refreshOauthAccessTokenSingleflightMock.mockReset(); recordOauthQuotaResetHintMock.mockReset(); dbInsertMock.mockClear(); + insertedProxyLogs.length = 0; selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -431,4 +436,35 @@ describe('responses proxy codex oauth refresh', () => { expect(secondBody.store).toBe(false); expect(secondBody.stream).toBe(true); }); + + it('does not record success when a streaming responses request ends with response.failed', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_failed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.failed\n', + 'data: {"type":"response.failed","response":{"id":"resp_codex_failed","model":"gpt-5.4","status":"failed","error":{"message":"tool execution failed"}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('response.failed'); + expect(response.body).not.toContain('response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed'); + }); }); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index fe73c68..6edc5ff 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -457,9 +457,32 @@ export async function responsesProxyRoute(app: FastifyInstance) { reply.raw.write(chunk); }, }); - await streamSession.run(reader, reply.raw); + const streamResult = await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index e6bb702..0494e48 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -390,7 +390,7 @@ function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); } async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { diff --git a/src/server/transformers/openai/chat/proxyStream.ts b/src/server/transformers/openai/chat/proxyStream.ts index 15af7ad..54161ae 100644 --- a/src/server/transformers/openai/chat/proxyStream.ts +++ b/src/server/transformers/openai/chat/proxyStream.ts @@ -14,6 +14,7 @@ type StreamReader = { type ChatProxyStreamSessionInput = { downstreamFormat: DownstreamFormat; modelName: string; + successfulUpstreamPath: string; onParsedPayload?: (payload: unknown) => void; writeLines: (lines: string[]) => void; writeRaw: (chunk: string) => void; @@ -23,6 +24,11 @@ type ResponseSink = { end(): void; }; +type ChatProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) { const downstreamTransformer = input.downstreamFormat === 'claude' ? anthropicMessagesTransformer @@ -39,6 +45,36 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) ? createOpenAiChatAggregateState() : null; let finalized = false; + let terminalResult: ChatProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; + + const extractFailureMessage = (payload: unknown, fallback = 'upstream stream failed'): string => { + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const record = payload as Record; + if (record.error && typeof record.error === 'object' && !Array.isArray(record.error)) { + const message = (record.error as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + if (typeof record.message === 'string' && record.message.trim()) return record.message.trim(); + if (record.response && typeof record.response === 'object' && !Array.isArray(record.response)) { + const responseError = (record.response as Record).error; + if (responseError && typeof responseError === 'object' && !Array.isArray(responseError)) { + const message = (responseError as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + } + } + return fallback; + }; + + const markFailed = (payload: unknown, fallbackMessage?: string) => { + terminalResult = { + status: 'failed', + errorMessage: extractFailureMessage(payload, fallbackMessage), + }; + }; const finalize = () => { if (finalized) return; @@ -51,7 +87,12 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) return; } - if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) { + if ( + input.downstreamFormat === 'openai' + && terminalResult.status !== 'failed' + && chatAggregateState + && chatAggregateState.choices.size > 0 + ) { const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values()) .some((choice) => !choice.finishReason); if (needsTerminalFinishChunk) { @@ -109,6 +150,12 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) } if (parsedPayload && typeof parsedPayload === 'object') { + const payloadType = typeof (parsedPayload as Record).type === 'string' + ? String((parsedPayload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(parsedPayload); + } const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName); if (input.downstreamFormat === 'openai' && chatAggregateState) { applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent); @@ -129,10 +176,18 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) }; return { - consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ChatProxyStreamResult { if (payload && typeof payload === 'object') { input.onParsedPayload?.(payload); } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const payloadType = typeof (payload as Record).type === 'string' + ? String((payload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(payload); + } + } if (input.downstreamFormat === 'openai') { const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText); streamContext.id = normalizedFinal.id; @@ -156,8 +211,9 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) } finalize(); response?.end(); + return terminalResult; }, - async run(reader: StreamReader | null | undefined, response: ResponseSink) { + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, response, @@ -166,6 +222,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) onEof: finalize, }); await lifecycle.run(); + return terminalResult; }, }; } diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index 14ebc71..7fbc9e3 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -13,6 +13,11 @@ type ResponseSink = { end(): void; }; +type ResponsesProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + type ResponsesProxyStreamSessionInput = { modelName: string; successfulUpstreamPath: string; @@ -33,20 +38,58 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } +function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream stream failed'): string { + if (isRecord(payload)) { + if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + if (isRecord(payload.response) && isRecord(payload.response.error) && typeof payload.response.error.message === 'string' && payload.response.error.message.trim()) { + return payload.response.error.message.trim(); + } + } + return fallback; +} + export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { const streamContext = openAiResponsesStream.createContext(input.modelName); const responsesState = createOpenAiResponsesAggregateState(input.modelName); let finalized = false; + let terminalResult: ResponsesProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; const finalize = () => { if (finalized) return; finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage())); }; + const fail = (payload: unknown, fallbackMessage?: string) => { + if (finalized) return; + finalized = true; + terminalResult = { + status: 'failed', + errorMessage: getResponsesStreamFailureMessage(payload, fallbackMessage), + }; + input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), payload)); + }; + + const closeOut = () => { + if (finalized) return; + finalize(); + }; + const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => { if (eventBlock.data === '[DONE]') { - finalize(); + closeOut(); return true; } @@ -71,8 +114,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes || payloadType === 'response.failed' ); if (isFailureEvent) { - input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), parsedPayload)); - finalized = true; + fail(parsedPayload); return true; } @@ -97,15 +139,16 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes }; return { - async run(reader: StreamReader | null | undefined, response: ResponseSink) { + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, response, pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer), handleEvent: handleEventBlock, - onEof: finalize, + onEof: closeOut, }); await lifecycle.run(); + return terminalResult; }, }; } diff --git a/src/web/components/CenteredModal.tsx b/src/web/components/CenteredModal.tsx index a0b2c26..f8dffb3 100644 --- a/src/web/components/CenteredModal.tsx +++ b/src/web/components/CenteredModal.tsx @@ -10,6 +10,9 @@ type CenteredModalProps = { footer?: React.ReactNode; maxWidth?: number; bodyStyle?: React.CSSProperties; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + showCloseButton?: boolean; }; export default function CenteredModal({ @@ -20,6 +23,9 @@ export default function CenteredModal({ footer, maxWidth = 860, bodyStyle, + closeOnBackdrop = false, + closeOnEscape = false, + showCloseButton = true, }: CenteredModalProps) { const presence = useAnimatedVisibility(open, 220); @@ -33,7 +39,7 @@ export default function CenteredModal({ }, [open]); useEffect(() => { - if (!open || typeof document === 'undefined') return; + if (!open || !closeOnEscape || typeof document === 'undefined') return; const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; @@ -41,21 +47,33 @@ export default function CenteredModal({ return () => { document.removeEventListener('keydown', handleKeydown); }; - }, [open, onClose]); + }, [closeOnEscape, open, onClose]); if (!presence.shouldRender) return null; const modal = (
event.stopPropagation()} > -
{title}
+
+
{title}
+ {showCloseButton ? ( + + ) : null} +
{children}
diff --git a/src/web/components/centered-modal.test.tsx b/src/web/components/centered-modal.test.tsx index 4fa8346..5ffcf46 100644 --- a/src/web/components/centered-modal.test.tsx +++ b/src/web/components/centered-modal.test.tsx @@ -1,8 +1,46 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { act, create } from 'react-test-renderer'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import CenteredModal from './CenteredModal.js'; describe('CenteredModal component', () => { + it('does not close on backdrop click by default and exposes an explicit close button', async () => { + const onClose = vi.fn(); + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + +
content
+
, + ); + }); + + const closeButton = root.root.find((node) => ( + node.type === 'button' + && node.props['aria-label'] === '关闭弹框' + )); + + const backdrop = root.root.find((node) => ( + typeof node.props.className === 'string' + && node.props.className.includes('modal-backdrop') + )); + + expect(backdrop.props.onClick).toBeUndefined(); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + closeButton.props.onClick(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + } finally { + root?.unmount(); + } + }); + it('uses the shared centered modal shell pattern', () => { const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8'); diff --git a/src/web/index.css b/src/web/index.css index ab00a2d..d30401a 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -2598,11 +2598,47 @@ textarea { .modal-header { padding: 20px 24px 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; font-size: 16px; font-weight: 600; color: var(--color-text-primary); } +.modal-title { + min-width: 0; + flex: 1; +} + +.modal-close-button { + border: 0; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: 24px; + line-height: 1; + padding: 0; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + transition: background 0.2s ease, color 0.2s ease; +} + +.modal-close-button:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.modal-close-button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + .modal-body { padding: 16px 24px; } diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 3a9bd94..7bb9375 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -358,7 +358,9 @@ export default function Accounts() { skipModelFetch: tokenForm.skipModelFetch, }); closeAddPanel(); - if (result.tokenType === 'apikey') { + if (result.queued) { + toast.info(result.message || '账号已添加,后台正在同步初始化信息。'); + } else if (result.tokenType === 'apikey') { toast.success('已添加为 API Key 账号(可用于代理转发)'); } else { const parts: string[] = []; diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index 36ac5d6..80b3981 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -162,7 +162,7 @@ function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); } function routeTitle(route: RouteSelectorItem): string { diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index d80cab3..3bc45bb 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -159,7 +159,7 @@ function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; if (isRegexModelPattern(normalized)) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); } function routeTitle(route: RouteSelectorItem): string { diff --git a/src/web/pages/helpers/modelTesterSession.ts b/src/web/pages/helpers/modelTesterSession.ts index ec86e73..c836d54 100644 --- a/src/web/pages/helpers/modelTesterSession.ts +++ b/src/web/pages/helpers/modelTesterSession.ts @@ -226,7 +226,7 @@ const isExactModelPattern = (modelPattern: string): boolean => { const normalized = modelPattern.trim(); if (!normalized) return false; if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); }; const splitCommaSeparated = (value: string): string[] => diff --git a/src/web/pages/token-routes/utils.exact-pattern.test.ts b/src/web/pages/token-routes/utils.exact-pattern.test.ts new file mode 100644 index 0000000..fc24b34 --- /dev/null +++ b/src/web/pages/token-routes/utils.exact-pattern.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { isExactModelPattern } from './utils.js'; + +describe('isExactModelPattern', () => { + it('treats bracket-prefixed literal model names as exact patterns', () => { + expect(isExactModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true); + }); +}); diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts index 457e719..4f70031 100644 --- a/src/web/pages/token-routes/utils.ts +++ b/src/web/pages/token-routes/utils.ts @@ -45,7 +45,7 @@ export function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; if (isRegexModelPattern(normalized)) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); } export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } { diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index d14c55b..f76dca5 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -79,6 +79,54 @@ describe('TokenRoutes grouped source models', () => { vi.clearAllMocks(); }); + it('does not treat bracket-prefixed exact model routes as group filters', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 4386, modelPattern: '[NV]deepseek-v3.1-terminus', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 1, enabledChannelCount: 1, siteNames: ['test'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 3383, modelPattern: 're:^claude-(opus|sonnet)-4-5$', displayName: 'claude-opus-4-6', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 4, enabledChannelCount: 4, siteNames: ['site-a', 'site-b'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const filterToggle = findButtonByText(root.root, '筛选'); + await act(async () => { + filterToggle.props.onClick(); + }); + await flushMicrotasks(); + + const text = collectText(root.root); + expect(text).toContain('全部群组1'); + + const bracketGroupButtons = root.root.findAll((node) => ( + node.type === 'button' + && collectText(node).includes('[NV]deepseek-v3.1-terminus') + )); + expect(bracketGroupButtons).toHaveLength(0); + } finally { + root?.unmount(); + } + }); + it('collapses source-model groups by default for wildcard routes', async () => { const channels = [ {