diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index c3ea533..ced9a69 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -274,6 +274,75 @@ describe('refreshModelsForAccount credential discovery', () => { expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); }); + it('normalizes anyrouter html challenge parse errors during model discovery', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error("Unexpected token '<', \" { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('HTTP 403: ')); + + const site = await db.insert(schema.sites).values({ + name: 'site-anyrouter-403', + url: 'https://anyrouter-403.example.com', + platform: 'anyrouter', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'shielded-user-403', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + errorCode: 'unauthorized', + errorMessage: '模型获取失败:站点返回了防护页面,请在目标站点创建 API Key 后再同步模型', + }); + }); + it('does not scan hidden managed tokens for direct apikey connections', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index f346071..8b182ac 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -106,6 +106,26 @@ export type ModelRefreshResult = | ModelRefreshFailureResult | ModelRefreshSuccessResult; +function looksLikeHtmlJsonParseError(message: string): boolean { + const lowered = String(message || '').trim().toLowerCase(); + return ( + lowered.includes('unexpected token') + && lowered.includes('not valid json') + && (lowered.includes(' { }); if (req.url === '/v1/models') { + if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${OPENAI_MODELS_SHIELDED_TOKEN}`) { + const cookieHeader = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; + if (!cookieHeader.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Set-Cookie': `cdn_sec_tc=${SHIELD_LOGIN_COOKIE}; Path=/; HttpOnly`, + }); + res.end(ANYROUTER_CHALLENGE_HTML); + return; + } + if ( + !cookieHeader.includes(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`) + || !cookieHeader.includes(`session=${OPENAI_MODELS_SHIELDED_TOKEN}`) + ) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'missing shield cookie context' } })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + data: [ + { id: 'claude-sonnet-4-5-20250929' }, + { id: 'claude-opus-4-6' }, + ], + })); + return; + } + res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'invalid token' } })); return; @@ -426,6 +456,29 @@ describe('NewApiAdapter', () => { ).toBe(true); }); + it('reuses shield cookie retry when anyrouter /v1/models returns challenge html', async () => { + const adapter = new AnyRouterAdapter(); + const models = await adapter.getModels(baseUrl, OPENAI_MODELS_SHIELDED_TOKEN); + + expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-opus-4-6']); + expect( + requests.some( + (r) => + r.url === '/v1/models' + && typeof r.headers.cookie === 'string' + && r.headers.cookie.includes(`session=${OPENAI_MODELS_SHIELDED_TOKEN}`), + ), + ).toBe(true); + expect( + requests.some( + (r) => + r.url === '/v1/models' + && typeof r.headers.cookie === 'string' + && r.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`), + ), + ).toBe(true); + }); + it('parses token list response with data.items[] shape', async () => { const adapter = new NewApiAdapter(); const token = await adapter.getApiToken(baseUrl, 'session-token', 11494); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index f74ec60..deccdfb 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -2,6 +2,7 @@ import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo import type { RequestInit as UndiciRequestInit } from 'undici'; import { createContext, runInContext } from 'node:vm'; import { withSiteProxyRequestInit } from '../siteProxy.js'; +import { fetchJsonWithShieldCookieRetry } from './newApiShield.js'; export class NewApiAdapter extends BasePlatformAdapter { readonly platformName: string = 'new-api'; @@ -761,12 +762,39 @@ export class NewApiAdapter extends BasePlatformAdapter { return []; } + private extractOpenAiModels(payload: any): string[] { + if (!Array.isArray(payload?.data)) return []; + return payload.data.map((m: any) => m?.id).filter(Boolean); + } + + private async getOpenAiModelsViaShieldCookie(baseUrl: string, token: string): Promise { + for (const cookie of this.buildCookieCandidates(token)) { + try { + const { data } = await fetchJsonWithShieldCookieRetry(`${baseUrl}/v1/models`, { + headers: { + Authorization: `Bearer ${token}`, + Cookie: cookie, + }, + }); + const models = this.extractOpenAiModels(data); + if (models.length > 0) return models; + } catch {} + } + return []; + } + private async getOpenAiModels(baseUrl: string, token: string): Promise { + const shouldTryShieldCookie = this.platformName === 'anyrouter' || token.includes('='); + if (shouldTryShieldCookie) { + const shieldModels = await this.getOpenAiModelsViaShieldCookie(baseUrl, token); + if (shieldModels.length > 0) return shieldModels; + } + try { const res = await this.fetchJson(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${token}` }, }); - return (res?.data || []).map((m: any) => m.id).filter(Boolean); + return this.extractOpenAiModels(res); } catch { return []; }