forked from boos/BoosAPI
Merge pull request #151 from cita-777/codex/fix-connection-and-release-regressions
[codex] fix connection and release regressions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Array<{ name: string; value: string }>>((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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,17 @@ type AccountCapabilities = {
|
||||
proxyOnly: boolean;
|
||||
};
|
||||
|
||||
type AccountInitializationParams = {
|
||||
accountId: number;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
adapter: NonNullable<ReturnType<typeof getAdapter>>;
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<number | null> {
|
||||
const token = await db.select().from(schema.accountTokens)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -63,6 +63,12 @@ type ExecuteEndpointFlowInput = {
|
||||
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||
};
|
||||
|
||||
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<EndpointFlowResult> {
|
||||
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)
|
||||
|
||||
@@ -16,10 +16,14 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
||||
}));
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const recordOauthQuotaResetHintMock = vi.fn();
|
||||
const insertedProxyLogs: Record<string, unknown>[] = [];
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
}),
|
||||
values: (values: Record<string, unknown>) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
if (record.error && typeof record.error === 'object' && !Array.isArray(record.error)) {
|
||||
const message = (record.error as Record<string, unknown>).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<string, unknown>).error;
|
||||
if (responseError && typeof responseError === 'object' && !Array.isArray(responseError)) {
|
||||
const message = (responseError as Record<string, unknown>).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<string, unknown>).type === 'string'
|
||||
? String((parsedPayload as Record<string, unknown>).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<string, unknown>).type === 'string'
|
||||
? String((payload as Record<string, unknown>).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<ChatProxyStreamResult> {
|
||||
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
||||
reader,
|
||||
response,
|
||||
@@ -166,6 +222,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
||||
onEof: finalize,
|
||||
});
|
||||
await lifecycle.run();
|
||||
return terminalResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<ResponsesProxyStreamResult> {
|
||||
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
||||
reader,
|
||||
response,
|
||||
pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer),
|
||||
handleEvent: handleEventBlock,
|
||||
onEof: finalize,
|
||||
onEof: closeOut,
|
||||
});
|
||||
await lifecycle.run();
|
||||
return terminalResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={onClose}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">{title}</div>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal-body" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={onClose} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user