[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
+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,
};
}