[codex] improve proxy stability, routing, and site subscription visibility (#194)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy
This commit is contained in:
Bainily
2026-03-20 16:18:28 +08:00
committed by GitHub
parent e2595bcab3
commit e9c1bd898e
37 changed files with 3424 additions and 236 deletions
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildStoredSub2ApiSubscriptionSummary,
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
getSub2ApiSubscriptionFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
normalizeCredentialMode,
@@ -105,4 +107,37 @@ describe('accountExtraConfig', () => {
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
it('parses stored sub2api subscription summary from extra config', () => {
const extraConfig = mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
});
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
updatedAt: 1760000000000,
});
});
});
+144
View File
@@ -1,3 +1,5 @@
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
type AutoReloginConfig = {
username?: unknown;
passwordCipher?: unknown;
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
tokenExpiresAt?: unknown;
};
type Sub2ApiSubscriptionConfig = {
updatedAt?: unknown;
activeCount?: unknown;
totalUsedUsd?: unknown;
subscriptions?: unknown;
};
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
};
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
[key: string]: unknown;
};
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
return undefined;
}
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw * 1_000_000) / 1_000_000;
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.round(parsed * 1_000_000) / 1_000_000;
}
}
return undefined;
}
function normalizeIsoDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
@@ -129,6 +172,10 @@ export type ManagedSub2ApiAuth = {
tokenExpiresAt?: number;
};
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
updatedAt: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
@@ -141,6 +188,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
: { refreshToken };
}
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const normalized: SubscriptionPlanSummary = {};
const id = normalizeUserId(item.id);
if (id) normalized.id = id;
const groupId = normalizeUserId(item.groupId ?? item.group_id);
if (groupId) normalized.groupId = groupId;
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
if (groupName) normalized.groupName = groupName;
const status = normalizeNonEmptyString(item.status);
if (status) normalized.status = status;
const expiresAt = normalizeIsoDateTime(
item.expiresAt
?? item.expires_at
?? item.expiredAt
?? item.expired_at
?? item.endAt
?? item.end_at,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => normalizeSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
export function normalizeSub2ApiSubscriptionSummary(
raw: unknown,
): StoredSub2ApiSubscriptionSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const body = raw as Record<string, unknown>;
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
return {
activeCount: Math.trunc(activeCount ?? subscriptions.length),
totalUsedUsd: totalUsedUsd ?? 0,
subscriptions,
updatedAt: updatedAt ?? Date.now(),
};
}
export function buildStoredSub2ApiSubscriptionSummary(
summary: SubscriptionSummary,
updatedAt = Date.now(),
): StoredSub2ApiSubscriptionSummary {
return normalizeSub2ApiSubscriptionSummary({
...summary,
updatedAt,
}) || {
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
updatedAt,
};
}
export function getSub2ApiSubscriptionFromExtraConfig(
extraConfig?: string | null,
): StoredSub2ApiSubscriptionSummary | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
}
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
const text = (username || '').trim();
if (!text) return undefined;
+6
View File
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
import { reportTokenExpired } from './alertService.js';
import {
buildStoredSub2ApiSubscriptionSummary,
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
@@ -477,6 +478,11 @@ export async function refreshBalance(accountId: number) {
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
}
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
});
}
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
+21
View File
@@ -8,12 +8,33 @@ export interface CheckinResult {
reward?: string;
}
export interface SubscriptionPlanSummary {
id?: number;
groupId?: number;
groupName?: string;
status?: string;
expiresAt?: string;
dailyUsedUsd?: number;
dailyLimitUsd?: number;
weeklyUsedUsd?: number;
weeklyLimitUsd?: number;
monthlyUsedUsd?: number;
monthlyLimitUsd?: number;
}
export interface SubscriptionSummary {
activeCount: number;
totalUsedUsd: number;
subscriptions: SubscriptionPlanSummary[];
}
export interface BalanceInfo {
balance: number;
used: number;
quota: number;
todayIncome?: number;
todayQuotaConsumption?: number;
subscriptionSummary?: SubscriptionSummary;
}
interface LoginResult {
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { Sub2ApiAdapter } from './sub2api.js';
class MockSub2ApiAdapter extends Sub2ApiAdapter {
constructor(private readonly responses: Record<string, unknown>) {
super();
}
protected override async fetchJson<T>(url: string): Promise<T> {
const parsed = new URL(url);
const key = `${parsed.pathname}${parsed.search}`;
const value = this.responses[key];
if (value instanceof Error) throw value;
if (value === undefined) throw new Error(`Unexpected request: ${key}`);
return value as T;
}
}
describe('Sub2ApiAdapter subscription summary parsing', () => {
it('returns summary data from /api/v1/subscriptions/summary', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': {
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.2,
subscriptions: [
{
id: 9,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.2,
monthly_limit_usd: 20,
},
],
},
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.2,
subscriptions: [
{
id: 9,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.2,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': new Error('HTTP 404'),
'/api/v1/subscriptions/active': {
code: 0,
message: 'success',
data: [
{
id: 10,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 1.5,
monthly_limit_usd: 10,
},
],
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 1.5,
subscriptions: [
{
id: 10,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 1.5,
monthlyLimitUsd: 10,
},
],
});
});
});
@@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => {
expect(balance.used).toBe(0);
});
it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.75,
subscriptions: [
{
id: 3,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.75,
monthly_limit_usd: 20,
},
],
},
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.75,
subscriptions: [
{
id: 3,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.75,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to active subscriptions when summary endpoint is unavailable', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 404, message: 'not found' }));
return;
}
if (req.url === '/api/v1/subscriptions/active') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: [
{
id: 9,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 2.5,
monthly_limit_usd: 15,
},
],
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 2.5,
subscriptions: [
{
id: 9,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 2.5,
monthlyLimitUsd: 15,
},
],
});
});
it('fetches user info from /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
+195 -1
View File
@@ -4,6 +4,8 @@ import {
CheckinResult,
BalanceInfo,
CreateApiTokenOptions,
SubscriptionPlanSummary,
SubscriptionSummary,
type SiteAnnouncement,
UserInfo,
} from './base.js';
@@ -22,6 +24,193 @@ function normalizeBaseUrl(baseUrl: string): string {
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api';
private roundCurrency(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
private parsePositiveInteger(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
if (typeof raw === 'string') {
const parsed = Number.parseInt(raw.trim(), 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
return undefined;
}
private parseNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return this.roundCurrency(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return this.roundCurrency(parsed);
}
}
return undefined;
}
private parseDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group)
? item.group as Record<string, unknown>
: null;
const normalized: SubscriptionPlanSummary = {};
const id = this.parsePositiveInteger(item.id);
if (id) normalized.id = id;
const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id);
if (groupId) normalized.groupId = groupId;
const groupNameCandidates = [
item.group_name,
item.groupName,
item.name,
item.title,
group?.name,
group?.title,
];
for (const candidate of groupNameCandidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (!trimmed) continue;
normalized.groupName = trimmed;
break;
}
if (typeof item.status === 'string' && item.status.trim()) {
normalized.status = item.status.trim();
}
const expiresAt = this.parseDateTime(
item.expires_at
?? item.expiresAt
?? item.expired_at
?? item.expiredAt
?? item.end_at
?? item.endAt
?? item.end_time
?? item.endTime
?? item.current_period_end
?? item.currentPeriodEnd,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = this.parseNonNegativeNumber(
item.monthly_used_usd
?? item.monthlyUsedUsd
?? item.used_usd
?? item.usedUsd
?? item.total_used_usd
?? item.totalUsedUsd,
);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = this.parseNonNegativeNumber(
item.monthly_limit_usd
?? item.monthlyLimitUsd
?? item.limit_usd
?? item.limitUsd
?? item.total_limit_usd
?? item.totalLimitUsd,
);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
const rawItems = (() => {
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object') {
const body = raw as Record<string, unknown>;
if (Array.isArray(body.subscriptions)) return body.subscriptions;
if (Array.isArray(body.items)) return body.items;
if (Array.isArray(body.list)) return body.list;
if (Array.isArray(body.data)) return body.data;
}
return [];
})();
return rawItems
.map((item) => this.parseSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
private buildSubscriptionSummary(payload: unknown): SubscriptionSummary {
const body = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
const subscriptions = this.parseSubscriptionItems(payload);
const activeCount = this.parsePositiveInteger(body.active_count ?? body.activeCount);
const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd);
const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0);
return {
activeCount: activeCount ?? subscriptions.length,
totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd),
subscriptions,
};
}
private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise<SubscriptionSummary | undefined> {
const headers = this.buildAuthHeader(accessToken);
const summaryEndpoint = '/api/v1/subscriptions/summary';
try {
const res = await this.fetchJson<any>(`${baseUrl}${summaryEndpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, summaryEndpoint);
return this.buildSubscriptionSummary(data);
} catch {}
const fallbackEndpoints = ['/api/v1/subscriptions/active'];
for (const endpoint of fallbackEndpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
return this.buildSubscriptionSummary(data);
} catch {}
}
return undefined;
}
private stripBearerPrefix(value?: string | null): string {
const trimmed = (value || '').trim();
if (!trimmed) return '';
@@ -462,13 +651,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
// --- Balance ---
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
const user = await this.fetchAuthMe(baseUrl, accessToken);
const normalizedBase = normalizeBaseUrl(baseUrl);
const [user, subscriptionSummary] = await Promise.all([
this.fetchAuthMe(normalizedBase, accessToken),
this.fetchSubscriptionSummary(normalizedBase, accessToken),
]);
const quotaValue = this.usdToQuota(user.balance);
// Sub2API only provides current balance, no usage breakdown
return {
balance: quotaValue / 500000,
used: 0,
quota: quotaValue / 500000,
subscriptionSummary,
};
}
+12 -6
View File
@@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => {
).toBe(true);
});
it('retries on generic client request errors for cross-channel fallback', () => {
it('does not retry obvious request-shape errors that will fail on every channel', () => {
expect(
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
).toBe(true);
).toBe(false);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
).toBe(false);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(false);
});
it('keeps retrying channel-local compatibility and auth failures', () => {
expect(
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
).toBe(true);
@@ -31,10 +40,7 @@ describe('proxyRetryPolicy', () => {
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'),
).toBe(true);
});
});
+51 -4
View File
@@ -14,14 +14,61 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
];
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
/unsupported\s+legacy\s+protocol/i,
/please\s+use\s+\/v1\/responses/i,
/please\s+use\s+\/v1\/messages/i,
/please\s+use\s+\/v1\/chat\/completions/i,
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i,
/unsupported\s+endpoint/i,
/unsupported\s+path/i,
/unknown\s+endpoint/i,
/unrecognized\s+request\s+url/i,
/no\s+route\s+matched/i,
/invalid\s+api\s+key/i,
/invalid\s+access\s+token/i,
/forbidden/i,
/rate\s+limit/i,
/quota/i,
/bad\s+gateway/i,
/gateway\s+time-?out/i,
/service\s+unavailable/i,
/cpu\s+overloaded/i,
/timeout/i,
];
const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
/invalid\s+request\s+body/i,
/validation/i,
/missing\s+required/i,
/required\s+parameter/i,
/unknown\s+parameter/i,
/unrecognized\s+(field|key|parameter)/i,
/malformed/i,
/invalid\s+json/i,
/cannot\s+parse/i,
/unsupported\s+media\s+type/i,
];
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 400) return true;
if (!upstreamErrorText) return false;
return isModelUnsupportedErrorMessage(upstreamErrorText);
function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return patterns.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 500) return true;
if (status === 408 || status === 409 || status === 425 || status === 429) return true;
if (status === 401 || status === 403) return true;
if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true;
if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false;
if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true;
if (status === 400 || status === 404 || status === 422) return false;
return false;
}
+2 -1
View File
@@ -1,10 +1,11 @@
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'round_robin') return 'round_robin';
if (normalized === 'stable_first') return 'stable_first';
return DEFAULT_ROUTE_ROUTING_STRATEGY;
}
@@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let config: ConfigModule['config'];
let dataDir = '';
let originalCacheTtlMs = 0;
@@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
config = configModule.config;
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
});
@@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
@@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let config: ConfigModule['config'];
let dataDir = '';
let idSeed = 0;
@@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
config = configModule.config;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
@@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => {
mockedCatalogRoutingCost.mockReturnValue(null);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -398,4 +406,400 @@ describe('TokenRouter selection scoring', () => {
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
});
it('downweights a site after transient failures and restores it quickly after success', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('runtime-a');
const accountA = await createAccount(siteA.id, 'runtime-user-a');
const tokenA = await createToken(accountA.id, 'runtime-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('runtime-b');
const accountB = await createAccount(siteB.id, 'runtime-user-b');
const tokenB = await createToken(accountB.id, 'runtime-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
let decision = await router.explainSelection('gpt-5.4');
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA?.probability).toBeCloseTo(50, 1);
expect(candidateB?.probability).toBeCloseTo(50, 1);
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Bad gateway',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan(30);
expect(candidateA?.reason || '').toContain('运行时健康=');
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
await router.recordSuccess(channelA.id, 800, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
expect((candidateB?.probability || 0)).toBeLessThan(60);
});
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.3');
const siteA = await createSite('breaker-a');
const accountA = await createAccount(siteA.id, 'breaker-user-a');
const tokenA = await createToken(accountA.id, 'breaker-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('breaker-b');
const accountB = await createAccount(siteB.id, 'breaker-user-b');
const tokenB = await createToken(accountB.id, 'breaker-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
let decision = await router.explainSelection('gpt-5.3');
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
expect((breakerCandidateA?.probability || 0)).toBe(0);
expect((breakerCandidateB?.probability || 0)).toBe(100);
expect(decision.summary.join(' ')).toContain('站点熔断避让');
await router.recordSuccess(channelA.id, 600, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.3');
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
});
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-4-sonnet');
const siteStable = await createSite('history-stable');
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 90,
failCount: 10,
totalLatencyMs: 90 * 240,
}).run();
const siteWeak = await createSite('history-weak');
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 20,
failCount: 30,
totalLatencyMs: 20 * 5200,
}).run();
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('历史健康=');
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-4o-mini');
const siteA = await createSite('persist-a');
const accountA = await createAccount(siteA.id, 'persist-user-a');
const tokenA = await createToken(accountA.id, 'persist-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('persist-b');
const accountB = await createAccount(siteB.id, 'persist-user-b');
const tokenB = await createToken(accountB.id, 'persist-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-4o-mini',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
await flushSiteRuntimeHealthPersistence();
const persisted = await db.select().from(schema.settings)
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
.get();
expect(persisted?.value).toBeTruthy();
resetSiteRuntimeHealthState();
invalidateTokenRouterCache();
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const gptRoute = await createRoute('gpt-5.4');
const claudeRoute = await createRoute('claude-sonnet-4-6');
const siteA = await createSite('model-aware-a');
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
const gptChannelA = await db.insert(schema.routeChannels).values({
routeId: gptRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: claudeRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const siteB = await createSite('model-aware-b');
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
await db.insert(schema.routeChannels).values([
{
routeId: gptRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: claudeRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const router = new TokenRouter();
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'Bad gateway',
modelName: 'gpt-5.4',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
invalidateTokenRouterCache();
const gptDecision = await router.explainSelection('gpt-5.4');
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('stable_first deterministically chooses the healthiest candidate', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-first-a');
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('stable-first-b');
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.1',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.1');
const decision = await router.explainSelection('gpt-5.1');
expect(preview?.channel.id).toBe(channelB.id);
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
});
File diff suppressed because it is too large Load Diff