diff --git a/README.md b/README.md
index 65caad2..f781f70 100644
--- a/README.md
+++ b/README.md
@@ -349,7 +349,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均
## ⭐ Star History
-[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
+[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
---
diff --git a/docs/faq.md b/docs/faq.md
index 8b0fb1b..18d5218 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -4,6 +4,50 @@
---
+
+
+## 账号认证方式与兼容性(Access Token / Cookie)
+
+### Q: Access Token 和 Cookie 有什么区别?优先用哪个?
+
+**A:** Metapi 支持两种 Session 凭证来源(都用于管理接口,如余额、签到等):
+
+| 凭证 | 特点 | 适用场景 |
+|------|------|----------|
+| **Access Token(系统访问令牌)** | 支持多账号、通常更稳定、更适合长期使用 | 标准 NewAPI / OneAPI / OneHub / DoneHub / Veloera 等站点 |
+| **Cookie(浏览器登录态)** | 兼容性强,但可能过期,且多账号体验较差 | 访问令牌不可用、魔改站点、特殊兼容场景 |
+
+> 结论:优先使用 **Access Token**。仅在站点不支持或令牌不可用时使用 Cookie。
+
+### Q: 在 Metapi 里怎么切换认证方式?
+
+**A:** 在「账号管理 → 添加账号 → Cookie / Token 导入」中:
+
+1. 选择站点
+2. 在「凭证模式」下拉选择:
+ - 自动识别(推荐)
+ - Session 模式(Access Token / Cookie)
+ - API Key 模式(仅代理)
+3. 粘贴凭证并点击「验证 Token」
+
+### Q: 以 NewAPI 为例,Access Token 在哪里生成?
+
+**A:** 通常路径是:**控制台 → 个人设置 → 安全设置 → 系统访问令牌**。
+如果你确实需要 Cookie 兜底,可在浏览器 `F12 → Application → Cookie` 获取对应登录态。
+
+### Q: AnyRouter 这类魔改站点为什么更容易报错?
+
+**A:** AnyRouter 为魔改实现,删除了Access Token入口,也有其他类似的魔改中转站。建议:
+
+1. 先尝试 Session 模式 + Access Token
+2. 若站点不支持或报认证相关错误,再尝试 Cookie
+
+### Q: Sub2API 站点怎么处理?
+
+**A:** Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。当前建议:
+
+
+
## 部署相关
### Q: 启动后无法访问管理后台
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
index 584dea8..3e30a48 100644
--- a/src/server/db/index.ts
+++ b/src/server/db/index.ts
@@ -34,6 +34,7 @@ function ensureTokenManagementSchema() {
account_id integer NOT NULL,
name text NOT NULL,
token text NOT NULL,
+ token_group text,
source text DEFAULT 'manual',
enabled integer DEFAULT true,
is_default integer DEFAULT false,
@@ -47,6 +48,10 @@ function ensureTokenManagementSchema() {
sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;');
}
+ if (!tableColumnExists('account_tokens', 'token_group')) {
+ sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
+ }
+
sqlite.exec(`
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
SELECT
@@ -123,6 +128,33 @@ function ensureSiteProxySchema() {
}
}
+function ensureSiteExternalCheckinUrlSchema() {
+ if (!tableExists('sites')) {
+ return;
+ }
+
+ if (!tableColumnExists('sites', 'external_checkin_url')) {
+ sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`);
+ }
+}
+
+function ensureSiteGlobalWeightSchema() {
+ if (!tableExists('sites')) {
+ return;
+ }
+
+ if (!tableColumnExists('sites', 'global_weight')) {
+ sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`);
+ }
+
+ sqlite.exec(`
+ UPDATE sites
+ SET global_weight = 1
+ WHERE global_weight IS NULL
+ OR global_weight <= 0;
+ `);
+}
+
function ensureRouteGroupingSchema() {
if (!tableExists('token_routes') || !tableExists('route_channels')) {
return;
@@ -184,6 +216,8 @@ function ensureDownstreamApiKeySchema() {
ensureTokenManagementSchema();
ensureSiteStatusSchema();
ensureSiteProxySchema();
+ensureSiteExternalCheckinUrlSchema();
+ensureSiteGlobalWeightSchema();
ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema();
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 8a9d1e6..a5563c6 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -5,11 +5,13 @@ export const sites = sqliteTable('sites', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
url: text('url').notNull(),
+ externalCheckinUrl: text('external_checkin_url'),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
proxyUrl: text('proxy_url'),
status: text('status').notNull().default('active'), // 'active' | 'disabled'
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
+ globalWeight: real('global_weight').default(1),
apiKey: text('api_key'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
@@ -48,6 +50,7 @@ export const accountTokens = sqliteTable('account_tokens', {
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
token: text('token').notNull(),
+ tokenGroup: text('token_group'),
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
enabled: integer('enabled', { mode: 'boolean' }).default(true),
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts
index 6f2932b..1d585b6 100644
--- a/src/server/routes/api/accountTokens.ts
+++ b/src/server/routes/api/accountTokens.ts
@@ -215,7 +215,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise
({
+ getAdapter: () => ({
+ verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
+ getModels: (...args: unknown[]) => getModelsMock(...args),
+ getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
+ }),
+}));
+
+type DbModule = typeof import('../../db/index.js');
+
+describe('accounts credential mode', () => {
+ let app: FastifyInstance;
+ let db: DbModule['db'];
+ let schema: DbModule['schema'];
+ let dataDir = '';
+
+ beforeAll(async () => {
+ dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-credential-mode-'));
+ process.env.DATA_DIR = dataDir;
+
+ await import('../../db/migrate.js');
+ const dbModule = await import('../../db/index.js');
+ const routesModule = await import('./accounts.js');
+ db = dbModule.db;
+ schema = dbModule.schema;
+
+ app = Fastify();
+ await app.register(routesModule.accountsRoutes);
+ });
+
+ beforeEach(() => {
+ verifyTokenMock.mockReset();
+ getModelsMock.mockReset();
+ getApiTokensMock.mockReset();
+ getApiTokensMock.mockResolvedValue([]);
+
+ db.delete(schema.proxyLogs).run();
+ db.delete(schema.checkinLogs).run();
+ db.delete(schema.routeChannels).run();
+ db.delete(schema.tokenRoutes).run();
+ db.delete(schema.tokenModelAvailability).run();
+ db.delete(schema.modelAvailability).run();
+ db.delete(schema.accountTokens).run();
+ db.delete(schema.accounts).run();
+ db.delete(schema.sites).run();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ delete process.env.DATA_DIR;
+ });
+
+ it('uses API-key fast verify path when credentialMode is apikey', async () => {
+ verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
+ getModelsMock.mockResolvedValueOnce(['gpt-5-mini', 'gpt-4o-mini']);
+
+ const site = db.insert(schema.sites).values({
+ name: 'Fast Verify Site',
+ url: 'https://fast-verify.example.com',
+ platform: 'new-api',
+ }).returning().get();
+
+ const response = await app.inject({
+ method: 'POST',
+ url: '/api/accounts/verify-token',
+ payload: {
+ siteId: site.id,
+ accessToken: 'sk-fast-verify',
+ credentialMode: 'apikey',
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toMatchObject({
+ success: true,
+ tokenType: 'apikey',
+ modelCount: 2,
+ });
+ expect(getModelsMock).toHaveBeenCalledTimes(1);
+ expect(verifyTokenMock).not.toHaveBeenCalled();
+ });
+
+ it('adds account as proxy-only when credentialMode is apikey', async () => {
+ verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
+ getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
+
+ const site = db.insert(schema.sites).values({
+ name: 'Proxy Only Site',
+ url: 'https://proxy-only.example.com',
+ platform: 'new-api',
+ }).returning().get();
+
+ const response = await app.inject({
+ method: 'POST',
+ url: '/api/accounts',
+ payload: {
+ siteId: site.id,
+ accessToken: 'sk-proxy-only',
+ credentialMode: 'apikey',
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const body = response.json() as { tokenType?: string; capabilities?: { proxyOnly?: boolean } };
+ expect(body.tokenType).toBe('apikey');
+ expect(body.capabilities?.proxyOnly).toBe(true);
+
+ const accounts = db.select().from(schema.accounts).all();
+ expect(accounts).toHaveLength(1);
+ expect(accounts[0]?.accessToken || '').toBe('');
+ expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
+ expect(accounts[0]?.checkinEnabled).toBe(false);
+
+ const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
+ expect(parsedExtra.credentialMode).toBe('apikey');
+ });
+
+ it('stores managed refresh token for sub2api session account', async () => {
+ verifyTokenMock.mockResolvedValueOnce({
+ tokenType: 'session',
+ userInfo: { username: 'sub2-user' },
+ });
+
+ const site = db.insert(schema.sites).values({
+ name: 'Sub2 Site',
+ url: 'https://sub2.example.com',
+ platform: 'sub2api',
+ }).returning().get();
+
+ const response = await app.inject({
+ method: 'POST',
+ url: '/api/accounts',
+ payload: {
+ siteId: site.id,
+ accessToken: 'jwt-access-token',
+ refreshToken: 'jwt-refresh-token',
+ tokenExpiresAt: 1760000000000,
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const created = db.select().from(schema.accounts).all()[0];
+ const parsedExtra = JSON.parse(created?.extraConfig || '{}') as {
+ credentialMode?: string;
+ sub2apiAuth?: {
+ refreshToken?: string;
+ tokenExpiresAt?: number;
+ };
+ };
+ expect(parsedExtra.credentialMode).toBe('session');
+ expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('jwt-refresh-token');
+ expect(parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000);
+ });
+
+ it('updates and clears managed refresh token via account update API', async () => {
+ const site = db.insert(schema.sites).values({
+ name: 'Sub2 Site',
+ url: 'https://sub2.example.com',
+ platform: 'sub2api',
+ }).returning().get();
+ const account = db.insert(schema.accounts).values({
+ siteId: site.id,
+ username: 'sub2-user',
+ accessToken: 'access-token',
+ extraConfig: JSON.stringify({
+ credentialMode: 'session',
+ sub2apiAuth: {
+ refreshToken: 'old-refresh-token',
+ tokenExpiresAt: 1750000000000,
+ },
+ }),
+ }).returning().get();
+
+ const updateResponse = await app.inject({
+ method: 'PUT',
+ url: `/api/accounts/${account.id}`,
+ payload: {
+ refreshToken: 'new-refresh-token',
+ tokenExpiresAt: 1760000000000,
+ },
+ });
+ expect(updateResponse.statusCode).toBe(200);
+
+ const updated = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
+ const parsedUpdated = JSON.parse(updated?.extraConfig || '{}') as {
+ sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
+ };
+ expect(parsedUpdated.sub2apiAuth?.refreshToken).toBe('new-refresh-token');
+ expect(parsedUpdated.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000);
+
+ const clearResponse = await app.inject({
+ method: 'PUT',
+ url: `/api/accounts/${account.id}`,
+ payload: {
+ refreshToken: '',
+ },
+ });
+ expect(clearResponse.statusCode).toBe(200);
+
+ const cleared = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
+ const parsedCleared = JSON.parse(cleared?.extraConfig || '{}') as {
+ sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
+ };
+ expect(parsedCleared.sub2apiAuth).toBeUndefined();
+ });
+});
diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts
index 076a192..4c46f5d 100644
--- a/src/server/routes/api/accounts.ts
+++ b/src/server/routes/api/accounts.ts
@@ -5,7 +5,15 @@ import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
-import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
+import {
+ getCredentialModeFromExtraConfig,
+ guessPlatformUserIdFromUsername,
+ getSub2ApiAuthFromExtraConfig,
+ mergeAccountExtraConfig,
+ normalizeCredentialMode as normalizeCredentialModeInput,
+ resolvePlatformUserId,
+ type AccountCredentialMode,
+} from '../../services/accountExtraConfig.js';
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
@@ -28,11 +36,50 @@ type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
siteName: string;
- status: 'success' | 'failed' | 'skipped';
- state: RuntimeHealthState;
+ status: 'success' | 'failed' | 'skipped';
+ state: RuntimeHealthState;
message: string;
};
+type AccountCapabilities = {
+ canCheckin: boolean;
+ canRefreshBalance: boolean;
+ proxyOnly: boolean;
+};
+
+function hasSessionTokenValue(value: string | null | undefined): boolean {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function resolveRequestedCredentialMode(input: unknown): AccountCredentialMode {
+ return normalizeCredentialModeInput(input) || 'auto';
+}
+
+function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelect): AccountCredentialMode {
+ const fromConfig = getCredentialModeFromExtraConfig(account.extraConfig);
+ if (fromConfig && fromConfig !== 'auto') return fromConfig;
+ return hasSessionTokenValue(account.accessToken) ? 'session' : 'apikey';
+}
+
+function buildCapabilitiesFromCredentialMode(
+ credentialMode: AccountCredentialMode,
+ hasSessionToken: boolean,
+): AccountCapabilities {
+ const sessionCapable = credentialMode === 'session'
+ ? hasSessionToken
+ : (credentialMode === 'apikey' ? false : hasSessionToken);
+ return {
+ canCheckin: sessionCapable,
+ canRefreshBalance: sessionCapable,
+ proxyOnly: !sessionCapable,
+ };
+}
+
+function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
+ const credentialMode = resolveStoredCredentialMode(account);
+ return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
+}
+
function normalizePinnedFlag(input: unknown): boolean | null {
if (input === undefined || input === null) return null;
if (typeof input === 'boolean') return input;
@@ -52,6 +99,21 @@ function normalizeSortOrder(input: unknown): number | null {
return Math.max(0, parsed);
}
+function normalizeManagedRefreshToken(input: unknown): string | undefined {
+ if (typeof input !== 'string') return undefined;
+ const trimmed = input.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+function normalizeManagedTokenExpiresAt(input: unknown): number | undefined {
+ if (typeof input === 'number' && Number.isFinite(input) && input > 0) return Math.trunc(input);
+ if (typeof input === 'string') {
+ const parsed = Number.parseInt(input.trim(), 10);
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
+ }
+ return undefined;
+}
+
function getNextAccountSortOrder(): number {
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
@@ -195,24 +257,32 @@ export async function accountsRoutes(app: FastifyInstance) {
parsedRewardCountByAccount[log.accountId] = (parsedRewardCountByAccount[log.accountId] || 0) + 1;
}
- return rows.map((r) => ({
- ...r.accounts,
- site: r.sites,
- todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
- todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
- day: localDay,
- successCount: successCountByAccount[r.accounts.id] || 0,
- parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0,
- rewardSum: rewardByAccount[r.accounts.id] || 0,
- extraConfig: r.accounts.extraConfig,
- }) * 1_000_000) / 1_000_000,
- runtimeHealth: buildRuntimeHealthForAccount({
- accountStatus: r.accounts.status,
- siteStatus: r.sites.status,
- extraConfig: r.accounts.extraConfig,
- }),
- }));
- });
+ return rows.map((r) => {
+ const credentialMode = resolveStoredCredentialMode(r.accounts);
+ return {
+ ...r.accounts,
+ site: r.sites,
+ credentialMode,
+ capabilities: buildCapabilitiesFromCredentialMode(
+ credentialMode,
+ hasSessionTokenValue(r.accounts.accessToken),
+ ),
+ todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
+ todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
+ day: localDay,
+ successCount: successCountByAccount[r.accounts.id] || 0,
+ parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0,
+ rewardSum: rewardByAccount[r.accounts.id] || 0,
+ extraConfig: r.accounts.extraConfig,
+ }) * 1_000_000) / 1_000_000,
+ runtimeHealth: buildRuntimeHealthForAccount({
+ accountStatus: r.accounts.status,
+ siteStatus: r.sites.status,
+ extraConfig: r.accounts.extraConfig,
+ }),
+ };
+ });
+ });
// Login to a site and auto-create account
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
@@ -249,12 +319,13 @@ export async function accountsRoutes(app: FastifyInstance) {
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
- const extraConfigPatch: Record = {
- autoRelogin: {
- username,
- passwordCipher: encryptAccountPassword(password),
- updatedAt: new Date().toISOString(),
- },
+ const extraConfigPatch: Record = {
+ credentialMode: 'session',
+ autoRelogin: {
+ username,
+ passwordCipher: encryptAccountPassword(password),
+ updatedAt: new Date().toISOString(),
+ },
};
if (guessedPlatformUserId) {
extraConfigPatch.platformUserId = guessedPlatformUserId;
@@ -318,15 +389,45 @@ export async function accountsRoutes(app: FastifyInstance) {
};
});
- // Verify a token against a site - auto-detects token type (session vs API key)
- app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number } }>('/api/accounts/verify-token', async (request) => {
- const { siteId, accessToken, platformUserId } = request.body;
- const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
- if (!site) return { success: false, message: 'site not found' };
-
- const adapter = getAdapter(site.platform);
- if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
-
+ // Verify credentials against a site.
+ app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => {
+ const { siteId, platformUserId } = request.body;
+ const accessToken = (request.body.accessToken || '').trim();
+ const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
+ const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
+ if (!site) return { success: false, message: 'site not found' };
+
+ if (!accessToken) {
+ return { success: false, message: 'Token 不能为空' };
+ }
+
+ const adapter = getAdapter(site.platform);
+ if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
+
+ if (credentialMode === 'apikey') {
+ try {
+ const models = await adapter.getModels(site.url, accessToken, platformUserId);
+ const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
+ if (availableModels.length === 0) {
+ return {
+ success: false,
+ message: 'API Key 验证失败:未获取到可用模型',
+ };
+ }
+ return {
+ success: true,
+ tokenType: 'apikey',
+ modelCount: availableModels.length,
+ models: availableModels.slice(0, 10),
+ };
+ } catch (err: any) {
+ return {
+ success: false,
+ message: err?.message || 'API Key 验证失败',
+ };
+ }
+ }
+
let result: any;
try {
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
@@ -336,103 +437,110 @@ export async function accountsRoutes(app: FastifyInstance) {
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
};
}
-
- if (result.tokenType === 'session') {
- return {
- success: true,
- tokenType: 'session',
- userInfo: result.userInfo,
- balance: result.balance,
- apiToken: result.apiToken,
- };
- }
-
- if (result.tokenType === 'apikey') {
- return {
- success: true,
- tokenType: 'apikey',
- modelCount: result.models?.length || 0,
- models: result.models?.slice(0, 10),
- };
- }
-
- // Try to explain unknown failures: missing user id vs anti-bot challenge page.
- type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
- const detectVerifyFailureReason = async (): Promise => {
- 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|