/** * Admin Groups API endpoints * Handles API key group management for administrators */ import { apiClient } from '../client' import type { AdminGroup, GroupPlatform, CompositeModelRoute, CompositeModelRouteInput, CompositeRoutePreviewRequest, CompositeRouteDecision, CreateGroupRequest, UpdateGroupRequest, PaginatedResponse } from '@/types' export interface LiveCapability { supported: boolean reason?: string } /** * List all groups with pagination * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) * @param filters - Optional filters (platform, status, is_exclusive, search) * @returns Paginated list of groups */ export async function list( page: number = 1, pageSize: number = 20, filters?: { platform?: GroupPlatform status?: 'active' | 'inactive' is_exclusive?: boolean search?: string sort_by?: string sort_order?: 'asc' | 'desc' }, options?: { signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/groups', { params: { page, page_size: pageSize, ...filters }, signal: options?.signal }) return data } /** * Get all active groups (without pagination) * @param platform - Optional platform filter * @returns List of all active groups */ export async function getAll(platform?: GroupPlatform): Promise { const { data } = await apiClient.get('/admin/groups/all', { params: platform ? { platform } : undefined }) return data } /** * Get ALL groups including disabled ones — used by the API Key group filter so * that admins can filter users whose keys are still bound to a now-disabled group. */ export async function getAllIncludingInactive(): Promise { const { data } = await apiClient.get('/admin/groups/all', { params: { include_inactive: true } }) return data } /** * Get active groups by platform * @param platform - Platform to filter by * @returns List of groups for the specified platform */ export async function getByPlatform(platform: GroupPlatform): Promise { return getAll(platform) } /** 获取当前 Sub2API 服务端的 Live 运行环境能力。 */ export async function getLiveCapability(): Promise { const { data } = await apiClient.get('/admin/groups/live-capability') return data } /** * Get group by ID * @param id - Group ID * @returns Group details */ export async function getById(id: number): Promise { const { data } = await apiClient.get(`/admin/groups/${id}`) return data } /** * Get candidate models for custom /v1/models list. * id=0 returns platform default models for create flow. */ export async function getModelsListCandidates( id: number, platform?: GroupPlatform ): Promise { const { data } = await apiClient.get<{ models: string[] }>( `/admin/groups/${id}/models-list-candidates`, { params: platform ? { platform } : undefined } ) return data.models || [] } /** * Create new group * @param groupData - Group data * @returns Created group */ export async function create(groupData: CreateGroupRequest): Promise { const { data } = await apiClient.post('/admin/groups', groupData) return data } /** * Duplicate a group on the server so configuration that is not present in the * list response is preserved. Keep the operation key after ambiguous failures * so a retry replays the original operation instead of creating another group. */ const duplicateOperationKeys = new Map() interface DuplicateOperationScope { adminID: string key: string } function getCurrentAdminID(): string | null { try { const rawUser = globalThis.localStorage?.getItem('auth_user') if (!rawUser) return null const user: unknown = JSON.parse(rawUser) if (typeof user !== 'object' || user === null) return null const id = (user as { id?: unknown }).id if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null return String(id) } catch { return null } } function duplicateOperationScope(id: number): DuplicateOperationScope | null { const adminID = getCurrentAdminID() if (!adminID) return null return { adminID, key: `sub2api:admin:group-duplicate:${adminID}:${id}` } } function getStoredDuplicateOperationKey(storageKey: string): string | null { try { return globalThis.sessionStorage?.getItem(storageKey) ?? null } catch { return null } } function storeDuplicateOperationKey(storageKey: string, key: string | null): void { try { if (key) globalThis.sessionStorage?.setItem(storageKey, key) else globalThis.sessionStorage?.removeItem(storageKey) } catch { // In-memory retry protection still works when browser storage is unavailable. } } export async function duplicate(id: number): Promise { const scope = duplicateOperationScope(id) let idempotencyKey = scope ? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key) : null if (!idempotencyKey) { const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}` } if (scope) { duplicateOperationKeys.set(scope.key, idempotencyKey) storeDuplicateOperationKey(scope.key, idempotencyKey) } const { data } = await apiClient.post(`/admin/groups/${id}/duplicate`, undefined, { headers: { 'Idempotency-Key': idempotencyKey } }) if (scope) { duplicateOperationKeys.delete(scope.key) storeDuplicateOperationKey(scope.key, null) } return data } /** * Update group * @param id - Group ID * @param updates - Fields to update * @returns Updated group */ export async function update(id: number, updates: UpdateGroupRequest): Promise { const { data } = await apiClient.put(`/admin/groups/${id}`, updates) return data } /** * Delete group * @param id - Group ID * @returns Success confirmation */ export async function deleteGroup(id: number): Promise<{ message: string }> { const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`) return data } /** * Toggle group status * @param id - Group ID * @param status - New status * @returns Updated group */ export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { return update(id, { status }) } /** * Get group statistics * @param id - Group ID * @returns Group usage statistics */ export async function getStats(id: number): Promise<{ total_api_keys: number active_api_keys: number total_requests: number total_cost: number }> { const { data } = await apiClient.get<{ total_api_keys: number active_api_keys: number total_requests: number total_cost: number }>(`/admin/groups/${id}/stats`) return data } /** * Get API keys in a group * @param id - Group ID * @param page - Page number * @param pageSize - Items per page * @returns Paginated list of API keys in the group */ export async function getGroupApiKeys( id: number, page: number = 1, pageSize: number = 20 ): Promise> { const { data } = await apiClient.get>(`/admin/groups/${id}/api-keys`, { params: { page, page_size: pageSize } }) return data } export async function listCompositeRoutes(id: number): Promise { const { data } = await apiClient.get(`/admin/groups/${id}/composite-routes`) return data } export async function createCompositeRoute( id: number, route: CompositeModelRouteInput ): Promise { const { data } = await apiClient.post( `/admin/groups/${id}/composite-routes`, route ) return data } export async function updateCompositeRoute( id: number, routeId: number, route: CompositeModelRouteInput ): Promise { const { data } = await apiClient.put( `/admin/groups/${id}/composite-routes/${routeId}`, route ) return data } export async function deleteCompositeRoute( id: number, routeId: number ): Promise<{ message: string }> { const { data } = await apiClient.delete<{ message: string }>( `/admin/groups/${id}/composite-routes/${routeId}` ) return data } export async function previewCompositeRoute( id: number, request: CompositeRoutePreviewRequest ): Promise { const { data } = await apiClient.post( `/admin/groups/${id}/composite-routes/preview`, request ) return data } /** * Rate multiplier entry for a user in a group */ export interface GroupRateMultiplierEntry { user_id: number user_name: string user_email: string user_notes: string user_status: string rate_multiplier?: number | null rpm_override?: number | null } /** * Get rate multipliers for users in a group * @param id - Group ID * @returns List of user rate multiplier entries */ export async function getGroupRateMultipliers(id: number): Promise { const { data } = await apiClient.get( `/admin/groups/${id}/rate-multipliers` ) return data } /** * Update group sort orders * @param updates - Array of { id, sort_order } objects * @returns Success confirmation */ export async function updateSortOrder( updates: Array<{ id: number; sort_order: number }> ): Promise<{ message: string }> { const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', { updates }) return data } /** * Clear all rate multipliers for a group * @param id - Group ID * @returns Success confirmation */ export async function clearGroupRateMultipliers(id: number): Promise<{ message: string }> { const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rate-multipliers`) return data } /** * Batch set rate multipliers for users in a group * Only touches rate_multiplier column; preserves rpm_override on existing rows. */ export async function batchSetGroupRateMultipliers( id: number, entries: Array<{ user_id: number; rate_multiplier: number }> ): Promise<{ message: string }> { const { data } = await apiClient.put<{ message: string }>( `/admin/groups/${id}/rate-multipliers`, { entries } ) return data } /** * RPM override entry for a user in a group */ export interface GroupRPMOverrideEntry { user_id: number user_name: string user_email: string user_notes: string user_status: string rpm_override: number } /** * Get RPM overrides for users in a group (subset of rate-multipliers endpoint). */ export async function getGroupRPMOverrides(id: number): Promise { const { data } = await apiClient.get( `/admin/groups/${id}/rate-multipliers` ) return data .filter(e => e.rpm_override != null) .map(e => ({ user_id: e.user_id, user_name: e.user_name, user_email: e.user_email, user_notes: e.user_notes, user_status: e.user_status, rpm_override: e.rpm_override as number })) } /** * Batch set RPM overrides for users in a group. * Only touches rpm_override column; preserves rate_multiplier on existing rows. */ export async function batchSetGroupRPMOverrides( id: number, entries: Array<{ user_id: number; rpm_override: number }> ): Promise<{ message: string }> { const { data } = await apiClient.put<{ message: string }>( `/admin/groups/${id}/rpm-overrides`, { entries } ) return data } /** * Clear all RPM overrides for a group (preserves rate_multiplier). */ export async function clearGroupRPMOverrides(id: number): Promise<{ message: string }> { const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rpm-overrides`) return data } /** * Get usage summary (today + yesterday + cumulative cost) for all groups * @returns Array of group usage summaries */ export async function getUsageSummary(): Promise< { group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[] > { const { data } = await apiClient.get< { group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[] >('/admin/groups/usage-summary') return data } /** * Get capacity summary (concurrency/sessions/RPM) for all active groups */ export async function getCapacitySummary(): Promise< { group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[] > { const { data } = await apiClient.get< { group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[] >('/admin/groups/capacity-summary') return data } export const groupsAPI = { list, getAll, getByPlatform, getAllIncludingInactive, getLiveCapability, getById, getModelsListCandidates, create, duplicate, update, delete: deleteGroup, toggleStatus, getStats, getGroupApiKeys, listCompositeRoutes, createCompositeRoute, updateCompositeRoute, deleteCompositeRoute, previewCompositeRoute, getGroupRateMultipliers, clearGroupRateMultipliers, batchSetGroupRateMultipliers, getGroupRPMOverrides, clearGroupRPMOverrides, batchSetGroupRPMOverrides, updateSortOrder, getUsageSummary, getCapacitySummary } export default groupsAPI