fix: restore security hardening compatibility and validation

Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
elky
2026-09-07 21:14:27 +08:00
parent a5c3699ae9
commit a90d564931
191 changed files with 6785 additions and 1643 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"test:ui": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs --ui",
"test:run": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
"lint": "eslint . --fix",
"type-check": "vue-tsc --noEmit",
"type-check": "vue-tsc -b --force --pretty false",
"version": "git describe --tags --always"
},
"dependencies": {
+17 -21
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import type { AxiosAdapter, InternalAxiosRequestConfig } from 'axios'
import apiClient, {
AUTH_SESSION_SIGNAL_KEY,
@@ -8,10 +8,6 @@ import apiClient, {
} from '@/api/client'
import { cache, cachedRequest } from '@/utils/cache'
type TestableApiClient = typeof apiClient & {
client: AxiosInstance
}
describe('apiClient auth state change event', () => {
beforeEach(() => {
localStorage.clear()
@@ -55,10 +51,10 @@ describe('apiClient auth state change event', () => {
})
it('restores a session through the refresh cookie and stores the result in memory only', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({
data: { access_token: 'restored-access-token' },
status: 200,
statusText: 'OK',
@@ -72,16 +68,16 @@ describe('apiClient auth state change event', () => {
expect(localStorage.getItem('access_token')).toBeNull()
expect(sessionStorage.getItem('access_token')).toBeNull()
} finally {
rawClient.client.defaults.adapter = previousAdapter
rawClient.defaults.adapter = previousAdapter
}
})
it('does not resurrect a session when logout wins an in-flight restore', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
let resolveRefresh!: (response: Awaited<ReturnType<AxiosAdapter>>) => void
rawClient.client.defaults.adapter = (() => new Promise((resolve) => {
rawClient.defaults.adapter = (() => new Promise((resolve) => {
resolveRefresh = resolve
})) as AxiosAdapter
@@ -100,7 +96,7 @@ describe('apiClient auth state change event', () => {
await expect(restore).rejects.toThrow('Auth state changed')
expect(apiClient.getToken()).toBeNull()
} finally {
rawClient.client.defaults.adapter = previousAdapter
rawClient.defaults.adapter = previousAdapter
}
})
@@ -141,11 +137,11 @@ describe('apiClient auth state change event', () => {
})
it('sends auth refresh without a request body', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
const requests: InternalAxiosRequestConfig[] = []
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
requests.push(config)
return {
data: { access_token: 'new-access-token' },
@@ -165,16 +161,16 @@ describe('apiClient auth state change event', () => {
expect(requests[0].method).toBe('post')
expect(requests[0].data).toBeUndefined()
} finally {
rawClient.client.defaults.adapter = previousAdapter
rawClient.defaults.adapter = previousAdapter
}
})
it('authenticates protected gateway operational requests', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
const rawClient = apiClient['client']
const previousAdapter = rawClient.defaults.adapter
const requests: InternalAxiosRequestConfig[] = []
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
requests.push(config)
return {
data: '',
@@ -193,7 +189,7 @@ describe('apiClient auth state change event', () => {
expect(requests[0].headers.Authorization).toBe('Bearer operational-access-token')
expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy()
} finally {
rawClient.client.defaults.adapter = previousAdapter
rawClient.defaults.adapter = previousAdapter
}
})
})
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { revealEndpointRules } from '../endpoints/endpoints'
const client = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../client', () => ({ default: client }))
beforeEach(() => {
client.get.mockReset().mockResolvedValue({ data: { header_rules: [], body_rules: [], response_header_rules: [] } })
})
describe('endpoint rule reveal API', () => {
it('uses the scoped route and forwards cancellation', async () => {
const controller = new AbortController()
await revealEndpointRules('endpoint/with?reserved', controller.signal)
expect(client.get).toHaveBeenCalledWith(
'/api/admin/endpoints/endpoint%2Fwith%3Freserved/rules/reveal',
{ signal: controller.signal },
)
})
it('fetches each reveal without retaining a cached response', async () => {
await revealEndpointRules('endpoint-1')
await revealEndpointRules('endpoint-1')
expect(client.get).toHaveBeenCalledTimes(2)
})
})
+27 -5
View File
@@ -215,7 +215,18 @@ export const adminWalletApi = {
credited_at: string | null
}
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/recharge`, payload)
const response = await apiClient.post<{
wallet: AdminWallet
payment_order: {
id: string
order_no: string
amount_usd: number
payment_method: string
status: string
created_at: string
credited_at: string | null
}
}>(`/api/admin/wallets/${walletId}/recharge`, payload)
return response.data
},
@@ -223,7 +234,10 @@ export const adminWalletApi = {
wallet: AdminWallet
transaction: WalletTransaction
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/adjust`, payload)
const response = await apiClient.post<{
wallet: AdminWallet
transaction: WalletTransaction
}>(`/api/admin/wallets/${walletId}/adjust`, payload)
return response.data
},
@@ -232,7 +246,11 @@ export const adminWalletApi = {
refund: RefundRequest
transaction: WalletTransaction
}> {
const response = await apiClient.post(
const response = await apiClient.post<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction
}>(
`/api/admin/wallets/${walletId}/refunds/${refundId}/process`,
{}
)
@@ -244,7 +262,11 @@ export const adminWalletApi = {
refund: RefundRequest
transaction: WalletTransaction | null
}> {
const response = await apiClient.post(
const response = await apiClient.post<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction | null
}>(
`/api/admin/wallets/${walletId}/refunds/${refundId}/fail`,
payload
)
@@ -256,7 +278,7 @@ export const adminWalletApi = {
refundId: string,
payload: RefundCompleteRequest
): Promise<{ refund: RefundRequest }> {
const response = await apiClient.post(
const response = await apiClient.post<{ refund: RefundRequest }>(
`/api/admin/wallets/${walletId}/refunds/${refundId}/complete`,
payload
)
+7 -2
View File
@@ -8,6 +8,11 @@ import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs'
export interface AdminTimeSeriesPoint extends Record<string, unknown> {
date: string
total_cost: number
}
export interface AdminSystemConfigItem {
key: string
value: unknown
@@ -1594,12 +1599,12 @@ export const adminApi = {
provider_name?: string
},
options?: AdminAnalyticsRequestOptions
): Promise<Array<Record<string, unknown>>> {
): Promise<AdminTimeSeriesPoint[]> {
const cacheKey = buildCacheKey('admin:stats:time-series', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
const response = await apiClient.get<AdminTimeSeriesPoint[]>('/api/admin/stats/time-series', { params })
return response.data
},
options?.skipCache ? 0 : 20 * 1000
+10 -10
View File
@@ -57,61 +57,61 @@ export const announcementApi = {
limit?: number
offset?: number
}): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements', { params })
const response = await apiClient.get<AnnouncementListResponse>('/api/announcements', { params })
return response.data
},
// 获取当前有效的公告
async getActiveAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/active')
const response = await apiClient.get<AnnouncementListResponse>('/api/announcements/active')
return response.data
},
// 获取单个公告
async getAnnouncement(id: string): Promise<Announcement> {
const response = await apiClient.get(`/api/announcements/${id}`)
const response = await apiClient.get<Announcement>(`/api/announcements/${id}`)
return response.data
},
// 标记公告为已读
async markAsRead(id: string): Promise<{ message: string }> {
const response = await apiClient.patch(`/api/announcements/${id}/read-status`)
const response = await apiClient.patch<{ message: string }>(`/api/announcements/${id}/read-status`)
return response.data
},
// 标记所有公告为已读
async markAllAsRead(): Promise<{ message: string }> {
const response = await apiClient.post('/api/announcements/read-all')
const response = await apiClient.post<{ message: string }>('/api/announcements/read-all')
return response.data
},
// 获取未读公告数量
async getUnreadCount(): Promise<{ unread_count: number }> {
const response = await apiClient.get('/api/announcements/users/me/unread-count')
const response = await apiClient.get<{ unread_count: number }>('/api/announcements/users/me/unread-count')
return response.data
},
async getRequiredUnreadAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/users/me/required-unread')
const response = await apiClient.get<AnnouncementListResponse>('/api/announcements/users/me/required-unread')
return response.data
},
// 管理员方法
// 创建公告
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
const response = await apiClient.post('/api/announcements', data)
const response = await apiClient.post<{ id: string; title: string; message: string }>('/api/announcements', data)
return response.data
},
// 更新公告
async updateAnnouncement(id: string, data: UpdateAnnouncementRequest): Promise<{ message: string }> {
const response = await apiClient.put(`/api/announcements/${id}`, data)
const response = await apiClient.put<{ message: string }>(`/api/announcements/${id}`, data)
return response.data
},
// 删除公告
async deleteAnnouncement(id: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/announcements/${id}`)
const response = await apiClient.delete<{ message: string }>(`/api/announcements/${id}`)
return response.data
}
}
+6 -6
View File
@@ -180,27 +180,27 @@ export const asyncTasksApi = {
const query = searchParams.toString()
const url = query ? `/api/admin/tasks?${query}` : '/api/admin/tasks'
const response = await apiClient.get(url)
const response = await apiClient.get<AsyncTaskListResponse>(url)
return response.data
},
async getStats(): Promise<AsyncTaskStatsResponse> {
const response = await apiClient.get('/api/admin/tasks/stats')
const response = await apiClient.get<AsyncTaskStatsResponse>('/api/admin/tasks/stats')
return response.data
},
async getDetail(taskId: string): Promise<AsyncTaskDetail> {
const response = await apiClient.get(`/api/admin/tasks/${taskId}`)
const response = await apiClient.get<AsyncTaskDetail>(`/api/admin/tasks/${taskId}`)
return response.data
},
async getEvents(taskId: string): Promise<{ items: AsyncTaskEvent[] }> {
const response = await apiClient.get(`/api/admin/tasks/${taskId}/events`)
const response = await apiClient.get<{ items: AsyncTaskEvent[] }>(`/api/admin/tasks/${taskId}/events`)
return response.data
},
async cancel(taskId: string): Promise<{ id: string; status: string; message: string }> {
const response = await apiClient.post(`/api/admin/tasks/${taskId}/cancel`)
const response = await apiClient.post<{ id: string; status: string; message: string }>(`/api/admin/tasks/${taskId}/cancel`)
return response.data
},
@@ -213,7 +213,7 @@ export const asyncTasksApi = {
},
async trigger(taskKey: string, payload: Record<string, unknown> = {}): Promise<{ run_id: string; status: string }> {
const response = await apiClient.post(`/api/admin/tasks/${taskKey}/trigger`, payload)
const response = await apiClient.post<{ run_id: string; status: string }>(`/api/admin/tasks/${taskKey}/trigger`, payload)
return response.data
},
}
+10 -4
View File
@@ -57,13 +57,13 @@ export const auditApi = {
limit?: number
offset?: number
}): Promise<AuditLogsResponse> {
const response = await apiClient.get('/api/monitoring/my-audit-logs', { params: filters })
const response = await apiClient.get<Record<string, unknown>>('/api/monitoring/my-audit-logs', { params: filters })
return normalizeAuditResponse(response.data)
},
// 获取所有审计日志 (管理员)
async getAuditLogs(filters?: AuditFilters): Promise<AuditLogsResponse> {
const response = await apiClient.get('/api/admin/monitoring/audit-logs', { params: filters })
const response = await apiClient.get<Record<string, unknown>>('/api/admin/monitoring/audit-logs', { params: filters })
return normalizeAuditResponse(response.data)
},
@@ -72,7 +72,10 @@ export const auditApi = {
activities: AuditLog[]
count: number
}> {
const response = await apiClient.get('/api/admin/monitoring/suspicious-activities', {
const response = await apiClient.get<{
activities: AuditLog[]
count: number
}>('/api/admin/monitoring/suspicious-activities', {
params: { hours, limit }
})
return response.data
@@ -83,7 +86,10 @@ export const auditApi = {
analysis: Record<string, unknown>
recommendations: string[]
}> {
const response = await apiClient.get(`/api/admin/monitoring/user-behavior/${userId}`, {
const response = await apiClient.get<{
analysis: Record<string, unknown>
recommendations: string[]
}>(`/api/admin/monitoring/user-behavior/${userId}`, {
params: { days }
})
return response.data
+21 -15
View File
@@ -89,7 +89,7 @@ export const cacheApi = {
* 获取缓存统计信息
*/
async getStats(): Promise<CacheStats> {
const response = await api.get('/api/admin/monitoring/cache/stats')
const response = await api.get<{ data: CacheStats }>('/api/admin/monitoring/cache/stats')
return response.data.data
},
@@ -97,7 +97,7 @@ export const cacheApi = {
* 获取缓存配置
*/
async getConfig(): Promise<CacheConfig> {
const response = await api.get('/api/admin/monitoring/cache/config')
const response = await api.get<{ data: CacheConfig }>('/api/admin/monitoring/cache/config')
return response.data.data
},
@@ -107,7 +107,7 @@ export const cacheApi = {
* @param userIdentifier 用户标识符,支持:用户名、邮箱、User UUID、API Key ID
*/
async getUserAffinity(userIdentifier: string): Promise<UserAffinity[] | null> {
const response = await api.get(`/api/admin/monitoring/cache/affinity/${userIdentifier}`)
const response = await api.get<{ status: string; affinities: UserAffinity[] }>(`/api/admin/monitoring/cache/affinity/${userIdentifier}`)
if (response.data.status === 'not_found') {
return null
}
@@ -151,7 +151,7 @@ export const cacheApi = {
* 清除所有缓存
*/
async clearAllCache(): Promise<{ count: number }> {
const response = await api.delete('/api/admin/monitoring/cache')
const response = await api.delete<{ count: number }>('/api/admin/monitoring/cache')
return response.data
},
@@ -159,7 +159,7 @@ export const cacheApi = {
* 清除指定Provider的所有缓存
*/
async clearProviderCache(providerId: string): Promise<{ count: number; provider_id: string }> {
const response = await api.delete(`/api/admin/monitoring/cache/providers/${providerId}`)
const response = await api.delete<{ count: number; provider_id: string }>(`/api/admin/monitoring/cache/providers/${providerId}`)
return response.data
},
@@ -167,7 +167,13 @@ export const cacheApi = {
* 获取缓存亲和性列表
*/
async listAffinities(keyword?: string): Promise<AffinityListResponse> {
const response = await api.get('/api/admin/monitoring/cache/affinities', {
const response = await api.get<{
data?: {
items?: UserAffinity[]
meta?: { total?: number }
matched_user_id?: string | null
}
}>('/api/admin/monitoring/cache/affinities', {
params: keyword ? { keyword } : undefined
})
const data = response.data.data ?? {}
@@ -212,7 +218,7 @@ export const redisCacheApi = {
* 获取 Redis 缓存分类概览
*/
async getCategories(): Promise<RedisCacheCategoriesResponse> {
const response = await api.get('/api/admin/monitoring/cache/redis-keys')
const response = await api.get<{ data: RedisCacheCategoriesResponse }>('/api/admin/monitoring/cache/redis-keys')
return response.data.data
},
@@ -220,7 +226,7 @@ export const redisCacheApi = {
* 清除指定分类的 Redis 缓存
*/
async clearCategory(category: string): Promise<{ status: string; message: string; category: string; deleted_count: number }> {
const response = await api.delete(`/api/admin/monitoring/cache/redis-keys/${category}`)
const response = await api.delete<{ status: string; message: string; category: string; deleted_count: number }>(`/api/admin/monitoring/cache/redis-keys/${category}`)
return response.data
}
}
@@ -308,7 +314,7 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): Promise<TTLAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
const response = await api.get<TTLAnalysisResponse>('/api/admin/usage/cache-affinity/ttl-analysis', { params })
return response.data
},
@@ -320,7 +326,7 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): Promise<CacheHitAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
const response = await api.get<CacheHitAnalysisResponse>('/api/admin/usage/cache-affinity/hit-analysis', { params })
return response.data
},
@@ -339,7 +345,7 @@ export const cacheAnalysisApi = {
return cachedRequest(
cacheKey,
async () => {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
const response = await api.get<IntervalTimelineResponse>('/api/admin/usage/cache-affinity/interval-timeline', { params })
return response.data
},
30000
@@ -408,7 +414,7 @@ export const modelMappingCacheApi = {
* 获取模型映射缓存统计
*/
async getStats(): Promise<ModelMappingCacheStats> {
const response = await api.get('/api/admin/monitoring/cache/model-mapping/stats')
const response = await api.get<{ data: ModelMappingCacheStats }>('/api/admin/monitoring/cache/model-mapping/stats')
return response.data.data
},
@@ -416,7 +422,7 @@ export const modelMappingCacheApi = {
* 清除所有模型映射缓存
*/
async clearAll(): Promise<ClearModelMappingCacheResponse> {
const response = await api.delete('/api/admin/monitoring/cache/model-mapping')
const response = await api.delete<ClearModelMappingCacheResponse>('/api/admin/monitoring/cache/model-mapping')
return response.data
},
@@ -424,7 +430,7 @@ export const modelMappingCacheApi = {
* 清除指定模型名称的映射缓存
*/
async clearByName(modelName: string): Promise<ClearModelMappingCacheResponse> {
const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/${encodeURIComponent(modelName)}`)
const response = await api.delete<ClearModelMappingCacheResponse>(`/api/admin/monitoring/cache/model-mapping/${encodeURIComponent(modelName)}`)
return response.data
},
@@ -432,7 +438,7 @@ export const modelMappingCacheApi = {
* 清除指定 Provider 和 GlobalModel 的映射缓存
*/
async clearProviderModel(providerId: string, globalModelId: string): Promise<ClearModelMappingCacheResponse> {
const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/provider/${providerId}/${globalModelId}`)
const response = await api.delete<ClearModelMappingCacheResponse>(`/api/admin/monitoring/cache/model-mapping/provider/${providerId}/${globalModelId}`)
return response.data
}
}
+1 -1
View File
@@ -224,7 +224,7 @@ class ApiClient {
/**
* 处理响应错误
*/
private async handleResponseError(error: unknown): Promise<never> {
private async handleResponseError(error: unknown): Promise<AxiosResponse> {
// 请求被取消
if (axios.isCancel(error)) {
return Promise.reject(error)
+8 -8
View File
@@ -278,14 +278,14 @@ export interface RequestDetail {
end_to_end_first_byte_time_ms?: number | null
created_at: string
updated_at?: string | null
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
request_headers?: Record<string, unknown> | null
request_body?: Record<string, unknown> | null
provider_request_headers?: Record<string, unknown> | null
provider_request_body?: Record<string, unknown> | null
response_headers?: Record<string, unknown> | null
client_response_headers?: Record<string, unknown> | null
response_body?: Record<string, unknown> | null
client_response_body?: Record<string, unknown> | null
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
+16 -4
View File
@@ -17,7 +17,13 @@ export async function toggleAdaptiveMode(
rpm_limit: number | null
effective_limit: number | null
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
const response = await client.patch<{
message: string
key_id: string
is_adaptive: boolean
rpm_limit: number | null
effective_limit: number | null
}>(`/api/admin/adaptive/keys/${keyId}/mode`, data)
return response.data
}
@@ -34,7 +40,13 @@ export async function setRpmLimit(
rpm_limit: number
previous_mode: string
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
const response = await client.patch<{
message: string
key_id: string
is_adaptive: boolean
rpm_limit: number
previous_mode: string
}>(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
params: { limit }
})
return response.data
@@ -44,7 +56,7 @@ export async function setRpmLimit(
* 获取 Key 的自适应统计
*/
export async function getAdaptiveStats(keyId: string): Promise<AdaptiveStatsResponse> {
const response = await client.get(`/api/admin/adaptive/keys/${keyId}/stats`)
const response = await client.get<AdaptiveStatsResponse>(`/api/admin/adaptive/keys/${keyId}/stats`)
return response.data
}
@@ -52,6 +64,6 @@ export async function getAdaptiveStats(keyId: string): Promise<AdaptiveStatsResp
* 重置 Key 的学习状态
*/
export async function resetAdaptiveLearning(keyId: string): Promise<{ message: string; key_id: string }> {
const response = await client.delete(`/api/admin/adaptive/keys/${keyId}/learning`)
const response = await client.delete<{ message: string; key_id: string }>(`/api/admin/adaptive/keys/${keyId}/learning`)
return response.data
}
+20 -6
View File
@@ -1,11 +1,25 @@
import client from '../client'
import type { ProviderEndpoint, ProxyConfig, HeaderRule, BodyRule, FormatAcceptanceConfig } from './types'
export interface ProviderEndpointRules {
header_rules: HeaderRule[]
body_rules: BodyRule[]
response_header_rules: HeaderRule[]
}
export async function revealEndpointRules(endpointId: string, signal?: AbortSignal): Promise<ProviderEndpointRules> {
const response = await client.get<ProviderEndpointRules>(
`/api/admin/endpoints/${encodeURIComponent(endpointId)}/rules/reveal`,
{ signal },
)
return response.data
}
/**
* 获取指定 Provider 的所有 Endpoints
*/
export async function getProviderEndpoints(providerId: string): Promise<ProviderEndpoint[]> {
const response = await client.get(`/api/admin/endpoints/providers/${providerId}/endpoints`)
const response = await client.get<ProviderEndpoint[]>(`/api/admin/endpoints/providers/${providerId}/endpoints`)
return response.data
}
@@ -13,7 +27,7 @@ export async function getProviderEndpoints(providerId: string): Promise<Provider
* 获取 Endpoint 详情
*/
export async function getEndpoint(endpointId: string): Promise<ProviderEndpoint> {
const response = await client.get(`/api/admin/endpoints/${endpointId}`)
const response = await client.get<ProviderEndpoint>(`/api/admin/endpoints/${endpointId}`)
return response.data
}
@@ -36,7 +50,7 @@ export async function createEndpoint(
format_acceptance_config?: FormatAcceptanceConfig | null
}
): Promise<ProviderEndpoint> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
const response = await client.post<ProviderEndpoint>(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
return response.data
}
@@ -57,7 +71,7 @@ export async function updateEndpoint(
format_acceptance_config: FormatAcceptanceConfig | null
}>
): Promise<ProviderEndpoint> {
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
const response = await client.put<ProviderEndpoint>(`/api/admin/endpoints/${endpointId}`, data)
return response.data
}
@@ -65,7 +79,7 @@ export async function updateEndpoint(
* 删除 Endpoint
*/
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; affected_keys_count: number }> {
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
const response = await client.delete<{ message: string; affected_keys_count: number }>(`/api/admin/endpoints/${endpointId}`)
return response.data
}
@@ -75,6 +89,6 @@ export async function deleteEndpoint(endpointId: string): Promise<{ message: str
export async function getDefaultBodyRules(apiFormat: string, providerType?: string): Promise<{ api_format: string; body_rules: BodyRule[] }> {
const params: Record<string, string> = {}
if (providerType) params.provider_type = providerType
const response = await client.get(`/api/admin/endpoints/defaults/${apiFormat}/body-rules`, { params })
const response = await client.get<{ api_format: string; body_rules: BodyRule[] }>(`/api/admin/endpoints/defaults/${apiFormat}/body-rules`, { params })
return response.data
}
+19 -8
View File
@@ -12,6 +12,7 @@ import type {
// 重新导出路由相关类型供外部使用
export type {
GlobalModelResponse,
RoutingKeyInfo,
RoutingEndpointInfo,
RoutingModelMapping,
@@ -37,7 +38,7 @@ export async function getGlobalModels(params?: {
return cachedRequest(
key,
async () => {
const response = await client.get('/api/admin/models/global', { params })
const response = await client.get<GlobalModelListResponse>('/api/admin/models/global', { params })
return response.data
},
cacheTtlMs,
@@ -49,7 +50,7 @@ export async function getGlobalModels(params?: {
*/
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
return dedupedRequest(`global-models:detail:${id}`, async () => {
const response = await client.get(`/api/admin/models/global/${id}`)
const response = await client.get<GlobalModelWithStats>(`/api/admin/models/global/${id}`)
return response.data
})
}
@@ -58,7 +59,7 @@ export async function getGlobalModel(id: string): Promise<GlobalModelWithStats>
* 创建 GlobalModel
*/
export async function createGlobalModel(data: GlobalModelCreate): Promise<GlobalModelResponse> {
const response = await client.post('/api/admin/models/global', data)
const response = await client.post<GlobalModelResponse>('/api/admin/models/global', data)
return response.data
}
@@ -69,7 +70,7 @@ export async function updateGlobalModel(
id: string,
data: GlobalModelUpdate
): Promise<GlobalModelResponse> {
const response = await client.patch(`/api/admin/models/global/${id}`, data)
const response = await client.patch<GlobalModelResponse>(`/api/admin/models/global/${id}`, data)
return response.data
}
@@ -89,7 +90,7 @@ export async function deleteGlobalModel(
export async function batchDeleteGlobalModels(
ids: string[]
): Promise<{ success_count: number; failed: Array<{ id: string; error: string }> }> {
const response = await client.post('/api/admin/models/global/batch-delete', { ids })
const response = await client.post<{ success_count: number; failed: Array<{ id: string; error: string }> }>('/api/admin/models/global/batch-delete', { ids })
return response.data
}
@@ -113,7 +114,17 @@ export async function batchAssignToProviders(
error: string
}>
}> {
const response = await client.post(
const response = await client.post<{
success: Array<{
provider_id: string
provider_name: string
model_id?: string
}>
errors: Array<{
provider_id: string
error: string
}>
}>(
`/api/admin/models/global/${globalModelId}/assign-to-providers`,
data
)
@@ -128,7 +139,7 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
total: number
}> {
return dedupedRequest(`global-models:providers:${globalModelId}`, async () => {
const response = await client.get(
const response = await client.get<{ providers: ModelCatalogProviderDetail[]; total: number }>(
`/api/admin/models/global/${globalModelId}/providers`
)
return response.data
@@ -141,7 +152,7 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
export async function getGlobalModelRoutingPreview(
globalModelId: string
): Promise<ModelRoutingPreviewResponse> {
const response = await client.get(
const response = await client.get<ModelRoutingPreviewResponse>(
`/api/admin/models/global/${globalModelId}/routing`
)
return response.data
+28 -12
View File
@@ -14,7 +14,7 @@ import type {
* 获取健康状态摘要
*/
export async function getHealthSummary(): Promise<HealthSummary> {
const response = await client.get('/api/admin/endpoints/health/summary')
const response = await client.get<HealthSummary>('/api/admin/endpoints/health/summary')
return response.data
}
@@ -22,7 +22,7 @@ export async function getHealthSummary(): Promise<HealthSummary> {
* 获取 Endpoint 健康状态
*/
export async function getEndpointHealth(endpointId: string): Promise<HealthStatus> {
const response = await client.get(`/api/admin/endpoints/health/endpoint/${endpointId}`)
const response = await client.get<HealthStatus>(`/api/admin/endpoints/health/endpoint/${endpointId}`)
return response.data
}
@@ -30,7 +30,7 @@ export async function getEndpointHealth(endpointId: string): Promise<HealthStatu
* 获取 Key 健康状态
*/
export async function getKeyHealth(keyId: string): Promise<HealthStatus> {
const response = await client.get(`/api/admin/endpoints/health/key/${keyId}`)
const response = await client.get<HealthStatus>(`/api/admin/endpoints/health/key/${keyId}`)
return response.data
}
@@ -48,7 +48,15 @@ export async function recoverKeyHealth(keyId: string, apiFormat?: string): Promi
is_active: boolean
}
}> {
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`, null, {
const response = await client.patch<{
message: string
details: {
api_format?: string
health_score: number
circuit_breaker_open: boolean
is_active: boolean
}
}>(`/api/admin/endpoints/health/keys/${keyId}`, null, {
params: apiFormat ? { api_format: apiFormat } : undefined
})
return response.data
@@ -66,7 +74,15 @@ export async function recoverAllKeysHealth(): Promise<{
endpoint_id: string
}>
}> {
const response = await client.patch('/api/admin/endpoints/health/keys')
const response = await client.patch<{
message: string
recovered_count: number
recovered_keys: Array<{
key_id: string
key_name: string
endpoint_id: string
}>
}>('/api/admin/endpoints/health/keys')
return response.data
}
@@ -77,7 +93,7 @@ export async function getEndpointStatusMonitor(params?: {
lookback_hours?: number
per_format_limit?: number
}): Promise<EndpointStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/api-formats', {
const response = await client.get<EndpointStatusMonitorResponse>('/api/admin/endpoints/health/api-formats', {
params
})
return response.data
@@ -90,7 +106,7 @@ export async function getPublicEndpointStatusMonitor(params?: {
lookback_hours?: number
per_format_limit?: number
}): Promise<PublicEndpointStatusMonitorResponse> {
const response = await client.get('/api/public/health/api-formats', {
const response = await client.get<PublicEndpointStatusMonitorResponse>('/api/public/health/api-formats', {
params
})
return response.data
@@ -104,7 +120,7 @@ export async function getModelStatusMonitor(params?: {
model_limit?: number
per_model_limit?: number
}): Promise<ModelStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/models', {
const response = await client.get<ModelStatusMonitorResponse>('/api/admin/endpoints/health/models', {
params
})
return response.data
@@ -118,7 +134,7 @@ export async function getPublicModelStatusMonitor(params?: {
model_limit?: number
per_model_limit?: number
}): Promise<ModelStatusMonitorResponse> {
const response = await client.get('/api/public/health/models', {
const response = await client.get<ModelStatusMonitorResponse>('/api/public/health/models', {
params
})
return response.data
@@ -133,7 +149,7 @@ export async function getProviderStatusMonitor(params?: {
per_provider_model_limit?: number
per_model_limit?: number
}): Promise<ProviderStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/providers', {
const response = await client.get<ProviderStatusMonitorResponse>('/api/admin/endpoints/health/providers', {
params
})
return response.data
@@ -146,7 +162,7 @@ export async function getHealthRelatedMonitor(params: {
related_limit?: number
per_item_limit?: number
}): Promise<HealthRelatedMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/related', {
const response = await client.get<HealthRelatedMonitorResponse>('/api/admin/endpoints/health/related', {
params
})
return response.data
@@ -159,7 +175,7 @@ export async function getPublicHealthRelatedMonitor(params: {
related_limit?: number
per_item_limit?: number
}): Promise<HealthRelatedMonitorResponse> {
const response = await client.get('/api/public/health/related', {
const response = await client.get<HealthRelatedMonitorResponse>('/api/public/health/related', {
params
})
return response.data
+18 -14
View File
@@ -37,7 +37,7 @@ export interface ModelCapabilitiesResponse {
* 获取所有能力定义
*/
export async function getAllCapabilities(): Promise<CapabilityDefinition[]> {
const response = await client.get('/api/capabilities')
const response = await client.get<{ capabilities: CapabilityDefinition[] }>('/api/capabilities')
return response.data.capabilities
}
@@ -45,7 +45,7 @@ export async function getAllCapabilities(): Promise<CapabilityDefinition[]> {
* 获取用户可配置的能力列表
*/
export async function getUserConfigurableCapabilities(): Promise<CapabilityDefinition[]> {
const response = await client.get('/api/capabilities/user-configurable')
const response = await client.get<{ capabilities: CapabilityDefinition[] }>('/api/capabilities/user-configurable')
return response.data.capabilities
}
@@ -53,7 +53,7 @@ export async function getUserConfigurableCapabilities(): Promise<CapabilityDefin
* 获取指定模型支持的能力列表
*/
export async function getModelCapabilities(modelName: string): Promise<ModelCapabilitiesResponse> {
const response = await client.get(`/api/capabilities/model/${encodeURIComponent(modelName)}`)
const response = await client.get<ModelCapabilitiesResponse>(`/api/capabilities/model/${encodeURIComponent(modelName)}`)
return response.data
}
@@ -68,7 +68,7 @@ export interface RevealKeyResult {
}
export async function revealEndpointKey(keyId: string): Promise<RevealKeyResult> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
const response = await client.get<RevealKeyResult>(`/api/admin/endpoints/keys/${keyId}/reveal`)
return response.data
}
@@ -76,7 +76,7 @@ export async function revealEndpointKey(keyId: string): Promise<RevealKeyResult>
* 导出 OAuth Key 凭据(扁平 JSON,用于跨实例迁移)
*/
export async function exportKey(keyId: string): Promise<Record<string, unknown>> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/export`)
const response = await client.get<Record<string, unknown>>(`/api/admin/endpoints/keys/${keyId}/export`)
return response.data
}
@@ -84,7 +84,7 @@ export async function exportKey(keyId: string): Promise<Record<string, unknown>>
* 删除 Key
*/
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
const response = await client.delete<{ message: string }>(`/api/admin/endpoints/keys/${keyId}`)
return response.data
}
@@ -98,7 +98,7 @@ export interface BatchDeleteKeysResult {
}
export async function batchDeleteEndpointKeys(ids: string[]): Promise<BatchDeleteKeysResult> {
const response = await client.post('/api/admin/endpoints/keys/batch-delete', { ids })
const response = await client.post<BatchDeleteKeysResult>('/api/admin/endpoints/keys/batch-delete', { ids })
return response.data
}
@@ -215,7 +215,7 @@ export async function addProviderKey(
model_exclude_patterns?: string[] // 模型排除规则
}
): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
const response = await client.post<EndpointAPIKey>(`/api/admin/endpoints/providers/${providerId}/keys`, data)
return response.data
}
@@ -251,7 +251,7 @@ export async function updateProviderKey(
}>,
requestOptions?: KeyRequestOptions,
): Promise<EndpointAPIKey> {
const response = await client.put(
const response = await client.put<EndpointAPIKey>(
`/api/admin/endpoints/keys/${keyId}`,
data,
requestOptions,
@@ -263,7 +263,7 @@ export async function updateProviderKey(
* 清除 Key 的 OAuth 失效标记
*/
export async function clearOAuthInvalid(keyId: string): Promise<{ message: string }> {
const response = await client.post(`/api/admin/endpoints/keys/${keyId}/clear-oauth-invalid`)
const response = await client.post<{ message: string }>(`/api/admin/endpoints/keys/${keyId}/clear-oauth-invalid`)
return response.data
}
@@ -275,7 +275,11 @@ export async function resetProviderKeyCycleStats(keyId: string): Promise<{
reset_at: number
windows: number
}> {
const response = await client.post(`/api/admin/endpoints/keys/${keyId}/reset-cycle-stats`)
const response = await client.post<{
message: string
reset_at: number
windows: number
}>(`/api/admin/endpoints/keys/${keyId}/reset-cycle-stats`)
return response.data
}
@@ -311,7 +315,7 @@ export async function refreshProviderQuota(
keyIds?: string[],
): Promise<RefreshQuotaResult> {
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
const response = await client.post(
const response = await client.post<RefreshQuotaResult>(
`/api/admin/endpoints/providers/${providerId}/refresh-quota`,
body,
{ timeout: 5 * 60 * 1000 },
@@ -350,7 +354,7 @@ export async function consumeCodexResetCredit(
keyId: string,
payload: ConsumeCodexResetCreditPayload,
): Promise<ConsumeCodexResetCreditResult> {
const response = await client.post(
const response = await client.post<ConsumeCodexResetCreditResult>(
`/api/admin/endpoints/keys/${keyId}/codex-reset-credit/consume`,
payload,
{ timeout: 5 * 60 * 1000 },
@@ -383,7 +387,7 @@ export async function batchImportOAuth(
credentials: string,
proxyNodeId?: string
): Promise<BatchImportResult> {
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
const response = await client.post<BatchImportResult>(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
credentials,
proxy_node_id: proxyNodeId || undefined,
})
+20 -10
View File
@@ -19,7 +19,7 @@ export async function getProviderModels(
limit?: number
}
): Promise<Model[]> {
const response = await client.get(`/api/admin/providers/${providerId}/models`, { params })
const response = await client.get<Model[]>(`/api/admin/providers/${providerId}/models`, { params })
return response.data
}
@@ -30,7 +30,7 @@ export async function createModel(
providerId: string,
data: ModelCreate
): Promise<Model> {
const response = await client.post(`/api/admin/providers/${providerId}/models`, data)
const response = await client.post<Model>(`/api/admin/providers/${providerId}/models`, data)
return response.data
}
@@ -41,7 +41,7 @@ export async function getModel(
providerId: string,
modelId: string
): Promise<Model> {
const response = await client.get(`/api/admin/providers/${providerId}/models/${modelId}`)
const response = await client.get<Model>(`/api/admin/providers/${providerId}/models/${modelId}`)
return response.data
}
@@ -53,7 +53,7 @@ export async function updateModel(
modelId: string,
data: ModelUpdate
): Promise<Model> {
const response = await client.patch(`/api/admin/providers/${providerId}/models/${modelId}`, data)
const response = await client.patch<Model>(`/api/admin/providers/${providerId}/models/${modelId}`, data)
return response.data
}
@@ -64,7 +64,7 @@ export async function deleteModel(
providerId: string,
modelId: string
): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/providers/${providerId}/models/${modelId}`)
const response = await client.delete<{ message: string }>(`/api/admin/providers/${providerId}/models/${modelId}`)
return response.data
}
@@ -75,7 +75,7 @@ export async function batchCreateModels(
providerId: string,
modelsData: ModelCreate[]
): Promise<Model[]> {
const response = await client.post(`/api/admin/providers/${providerId}/models/batch`, modelsData)
const response = await client.post<Model[]>(`/api/admin/providers/${providerId}/models/batch`, modelsData)
return response.data
}
@@ -83,7 +83,7 @@ export async function batchCreateModels(
* 获取统一模型目录
*/
export async function getModelCatalog(): Promise<ModelCatalogResponse> {
const response = await client.get('/api/admin/models/catalog')
const response = await client.get<ModelCatalogResponse>('/api/admin/models/catalog')
return response.data
}
@@ -93,7 +93,7 @@ export async function getModelCatalog(): Promise<ModelCatalogResponse> {
export async function getProviderAvailableSourceModels(
providerId: string
): Promise<ProviderAvailableSourceModelsResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/available-source-models`)
const response = await client.get<ProviderAvailableSourceModelsResponse>(`/api/admin/providers/${providerId}/available-source-models`)
return response.data
}
@@ -114,7 +114,17 @@ export async function batchAssignModelsToProvider(
error: string
}>
}> {
const response = await client.post(
const response = await client.post<{
success: Array<{
global_model_id: string
global_model_name: string
model_id: string
}>
errors: Array<{
global_model_id: string
error: string
}>
}>(
`/api/admin/providers/${providerId}/assign-global-models`,
{ global_model_ids: globalModelIds }
)
@@ -137,7 +147,7 @@ export async function importModelsFromUpstream(
price_per_request?: number
}
): Promise<ImportFromUpstreamResponse> {
const response = await client.post(
const response = await client.post<ImportFromUpstreamResponse>(
`/api/admin/providers/${providerId}/import-from-upstream`,
{
model_ids: modelIds,
+4 -3
View File
@@ -2,6 +2,7 @@ import client from '../client'
import { buildCacheKey, cachedRequest } from '@/utils/cache'
import type {
AllowedModels,
ProviderType,
OAuthOrganizationInfo,
ProxyConfig,
UpstreamMetadata,
@@ -77,7 +78,7 @@ export async function resetPoolCost(
export interface PoolOverviewItem {
provider_id: string
provider_name: string
provider_type: string
provider_type: ProviderType
total_keys: number
active_keys: number
cooldown_count: number
@@ -508,7 +509,7 @@ export async function batchActionPoolKeys(
providerId: string,
body: PoolBatchAction,
): Promise<{ affected: number; message: string; task_id?: string }> {
const response = await client.post(
const response = await client.post<{ affected: number; message: string; task_id?: string }>(
`/api/admin/pool/${providerId}/keys/batch-action`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
@@ -561,7 +562,7 @@ export async function getPoolBatchDeleteTask(
export async function cleanupBannedPoolKeys(
providerId: string,
): Promise<{ affected: number; message: string }> {
const response = await client.post(
const response = await client.post<{ affected: number; message: string }>(
`/api/admin/pool/${providerId}/keys/cleanup-banned`,
undefined,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
+11 -11
View File
@@ -230,14 +230,14 @@ export function normalizeBatchImportCredentials(text: string): BatchImportCreden
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
const resp = await client.post<ProviderOAuthCompleteResponse>(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
}
// Provider-level OAuth (不需要预先创建 key)
export async function startProviderLevelOAuth(providerId: string): Promise<ProviderOAuthStartResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/start`)
const resp = await client.post<ProviderOAuthStartResponse>(`/api/admin/provider-oauth/providers/${providerId}/start`)
return resp.data
}
@@ -245,7 +245,7 @@ export async function completeProviderLevelOAuth(
providerId: string,
data: ProviderOAuthCompleteRequest
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
const resp = await client.post<ProviderOAuthCompleteResponseWithKey>(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
return resp.data
}
@@ -253,7 +253,7 @@ export async function authorizeProviderWithCookie(
providerId: string,
data: ProviderCookieAuthorizeRequest
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(
const resp = await client.post<ProviderOAuthCompleteResponseWithKey>(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize`,
data,
{ timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS },
@@ -265,7 +265,7 @@ export async function startProviderCookieAuthorizeTask(
providerId: string,
data: ProviderCookieAuthorizeBatchTaskRequest,
): Promise<OAuthBatchImportTaskStartResponse> {
const resp = await client.post(
const resp = await client.post<OAuthBatchImportTaskStartResponse>(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks`,
data,
)
@@ -276,7 +276,7 @@ export async function getProviderCookieAuthorizeTaskStatus(
providerId: string,
taskId: string,
): Promise<OAuthBatchImportTaskStatusResponse> {
const resp = await client.get(
const resp = await client.get<OAuthBatchImportTaskStatusResponse>(
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks/${taskId}`,
)
return resp.data
@@ -313,7 +313,7 @@ export async function importProviderRefreshToken(
headers?: Record<string, string>
}
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
const resp = await client.post<ProviderOAuthCompleteResponseWithKey>(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data
}
@@ -325,7 +325,7 @@ export async function startBatchImportOAuthTask(
const route = containsAgentIdentityImport(credentials)
? 'agent-identity-import/tasks'
: 'batch-import/tasks'
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/${route}`, {
const resp = await client.post<OAuthBatchImportTaskStartResponse>(`/api/admin/provider-oauth/providers/${providerId}/${route}`, {
credentials,
proxy_node_id: proxyNodeId || undefined,
})
@@ -339,7 +339,7 @@ export async function getBatchImportOAuthTaskStatus(
const route = taskId.startsWith('agent-identity-')
? 'agent-identity-import/tasks'
: 'batch-import/tasks'
const resp = await client.get(`/api/admin/provider-oauth/providers/${providerId}/${route}/${taskId}`)
const resp = await client.get<OAuthBatchImportTaskStatusResponse>(`/api/admin/provider-oauth/providers/${providerId}/${route}/${taskId}`)
return resp.data
}
@@ -412,7 +412,7 @@ export async function startDeviceAuthorize(
providerId: string,
data: DeviceAuthorizeRequest
): Promise<DeviceAuthorizeResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-authorize`, data)
const resp = await client.post<DeviceAuthorizeResponse>(`/api/admin/provider-oauth/providers/${providerId}/device-authorize`, data)
return resp.data
}
@@ -420,6 +420,6 @@ export async function pollDeviceAuthorize(
providerId: string,
data: DevicePollRequest
): Promise<DevicePollResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-poll`, data)
const resp = await client.post<DevicePollResponse>(`/api/admin/provider-oauth/providers/${providerId}/device-poll`, data)
return resp.data
}
+5 -4
View File
@@ -14,6 +14,7 @@ import {
normalizePoolAdvancedConfig as normalizePoolAdvanced,
} from './types'
export type { ProviderWithEndpointsSummary } from './types'
interface ProviderRequestOptions {
timeout?: number
}
@@ -140,7 +141,7 @@ export async function updateProvider(
}>,
requestOptions?: ProviderRequestOptions,
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data, requestOptions)
const response = await client.patch<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}`, data, requestOptions)
return normalizeProviderSummary(response.data)
}
@@ -175,7 +176,7 @@ export async function createProvider(
config?: ProviderConfig | null
}
): Promise<{ id: string; name: string; message?: string }> {
const response = await client.post('/api/admin/providers/', data)
const response = await client.post<{ id: string; name: string; message?: string }>('/api/admin/providers/', data)
return response.data
}
@@ -261,7 +262,7 @@ export async function testModel(
data: TestModelRequest,
options: { signal?: AbortSignal } = {},
): Promise<TestModelResponse> {
const response = await client.post('/api/admin/provider-query/test-model', data, {
const response = await client.post<TestModelResponse>('/api/admin/provider-query/test-model', data, {
timeout: 10 * 60 * 1000,
signal: options.signal,
})
@@ -350,7 +351,7 @@ export async function testModelFailover(
const failoverModels = Array.isArray(data.failover_models) && data.failover_models.length > 0
? data.failover_models
: (normalizedModelName ? [normalizedModelName] : undefined)
const response = await client.post('/api/admin/provider-query/test-model-failover', {
const response = await client.post<TestModelFailoverResponse>('/api/admin/provider-query/test-model-failover', {
...data,
...(failoverModels ? { failover_models: failoverModels } : {}),
}, {
+27 -7
View File
@@ -1,5 +1,12 @@
import type { ProviderModelMapping } from './provider'
export interface ModelProviderReference {
id: string
model_id?: string | null
name: string
is_active: boolean
}
// ========== 阶梯计费类型 ==========
/** 缓存时长定价配置 */
@@ -67,13 +74,26 @@ export interface ProviderTieredPricingConfig {
[key: string]: unknown
}
export interface ModelConfig extends Record<string, unknown> {
description?: string
model_mappings?: string[]
api_formats?: string[]
billing?: {
video?: {
price_per_second_by_resolution?: Record<string, number>
[key: string]: unknown
}
[key: string]: unknown
}
}
export interface Model {
id: string
provider_id: string
global_model_id: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的主模型名称
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
config?: Record<string, unknown> | null // 额外配置(如 billing/video 等)
config?: ModelConfig | null // 额外配置(如 billing/video 等)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 原始覆盖,可仅包含 processing_tiers
@@ -102,7 +122,7 @@ export interface Model {
global_model_name?: string
global_model_display_name?: string
// 有效配置(合并 Model 和 GlobalModel 的 config)
effective_config?: Record<string, unknown> | null
effective_config?: ModelConfig | null
model_test_capabilities?: ModelTestCapabilities | null
}
@@ -120,7 +140,7 @@ export interface ModelCreate {
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
config?: Record<string, unknown>
config?: ModelConfig
}
export interface ModelUpdate {
@@ -136,7 +156,7 @@ export interface ModelUpdate {
supports_image_generation?: boolean
is_active?: boolean
is_available?: boolean
config?: Record<string, unknown> | null
config?: ModelConfig | null
}
export interface ModelCapabilities {
@@ -243,7 +263,7 @@ export interface GlobalModelCreate {
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[]
// 模型配置(JSON格式)- 包含能力、规格、元信息等
config?: Record<string, unknown>
config?: ModelConfig
is_active?: boolean
}
@@ -257,7 +277,7 @@ export interface GlobalModelUpdate {
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置(JSON格式)- 包含能力、规格、元信息等
config?: Record<string, unknown> | null
config?: ModelConfig | null
}
export interface GlobalModelResponse {
@@ -273,7 +293,7 @@ export interface GlobalModelResponse {
supported_capabilities?: string[] | null
supports_embedding?: boolean | null
// 模型配置(JSON格式)
config?: Record<string, unknown> | null
config?: ModelConfig | null
// 统计数据
provider_count?: number
active_provider_count?: number
+6 -6
View File
@@ -68,7 +68,7 @@ export const geminiFilesApi = {
* 获取文件映射统计
*/
async getStats(): Promise<FileMappingStatsResponse> {
const response = await apiClient.get('/api/admin/gemini-files/stats')
const response = await apiClient.get<FileMappingStatsResponse>('/api/admin/gemini-files/stats')
return response.data
},
@@ -76,7 +76,7 @@ export const geminiFilesApi = {
* 列出文件映射
*/
async listMappings(params?: ListMappingsParams): Promise<FileMappingListResponse> {
const response = await apiClient.get('/api/admin/gemini-files/mappings', { params })
const response = await apiClient.get<FileMappingListResponse>('/api/admin/gemini-files/mappings', { params })
return response.data
},
@@ -84,7 +84,7 @@ export const geminiFilesApi = {
* 删除指定映射
*/
async deleteMapping(mappingId: string): Promise<{ message: string; file_name: string }> {
const response = await apiClient.delete(`/api/admin/gemini-files/mappings/${mappingId}`)
const response = await apiClient.delete<{ message: string; file_name: string }>(`/api/admin/gemini-files/mappings/${mappingId}`)
return response.data
},
@@ -92,7 +92,7 @@ export const geminiFilesApi = {
* 清理过期映射
*/
async cleanupExpired(): Promise<{ message: string; deleted_count: number }> {
const response = await apiClient.delete('/api/admin/gemini-files/mappings')
const response = await apiClient.delete<{ message: string; deleted_count: number }>('/api/admin/gemini-files/mappings')
return response.data
},
@@ -100,7 +100,7 @@ export const geminiFilesApi = {
* 获取可用的 Key 列表
*/
async getCapableKeys(): Promise<CapableKeyResponse[]> {
const response = await apiClient.get('/api/admin/gemini-files/capable-keys')
const response = await apiClient.get<CapableKeyResponse[]>('/api/admin/gemini-files/capable-keys')
return response.data
},
@@ -110,7 +110,7 @@ export const geminiFilesApi = {
async uploadFile(file: File, keyIds: string[]): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
const response = await apiClient.post(
const response = await apiClient.post<UploadResponse>(
`/api/admin/gemini-files/upload?key_ids=${keyIds.join(',')}`,
formData,
{
+86 -16
View File
@@ -1,4 +1,5 @@
import apiClient from './client'
import type { ImageProgress } from './requestTrace'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
@@ -238,13 +239,13 @@ export const meApi = {
username?: string
feature_settings?: FeatureSettingsMap | null
}): Promise<{ message: string }> {
const response = await apiClient.put('/api/users/me', data)
const response = await apiClient.put<{ message: string }>('/api/users/me', data)
return response.data
},
// 修改密码
async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
const response = await apiClient.patch('/api/users/me/password', data)
const response = await apiClient.patch<{ message: string }>('/api/users/me/password', data)
return response.data
},
@@ -261,12 +262,12 @@ export const meApi = {
},
async revokeSession(sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`)
const response = await apiClient.delete<{ message: string }>(`/api/users/me/sessions/${sessionId}`)
return response.data
},
async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete('/api/users/me/sessions/others')
const response = await apiClient.delete<{ message: string; revoked_count: number }>('/api/users/me/sessions/others')
return response.data
},
@@ -303,7 +304,7 @@ export const meApi = {
},
async deleteApiKey(keyId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/api-keys/${keyId}`)
const response = await apiClient.delete<{ message: string }>(`/api/users/me/api-keys/${keyId}`)
return response.data
},
@@ -396,16 +397,61 @@ export const meApi = {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
image_progress?: ImageProgress | null
}>
}> {
const params = ids ? { ids } : {}
const response = await apiClient.get('/api/users/me/usage/active', { params })
const response = await apiClient.get<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
effective_input_tokens?: number | null
output_tokens: number
cache_creation_input_tokens?: number | null
cache_creation_ephemeral_5m_input_tokens?: number | null
cache_creation_ephemeral_1h_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
end_to_end_first_byte_time_ms?: number | null
updated_at?: string | null
response_time_updated_at?: string | null
status_code?: number | null
error_message?: string | null
api_format?: string | null
endpoint_api_format?: string | null
is_stream?: boolean | null
is_websocket?: boolean | null
websocket_transport?: string | null
usage_available?: boolean | null
usage_pricing_available?: boolean | null
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean | null
client_requested_stream?: boolean | null
client_is_stream?: boolean | null
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
image_progress?: ImageProgress | null
}>
}>('/api/users/me/usage/active', { params })
return response.data
},
// 获取可用的提供商
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get('/api/users/me/providers')
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/users/me/providers')
return response.data
},
@@ -429,24 +475,38 @@ export const meApi = {
}>
total: number
}> {
const response = await apiClient.get('/api/users/me/available-models', { params })
const response = await apiClient.get<{
models: Array<{
id: string
name: string
display_name: string | null
is_active: boolean
default_price_per_request: number | null
default_tiered_pricing: TieredPricingConfig | null
supported_capabilities: string[] | null
supports_embedding?: boolean | null
config: Record<string, unknown> | null
usage_count: number
}>
total: number
}>('/api/users/me/available-models', { params })
return response.data
},
// 获取端点状态(不包含敏感信息)
async getEndpointStatus(): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get('/api/users/me/endpoint-status')
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/users/me/endpoint-status')
return response.data
},
// 偏好设置
async getPreferences(): Promise<UserPreferences> {
const response = await apiClient.get('/api/users/me/preferences')
const response = await apiClient.get<UserPreferences>('/api/users/me/preferences')
return response.data
},
async updatePreferences(data: Partial<UserPreferences>): Promise<{ message: string }> {
const response = await apiClient.put('/api/users/me/preferences', data)
const response = await apiClient.put<{ message: string }>('/api/users/me/preferences', data)
return response.data
},
@@ -456,7 +516,7 @@ export const meApi = {
async updateApiKeyProviders(keyId: string, data: {
allowed_providers?: ProviderConfig[]
}): Promise<{ message: string }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
const response = await apiClient.put<{ message: string }>(`/api/users/me/api-keys/${keyId}/providers`, data)
return response.data
},
@@ -464,7 +524,7 @@ export const meApi = {
async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null
}): Promise<{ message: string; force_capabilities?: Record<string, boolean> | null }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/capabilities`, data)
const response = await apiClient.put<{ message: string; force_capabilities?: Record<string, boolean> | null }>(`/api/users/me/api-keys/${keyId}/capabilities`, data)
return response.data
},
@@ -472,7 +532,9 @@ export const meApi = {
async getModelCapabilitySettings(): Promise<{
model_capability_settings: Record<string, Record<string, boolean>>
}> {
const response = await apiClient.get('/api/users/me/model-capabilities')
const response = await apiClient.get<{
model_capability_settings: Record<string, Record<string, boolean>>
}>('/api/users/me/model-capabilities')
return response.data
},
@@ -482,7 +544,10 @@ export const meApi = {
message: string
model_capability_settings: Record<string, Record<string, boolean>> | null
}> {
const response = await apiClient.put('/api/users/me/model-capabilities', data)
const response = await apiClient.put<{
message: string
model_capability_settings: Record<string, Record<string, boolean>> | null
}>('/api/users/me/model-capabilities', data)
return response.data
},
@@ -500,7 +565,12 @@ export const meApi = {
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
const response = await apiClient.get<{
analysis_period_hours: number
total_points: number
points: Array<{ x: string; y: number; model?: string }>
models?: string[]
}>('/api/users/me/usage/interval-timeline', { params })
return response.data
},
30000
+64
View File
@@ -0,0 +1,64 @@
export interface CredentialsSchema {
type: 'object'
properties: Record<string, SchemaProperty>
required?: string[]
'x-field-groups'?: SchemaFieldGroup[]
'x-auth-type'?: string
'x-auth-method'?: string
'x-validation'?: SchemaValidation[]
'x-quota-divisor'?: number | null
'x-currency'?: string
'x-default-base-url'?: string
'x-balance-extra-format'?: BalanceExtraFormat[]
'x-field-hooks'?: Record<string, { action: string; target: string }>
}
export interface SchemaProperty {
type: string
title?: string
description?: string
'x-sensitive'?: boolean
'x-input-type'?: string
'x-default-value'?: string
'x-help'?: string
}
export interface SchemaFieldGroup {
fields: string[]
layout?: 'inline' | 'vertical'
'x-flex'?: Record<string, number>
'x-help'?: string
}
interface SchemaValidation {
type: 'required' | 'any_required' | 'conditional_required'
fields?: string[]
message: string
/** conditional_required: 当此字段有值时 */
if?: string
/** conditional_required: 除非此字段有值 */
unless?: string
/** conditional_required: 则这些字段必填 */
then?: string[]
}
export interface BalanceExtraFormat {
label: string
type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry'
/** window_limit: extra 中的字段名 */
source?: string
/** window_limit: 单位除数 */
unit_divisor?: number
/** daily_quota / weekly_spent: limit 字段名 */
source_limit?: string
/** daily_quota: remaining 字段名 */
source_remaining?: string
/** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */
source_start_date?: string
/** weekly_spent: spent 字段名 */
source_spent?: string
/** weekly_spent: resets_at 字段名 */
source_resets_at?: string
/** monthly_expiry: 到期日期字段名 */
source_end_date?: string
}
+3 -2
View File
@@ -8,6 +8,7 @@
*/
import client from './client'
import type { CredentialsSchema } from './providerCredentials'
// ==================== Types ====================
@@ -46,11 +47,11 @@ export interface ArchitectureInfo {
architecture_id: string
display_name: string
description: string
credentials_schema: Record<string, unknown>
credentials_schema: CredentialsSchema
supported_auth_types: Array<{
type: string
display_name: string
credentials_schema?: Record<string, unknown>
credentials_schema?: CredentialsSchema
}>
supported_actions: Array<{
type: string
+1 -1
View File
@@ -36,6 +36,6 @@ export async function getPublicGlobalModels(params?: {
is_active?: boolean
search?: string
}): Promise<PublicGlobalModelListResponse> {
const response = await client.get('/api/public/global-models', { params })
const response = await client.get<PublicGlobalModelListResponse>('/api/public/global-models', { params })
return response.data
}
+4 -4
View File
@@ -87,7 +87,7 @@ export const referralApi = {
async getAdminReferrals(
params: ReferralRelationshipQuery = {}
): Promise<ReferralListResponse<ReferralRelationshipRecord>> {
const response = await apiClient.get('/api/admin/referrals', {
const response = await apiClient.get<ReferralListResponse<ReferralRelationshipRecord>>('/api/admin/referrals', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
@@ -96,19 +96,19 @@ export const referralApi = {
async getAdminReferralRewards(
params: ReferralRewardQuery = {}
): Promise<ReferralListResponse<ReferralRewardRecord>> {
const response = await apiClient.get('/api/admin/referral-rewards', {
const response = await apiClient.get<ReferralListResponse<ReferralRewardRecord>>('/api/admin/referral-rewards', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note })
const response = await apiClient.post<{ reward: ReferralRewardRecord }>(`/api/admin/referral-rewards/${id}/retry`, { note })
return response.data
},
async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note })
const response = await apiClient.post<{ reward: ReferralRewardRecord }>(`/api/admin/referral-rewards/${id}/void`, { note })
return response.data
}
}
+42
View File
@@ -31,6 +31,32 @@ export interface CandidateResponseBoundary {
body_state?: string | null
}
export interface CandidateProxyTiming {
body_read_ms?: number | null
decompress_ms?: number | null
wire_size?: number | null
body_size?: number | null
ttfb_ms?: number | null
upstream_ms?: number | null
response_wait_ms?: number | null
connection_acquire_ms?: number | null
upstream_processing_ms?: number | null
connect_ms?: number | null
tls_ms?: number | null
dns_ms?: number | null
total_ms?: number | null
connection_reused?: boolean | null
}
export interface CandidateProxy {
node_name?: string | null
node_id?: string | null
url?: string | null
source?: string | null
ttfb_ms?: number | null
timing?: CandidateProxyTiming | null
}
export interface CandidateRecord {
id: string
request_id: string
@@ -70,6 +96,22 @@ export interface CandidateRecord {
ranking?: CandidateRankingMetadata | null
image_progress?: ImageProgress | null
extra_data?: Record<string, unknown> & {
first_byte_time_ms?: number | null
needs_conversion?: boolean
provider_api_format?: string | null
proxy?: CandidateProxy | null
pool_selection?: {
reason: string
cost_soft_threshold?: number | boolean | null
cost_window_usage?: number | null
cost_limit?: number | null
} | null
pool_skip?: {
type: string
cooldown_reason?: string | null
cooldown_ttl?: number | null
cost_window_usage?: number | null
} | null
upstream_response?: CandidateResponseBoundary
image_progress?: ImageProgress | null
}
+3 -3
View File
@@ -59,7 +59,7 @@ export const blacklistApi = {
* 获取黑名单统计
*/
async getStats(): Promise<BlacklistStats> {
const response = await apiClient.get('/api/admin/security/ip/blacklist/stats')
const response = await apiClient.get<BlacklistStats>('/api/admin/security/ip/blacklist/stats')
return response.data
},
@@ -67,7 +67,7 @@ export const blacklistApi = {
* 获取黑名单列表
*/
async getList(): Promise<BlacklistResponse> {
const response = await apiClient.get('/api/admin/security/ip/blacklist')
const response = await apiClient.get<BlacklistResponse>('/api/admin/security/ip/blacklist')
return response.data
}
}
@@ -96,7 +96,7 @@ export const whitelistApi = {
* 获取白名单列表
*/
async getList(): Promise<WhitelistResponse> {
const response = await apiClient.get('/api/admin/security/ip/whitelist')
const response = await apiClient.get<WhitelistResponse>('/api/admin/security/ip/whitelist')
return response.data
}
}
+64 -3
View File
@@ -2,6 +2,7 @@ import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
import type { ImageProgress } from './requestTrace'
import type { UsageRecord as UsageListRecord } from './usageRecords'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
@@ -58,6 +59,12 @@ export interface UsageStats {
avg_response_time: number
error_count?: number
error_rate?: number
cache_stats?: {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost: number
cache_read_cost: number
}
today?: {
requests: number
tokens: number
@@ -67,6 +74,7 @@ export interface UsageStats {
}
export interface UsageByModel {
actual_cost?: number
model: string
request_count: number
total_tokens: number
@@ -511,7 +519,7 @@ export const usageApi = {
limit?: number
offset?: number
}): Promise<{
records: Array<Record<string, unknown>>
records: UsageListRecord[]
total: number
limit: number
offset: number
@@ -519,7 +527,13 @@ export const usageApi = {
}> {
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => {
const response = await apiClient.get('/api/admin/usage/records', { params })
const response = await apiClient.get<{
records: UsageListRecord[]
total: number
limit: number
offset: number
total_is_estimated?: boolean
}>('/api/admin/usage/records', { params })
return response.data
})
},
@@ -630,7 +644,54 @@ export const usageApi = {
if (typeof timeRange?.tz_offset_minutes === 'number') {
params.tz_offset_minutes = timeRange.tz_offset_minutes
}
const response = await apiClient.get('/api/admin/usage/active', { params })
const response = await apiClient.get<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
effective_input_tokens?: number | null
output_tokens: number
cache_creation_input_tokens?: number | null
cache_creation_ephemeral_5m_input_tokens?: number | null
cache_creation_ephemeral_1h_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
end_to_end_time_ms?: number | null
end_to_end_first_byte_time_ms?: number | null
updated_at?: string | null
response_time_updated_at?: string | null
status_code?: number | null
error_message?: string | null
provider?: string | null
api_key_name?: string | null
provider_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
is_stream?: boolean | null
is_websocket?: boolean | null
websocket_transport?: string | null
usage_available?: boolean | null
usage_pricing_available?: boolean | null
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean | null
client_requested_stream?: boolean | null
client_is_stream?: boolean | null
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
image_progress?: ImageProgress | null
}>
}>('/api/admin/usage/active', { params })
return response.data
},
+69
View File
@@ -0,0 +1,69 @@
import type { ImageProgress } from './requestTrace'
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
export interface UsageRecord {
id: string
user_id?: string
username?: string
user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
provider_key_name?: string | null
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
input_tokens: number
effective_input_tokens?: number
output_tokens: number
reasoning_tokens?: number
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number
cache_creation_ephemeral_1h_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost: number
actual_cost?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时
end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时
is_stream: boolean
is_websocket?: boolean
websocket_transport?: string | null
usage_available?: boolean
usage_pricing_available?: boolean
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
created_at: string
updated_at?: string | null
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
image_progress?: ImageProgress | null
}
+1 -1
View File
@@ -60,7 +60,7 @@ export interface User {
export interface CreateUserRequest {
username: string
password: string
email: string
email?: string
role?: UserRole
initial_gift_usd?: number | null
unlimited?: boolean
+17 -4
View File
@@ -223,7 +223,10 @@ export const walletApi = {
order: PaymentOrder
payment_instructions: Record<string, unknown>
}> {
const response = await apiClient.post('/api/wallet/recharge', payload)
const response = await apiClient.post<{
order: PaymentOrder
payment_instructions: Record<string, unknown>
}>('/api/wallet/recharge', payload)
return response.data
},
@@ -238,12 +241,17 @@ export const walletApi = {
limit: number
offset: number
}> {
const response = await apiClient.get('/api/wallet/recharge', { params })
const response = await apiClient.get<{
items: PaymentOrder[]
total: number
limit: number
offset: number
}>('/api/wallet/recharge', { params })
return response.data
},
async getRechargeOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.get(`/api/wallet/recharge/${orderId}`)
const response = await apiClient.get<{ order: PaymentOrder }>(`/api/wallet/recharge/${orderId}`)
return response.data
},
@@ -253,7 +261,12 @@ export const walletApi = {
limit: number
offset: number
}> {
const response = await apiClient.get('/api/wallet/refunds', { params })
const response = await apiClient.get<{
items: RefundRequest[]
total: number
limit: number
offset: number
}>('/api/wallet/refunds', { params })
return response.data
},
+13 -13
View File
@@ -46,6 +46,7 @@
</template>
<script setup lang="ts">
import type { TimeScatterChartData, TimeScatterPoint } from './types'
import { getI18nLocale, useI18n } from '@/i18n'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import {
@@ -57,7 +58,6 @@ import {
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions,
type Plugin,
type Scale
@@ -84,7 +84,7 @@ ChartJS.register(
)
interface Props {
data: ChartData<'scatter'>
data: TimeScatterChartData
options?: ChartOptions<'scatter'>
height?: number
compressGaps?: boolean
@@ -117,13 +117,13 @@ interface GapInfo {
const chartRef = ref<HTMLCanvasElement>()
const { locale, t } = useI18n()
let chart: ChartJS<'scatter'> | null = null
let chart: ChartJS<'scatter', TimeScatterPoint[]> | null = null
const crosshairY = ref<number | null>(null)
const gapInfoList = ref<GapInfo[]>([])
interface PreparedRenderData {
chartData: ChartData<'scatter'>
chartData: TimeScatterChartData
gaps: GapInfo[]
}
@@ -141,7 +141,7 @@ const crosshairStats = computed<CrosshairStats | null>(() => {
let dsTotal = 0
for (const point of dataset.data) {
const p = point as { x: string; y: number }
const p = point
if (typeof p.y === 'number') {
dsTotal++
totalCount++
@@ -205,8 +205,8 @@ function toRealValue(displayValue: number): number {
}
// 压缩时间间隙的数据转换
function compressTimeGaps(data: ChartData<'scatter'>): {
data: ChartData<'scatter'>
function compressTimeGaps(data: TimeScatterChartData): {
data: TimeScatterChartData
gaps: GapInfo[]
timeMapping: Map<number, number> // 原始时间 -> 压缩后时间
} {
@@ -216,7 +216,7 @@ function compressTimeGaps(data: ChartData<'scatter'>): {
// 收集所有数据点的时间戳并排序
const allTimestamps: number[] = []
for (const dataset of data.datasets) {
for (const point of dataset.data as Array<{ x: string; y: number }>) {
for (const point of dataset.data) {
allTimestamps.push(new Date(point.x).getTime())
}
}
@@ -268,11 +268,11 @@ function compressTimeGaps(data: ChartData<'scatter'>): {
}
// 转换数据
const compressedData: ChartData<'scatter'> = {
const compressedData: TimeScatterChartData = {
...data,
datasets: data.datasets.map(dataset => ({
...dataset,
data: (dataset.data as Array<{ x: string; y: number }>).map(point => {
data: (dataset.data).map(point => {
const originalTs = new Date(point.x).getTime()
const compressedTs = timeMapping.get(originalTs) ?? originalTs
return {
@@ -288,12 +288,12 @@ function compressTimeGaps(data: ChartData<'scatter'>): {
}
// 转换数据点的 Y 值
function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
function transformData(data: TimeScatterChartData): TimeScatterChartData {
return {
...data,
datasets: data.datasets.map(dataset => ({
...dataset,
data: (dataset.data as Array<{ x: string; y: number; _originalX?: string; _originalY?: number }>).map(point => ({
data: (dataset.data).map(point => ({
...point,
y: toDisplayValue(Math.min(point.y, 120)),
_originalY: point._originalY ?? point.y // 保存原始值用于 tooltip
@@ -547,7 +547,7 @@ function createChart() {
const { chartData, gaps } = prepareRenderData()
gapInfoList.value = gaps
chart = new ChartJS(chartRef.value, {
chart = new ChartJS<'scatter', TimeScatterPoint[]>(chartRef.value, {
type: 'scatter',
data: chartData,
options: {
+10
View File
@@ -0,0 +1,10 @@
import type { ChartData } from 'chart.js'
export interface TimeScatterPoint {
x: string | number
y: number
_originalX?: string | number
_originalY?: number
}
export type TimeScatterChartData = ChartData<'scatter', TimeScatterPoint[]>
@@ -21,7 +21,7 @@
: 'border-border hover:border-muted-foreground/40',
showManualInput ? 'opacity-0 pointer-events-none' : 'opacity-100',
]"
:inert="showManualInput ? '' : undefined"
:inert="showManualInput ? true : undefined"
:aria-hidden="showManualInput"
data-testid="json-import-file-panel"
@click="fileInputRef?.click()"
@@ -47,7 +47,7 @@
<div
class="space-y-1.5 transition-opacity duration-150"
:class="showManualInput ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="showManualInput ? undefined : ''"
:inert="showManualInput ? undefined : true"
:aria-hidden="!showManualInput"
data-testid="json-import-manual-panel"
>
@@ -30,7 +30,7 @@ describe('JsonImportInput', () => {
expect(filePanel?.getAttribute('aria-hidden')).toBe('false')
expect(filePanel?.hasAttribute('inert')).toBe(false)
expect(manualPanel?.getAttribute('aria-hidden')).toBe('true')
expect(manualPanel?.getAttribute('inert')).toBe('')
expect(manualPanel?.hasAttribute('inert')).toBe(true)
root.querySelector<HTMLButtonElement>('[data-testid="json-import-mode-toggle"]')?.click()
await nextTick()
@@ -38,7 +38,7 @@ describe('JsonImportInput', () => {
expect(root.querySelector('[data-testid="json-import-file-panel"]')).toBe(filePanel)
expect(root.querySelector('[data-testid="json-import-manual-panel"]')).toBe(manualPanel)
expect(filePanel?.getAttribute('aria-hidden')).toBe('true')
expect(filePanel?.getAttribute('inert')).toBe('')
expect(filePanel?.hasAttribute('inert')).toBe(true)
expect(manualPanel?.getAttribute('aria-hidden')).toBe('false')
expect(manualPanel?.hasAttribute('inert')).toBe(false)
@@ -53,7 +53,7 @@ describe('JsonImportInput', () => {
await nextTick()
expect(value.value).toBe('')
expect(filePanel?.hasAttribute('inert')).toBe(false)
expect(manualPanel?.getAttribute('inert')).toBe('')
expect(manualPanel?.hasAttribute('inert')).toBe(true)
app.unmount()
root.remove()
@@ -91,7 +91,7 @@ const chartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: number) => `${value}s`
callback: (value: string | number) => `${value}s`
}
}
}
+2 -1
View File
@@ -10,6 +10,7 @@
</template>
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
@@ -17,7 +18,7 @@ interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
disabled?: boolean
class?: string
class?: ClassValue
type?: 'button' | 'submit' | 'reset'
}
+3 -2
View File
@@ -59,6 +59,7 @@
</template>
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { computed, useAttrs, ref } from 'vue'
import { Eye, EyeOff } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
@@ -70,8 +71,8 @@ const emit = defineEmits<{
}>()
interface Props {
modelValue?: string | number
class?: string
modelValue?: string | number | null
class?: ClassValue
autocomplete?: string
/**
* 输入框尺寸
+2 -1
View File
@@ -5,11 +5,12 @@
</template>
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
class?: ClassValue
}
const props = defineProps<Props>()
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { SelectTrigger as SelectTriggerPrimitive } from 'radix-vue'
import { ChevronDown } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
class?: ClassValue
disabled?: boolean
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { SelectRoot as SelectRootPrimitive } from 'radix-vue'
interface Props {
defaultValue?: string
modelValue?: string
modelValue?: string | null
open?: boolean
defaultOpen?: boolean
dir?: 'ltr' | 'rtl'
@@ -35,7 +35,7 @@ const openProp = computed(() =>
// modelValue 未传入时不绑定,让 radix-vue 走 uncontrolled 模式
const modelValueProp = computed(() =>
props.modelValue !== undefined ? { modelValue: props.modelValue } : {}
props.modelValue !== undefined ? { modelValue: props.modelValue ?? '' } : {}
)
</script>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useSlots } from 'vue'
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter } from 'lucide-vue-next'
@@ -9,7 +10,7 @@ import TableHead from './table-head.vue'
type SortDirection = 'asc' | 'desc'
const props = withDefaults(defineProps<{
class?: string
class?: ClassValue
columnKey?: string
sortable?: boolean
activeKey?: string | null
+2 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
class?: ClassValue
}
const props = defineProps<Props>()
+2 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
class?: ClassValue
}
const props = defineProps<Props>()
+2 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
class?: ClassValue
}
const props = defineProps<Props>()
+3 -2
View File
@@ -13,12 +13,13 @@
</template>
<script setup lang="ts">
import type { ClassValue } from 'clsx'
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
import { useI18n } from '@/i18n'
interface Props {
class?: string
class?: ClassValue
}
const props = defineProps<Props>()
@@ -41,7 +42,7 @@ const activeTab = inject<Ref<string>>('activeTab')
// 检查是否有 grid 类(由外部传入)
const hasGridClass = computed(() => {
return props.class?.includes('grid')
return cn(props.class).includes('grid')
})
const listClass = computed(() => {
+1 -1
View File
@@ -64,7 +64,7 @@ export function useEscapeKey(
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'SELECT' ||
activeElement.contentEditable === 'true' ||
(activeElement instanceof HTMLElement && activeElement.isContentEditable) ||
activeElement.getAttribute('role') === 'textbox' ||
activeElement.getAttribute('role') === 'combobox'
)
+2 -2
View File
@@ -1,3 +1,4 @@
import type { TimeScatterChartData } from '@/components/charts/types'
/**
* TTL 分析 composable
* 封装缓存亲和性 TTL 分析相关的状态和逻辑
@@ -10,7 +11,6 @@ import {
type CacheHitAnalysisResponse,
type IntervalTimelineResponse
} from '@/api/cache'
import type { ChartData } from 'chart.js'
import { log } from '@/utils/logger'
// 时间范围选项
@@ -162,7 +162,7 @@ export function useTTLAnalysis() {
}
// 用户时间线散点图数据
const userTimelineChartData = computed<ChartData<'scatter'>>(() => {
const userTimelineChartData = computed<TimeScatterChartData>(() => {
if (!userTimelineData.value || userTimelineData.value.points.length === 0) {
return { datasets: [] }
}
@@ -163,6 +163,7 @@ async function execute(action: string): Promise<string> {
if (!turnstile || !container || !turnstile.execute) {
throw new Error('Turnstile unavailable')
}
const execute = turnstile.execute.bind(turnstile)
clearWidget()
@@ -191,7 +192,7 @@ async function execute(action: string): Promise<string> {
},
})
widgetId.value = id
turnstile.execute(id)
execute(id)
})
}
@@ -1189,7 +1189,7 @@ function setEmbeddingEnabled(enabled: boolean) {
setConfigField('embedding', undefined)
if (form.value.config?.model_type === 'embedding') setConfigField('model_type', undefined)
if (Array.isArray(form.value.config?.api_formats)
&& form.value.config.api_formats.every((format) => embeddingApiFormats.includes(String(format)))) {
&& form.value.config.api_formats.every((format) => embeddingApiFormats.some((supportedFormat) => supportedFormat === String(format)))) {
setConfigField('api_formats', undefined)
}
}
@@ -567,7 +567,7 @@ import { getGlobalModelRoutingPreview } from '@/api/global-models'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
import type { TieredPricingConfig, PricingTier, ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import type { ModelProviderReference, TieredPricingConfig, PricingTier, ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import type { RoutingProviderInfo } from '@/api/global-models'
const props = withDefaults(defineProps<Props>(), {
@@ -578,9 +578,9 @@ const emit = defineEmits<{
'editModel': [model: GlobalModelResponse]
'toggleModelStatus': [model: GlobalModelResponse]
'addProvider': []
'editProvider': [provider: Record<string, unknown>]
'deleteProvider': [provider: Record<string, unknown>]
'toggleProviderStatus': [provider: Record<string, unknown>]
'editProvider': [provider: ModelProviderReference]
'deleteProvider': [provider: ModelProviderReference]
'toggleProviderStatus': [provider: ModelProviderReference]
'refreshModel': []
'linkProvider': [providerId: string]
'linkProviders': [providerIds: string[]]
@@ -246,8 +246,8 @@ const activeTierKey = ref('')
const processingTierEntries = computed<ProcessingTierEntry[]>(() => {
const processingTiers = props.pricing?.processing_tiers
if (!isRecord(processingTiers)) return []
const labels = new Map(KNOWN_PROCESSING_TIERS.map(entry => [entry.key, entry.label]))
const order = new Map(KNOWN_PROCESSING_TIERS.map((entry, index) => [entry.key, index]))
const labels = new Map<string, string>(KNOWN_PROCESSING_TIERS.map(entry => [entry.key, entry.label]))
const order = new Map<string, number>(KNOWN_PROCESSING_TIERS.map((entry, index) => [entry.key, index]))
return Object.entries(processingTiers)
.filter((entry): entry is [string, ProcessingTierPricingConfig] => (
isRecord(entry[1]) && processingPricingHasFacts(entry[1])
@@ -728,8 +728,8 @@ const activeProcessingTierUsesMultiplier = computed(() => (
const compactProcessingTierOptions = computed<CompactProcessingTierOption[]>(() => (
COMPACT_PROCESSING_TIERS.map(option => ({
...option,
accessibleLabel: [option.group, option.label, 'detail' in option ? option.detail : null]
.filter((part): part is string => Boolean(part))
accessibleLabel: ['group' in option ? option.group : null, option.label, 'detail' in option ? option.detail : null]
.filter((part) => Boolean(part))
.join(' · '),
...(processingTierMultiplierDrafts[option.key] ?? {
enabled: false,
@@ -1335,7 +1335,7 @@ function processingTierDisplayLabel(key: string): string {
'group' in compactTier ? compactTier.group : null,
compactTier.label,
'detail' in compactTier ? compactTier.detail : null,
].filter((part): part is string => Boolean(part)).join(' · ')
].filter((part) => Boolean(part)).join(' · ')
}
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
}
@@ -744,7 +744,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
const result = await batchActionPoolKeys(props.providerId, {
key_ids: batch,
action: selectedAction.value as 'enable' | 'disable' | 'clear_proxy' | 'set_proxy' | 'update_settings',
...(payload ? { payload } : {}),
...(payload ? { payload: { ...payload } } : {}),
})
successCount += result.affected
} catch (err) {
@@ -708,7 +708,7 @@ async function handleSave() {
try {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const latestAdvanced = latestProvider.pool_advanced
const existingPoolAdvanced = mergePoolAdvancedPatch(latestAdvanced, {})
const latestScoreRules = typeof existingPoolAdvanced.score_rules === 'object'
&& existingPoolAdvanced.score_rules !== null
@@ -192,7 +192,7 @@ const chartWidth = 640
const chartHeight = 220
const yTickRatios = [0, 0.25, 0.5, 0.75, 1]
const latest = computed(() => props.samples.at(-1) ?? null)
const latest = computed(() => props.samples[props.samples.length - 1] ?? null)
const maxChartValue = computed(() => {
const maxValue = props.samples.reduce((max, sample) => {
@@ -108,7 +108,7 @@ function metricForGroup(
const cycleMetricRows = computed(() => {
const smallGroup = props.cycleGroups.length > 1 ? props.cycleGroups[0] : undefined
const largeGroup = props.cycleGroups.at(-1)
const largeGroup = props.cycleGroups[props.cycleGroups.length - 1]
if (!largeGroup) return []
return CYCLE_METRIC_KEYS.map((key) => {
@@ -92,7 +92,7 @@
:title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)"
@click="triggerAction(action.event)"
>
<component
:is="action.icon"
@@ -253,7 +253,7 @@
:title="action.title"
@pointerenter="action.key === 'viewProvider' && emit('prefetchProvider')"
@focus="action.key === 'viewProvider' && emit('prefetchProvider')"
@click="emit(action.event)"
@click="triggerAction(action.event)"
>
<component
:is="action.icon"
@@ -413,6 +413,17 @@ const emit = defineEmits<{
refresh: []
}>()
function triggerAction(event: HeaderActionEvent) {
const handlers = {
import: () => emit('import'),
scheduling: () => emit('scheduling'),
viewProvider: () => emit('viewProvider'),
demandMetrics: () => emit('demandMetrics'),
advanced: () => emit('advanced'),
}
handlers[event]()
}
const { legacyT } = useI18n()
const providerModel = computed({
@@ -880,7 +880,7 @@ async function handleSave() {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const latestAdvanced = latestProvider.pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
})
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
buildPoolManagementQueryPatch,
readPoolManagementViewState,
resolvePoolManagementPageAfterLoad,
@@ -71,8 +72,9 @@ describe('poolManagementState', () => {
})
it('falls back to storage when query is missing', () => {
writePoolManagementViewState(
{
storage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify({
providerId: 'provider-c',
search: 'stored only',
status: 'active',
@@ -81,8 +83,7 @@ describe('poolManagementState', () => {
sortBy: 'last_used_at',
sortOrder: 'asc',
statsMode: 'account_total',
},
storage,
}),
)
const state = readPoolManagementViewState({}, storage)
@@ -23,7 +23,8 @@ export function mergePoolKeyQuotaSnapshots(
...(quotaSnapshot ? {
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
oauth: key.status_snapshot?.oauth ?? { code: 'none' },
account: key.status_snapshot?.account ?? { code: 'unknown', blocked: false },
quota: quotaSnapshot,
},
} : {}),
@@ -17,70 +17,8 @@ import { executeFieldHook } from './field-hooks'
// ==================== 类型定义 ====================
/** 后端 credentials_schema 的类型 */
export interface CredentialsSchema {
type: 'object'
properties: Record<string, SchemaProperty>
required?: string[]
'x-field-groups'?: SchemaFieldGroup[]
'x-auth-type'?: string
'x-auth-method'?: string
'x-validation'?: SchemaValidation[]
'x-quota-divisor'?: number | null
'x-currency'?: string
'x-default-base-url'?: string
'x-balance-extra-format'?: BalanceExtraFormat[]
'x-field-hooks'?: Record<string, { action: string; target: string }>
}
interface SchemaProperty {
type: string
title?: string
description?: string
'x-sensitive'?: boolean
'x-input-type'?: string
'x-default-value'?: string
'x-help'?: string
}
interface SchemaFieldGroup {
fields: string[]
layout?: 'inline' | 'vertical'
'x-flex'?: Record<string, number>
'x-help'?: string
}
interface SchemaValidation {
type: 'required' | 'any_required' | 'conditional_required'
fields?: string[]
message: string
/** conditional_required: 当此字段有值时 */
if?: string
/** conditional_required: 除非此字段有值 */
unless?: string
/** conditional_required: 则这些字段必填 */
then?: string[]
}
interface BalanceExtraFormat {
label: string
type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry'
/** window_limit: extra 中的字段名 */
source?: string
/** window_limit: 单位除数 */
unit_divisor?: number
/** daily_quota / weekly_spent: limit 字段名 */
source_limit?: string
/** daily_quota: remaining 字段名 */
source_remaining?: string
/** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */
source_start_date?: string
/** weekly_spent: spent 字段名 */
source_spent?: string
/** weekly_spent: resets_at 字段名 */
source_resets_at?: string
/** monthly_expiry: 到期日期字段名 */
source_end_date?: string
}
export type { CredentialsSchema } from '@/api/providerCredentials'
import type { CredentialsSchema, BalanceExtraFormat, SchemaProperty, SchemaFieldGroup } from '@/api/providerCredentials'
// ==================== Schema -> 表单字段 ====================
@@ -105,7 +105,9 @@ export function buildProxyConfig(formData: Record<string, unknown>): { proxy_nod
if (!formData.proxy_enabled || !formData.proxy_node_id) {
return {}
}
return { proxy_node_id: formData.proxy_node_id }
return typeof formData.proxy_node_id === 'string'
? { proxy_node_id: formData.proxy_node_id }
: {}
}
/**
@@ -205,7 +205,7 @@
v-if="!isEndpointConfigReadOnly"
v-model:open="endpointRulesExpanded[endpoint.id]"
>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<!-- 有规则时显示可折叠的触发器 -->
<CollapsibleTrigger
v-if="getTotalRulesCount(endpoint) > 0"
@@ -236,7 +236,18 @@
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<div class="flex w-full flex-wrap items-center justify-end gap-1 sm:w-auto">
<Button
v-if="getTotalRulesCount(endpoint) > 0"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="legacyT('查看已保存规则的原值')"
@click="revealRulesEndpointId = endpoint.id"
>
<Eye class="w-3 h-3 mr-1" />
{{ legacyT('查看原值') }}
</Button>
<Button
v-if="hasRulePanelChanges(endpoint)"
variant="ghost"
@@ -1016,6 +1027,12 @@
</template>
</Dialog>
<EndpointRulesRevealDialog
:model-value="modelValue && revealRulesEndpointId !== null"
:endpoint-id="revealRulesEndpointId"
@update:model-value="revealRulesEndpointId = null"
/>
<!-- 删除端点确认弹窗 -->
<AlertDialog
:model-value="deleteConfirmOpen"
@@ -1052,13 +1069,14 @@ import {
PopoverTrigger,
PopoverContent,
} from '@/components/ui'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft } from 'lucide-vue-next'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft, Eye } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import { useI18n } from '@/i18n'
import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import EndpointRulesRevealDialog from './EndpointRulesRevealDialog.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from './endpoint-default-paths'
import {
@@ -1444,6 +1462,7 @@ function handleBodyRuleDragEnd(endpointId: string) {
// 状态
const addingEndpoint = ref(false)
const savingEndpointId = ref<string | null>(null)
const revealRulesEndpointId = ref<string | null>(null)
const resettingDefaultRulesEndpointId = ref<string | null>(null)
const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
@@ -1544,7 +1563,7 @@ function validateJsonCondition(rule: Record<string, unknown>, label: string, ind
if (!isJsonObject(raw)) return formatJsonRuleError(label, index, 'condition 必须是对象')
const shapeError = validateJsonConditionShape(raw, formatJsonRuleFieldLabel(label, index, 'condition'))
if (shapeError) return shapeError
const editable = conditionToEditable(raw as BodyRule['condition'])
const editable = conditionToEditable(raw as NonNullable<BodyRule['condition']> & Record<string, unknown>)
const err = validateEditableCondition(editable)
return err ? formatJsonRuleError(label, index, err) : null
}
@@ -3307,7 +3326,8 @@ onMounted(() => {
})
// 监听 props 变化
watch(() => props.modelValue, (open) => {
watch(() => [props.modelValue, props.provider?.id] as const, ([open]) => {
revealRulesEndpointId.value = null
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
responseRuleSelectOpen.value = {}
@@ -3338,6 +3358,8 @@ watch(() => props.modelValue, (open) => {
} else {
// 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
endpointEditStates.value = {}
localEndpoints.value = []
}
}, { immediate: true })
@@ -3416,7 +3438,16 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
const submittedState = JSON.stringify(state)
const submittedJsonDraft = endpointRulesJsonDraft.value[endpoint.id]
const updatedEndpoint = await updateEndpoint(endpoint.id, payload)
if (endpointEditStates.value[endpoint.id] === state) {
localEndpoints.value = localEndpoints.value.map(current => current.id === endpoint.id ? updatedEndpoint : current)
if (JSON.stringify(state) === submittedState
&& endpointRulesJsonDraft.value[endpoint.id] === submittedJsonDraft) {
resetEndpointChanges(updatedEndpoint)
}
}
success(legacyT('端点已更新'))
emit('endpointUpdated')
} catch (error: unknown) {
@@ -0,0 +1,89 @@
<template>
<Dialog
:model-value="modelValue"
:title="legacyT('查看规则原值')"
size="2xl"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">
{{ legacyT('仅展示已保存的规则,可能包含密钥;不会覆盖未保存的编辑。关闭后清除明文。') }}
</p>
<div
v-if="loading"
role="status"
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
{{ legacyT('加载中...') }}
</div>
<p
v-else-if="failed"
role="alert"
class="text-sm text-destructive"
>
{{ legacyT('加载规则失败,请关闭后重试') }}
</p>
<Textarea
v-else-if="rulesJson"
:model-value="rulesJson"
:aria-label="legacyT('原始规则 JSON')"
readonly
spellcheck="false"
class="min-h-[320px] font-mono text-xs leading-relaxed"
/>
</div>
<template #footer>
<Button
variant="outline"
@click="emit('update:modelValue', false)"
>
{{ legacyT('关闭') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { Button, Dialog, Textarea } from '@/components/ui'
import { revealEndpointRules } from '@/api/endpoints'
import { useI18n } from '@/i18n'
const props = defineProps<{
modelValue: boolean
endpointId: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { legacyT } = useI18n()
const rulesJson = ref('')
const loading = ref(false)
const failed = ref(false)
watch(() => [props.modelValue, props.endpointId] as const, async ([open, endpointId], _previous, onCleanup) => {
rulesJson.value = ''
failed.value = false
loading.value = false
if (!open || !endpointId) return
const controller = new AbortController()
onCleanup(() => {
controller.abort()
rulesJson.value = ''
})
loading.value = true
try {
const rules = await revealEndpointRules(endpointId, controller.signal)
if (!controller.signal.aborted) {
rulesJson.value = JSON.stringify(rules, null, 2)
}
} catch {
if (!controller.signal.aborted) failed.value = true
} finally {
if (!controller.signal.aborted) loading.value = false
}
}, { immediate: true })
</script>
@@ -239,15 +239,15 @@ async function loadRelated() {
related_limit: 8,
per_item_limit: 100
}
if (!props.isAdmin && target.source.kind === 'provider') {
const dimension = target.source.kind
if (!props.isAdmin && dimension === 'provider') {
throw new Error('公开健康监控不支持 provider 详情')
}
const data = props.isAdmin
? await getHealthRelatedMonitor(params)
: await getPublicHealthRelatedMonitor({
...params,
dimension: target.source.kind
})
: dimension === 'provider'
? null
: await getPublicHealthRelatedMonitor({ ...params, dimension })
if (seq === requestSeq) {
related.value = data
}
@@ -117,7 +117,7 @@
<div
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'oauth' ? '' : undefined"
:inert="mode !== 'oauth' ? true : undefined"
:aria-hidden="mode !== 'oauth'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
@@ -612,7 +612,7 @@
v-if="isClaudeCodeProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'cookie' ? '' : undefined"
:inert="mode !== 'cookie' ? true : undefined"
:aria-hidden="mode !== 'cookie'"
>
<label
@@ -648,7 +648,7 @@
<div
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'import' ? '' : undefined"
:inert="mode !== 'import' ? true : undefined"
:aria-hidden="mode !== 'import'"
>
<div
@@ -780,7 +780,7 @@
v-if="isCodexProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'agent_identity' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'agent_identity' ? '' : undefined"
:inert="mode !== 'agent_identity' ? true : undefined"
:aria-hidden="mode !== 'agent_identity'"
>
<Textarea
@@ -94,7 +94,7 @@
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">启用代理</span>
<Switch
:model-value="formData[group.toggleKey] || false"
:model-value="formData[group.toggleKey] === true"
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
/>
</div>
@@ -107,7 +107,7 @@
>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
:model-value="formData.proxy_node_id || ''"
:model-value="stringFieldValue('proxy_node_id')"
trigger-class="h-8"
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
/>
@@ -146,7 +146,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -156,7 +156,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -182,7 +182,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -192,7 +192,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -201,7 +201,7 @@
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
@update:model-value="handleFieldChange(field.key, $event)"
>
<SelectTrigger>
@@ -221,7 +221,7 @@
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.placeholder"
rows="3"
@update:model-value="handleFieldChange(field.key, $event)"
@@ -417,6 +417,11 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
function stringFieldValue(key: string): string {
const value = formData.value[key]
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
@@ -439,11 +444,11 @@ const currentSchema = computed<CredentialsSchema | null>(() => {
if (selectedAuthType.value && arch.supported_auth_types.length > 1) {
const authType = arch.supported_auth_types.find((t) => t.type === selectedAuthType.value)
if (authType?.credentials_schema) {
return authType.credentials_schema as CredentialsSchema
return authType.credentials_schema
}
}
return (arch?.credentials_schema as CredentialsSchema) ?? null
return arch?.credentials_schema ?? null
})
// 表单是否可以验证(必填字段已填写)
@@ -464,7 +469,7 @@ const canVerify = computed(() => {
const error = validateFromSchema(schema, dataToValidate)
if (error) return false
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
return !!effectiveBaseUrl
})
@@ -503,6 +508,7 @@ function handleAuthTypeChange() {
}
function handleFieldChange(fieldKey: string, value: unknown) {
formData.value[fieldKey] = value
formChanged.value = true
// 执行 schema 定义的字段钩子
@@ -533,7 +539,7 @@ function resetFormData() {
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
data[key] = prop['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
@@ -575,7 +581,7 @@ async function handleVerify() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -672,7 +678,7 @@ async function handleSave() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -129,7 +129,7 @@
<ProviderKeyIdentityBlock
:api-key="key"
:masked-secret-label="getProviderMaskedSecretLabel(key, provider.provider_type)"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type ?? '') : null"
:oauth-plan-class="key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : ''"
:oauth-org-badge="getOAuthOrgBadge(key)"
:kiro-subscription-label="shouldShowKiroSubscriptionBadge(key) ? getKiroSubscriptionBadgeLabel(key) : null"
@@ -153,7 +153,7 @@
</div>
<ProviderKeyActionCluster
:api-key="key"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
:recoverable="isKeyRecoverable(key)"
:recover-title="getRecoverKeyTitle(key)"
:circuit-breaker-title="getKeyCircuitBreakerTitle(key)"
@@ -161,7 +161,7 @@
:health-score-bar-class="getHealthScoreBarColor(key.health_score || 0)"
:health-score-text-class="getHealthScoreColor(key.health_score || 0)"
:proxy-popover-open="proxyPopoverOpenKeyId === key.id"
:proxy-node-name="getKeyProxyNodeName(key)"
:proxy-node-name="getKeyProxyNodeName(key) ?? undefined"
:saving-proxy="savingProxyKeyId === key.id"
:toggling="togglingKeyId === key.id"
@recover="handleRecoverKey(key)"
@@ -847,7 +847,7 @@
v-if="open && oauthAccountDialogOpen && provider"
:open="oauthAccountDialogOpen"
:provider-id="provider.id"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
@close="oauthAccountDialogOpen = false"
@saved="handleKeyChanged"
/>
@@ -910,10 +910,10 @@
<AntigravityQuotaDialog
v-if="antigravityQuotaDialogKey"
:open="antigravityQuotaDialogOpen"
:metadata="antigravityQuotaDialogKey.upstream_metadata"
:metadata="antigravityQuotaDialogKey.upstream_metadata ?? null"
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
:key-name="antigravityQuotaDialogKey.name || legacyT('未命名密钥')"
:provider-id="providerId"
:provider-id="providerId ?? undefined"
:key-id="antigravityQuotaDialogKey.id"
@update:open="antigravityQuotaDialogOpen = $event"
/>
@@ -1554,7 +1554,7 @@ async function downloadRefreshToken(key: EndpointAPIKey) {
try {
const data = await exportKey(key.id)
const providerType = provider.value?.provider_type || 'unknown'
const safeName = (data.email || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const safeName = ((typeof data.email === 'string' && data.email) || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -1693,7 +1693,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
title: legacyT('清除账号异常标记'),
message: formatClearOAuthInvalidConfirmMessage(key),
confirmText: legacyT('确认清除'),
variant: 'default',
variant: 'info',
})
if (!confirmed) return
@@ -2474,7 +2474,7 @@ function isKiroBannedKey(key: EndpointAPIKey): boolean {
}
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
function formatBanTimestamp(timestamp: number | undefined): string {
function formatBanTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString(getI18nLocale(), {
@@ -2516,7 +2516,7 @@ function formatKiroUsage(value: number | undefined): string {
}
// 格式化 Kiro 重置时间
function formatKiroResetTime(timestamp: number | undefined): string {
function formatKiroResetTime(timestamp: number | null | undefined): string {
if (!timestamp) return ''
// timestamp 可能是毫秒或秒,需要判断
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000
@@ -2568,7 +2568,7 @@ function shouldShowKiroSubscriptionBadge(key: EndpointAPIKey): boolean {
const kiroLabel = getKiroSubscriptionBadgeLabel(key)
if (!kiroLabel) return false
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type)
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type ?? '')
if (!oauthPlanLabel) return true
return oauthPlanLabel.trim().toLowerCase() !== kiroLabel.trim().toLowerCase()
@@ -2887,6 +2887,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
}
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
const providerId = props.providerId
if (!providerId) return
antigravityQuotaDialogKey.value = key
antigravityQuotaDialogOpen.value = true
@@ -2895,7 +2897,8 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
if (refreshingQuota.value) return
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
const result = await refreshProviderQuota(providerId)
if (providerId !== props.providerId) return
applyQuotaResults(result.results)
// 更新弹窗引用的 key 数据
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
@@ -535,7 +535,7 @@ function buildSettingsPayload(
cache_ttl_minutes: source.cache_ttl_minutes,
max_probe_interval_minutes: source.max_probe_interval_minutes,
is_active: source.is_active,
note: source.note.trim() || null,
note: source.note?.trim() || null,
...((source.proxy_node_id || includeEmptyProxy)
? { proxy_node_id: source.proxy_node_id || null }
: {}),
@@ -658,4 +658,4 @@ async function submitImport(): Promise<void> {
importing.value = false
}
}
</script>
</script>
@@ -98,7 +98,7 @@
<div class="space-y-1.5">
<Label class="text-xs">代理节点</Label>
<ProxyNodeSelect
:model-value="settings.proxy_node_id"
:model-value="settings.proxy_node_id ?? ''"
trigger-class="h-10"
@update:model-value="updateSetting('proxy_node_id', $event)"
/>
@@ -106,7 +106,7 @@
<div class="space-y-1.5">
<Label class="text-xs">备注</Label>
<Input
:model-value="settings.note"
:model-value="settings.note ?? ''"
class="h-10"
placeholder="可选"
@update:model-value="updateSetting('note', String($event))"
@@ -186,4 +186,4 @@ function updateSetting<Key extends keyof ImportSettings>(
): void {
emit('update:settings', { ...props.settings, [key]: value })
}
</script>
</script>
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, ref, type App, type ComponentPublicInstance } from 'vue'
import EndpointFormDialog from '../EndpointFormDialog.vue'
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
const api = vi.hoisted(() => ({
createEndpoint: vi.fn(),
getDefaultBodyRules: vi.fn().mockResolvedValue({ body_rules: [] }),
updateEndpoint: vi.fn(),
deleteEndpoint: vi.fn(),
}))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/api/admin', () => ({ adminApi: { getApiFormats: vi.fn().mockResolvedValue({ formats: [] }) } }))
vi.mock('@/composables/useToast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() }) }))
vi.mock('@/stores/proxy-nodes', () => ({ useProxyNodesStore: () => ({ nodes: [], ensureLoaded: vi.fn() }) }))
vi.mock('../ProxyNodeSelect.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointConditionEditor.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointRulesRevealDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common/AlertDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = defineComponent({
inheritAttrs: false,
setup: (_props, { slots }) => () => h('div', [slots.default?.(), slots.footer?.()]),
})
return Object.fromEntries([
'Dialog', 'Button', 'Input', 'Textarea', 'Label', 'Badge', 'Select', 'SelectTrigger',
'SelectValue', 'SelectContent', 'SelectItem', 'Switch', 'Collapsible', 'CollapsibleTrigger',
'CollapsibleContent', 'Popover', 'PopoverTrigger', 'PopoverContent',
].map(name => [name, passthrough]))
})
interface DialogState {
localEndpoints: ProviderEndpoint[]
endpointEditStates: Record<string, unknown>
endpointRulesJsonDirty: Record<string, boolean>
endpointRulesJsonDraft: Record<string, string>
enterEndpointRulesJsonMode: (endpoint: ProviderEndpoint) => void
updateEndpointRulesJsonDraft: (endpointId: string, value: string) => void
getEndpointEditRules: (endpointId: string) => Array<{ value: string, retainValue: boolean }>
updateEndpointRuleField: (endpointId: string, index: number, field: 'value', value: string) => void
hasRulePanelChanges: (endpoint: ProviderEndpoint) => boolean
saveEndpoint: (endpoint: ProviderEndpoint) => Promise<void>
}
const endpoint: ProviderEndpoint = {
id: 'endpoint-1', provider_id: 'provider-1', provider_name: 'Custom', api_format: 'openai:chat',
base_url: 'https://example.test', is_active: true,
header_rules: [{ action: 'set', key: 'x-auth', value: '***', has_value: true }],
body_rules: [], config: {}, max_retries: 0, total_keys: 0, active_keys: 0,
created_at: '2026-09-07T00:00:00Z', updated_at: '2026-09-07T00:00:00Z',
}
const mounted: Array<{ app: App, root: HTMLElement }> = []
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
async function mountDialog() {
const props = reactive({
modelValue: true,
provider: { id: 'provider-1', provider_type: 'custom', name: 'Custom' } as ProviderWithEndpointsSummary,
endpoints: [structuredClone(endpoint)],
})
const component = ref<ComponentPublicInstance>()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(EndpointFormDialog, { ...props, ref: component }) }))
app.mount(root)
mounted.push({ app, root })
await settle()
const { setupState: state } = component.value!.$ as unknown as { setupState: DialogState }
return { props, state }
}
beforeEach(() => {
api.updateEndpoint.mockReset().mockResolvedValue(structuredClone(endpoint))
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule saving', () => {
it('resets dirty state from the saved projection and preserves the new secret marker', async () => {
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'replacement-secret')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
await state.saveEndpoint(state.localEndpoints[0])
await settle()
expect(api.updateEndpoint).toHaveBeenCalledWith(endpoint.id, {
header_rules: [{ action: 'set', key: 'x-auth', value: 'replacement-secret' }],
})
expect(state.getEndpointEditRules(endpoint.id)[0]).toMatchObject({ value: '***', retainValue: true })
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
})
it('does not overwrite newer edits when a save finishes', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'first-edit')
const saving = state.saveEndpoint(state.localEndpoints[0])
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'newer-edit')
resolveSave(structuredClone(endpoint))
await saving
expect(state.getEndpointEditRules(endpoint.id)[0].value).toBe('newer-edit')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
})
it('refreshes JSON mode with the saved projection instead of retaining submitted plaintext', async () => {
const { state } = await mountDialog()
state.enterEndpointRulesJsonMode(state.localEndpoints[0])
state.updateEndpointRulesJsonDraft(endpoint.id, JSON.stringify({
header_rules: [{ action: 'set', key: 'x-auth', value: 'json-secret' }],
body_rules: [], response_header_rules: [],
}))
await state.saveEndpoint(state.localEndpoints[0])
const savedDraft = state.endpointRulesJsonDraft[endpoint.id]
expect(savedDraft).not.toContain('json-secret')
expect(JSON.parse(savedDraft).header_rules[0]).toMatchObject({ value: '***', has_value: true })
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
})
it('clears secret drafts on close and ignores the stale save response', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { props, state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'unsaved-secret')
const saving = state.saveEndpoint(state.localEndpoints[0])
props.modelValue = false
await settle()
resolveSave(structuredClone(endpoint))
await saving
expect(state.endpointEditStates).toEqual({})
expect(state.localEndpoints).toEqual([])
})
})
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue'
import EndpointRulesRevealDialog from '../EndpointRulesRevealDialog.vue'
import type { ProviderEndpointRules } from '@/api/endpoints'
const api = vi.hoisted(() => ({ revealEndpointRules: vi.fn() }))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Dialog: defineComponent({
props: { modelValue: Boolean },
setup: (props, { slots }) => () => props.modelValue
? h('section', [slots.default?.(), slots.footer?.()]) : null,
}),
Button: defineComponent({ setup: (_props, { slots }) => () => h('button', slots.default?.()) }),
Textarea: defineComponent({
props: { modelValue: String },
setup: (props) => () => h('textarea', { value: props.modelValue }),
}),
}
})
const mounted: Array<{ app: App, root: HTMLElement }> = []
const savedRules: ProviderEndpointRules = {
header_rules: [{ action: 'set', key: 'x-auth', value: 'request-secret' }],
body_rules: [{ action: 'set', path: 'auth.token', value: 'body-secret' }],
response_header_rules: [{ action: 'set', key: 'x-auth', value: 'response-secret' }],
}
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountDialog(open = true) {
const props = reactive({ modelValue: open, endpointId: 'endpoint-1' as string | null })
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(EndpointRulesRevealDialog, {
...props,
'onUpdate:modelValue': (value: boolean) => { props.modelValue = value },
}),
}))
app.mount(root)
mounted.push({ app, root })
return { props, root, app }
}
beforeEach(() => {
api.revealEndpointRules.mockReset().mockResolvedValue(savedRules)
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule reveal', () => {
it('fetches only on demand and displays saved rules read-only', async () => {
const { props, root } = mountDialog(false)
await settle()
expect(api.revealEndpointRules).not.toHaveBeenCalled()
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledOnce()
expect(api.revealEndpointRules.mock.calls[0][0]).toBe('endpoint-1')
const textarea = root.querySelector('textarea')!
expect(JSON.parse(textarea.value)).toEqual(savedRules)
expect(textarea.readOnly).toBe(true)
})
it('clears plaintext on close and fetches again instead of caching', async () => {
const { props, root } = mountDialog()
await settle()
const signal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
root.querySelector('button')!.click()
await settle()
expect(signal.aborted).toBe(true)
expect(root.querySelector('textarea')).toBeNull()
api.revealEndpointRules.mockResolvedValue({ ...savedRules, body_rules: [] })
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledTimes(2)
expect(JSON.parse(root.querySelector('textarea')!.value).body_rules).toEqual([])
})
it('ignores an old endpoint response after switching endpoints', async () => {
let resolveFirst!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const { props, root } = mountDialog()
await settle()
const oldSignal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
props.endpointId = 'endpoint-2'
api.revealEndpointRules.mockResolvedValue({ header_rules: [], body_rules: [], response_header_rules: [] })
await settle()
expect(oldSignal.aborted).toBe(true)
resolveFirst(savedRules)
await settle()
expect(JSON.parse(root.querySelector('textarea')!.value).header_rules).toEqual([])
})
it('ignores an in-flight response after the dialog closes', async () => {
let resolveRequest!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveRequest = resolve }))
const { props, root } = mountDialog()
await settle()
props.modelValue = false
await settle()
resolveRequest(savedRules)
await settle()
expect(root.querySelector('textarea')).toBeNull()
expect(root.innerHTML).not.toContain('secret')
})
it('does not expose API error details in the dialog', async () => {
api.revealEndpointRules.mockRejectedValue(new Error('Bearer upstream-secret'))
const { root } = mountDialog()
await settle()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
expect(root.innerHTML).not.toContain('upstream-secret')
})
})
@@ -132,11 +132,17 @@ describe('ModelMappingDialog', () => {
error: null,
warning: null,
})
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-1',
provider_model_name: 'provider-model-1',
provider_model_mappings: [],
} as Model
}
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -186,12 +192,18 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [],
} as Model
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
@@ -239,7 +251,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -249,7 +267,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [responsesEndpoint.id],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -304,7 +322,13 @@ describe('ModelMappingDialog', () => {
})
it('preserves an edited compact scope when endpoint capabilities are unavailable', async () => {
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -314,7 +338,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: ['endpoint-responses'],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -369,7 +393,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -379,7 +409,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [endpoint.id],
operations: ['Compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -5,29 +5,45 @@ import { renderToString } from '@vue/server-renderer'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
const provider = {
const provider: ProviderWithEndpointsSummary = {
id: 'provider-demo',
name: 'Demo Provider',
provider_type: 'custom',
is_active: true,
active_keys: 0,
api_formats: [],
} as ProviderWithEndpointsSummary
provider_priority: 0,
keep_priority_on_conversion: false,
enable_format_conversion: true,
total_endpoints: 0,
active_endpoints: 0,
total_keys: 0,
total_models: 0,
active_models: 0,
global_model_ids: [],
avg_health_score: null,
unhealthy_endpoints: 0,
endpoint_health_details: [],
ops_configured: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
describe('ModelMappingTab response contracts', () => {
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
const props: InstanceType<typeof ModelMappingTab>['$props'] = {
provider,
models: [],
endpoints: [],
providerKeys: [],
loading: false,
}
Reflect.set(props, 'mappingPreview', {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
})
const app = createSSRApp({
render: () => h(ModelMappingTab, {
provider,
models: [],
endpoints: [],
providerKeys: [],
mappingPreview: {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
},
loading: false,
}),
render: () => h(ModelMappingTab, props),
})
const html = await renderToString(app)
@@ -582,8 +582,8 @@ describe('OAuthAccountDialog authorization and import', () => {
'批量授权完成:成功 0 个(新增 0 个,替换 0 个),失败 4 个;#2 invalid cookie;#3 expired cookie',
'错误',
)
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('third safe reason')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('third safe reason')
expect(toastMocks.warning).not.toHaveBeenCalled()
})
@@ -85,7 +85,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyActionCluster>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -47,7 +47,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyIdentityBlock>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -277,7 +277,7 @@ export function getVisibleCodexResetCreditItems(
if (!snapshot || !Array.isArray(credits)) return []
return credits
.map((item) => {
.map((item): CodexResetCreditDisplayCandidate | null => {
if (!codexResetCreditStatusIsDisplayable(item)) return null
const remainingSeconds = codexResetCreditRemainingSeconds(item, snapshot, nowUnixSecs)
if (remainingSeconds === null || remainingSeconds <= 0) return null
@@ -468,7 +468,8 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
apiFormat = group.apiFormats[0]
} else if (group.apiFormats.length === 0) {
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
apiFormat = group.model.effective_api_format || group.model.api_format
const formats = group.model.effective_config?.api_formats ?? group.model.config?.api_formats ?? []
if (formats.length === 1) apiFormat = formats[0]
}
const result = await testModel(
@@ -456,7 +456,7 @@ function hasRequestPricing(model: Model): boolean {
function hasVideoPricing(model: Model): boolean {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
return !!priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频计费的显示文本
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, type App } from 'vue'
import type { ActionResultResponse } from '@/api/providerOps'
import { useProviderBalance } from '../useProviderBalance'
const api = vi.hoisted(() => ({
batchQueryBalance: vi.fn<() => Promise<Record<string, ActionResultResponse>>>(),
getArchitectures: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/api/providerOps', () => api)
let app: App | undefined
let root: HTMLDivElement
function mountBalance() {
let balance!: ReturnType<typeof useProviderBalance>
root = document.createElement('div')
app = createApp({
setup() {
balance = useProviderBalance()
return () => null
},
})
app.mount(root)
return balance
}
function result(status: ActionResultResponse['status'], available: number): ActionResultResponse {
return {
status,
action_type: 'query_balance',
data: { total_available: available, currency: 'USD', extra: {} },
message: null,
executed_at: '2026-09-07T00:00:00Z',
response_time_ms: 0,
cache_ttl_seconds: 0,
}
}
beforeEach(() => {
vi.useFakeTimers()
api.batchQueryBalance.mockReset()
})
afterEach(() => {
app?.unmount()
app = undefined
root?.remove()
vi.useRealTimers()
})
describe('provider balance refresh', () => {
it('does not overwrite a newer refresh with an older pending retry', async () => {
const balance = mountBalance()
const providers = [{ id: 'provider-1', ops_configured: true }]
let resolveRetry!: (value: Record<string, ActionResultResponse>) => void
api.batchQueryBalance
.mockResolvedValueOnce({ 'provider-1': result('pending', 0) })
.mockImplementationOnce(() => new Promise(resolve => { resolveRetry = resolve }))
.mockResolvedValueOnce({ 'provider-1': result('success', 20) })
await balance.loadBalances(providers)
await vi.advanceTimersByTimeAsync(12_000)
await balance.loadBalances(providers)
resolveRetry({ 'provider-1': result('success', 10) })
await Promise.resolve()
expect(balance.getProviderBalance('provider-1')).toEqual({ available: 20, currency: 'USD' })
})
it('ignores a pending response after unmount', async () => {
const balance = mountBalance()
let resolveLoad!: (value: Record<string, ActionResultResponse>) => void
api.batchQueryBalance.mockImplementationOnce(() => new Promise(resolve => { resolveLoad = resolve }))
const loading = balance.loadBalances([{ id: 'provider-1', ops_configured: true }])
app?.unmount()
app = undefined
resolveLoad({ 'provider-1': result('pending', 10) })
await loading
expect(balance.balanceCache.value).toEqual({})
expect(vi.getTimerCount()).toBe(0)
})
it('preserves zero balances and false check-in results', async () => {
const balance = mountBalance()
api.batchQueryBalance.mockResolvedValueOnce({
'provider-1': {
...result('success', 0),
data: {
total_available: 0,
currency: 'USD',
extra: { balance: 0, points: 0, checkin_success: false, checkin_message: 'try again' },
},
},
})
await balance.loadBalances([{ id: 'provider-1', ops_configured: true }])
expect(balance.getProviderBalanceBreakdown('provider-1')).toEqual({ balance: 0, points: 0, currency: 'USD' })
expect(balance.getProviderCheckin('provider-1')).toEqual({ success: false, message: 'try again' })
})
})
@@ -48,7 +48,7 @@ export function useProviderBalance() {
const schemas: Record<string, CredentialsSchema> = {}
for (const arch of archs) {
if (arch.credentials_schema) {
schemas[arch.architecture_id] = arch.credentials_schema as CredentialsSchema
schemas[arch.architecture_id] = arch.credentials_schema
}
}
architectureSchemas.value = schemas
@@ -59,7 +59,7 @@ export function useProviderBalance() {
}
// 异步加载余额数据(使用批量接口)
async function loadBalances(providers: ProviderWithEndpointsSummary[], fullReload = true) {
async function loadBalances(providers: Pick<ProviderWithEndpointsSummary, 'id' | 'ops_configured'>[], fullReload = true) {
if (fullReload) {
balanceCache.value = {}
}
@@ -108,6 +108,7 @@ export function useProviderBalance() {
async function retryPendingBalances(providerIds: string[], loadVersion: number, retryCount: number) {
try {
const results = await batchQueryBalance(providerIds)
if (loadVersion !== balanceLoadVersion) return
const stillPending: string[] = []
for (const [providerId, result] of Object.entries(results)) {
@@ -173,14 +174,16 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
if (!extra || extra.balance === undefined || extra.points === undefined) {
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || typeof extra.balance !== 'number' || typeof extra.points !== 'number') {
return null
}
return {
balance: extra.balance,
points: extra.points,
currency: data.currency || 'USD',
currency: typeof data.currency === 'string' && data.currency ? data.currency : 'USD',
}
}
@@ -224,13 +227,15 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
if (!extra || extra.checkin_success === undefined) {
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || (extra.checkin_success !== null && typeof extra.checkin_success !== 'boolean')) {
return null
}
return {
success: extra.checkin_success,
message: extra.checkin_message || '',
message: typeof extra.checkin_message === 'string' ? extra.checkin_message : '',
}
}
@@ -244,13 +249,15 @@ export function useProviderBalance() {
return null
}
const data = result.data as Record<string, unknown>
const extra = data.extra
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra || !extra.cookie_expired) {
return null
}
return {
expired: true,
message: extra.cookie_expired_message || 'Cookie 已失效',
message: typeof extra.cookie_expired_message === 'string' ? extra.cookie_expired_message : 'Cookie 已失效',
}
}
@@ -296,7 +303,9 @@ export function useProviderBalance() {
}
const data = result.data as Record<string, unknown>
const extra = data.extra
const extra = typeof data.extra === 'object' && data.extra !== null
? data.extra as Record<string, unknown>
: null
if (!extra) return []
// 从 schema 缓存中获取格式化配置
@@ -319,6 +328,7 @@ export function useProviderBalance() {
// 组件卸载时清理
function cleanup() {
balanceLoadVersion++
stopTick()
pendingTimers.forEach(clearTimeout)
pendingTimers.clear()
@@ -36,7 +36,10 @@ function providerModelsFetchResult(response: ProviderModelsQueryResponse): Fetch
if (response.success && response.data?.models) {
const partialWarning = response.data.warning ?? response.data.error
return {
models: response.data.models,
models: response.data.models.map((model) => ({
...model,
api_formats: model.api_formats ?? (model.api_format ? [model.api_format] : []),
})),
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
fromCache: response.data.from_cache,
}
@@ -77,7 +77,7 @@ describe('routingPolicy', () => {
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
expect(normalizeRoutingGroupConfig({
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3 },
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false },
}).default_policy.sticky_key_attempts).toBe(3)
expect(normalizeStickyKeyAttempts('5')).toBe(5)
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
@@ -179,7 +179,7 @@
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<span
v-if="row.kind === 'pool'"
v-if="poolProviderIds.has(row.id)"
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
>
Pool
@@ -42,12 +42,13 @@ describe('usage token normalization', () => {
})
it('does not subtract cache read tokens for Claude usage', () => {
expect(getEffectiveInputTokens({
const usage = {
input_tokens: 4941,
cache_creation_input_tokens: 687,
cache_read_input_tokens: 52873,
output_tokens: 973,
api_format: 'claude:messages',
})).toBe(4941)
}
expect(getEffectiveInputTokens(usage)).toBe(4941)
})
})
@@ -564,7 +564,7 @@ import Badge from '@/components/ui/badge.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import JsonContentPanel from './JsonContentPanel.vue'
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { requestTraceApi, type RequestTrace, type CandidateProxy, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { log } from '@/utils/logger'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
import { parseApiError } from '@/utils/errorParser'
@@ -722,8 +722,8 @@ const formatSize = (bytes: number): string => {
}
// 代理 timing 分阶段展示
const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
const t = proxy.timing as Record<string, number | null | undefined> | undefined
const proxyTimingBreakdown = (proxy: CandidateProxy): string => {
const t = proxy.timing
if (!t) return ''
const parts: string[] = []
@@ -2112,7 +2112,8 @@ const navigateAttempt = (direction: number) => {
// 加载请求追踪数据
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
if (!props.requestId || props.traceData) return
const requestId = props.requestId
if (!requestId || props.traceData) return
if (traceLoadInFlight) return traceLoadInFlight
traceLoadInFlight = (async () => {
@@ -2125,7 +2126,7 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -54,12 +54,13 @@
</template>
<script setup lang="ts">
import type { TimeScatterChartData } from '@/components/charts/types'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
import { meApi } from '@/api/me'
import type { ChartData, ChartOptions } from 'chart.js'
import type { ChartOptions } from 'chart.js'
import { log } from '@/utils/logger'
const props = withDefaults(defineProps<{
@@ -186,7 +187,7 @@ function formatModelName(model: string): string {
}
// 构建图表数据
const chartData = computed<ChartData<'scatter'>>(() => {
const chartData = computed<TimeScatterChartData>(() => {
if (!timelineData.value?.points) {
return { datasets: [] }
}
@@ -728,7 +728,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentHeaderData"
:current-header-data="currentHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderHeaders"
:header-stats="headerStats"
@@ -782,7 +782,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentResponseHeaderData"
:current-header-data="currentResponseHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderResponseHeaders"
:header-stats="responseHeaderStats"
@@ -1412,7 +1412,7 @@ const TIMELINE_MOUNT_DELAY_MS = 120
let loadDetailRequestId = 0
let bodyLoadRequestId = 0
let loadDetailInFlight = false
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
let timelineMountTimer: number | null = null
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
@@ -1990,7 +1990,7 @@ const effectiveCacheCreationCost = computed(() => {
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
].reduce((sum, value) => sum + (value ?? 0), 0)
].reduce<number>((sum, value) => sum + (value ?? 0), 0)
if (snapshotCost > 0) return snapshotCost
return toNumber(detail.value?.cache_creation_cost) ?? 0
})
@@ -115,10 +115,8 @@ interface DisplayLine extends JsonLine {
}
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
type JsonValue = Record<string, unknown> | unknown[] | string | number | boolean | null | undefined
const props = defineProps<{
data: JsonValue
data: unknown
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
@@ -171,8 +171,8 @@ const props = withDefaults(defineProps<{
headerStats: { added: number; modified: number; removed: number; unchanged: number }
isDark: boolean
// 泛化 props:允许传入任意 header 对和标签,用于复用为响应头对比
clientHeaders?: Record<string, unknown>
providerHeaders?: Record<string, unknown>
clientHeaders?: Record<string, unknown> | null
providerHeaders?: Record<string, unknown> | null
clientLabel?: string
providerLabel?: string
emptyMessage?: string
@@ -189,7 +189,7 @@ async function loadUsers(search: string) {
const options = result.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
email: user.email ?? '',
}))
if (!search.trim()) loadedInitialBatch = true
users.value = options
@@ -521,7 +521,7 @@ describe('HorizontalRequestTimeline', () => {
})
await nextTick()
const lastCall = onTraceState.mock.calls.at(-1)?.[0]
const lastCall = onTraceState.mock.calls[onTraceState.mock.calls.length - 1]?.[0]
expect(lastCall).toMatchObject({
finalStatus: 'streaming',
})
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn() }))
vi.mock('@/api/dashboard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail },
}
})
vi.mock('../HorizontalRequestTimeline.vue', () => ({ default: { render: () => null } }))
vi.mock('../JsonContentPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: { type: null, default: null } },
setup(props) {
return () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.data))
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
})
function buildDetail(captured: boolean): RequestDetail {
return {
id: 'usage-full-capture',
request_id: 'req-full-capture',
user: { id: 'user-1', username: 'test-user', email: '[email protected]' },
api_key: { id: 'key-1', name: 'test-key', display: 'test-key' },
provider: 'test-provider',
api_format: 'openai:chat',
model: 'test-model',
tokens: { input: 10, output: 20, total: 30 },
cost: { input: 0, output: 0, total: 0 },
request_type: 'chat',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-09-07T00:00:00Z',
request_headers: { 'content-type': 'application/json', authorization: '[redacted]' },
has_request_body: captured,
has_provider_request_body: false,
has_response_body: captured,
has_client_response_body: false,
}
}
async function openDrawer() {
const isOpen = ref(false)
const Host = defineComponent({
setup: () => () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-full-capture',
}),
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('请求头')
})
}
function findTab(label: string) {
return [...document.body.querySelectorAll('button')]
.find(button => button.textContent?.trim() === label)
}
describe('RequestDetailDrawer body capture', () => {
it('keeps body tabs from shallow availability and loads full captures on demand', async () => {
const shallow = buildDetail(true)
const full: RequestDetail = {
...shallow,
request_body: { messages: [{ role: 'user', content: 'captured request text' }] },
response_body: { choices: [{ message: { role: 'assistant', content: 'captured response text' } }] },
}
apiMocks.getRequestDetail.mockImplementation(async (_requestId, options) => (
options?.includeBodies ? full : shallow
))
await openDrawer()
expect(findTab('请求体')).toBeDefined()
expect(findTab('响应体')).toBeDefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(apiMocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findTab('请求体')!.click()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured request text')
})
expect(apiMocks.getRequestDetail).toHaveBeenLastCalledWith('usage-full-capture', { includeBodies: true })
findTab('响应体')!.click()
await nextTick()
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured response text')
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(2)
})
it('does not offer body tabs or fetch uncaptured bodies for basic records', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildDetail(false))
await openDrawer()
expect(findTab('请求体')).toBeUndefined()
expect(findTab('响应体')).toBeUndefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
})
})
@@ -160,7 +160,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
const statsRaw = statsData
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
@@ -187,7 +187,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
const raw = item
return {
model: item.model,
request_count: item.request_count || 0,
@@ -422,7 +422,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []) as UsageRecord[]
const nextRecords = (response.records || [])
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
const totalKey = buildAdminRecordTotalKey(params)
applyAdminRecordTotal(totalKey, response.total ?? 0, response.total_is_estimated === true)
@@ -524,8 +524,8 @@ export function useUsageData(options: UseUsageDataOptions) {
// 确定是否需要保护 status(避免刷新把已知状态覆盖为 undefined 或回退)
const hasExistingStatus = typeof existing.status === 'string' && existing.status.length > 0
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
const currentRank = hasExistingStatus ? (statusPriority[existing.status ?? ''] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status ?? ''] ?? -1) : -1
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
+1 -69
View File
@@ -1,4 +1,4 @@
import type { ImageProgress } from '@/api/requestTrace'
export type { UsageRecord, RequestStatus } from '@/api/usageRecords'
// 统计数据状态
export interface UsageStatsState {
@@ -77,74 +77,6 @@ export interface ApiFormatStatsItem {
// 请求记录
// 请求状态类型
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
export interface UsageRecord {
id: string
user_id?: string
username?: string
user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
provider_key_name?: string | null
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
input_tokens: number
effective_input_tokens?: number
output_tokens: number
reasoning_tokens?: number
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number
cache_creation_ephemeral_1h_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost: number
actual_cost?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时
end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时
is_stream: boolean
is_websocket?: boolean
websocket_transport?: string | null
usage_available?: boolean
usage_pricing_available?: boolean
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
created_at: string
updated_at?: string | null
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
image_progress?: ImageProgress | null
}
// 日期范围参数
export interface DateRangeParams {
start_date?: string
@@ -9,7 +9,7 @@ import {
describe('service tier facts', () => {
it('uses the final provider request tier for display and billing', () => {
const source = {
const source: Record<string, unknown> = {
service_tier: 'priority',
actual_service_tier: 'default',
settlement: {
@@ -29,7 +29,7 @@ describe('service tier facts', () => {
})
it('does not infer a tier from the provider response or settlement snapshot', () => {
const source = {
const source: Record<string, unknown> = {
actual_service_tier: 'flex',
settlement: {
settlement_snapshot: {

Some files were not shown because too many files have changed in this diff Show More