[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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user