Files
pikasTech-apistate-development/src/sub2api-client.ts
T
2026-07-17 13:10:39 +02:00

224 lines
7.4 KiB
TypeScript

import type { AppConfig } from "./config";
import type { RankingSourceRow, Sub2ApiUser } from "./types";
interface Envelope<T> {
code: number;
message: string;
data: T;
}
export interface Paginated<T> {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface Sub2ApiGroup {
id: number;
name: string;
platform: string;
status: string;
}
export interface Sub2ApiAccount {
id: number;
name: string;
platform: string;
status: string;
schedulable?: boolean;
priority?: number;
}
export interface Sub2ApiUsageRow {
id: number;
account_id: number | null;
group_id: number | null;
model: string;
stream: boolean;
input_tokens: number;
output_tokens: number;
actual_cost: number;
duration_ms: number | null;
first_token_ms: number | null;
created_at: string;
}
export interface Sub2ApiRequestError {
id: number;
request_id?: string;
account_id?: number | null;
status_code?: number;
phase?: string;
type?: string;
message?: string;
error_message?: string;
}
export interface Sub2ApiSystemLog {
id: number;
created_at: string;
message: string;
request_id?: string;
account_id?: number | null;
extra?: Record<string, unknown>;
}
export class Sub2ApiClient {
private token: string | null = null;
private tokenExpiresAt = 0;
constructor(
private readonly config: AppConfig,
private readonly credentials: { email: string; password: string },
) {}
private async request<T>(path: string, init: RequestInit = {}, authenticate = true): Promise<T> {
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
if (init.body) headers.set("content-type", "application/json");
if (authenticate) headers.set("authorization", `Bearer ${await this.accessToken()}`);
const response = await fetch(`${this.config.sub2api.baseUrl}${path}`, {
...init,
headers,
signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs),
});
const payload = (await response.json().catch(() => null)) as Envelope<T> | null;
if (!response.ok || !payload || payload.code !== 0) throw new Error(`Sub2API ${init.method ?? "GET"} ${path} failed: HTTP ${response.status} ${payload?.message ?? "invalid response"}`);
return payload.data;
}
private async accessToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
const data = await this.request<{ access_token: string; expires_in?: number }>("/auth/login", {
method: "POST",
body: JSON.stringify(this.credentials),
}, false);
if (!data.access_token) throw new Error("Sub2API login response is missing access_token");
this.token = data.access_token;
const lifetimeSeconds = data.expires_in && data.expires_in > 120 ? data.expires_in - 60 : 300;
this.tokenExpiresAt = Date.now() + lifetimeSeconds * 1000;
return this.token;
}
async listUsers(): Promise<Sub2ApiUser[]> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
const users = new Map<number, Sub2ApiUser>();
let expectedTotal = 0;
let page = 1;
for (;;) {
const params = new URLSearchParams({
page: String(page),
page_size: String(this.config.sub2api.pageSize),
sort_by: "id",
sort_order: "asc",
});
const data = await this.request<Paginated<Sub2ApiUser>>(`/admin/users?${params}`);
expectedTotal = data.total;
for (const user of data.items) users.set(user.id, user);
if (page >= data.pages) break;
page += 1;
}
if (users.size === expectedTotal) return [...users.values()].sort((left, right) => left.id - right.id);
}
throw new Error("Sub2API user list changed during pagination; retry the operation");
}
async getUsageRanking(startDate: string, endDate: string): Promise<{ ranking: RankingSourceRow[]; total_actual_cost: number; total_requests: number; total_tokens: number }> {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
timezone: this.config.ranking.timezone,
limit: String(this.config.ranking.sourceLimit),
});
return await this.request(`/admin/dashboard/users-ranking?${params}`);
}
async listGroups(): Promise<Sub2ApiGroup[]> {
return await this.request<Sub2ApiGroup[]>("/admin/groups/all");
}
async listGroupAccounts(groupId: number, platform: string): Promise<Sub2ApiAccount[]> {
const params = new URLSearchParams({
page: "1",
page_size: "1000",
group: String(groupId),
platform,
sort_by: "id",
sort_order: "asc",
});
return (await this.request<Paginated<Sub2ApiAccount>>(`/admin/accounts?${params}`)).items;
}
async listGroupUsage(groupId: number, start: Date, end: Date): Promise<Sub2ApiUsageRow[]> {
return await this.paginate<Sub2ApiUsageRow>("/admin/usage", {
group_id: String(groupId),
start_date: start.toISOString().slice(0, 10),
end_date: end.toISOString().slice(0, 10),
timezone: "UTC",
sort_by: "created_at",
sort_order: "desc",
}, 1000, (row) => {
const createdAt = Date.parse(row.created_at);
return Number.isFinite(createdAt) && createdAt >= start.getTime() && createdAt <= end.getTime();
});
}
async listRequestErrors(groupId: number, platform: string, start: Date): Promise<Sub2ApiRequestError[]> {
return await this.paginate<Sub2ApiRequestError>("/admin/ops/request-errors", {
group_id: String(groupId),
platform,
start_time: start.toISOString(),
view: "all",
}, 500);
}
async listSystemLogs(platform: string, start: Date, marker: string): Promise<Sub2ApiSystemLog[]> {
return await this.paginate<Sub2ApiSystemLog>("/admin/ops/system-logs", {
platform,
start_time: start.toISOString(),
q: marker,
}, 200);
}
async getOpsOverview(groupId: number, platform: string, start: Date): Promise<Record<string, unknown>> {
const params = new URLSearchParams({
group_id: String(groupId),
platform,
start_time: start.toISOString(),
query_mode: "raw",
});
return await this.request(`/admin/ops/dashboard/overview?${params}`);
}
async getOpsAccountAvailability(groupId: number, platform: string): Promise<Record<string, unknown>> {
const params = new URLSearchParams({ group_id: String(groupId), platform });
return await this.request(`/admin/ops/account-availability?${params}`);
}
async getOpsConcurrency(groupId: number, platform: string): Promise<Record<string, unknown>> {
const params = new URLSearchParams({ group_id: String(groupId), platform });
return await this.request(`/admin/ops/concurrency?${params}`);
}
private async paginate<T>(path: string, query: Record<string, string>, pageSize: number, keep: (row: T) => boolean = () => true): Promise<T[]> {
const rows: T[] = [];
let page = 1;
for (;;) {
const params = new URLSearchParams({ ...query, page: String(page), page_size: String(pageSize) });
const data = await this.request<Paginated<T>>(`${path}?${params}`);
rows.push(...data.items.filter(keep));
if (page >= data.pages) return rows;
page += 1;
}
}
async addBalance(userId: number, amountUsd: number, notes: string): Promise<Sub2ApiUser> {
return await this.request(`/admin/users/${userId}/balance`, {
method: "POST",
body: JSON.stringify({ balance: amountUsd, operation: "add", notes }),
});
}
}