[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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+927
-106
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user