forked from boos/BoosAPI
[codex] Fix Anyrouter model sync shield handling (#348)
* fix anyrouter model sync shield handling * fix shield challenge message precedence
This commit is contained in:
@@ -274,6 +274,75 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
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 '<', \"<html><scr\"... is not valid JSON"));
|
||||||
|
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-anyrouter',
|
||||||
|
url: 'https://anyrouter.example.com',
|
||||||
|
platform: 'anyrouter',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'shielded-user',
|
||||||
|
accessToken: 'session-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = await refreshModelsForAccount(account.id);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: account.id,
|
||||||
|
refreshed: true,
|
||||||
|
modelCount: 0,
|
||||||
|
modelsPreview: [],
|
||||||
|
tokenScanned: 0,
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'unknown',
|
||||||
|
errorMessage: '模型获取失败:站点返回了防护页面,请在目标站点创建 API Key 后再同步模型',
|
||||||
|
});
|
||||||
|
|
||||||
|
const latest = await db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, account.id))
|
||||||
|
.get();
|
||||||
|
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||||
|
expect(parsed.runtimeHealth?.reason).toBe('模型获取失败:站点返回了防护页面,请在目标站点创建 API Key 后再同步模型');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps shield guidance when challenge html arrives with http 403 discovery failure', async () => {
|
||||||
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
|
getModelsMock.mockRejectedValue(new Error('HTTP 403: <html><script>var arg1="abc123"</script></html>'));
|
||||||
|
|
||||||
|
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 () => {
|
it('does not scan hidden managed tokens for direct apikey connections', async () => {
|
||||||
getApiTokenMock.mockResolvedValue(null);
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||||
|
|||||||
@@ -106,6 +106,26 @@ export type ModelRefreshResult =
|
|||||||
| ModelRefreshFailureResult
|
| ModelRefreshFailureResult
|
||||||
| ModelRefreshSuccessResult;
|
| ModelRefreshSuccessResult;
|
||||||
|
|
||||||
|
function looksLikeHtmlJsonParseError(message: string): boolean {
|
||||||
|
const lowered = String(message || '').trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
lowered.includes('unexpected token')
|
||||||
|
&& lowered.includes('not valid json')
|
||||||
|
&& (lowered.includes('<html') || lowered.includes('<script'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeShieldChallenge(message: string): boolean {
|
||||||
|
const lowered = String(message || '').trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
lowered.includes('acw_sc__v2')
|
||||||
|
|| lowered.includes('var arg1')
|
||||||
|
|| lowered.includes('captcha')
|
||||||
|
|| lowered.includes('challenge')
|
||||||
|
|| lowered.includes('cloudflare tunnel error')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
||||||
const lowered = message.toLowerCase();
|
const lowered = message.toLowerCase();
|
||||||
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
||||||
@@ -115,7 +135,15 @@ function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string) {
|
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string, platform?: string | null) {
|
||||||
|
const raw = String(fallback || '').trim();
|
||||||
|
if (looksLikeHtmlJsonParseError(raw) || looksLikeShieldChallenge(raw)) {
|
||||||
|
const normalizedPlatform = String(platform || '').trim().toLowerCase();
|
||||||
|
if (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter') {
|
||||||
|
return '模型获取失败:站点返回了防护页面,请在目标站点创建 API Key 后再同步模型';
|
||||||
|
}
|
||||||
|
return '模型获取失败:站点返回了网页而不是 JSON 响应';
|
||||||
|
}
|
||||||
if (code === 'timeout') return '模型获取失败(请求超时)';
|
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||||||
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||||||
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||||||
@@ -844,7 +872,7 @@ export async function refreshModelsForAccount(
|
|||||||
if (accountModels.size === 0) {
|
if (accountModels.size === 0) {
|
||||||
const firstMessage = failureMessages[0] || '';
|
const firstMessage = failureMessages[0] || '';
|
||||||
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
||||||
const errorMessage = buildModelFailureMessage(errorCode, firstMessage);
|
const errorMessage = buildModelFailureMessage(errorCode, firstMessage, site.platform);
|
||||||
await setAccountRuntimeHealth(account.id, {
|
await setAccountRuntimeHealth(account.id, {
|
||||||
state: 'unhealthy',
|
state: 'unhealthy',
|
||||||
reason: errorMessage,
|
reason: errorMessage,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { NewApiAdapter } from './newApi.js';
|
import { NewApiAdapter } from './newApi.js';
|
||||||
|
import { AnyRouterAdapter } from './anyrouter.js';
|
||||||
|
|
||||||
interface RequestSnapshot {
|
interface RequestSnapshot {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -25,6 +26,7 @@ const SHIELD_LOGIN_COOKIE = 'challenge-seed';
|
|||||||
const COOKIE_ONLY_LOGIN_USERNAME = 'cookie-only-user';
|
const COOKIE_ONLY_LOGIN_USERNAME = 'cookie-only-user';
|
||||||
const COOKIE_ONLY_LOGIN_PASSWORD = 'cookie-only-pass';
|
const COOKIE_ONLY_LOGIN_PASSWORD = 'cookie-only-pass';
|
||||||
const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
|
const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
|
||||||
|
const OPENAI_MODELS_SHIELDED_TOKEN = 'openai-models-shielded-token';
|
||||||
const COOKIE_SHIELDED_TOKEN = Buffer.from(
|
const COOKIE_SHIELDED_TOKEN = Buffer.from(
|
||||||
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
@@ -67,6 +69,34 @@ describe('NewApiAdapter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (req.url === '/v1/models') {
|
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.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: 'invalid token' } }));
|
res.end(JSON.stringify({ error: { message: 'invalid token' } }));
|
||||||
return;
|
return;
|
||||||
@@ -426,6 +456,29 @@ describe('NewApiAdapter', () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it('parses token list response with data.items[] shape', async () => {
|
||||||
const adapter = new NewApiAdapter();
|
const adapter = new NewApiAdapter();
|
||||||
const token = await adapter.getApiToken(baseUrl, 'session-token', 11494);
|
const token = await adapter.getApiToken(baseUrl, 'session-token', 11494);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo
|
|||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { createContext, runInContext } from 'node:vm';
|
import { createContext, runInContext } from 'node:vm';
|
||||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
|
import { fetchJsonWithShieldCookieRetry } from './newApiShield.js';
|
||||||
|
|
||||||
export class NewApiAdapter extends BasePlatformAdapter {
|
export class NewApiAdapter extends BasePlatformAdapter {
|
||||||
readonly platformName: string = 'new-api';
|
readonly platformName: string = 'new-api';
|
||||||
@@ -761,12 +762,39 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
|||||||
return [];
|
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<string[]> {
|
||||||
|
for (const cookie of this.buildCookieCandidates(token)) {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchJsonWithShieldCookieRetry<any>(`${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<string[]> {
|
private async getOpenAiModels(baseUrl: string, token: string): Promise<string[]> {
|
||||||
|
const shouldTryShieldCookie = this.platformName === 'anyrouter' || token.includes('=');
|
||||||
|
if (shouldTryShieldCookie) {
|
||||||
|
const shieldModels = await this.getOpenAiModelsViaShieldCookie(baseUrl, token);
|
||||||
|
if (shieldModels.length > 0) return shieldModels;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
|
return this.extractOpenAiModels(res);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user