feat(proxy): enhance upstream endpoint handling and error summarization

- Added new utility functions for building minimal JSON headers and normalizing responses for compatibility.
- Implemented detailed error handling for unsupported media types and endpoint downgrade scenarios.
- Introduced a new endpoint flow mechanism to manage retries and downgrades between different upstream endpoints.
- Enhanced tests for endpoint flow and error summarization to cover various edge cases and scenarios.
- Updated CSS styles for accounts table to improve layout and responsiveness.
- Refactored ModelTester component to handle incremental text updates more effectively.
This commit is contained in:
cita
2026-03-04 20:59:48 +08:00
parent 82e9f3b601
commit 9fbf434bd6
14 changed files with 1999 additions and 224 deletions
+13 -2
View File
@@ -503,12 +503,21 @@ export async function accountsRoutes(app: FastifyInstance) {
}
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
// New-API family already runs shield-aware probing inside adapters.
// Raw fallback probe below does not include challenge-solving and can
// misclassify valid Cookie/Session flows as shield-blocked.
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
if (
!skipRawShieldDetection
&& ct.includes('text/html')
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
) {
return 'shield-blocked';
}
@@ -516,7 +525,9 @@ export async function accountsRoutes(app: FastifyInstance) {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
if (/shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) return 'shield-blocked';
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
return 'shield-blocked';
}
} catch { }
return null;
@@ -6,9 +6,11 @@ import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const undiciFetchMock = vi.fn();
let adapterPlatformName = 'new-api';
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
platformName: adapterPlatformName,
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
}),
}));
@@ -42,6 +44,7 @@ describe('accounts verify-token shield detection', () => {
beforeEach(() => {
verifyTokenMock.mockReset();
undiciFetchMock.mockReset();
adapterPlatformName = 'new-api';
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
@@ -84,7 +87,7 @@ describe('accounts verify-token shield detection', () => {
});
});
it('returns shieldBlocked when upstream /api/user/self responds with challenge html', async () => {
it('avoids raw shieldBlocked misclassification for new-api when verifyToken returned tokenType unknown', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
undiciFetchMock.mockResolvedValue({
text: async () => '<html><script>var arg1="ABC123";</script></html>',
@@ -108,6 +111,75 @@ describe('accounts verify-token shield detection', () => {
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
message: 'Token invalid: cannot use it as session cookie or API key',
});
expect(undiciFetchMock).toHaveBeenCalled();
});
it('uses adapter platformName to skip raw shield detection for newapi alias site platform', async () => {
adapterPlatformName = 'new-api';
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
undiciFetchMock.mockResolvedValue({
text: async () => '<html><script>var arg1="ABC123";</script></html>',
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
},
});
const site = db.insert(schema.sites).values({
name: 'AnyRouter Alias',
url: 'https://anyrouter-alias.example.com',
platform: 'newapi',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'session-or-cookie-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
message: 'Token invalid: cannot use it as session cookie or API key',
});
expect(response.json()).not.toMatchObject({
shieldBlocked: true,
});
expect(undiciFetchMock).toHaveBeenCalled();
});
it('still returns shieldBlocked for non-new-api platforms when challenge html is detected', async () => {
adapterPlatformName = 'one-api';
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
undiciFetchMock.mockResolvedValue({
text: async () => '<html><script>var arg1="ABC123";</script></html>',
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
},
});
const site = db.insert(schema.sites).values({
name: 'Legacy Shielded',
url: 'https://legacy-shield.example.com',
platform: 'one-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'session-or-cookie-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,