mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
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:
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
}, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
* 输入框尺寸
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
+128
@@ -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: '',
|
||||
|
||||
+29
-13
@@ -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)
|
||||
|
||||
+2
-2
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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',
|
||||
})
|
||||
|
||||
+130
@@ -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,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
Reference in New Issue
Block a user