mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
# Conflicts: # apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs # frontend/src/api/endpoints/pool.ts
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
cachedRequestMock: vi.fn(async (_key: string, fetcher: () => Promise<unknown>) => fetcher()),
|
||||
buildCacheKeyMock: vi.fn((prefix: string) => prefix),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/cache', () => ({
|
||||
cache: {
|
||||
clear: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cachedRequest: cachedRequestMock,
|
||||
buildCacheKey: buildCacheKeyMock,
|
||||
}))
|
||||
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
describe('adminApi analytics cache options', () => {
|
||||
const params = {
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-15',
|
||||
preset: 'custom',
|
||||
timezone: 'Asia/Shanghai',
|
||||
tz_offset_minutes: 480,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
getMock.mockResolvedValue({ data: {} })
|
||||
cachedRequestMock.mockClear()
|
||||
buildCacheKeyMock.mockClear()
|
||||
})
|
||||
|
||||
it('keeps the existing 20-second cache TTL by default', async () => {
|
||||
await adminApi.getTimeSeries(params)
|
||||
await adminApi.getPercentiles(params)
|
||||
await adminApi.getProviderPerformance(params)
|
||||
await adminApi.getErrorDistribution(params)
|
||||
|
||||
for (let call = 1; call <= 4; call += 1) {
|
||||
expect(cachedRequestMock).toHaveBeenNthCalledWith(
|
||||
call,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
20 * 1000
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a zero TTL when an analytics request skips the cache', async () => {
|
||||
const options = { skipCache: true }
|
||||
const providerParams = { ...params, include_timeline: false }
|
||||
|
||||
await adminApi.getTimeSeries(params, options)
|
||||
await adminApi.getPercentiles(params, options)
|
||||
await adminApi.getProviderPerformance(providerParams, options)
|
||||
await adminApi.getErrorDistribution(params, options)
|
||||
|
||||
for (let call = 1; call <= 4; call += 1) {
|
||||
expect(cachedRequestMock).toHaveBeenNthCalledWith(
|
||||
call,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/stats/time-series', { params })
|
||||
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/stats/performance/percentiles', {
|
||||
params,
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(3, '/api/admin/stats/performance/providers', {
|
||||
params: providerParams,
|
||||
})
|
||||
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
|
||||
import { cache, cachedRequest } from '@/utils/cache'
|
||||
|
||||
type TestableApiClient = typeof apiClient & {
|
||||
client: AxiosInstance
|
||||
@@ -34,6 +35,42 @@ describe('apiClient auth state change event', () => {
|
||||
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
|
||||
})
|
||||
|
||||
it('clears cached API data whenever the authentication identity changes', () => {
|
||||
apiClient.setToken('first-token')
|
||||
cache.set('dashboard', { owner: 'first-user' }, 30_000)
|
||||
|
||||
apiClient.setToken('second-token')
|
||||
|
||||
expect(cache.get('dashboard')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not share or restore an in-flight cached response across token changes', async () => {
|
||||
let resolveFirst!: (value: string) => void
|
||||
let resolveSecond!: (value: string) => void
|
||||
const firstResponse = new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondResponse = new Promise<string>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
const secondFetcher = vi.fn(() => secondResponse)
|
||||
|
||||
apiClient.setToken('first-token')
|
||||
const firstRequest = cachedRequest('dashboard', () => firstResponse, 30_000)
|
||||
|
||||
apiClient.setToken('second-token')
|
||||
const secondRequest = cachedRequest('dashboard', secondFetcher, 30_000)
|
||||
expect(secondFetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst('first-user-data')
|
||||
await expect(firstRequest).resolves.toBe('first-user-data')
|
||||
expect(cache.get('dashboard')).toBeNull()
|
||||
|
||||
resolveSecond('second-user-data')
|
||||
await expect(secondRequest).resolves.toBe('second-user-data')
|
||||
expect(cache.get('dashboard')).toBe('second-user-data')
|
||||
})
|
||||
|
||||
it('sends auth refresh without a request body', async () => {
|
||||
const rawClient = apiClient as TestableApiClient
|
||||
const previousAdapter = rawClient.client.defaults.adapter
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildModelsDevTieredPricing,
|
||||
resolveModelsDevTieredPricing,
|
||||
} from '@/api/models-dev-pricing'
|
||||
|
||||
describe('buildModelsDevTieredPricing', () => {
|
||||
it('maps context bands and cache prices without flattening them', () => {
|
||||
expect(buildModelsDevTieredPricing({
|
||||
input: 5,
|
||||
output: 30,
|
||||
cache_read: 0.5,
|
||||
cache_write: 6.25,
|
||||
tiers: [{
|
||||
input: 10,
|
||||
output: 45,
|
||||
cache_read: 1,
|
||||
cache_write: 12.5,
|
||||
tier: { type: 'context', size: 272_000 },
|
||||
}],
|
||||
})).toEqual({
|
||||
tiers: [
|
||||
{
|
||||
up_to: 271_999,
|
||||
input_price_per_1m: 5,
|
||||
output_price_per_1m: 30,
|
||||
cache_creation_price_per_1m: 6.25,
|
||||
cache_read_price_per_1m: 0.5,
|
||||
},
|
||||
{
|
||||
up_to: null,
|
||||
input_price_per_1m: 10,
|
||||
output_price_per_1m: 45,
|
||||
cache_creation_price_per_1m: 12.5,
|
||||
cache_read_price_per_1m: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts multiple context boundaries into contiguous Aether bands', () => {
|
||||
const cost = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [
|
||||
{ input: 5, output: 6, tier: { type: 'context' as const, size: 200_000 } },
|
||||
{ input: 3, output: 4, tier: { type: 'context' as const, size: 100_000 } },
|
||||
],
|
||||
}
|
||||
|
||||
expect(buildModelsDevTieredPricing(cost)?.tiers).toEqual([
|
||||
{ up_to: 99_999, input_price_per_1m: 1, output_price_per_1m: 2 },
|
||||
{ up_to: 199_999, input_price_per_1m: 3, output_price_per_1m: 4 },
|
||||
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 6 },
|
||||
])
|
||||
expect(cost.tiers.map(tier => tier.tier.size)).toEqual([200_000, 100_000])
|
||||
})
|
||||
|
||||
it('keeps flat token pricing as one unbounded band', () => {
|
||||
expect(buildModelsDevTieredPricing({ input: 0, output: 0.1 })).toEqual({
|
||||
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0.1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits an empty base band when context pricing starts at zero', () => {
|
||||
expect(buildModelsDevTieredPricing({
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [
|
||||
{ input: 3, output: 4, tier: { type: 'context', size: 0 } },
|
||||
{ input: 5, output: 6, tier: { type: 'context', size: 100_000 } },
|
||||
],
|
||||
})?.tiers).toEqual([
|
||||
{ up_to: 99_999, input_price_per_1m: 3, output_price_per_1m: 4 },
|
||||
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 6 },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ input: -1, output: 2 },
|
||||
{ input: 1, output: Number.POSITIVE_INFINITY },
|
||||
{
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [{ input: 3, output: 4, tier: { type: 'context', size: Number.MAX_SAFE_INTEGER + 1 } }],
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [{ input: 3, output: 4, tier: { type: 'context', size: -1 } }],
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [
|
||||
{ input: 3, output: 4, tier: { type: 'context', size: 100 } },
|
||||
{ input: 5, output: 6, tier: { type: 'context', size: 100 } },
|
||||
],
|
||||
},
|
||||
])('fails closed for malformed structured pricing', (cost) => {
|
||||
expect(buildModelsDevTieredPricing(cost)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveModelsDevTieredPricing', () => {
|
||||
it('uses the GPT-5.5 Pro context tier declared by models.dev without inventing cache prices', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.5-pro', {
|
||||
input: 30,
|
||||
output: 180,
|
||||
tiers: [{
|
||||
input: 60,
|
||||
output: 270,
|
||||
tier: { type: 'context', size: 272_000 },
|
||||
}],
|
||||
context_over_200k: {
|
||||
input: 60,
|
||||
output: 270,
|
||||
},
|
||||
})).toEqual({
|
||||
tiers: [
|
||||
{
|
||||
up_to: 271_999,
|
||||
input_price_per_1m: 30,
|
||||
output_price_per_1m: 180,
|
||||
},
|
||||
{
|
||||
up_to: null,
|
||||
input_price_per_1m: 60,
|
||||
output_price_per_1m: 270,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
])('uses the fetched models.dev cost for OpenAI model %s', (modelId) => {
|
||||
const fetchedCost = {
|
||||
input: 7,
|
||||
output: 11,
|
||||
cache_read: 0.7,
|
||||
cache_write: 8.75,
|
||||
tiers: [{
|
||||
input: 13,
|
||||
output: 17,
|
||||
cache_read: 1.3,
|
||||
cache_write: 16.25,
|
||||
tier: { type: 'context' as const, size: 123_000 },
|
||||
}],
|
||||
}
|
||||
|
||||
expect(resolveModelsDevTieredPricing('openai', modelId, fetchedCost)).toEqual({
|
||||
tiers: [
|
||||
{
|
||||
up_to: 122_999,
|
||||
input_price_per_1m: 7,
|
||||
output_price_per_1m: 11,
|
||||
cache_creation_price_per_1m: 8.75,
|
||||
cache_read_price_per_1m: 0.7,
|
||||
},
|
||||
{
|
||||
up_to: null,
|
||||
input_price_per_1m: 13,
|
||||
output_price_per_1m: 17,
|
||||
cache_creation_price_per_1m: 16.25,
|
||||
cache_read_price_per_1m: 1.3,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same fetched-cost conversion for every provider and model identity', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'other-model', {
|
||||
input: 1,
|
||||
output: 2,
|
||||
tiers: [{ input: 3, output: 4, tier: { type: 'context', size: 272_000 } }],
|
||||
})?.tiers.map(tier => tier.up_to)).toEqual([271_999, null])
|
||||
})
|
||||
|
||||
it('does not synthesize pricing when the fetched cost is absent', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
+88
-49
@@ -1,11 +1,19 @@
|
||||
import apiClient from './client'
|
||||
import type { ModelTestCapabilities } from './endpoints/types'
|
||||
import axios, { type AxiosRequestConfig } from 'axios'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import { cache, cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||
|
||||
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs'
|
||||
|
||||
export interface AdminSystemConfigItem {
|
||||
key: string
|
||||
value: unknown
|
||||
description?: string
|
||||
is_set?: boolean
|
||||
}
|
||||
|
||||
export interface SystemDataImportOptions {
|
||||
onUploadProgress?: AxiosRequestConfig['onUploadProgress']
|
||||
@@ -736,6 +744,10 @@ export interface QuotaUsageResponse {
|
||||
providers: QuotaUsageProvider[]
|
||||
}
|
||||
|
||||
export interface AdminAnalyticsRequestOptions {
|
||||
skipCache?: boolean
|
||||
}
|
||||
|
||||
export interface PercentileItem {
|
||||
date: string
|
||||
p50_response_time_ms?: number | null
|
||||
@@ -947,9 +959,17 @@ export const adminApi = {
|
||||
|
||||
// 系统配置相关
|
||||
// 获取所有系统配置
|
||||
async getAllSystemConfigs(): Promise<Array<{ key: string; value: unknown; description?: string }>> {
|
||||
const response = await apiClient.get<Array<{ key: string; value: unknown; description?: string }>>('/api/admin/system/configs')
|
||||
return response.data
|
||||
async getAllSystemConfigs(
|
||||
options: { cacheTtlMs?: number } = {},
|
||||
): Promise<AdminSystemConfigItem[]> {
|
||||
return cachedRequest(
|
||||
ALL_SYSTEM_CONFIGS_CACHE_KEY,
|
||||
async () => {
|
||||
const response = await apiClient.get<AdminSystemConfigItem[]>('/api/admin/system/configs')
|
||||
return response.data
|
||||
},
|
||||
options.cacheTtlMs ?? 0,
|
||||
)
|
||||
},
|
||||
|
||||
// 获取特定系统配置
|
||||
@@ -983,6 +1003,8 @@ export const adminApi = {
|
||||
{ value, description },
|
||||
requestConfig,
|
||||
)
|
||||
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
|
||||
cache.delete(buildCacheKey('admin:system:config', { key }))
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -991,6 +1013,8 @@ export const adminApi = {
|
||||
const response = await apiClient.delete<{ message: string }>(
|
||||
`/api/admin/system/configs/${key}`
|
||||
)
|
||||
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
|
||||
cache.delete(buildCacheKey('admin:system:config', { key }))
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -1021,6 +1045,7 @@ export const adminApi = {
|
||||
data,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
|
||||
)
|
||||
cache.clear()
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -1053,6 +1078,7 @@ export const adminApi = {
|
||||
data,
|
||||
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
|
||||
)
|
||||
cache.clear()
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -1388,13 +1414,16 @@ export const adminApi = {
|
||||
)
|
||||
},
|
||||
|
||||
async getPercentiles(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<PercentileItem[]> {
|
||||
async getPercentiles(
|
||||
params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
},
|
||||
options?: AdminAnalyticsRequestOptions
|
||||
): Promise<PercentileItem[]> {
|
||||
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
@@ -1404,26 +1433,30 @@ export const adminApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
options?.skipCache ? 0 : 20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getProviderPerformance(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
granularity?: 'day' | 'hour'
|
||||
limit?: number
|
||||
provider_id?: string
|
||||
model?: string
|
||||
api_format?: string
|
||||
endpoint_kind?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
}): Promise<ProviderPerformanceResponse> {
|
||||
async getProviderPerformance(
|
||||
params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
granularity?: 'day' | 'hour'
|
||||
include_timeline?: boolean
|
||||
limit?: number
|
||||
provider_id?: string
|
||||
model?: string
|
||||
api_format?: string
|
||||
endpoint_kind?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
},
|
||||
options?: AdminAnalyticsRequestOptions
|
||||
): Promise<ProviderPerformanceResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:performance:providers', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
@@ -1433,17 +1466,20 @@ export const adminApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
options?.skipCache ? 0 : 20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getErrorDistribution(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ErrorDistributionResponse> {
|
||||
async getErrorDistribution(
|
||||
params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
},
|
||||
options?: AdminAnalyticsRequestOptions
|
||||
): Promise<ErrorDistributionResponse> {
|
||||
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
@@ -1453,7 +1489,7 @@ export const adminApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
options?.skipCache ? 0 : 20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1543,17 +1579,20 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTimeSeries(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider_name?: string
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
async getTimeSeries(
|
||||
params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider_name?: string
|
||||
},
|
||||
options?: AdminAnalyticsRequestOptions
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const cacheKey = buildCacheKey('admin:stats:time-series', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
@@ -1561,7 +1600,7 @@ export const adminApi = {
|
||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
options?.skipCache ? 0 : 20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
+30
-10
@@ -2,15 +2,30 @@ import axios, { getAdapter } from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'
|
||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
||||
import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
|
||||
import { log } from '@/utils/logger'
|
||||
import { cache } from '@/utils/cache'
|
||||
|
||||
// 在开发环境下使用代理,生产环境使用环境变量
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
|
||||
|
||||
type MockRuntime = typeof import('@/mocks')
|
||||
|
||||
let mockRuntimePromise: Promise<MockRuntime> | null = null
|
||||
let currentMockUserToken: string | null = null
|
||||
|
||||
function loadMockRuntime(): Promise<MockRuntime> {
|
||||
if (!mockRuntimePromise) {
|
||||
mockRuntimePromise = import('@/mocks').catch((error) => {
|
||||
mockRuntimePromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return mockRuntimePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否为公共端点
|
||||
*/
|
||||
@@ -53,7 +68,9 @@ function createDemoAdapter(defaultAdapter: AxiosAdapter) {
|
||||
return async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
|
||||
if (isDemoMode()) {
|
||||
try {
|
||||
const mockResponse = await handleMockRequest({
|
||||
const mockRuntime = await loadMockRuntime()
|
||||
mockRuntime.setMockUserToken(currentMockUserToken)
|
||||
const mockResponse = await mockRuntime.handleMockRequest({
|
||||
method: config.method?.toUpperCase(),
|
||||
url: config.url,
|
||||
data: config.data,
|
||||
@@ -290,30 +307,33 @@ class ApiClient {
|
||||
}
|
||||
|
||||
private syncTokenState(token: string | null): void {
|
||||
this.token = token
|
||||
if (isDemoMode()) {
|
||||
setMockUserToken(token)
|
||||
if (this.token !== token) {
|
||||
cache.clear()
|
||||
}
|
||||
this.token = token
|
||||
currentMockUserToken = token
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
if (this.token === token) {
|
||||
cache.clear()
|
||||
}
|
||||
this.syncTokenState(token)
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('access_token')
|
||||
// 页面刷新时,从 localStorage 恢复 token 到 mock handler
|
||||
if (this.token && isDemoMode()) {
|
||||
setMockUserToken(this.token)
|
||||
}
|
||||
this.syncTokenState(localStorage.getItem('access_token'))
|
||||
}
|
||||
return this.token
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
|
||||
if (hadAuth && this.token === null) {
|
||||
cache.clear()
|
||||
}
|
||||
this.syncTokenState(null)
|
||||
localStorage.removeItem('access_token')
|
||||
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
|
||||
|
||||
@@ -154,6 +154,18 @@ export interface RequestSchedulingFailure {
|
||||
no_upstream_attempt?: boolean | null
|
||||
}
|
||||
|
||||
export interface RequestSettlementPricingSnapshot {
|
||||
requested_processing_tier?: string | null
|
||||
actual_processing_tier?: string | null
|
||||
billing_processing_tier?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestSettlementSnapshot {
|
||||
pricing_snapshot?: RequestSettlementPricingSnapshot | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestDetail {
|
||||
id: string // UUID
|
||||
request_id: string
|
||||
@@ -175,6 +187,7 @@ export interface RequestDetail {
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
@@ -262,6 +275,7 @@ export interface RequestDetail {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
price_per_request?: number
|
||||
settlement_snapshot?: RequestSettlementSnapshot | null
|
||||
} | null
|
||||
// 阶梯计费信息
|
||||
tiered_pricing?: {
|
||||
@@ -399,7 +413,7 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||
return response.data
|
||||
},
|
||||
10 * 1000
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
@@ -462,7 +476,7 @@ export const dashboardApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
60 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
@@ -337,24 +337,28 @@ export interface PoolBatchAction {
|
||||
| 'delete'
|
||||
| 'clear_proxy'
|
||||
| 'set_proxy'
|
||||
| 'update_settings'
|
||||
payload?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchUpdatePatch {
|
||||
api_formats?: string[]
|
||||
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
|
||||
allow_auth_channel_mismatch_formats?: string[] | null
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
export interface PoolKeySharedSettingsPatch {
|
||||
internal_priority?: number
|
||||
global_priority_by_format?: Record<string, number> | null
|
||||
rpm_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
allowed_models?: AllowedModels
|
||||
capabilities?: Record<string, boolean> | null
|
||||
cache_ttl_minutes?: number
|
||||
max_probe_interval_minutes?: number
|
||||
is_active?: boolean
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchUpdatePatch extends PoolKeySharedSettingsPatch {
|
||||
api_formats?: string[]
|
||||
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
|
||||
allow_auth_channel_mismatch_formats?: string[] | null
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
global_priority_by_format?: Record<string, number> | null
|
||||
allowed_models?: AllowedModels
|
||||
capabilities?: Record<string, boolean> | null
|
||||
auto_fetch_models?: boolean
|
||||
locked_models?: string[]
|
||||
model_include_patterns?: string[]
|
||||
@@ -382,6 +386,28 @@ export interface PoolKeyBatchUpdateResponse {
|
||||
model_sync: PoolKeyBatchModelSyncResult | null
|
||||
}
|
||||
|
||||
export interface PoolKeySettingsPatch extends PoolKeySharedSettingsPatch {
|
||||
proxy_node_id?: string | null
|
||||
}
|
||||
|
||||
export interface PoolBatchImportRequest {
|
||||
keys: Array<{
|
||||
name: string
|
||||
api_key: string
|
||||
auth_type: 'api_key' | 'bearer'
|
||||
api_formats?: string[]
|
||||
settings?: PoolKeySettingsPatch
|
||||
}>
|
||||
api_formats?: string[]
|
||||
settings?: PoolKeySettingsPatch
|
||||
}
|
||||
|
||||
export interface PoolBatchImportResult {
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: Array<{ index: number; reason: string }>
|
||||
}
|
||||
|
||||
interface PoolReadOptions {
|
||||
cacheTtlMs?: number
|
||||
}
|
||||
@@ -496,6 +522,18 @@ export async function batchUpdatePoolKeys(
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function batchImportPoolKeys(
|
||||
providerId: string,
|
||||
body: PoolBatchImportRequest,
|
||||
): Promise<PoolBatchImportResult> {
|
||||
const response = await client.post<PoolBatchImportResult>(
|
||||
`/api/admin/pool/${providerId}/keys/batch-import`,
|
||||
body,
|
||||
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export interface BatchDeleteTaskStatus {
|
||||
task_id: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
API_FORMATS,
|
||||
apiFormatPermissionCovers,
|
||||
formatApiFormat,
|
||||
formatApiFormatShort,
|
||||
groupApiFormats,
|
||||
@@ -14,6 +15,8 @@ describe('api format display helpers', () => {
|
||||
expect(normalizeApiFormatAlias('CLAUDE_MESSAGES')).toBe(API_FORMATS.CLAUDE_MESSAGES)
|
||||
expect(normalizeApiFormatAlias('OPENAI_RESPONSES')).toBe(API_FORMATS.OPENAI_RESPONSES)
|
||||
expect(normalizeApiFormatAlias('OPENAI_RESPONSES_COMPACT')).toBe(API_FORMATS.OPENAI_RESPONSES_COMPACT)
|
||||
expect(normalizeApiFormatAlias('OPENAI_SEARCH')).toBe(API_FORMATS.OPENAI_SEARCH)
|
||||
expect(normalizeApiFormatAlias('SEARCH')).toBe(API_FORMATS.OPENAI_SEARCH)
|
||||
expect(normalizeApiFormatAlias('GEMINI_GENERATE_CONTENT')).toBe(API_FORMATS.GEMINI_GENERATE_CONTENT)
|
||||
expect(normalizeApiFormatAlias('OPENAI_EMBEDDING')).toBe(API_FORMATS.OPENAI_EMBEDDING)
|
||||
expect(normalizeApiFormatAlias('OPENAI_RERANK')).toBe(API_FORMATS.OPENAI_RERANK)
|
||||
@@ -33,6 +36,25 @@ describe('api format display helpers', () => {
|
||||
expect(formatApiFormatShort(API_FORMATS.JINA_RERANK)).toBe('JR')
|
||||
})
|
||||
|
||||
it('formats OpenAI Search as a first-class api format', () => {
|
||||
expect(formatApiFormat(API_FORMATS.OPENAI_SEARCH)).toBe('OpenAI Search')
|
||||
expect(formatApiFormatShort(API_FORMATS.OPENAI_SEARCH)).toBe('OS')
|
||||
expect(sortApiFormats([
|
||||
API_FORMATS.OPENAI_EMBEDDING,
|
||||
API_FORMATS.OPENAI_SEARCH,
|
||||
API_FORMATS.OPENAI_RESPONSES,
|
||||
])).toEqual([
|
||||
API_FORMATS.OPENAI_RESPONSES,
|
||||
API_FORMATS.OPENAI_SEARCH,
|
||||
API_FORMATS.OPENAI_EMBEDDING,
|
||||
])
|
||||
})
|
||||
|
||||
it('applies Responses to Search permissions in one direction', () => {
|
||||
expect(apiFormatPermissionCovers('OPENAI_RESPONSES', 'openai:search')).toBe(true)
|
||||
expect(apiFormatPermissionCovers('openai:search', 'openai:responses')).toBe(false)
|
||||
})
|
||||
|
||||
it('formats embedding api format ids distinctly from chat formats', () => {
|
||||
expect(formatApiFormat(API_FORMATS.GEMINI_INTERACTIONS)).toBe('Gemini Interactions')
|
||||
expect(formatApiFormatShort(API_FORMATS.GEMINI_INTERACTIONS)).toBe('GI')
|
||||
|
||||
@@ -6,6 +6,7 @@ export const API_FORMATS = {
|
||||
OPENAI: 'openai:chat',
|
||||
OPENAI_RESPONSES: 'openai:responses',
|
||||
OPENAI_RESPONSES_COMPACT: 'openai:responses:compact',
|
||||
OPENAI_SEARCH: 'openai:search',
|
||||
OPENAI_IMAGE: 'openai:image',
|
||||
OPENAI_VIDEO: 'openai:video',
|
||||
OPENAI_EMBEDDING: 'openai:embedding',
|
||||
@@ -30,6 +31,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
||||
[API_FORMATS.OPENAI_RESPONSES]: 'OpenAI Responses',
|
||||
[API_FORMATS.OPENAI_RESPONSES_COMPACT]: 'OpenAI Responses Compact',
|
||||
[API_FORMATS.OPENAI_SEARCH]: 'OpenAI Search',
|
||||
[API_FORMATS.OPENAI_IMAGE]: 'OpenAI Image',
|
||||
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
||||
[API_FORMATS.OPENAI_EMBEDDING]: 'OpenAI Embedding',
|
||||
@@ -48,6 +50,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
OPENAI: 'OpenAI Chat',
|
||||
OPENAI_RESPONSES: 'OpenAI Responses',
|
||||
OPENAI_RESPONSES_COMPACT: 'OpenAI Responses Compact',
|
||||
OPENAI_SEARCH: 'OpenAI Search',
|
||||
OPENAI_IMAGE: 'OpenAI Image',
|
||||
OPENAI_VIDEO: 'OpenAI Video',
|
||||
OPENAI_EMBEDDING: 'OpenAI Embedding',
|
||||
@@ -69,6 +72,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
||||
[API_FORMATS.OPENAI]: 'O',
|
||||
[API_FORMATS.OPENAI_RESPONSES]: 'OR',
|
||||
[API_FORMATS.OPENAI_RESPONSES_COMPACT]: 'ORC',
|
||||
[API_FORMATS.OPENAI_SEARCH]: 'OS',
|
||||
[API_FORMATS.OPENAI_IMAGE]: 'OI',
|
||||
[API_FORMATS.OPENAI_VIDEO]: 'OV',
|
||||
[API_FORMATS.OPENAI_EMBEDDING]: 'OE',
|
||||
@@ -86,6 +90,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
|
||||
OPENAI: 'O',
|
||||
OPENAI_RESPONSES: 'OR',
|
||||
OPENAI_RESPONSES_COMPACT: 'ORC',
|
||||
OPENAI_SEARCH: 'OS',
|
||||
OPENAI_IMAGE: 'OI',
|
||||
OPENAI_VIDEO: 'OV',
|
||||
OPENAI_EMBEDDING: 'OE',
|
||||
@@ -109,6 +114,7 @@ export const API_FORMAT_ORDER: string[] = [
|
||||
API_FORMATS.OPENAI,
|
||||
API_FORMATS.OPENAI_RESPONSES,
|
||||
API_FORMATS.OPENAI_RESPONSES_COMPACT,
|
||||
API_FORMATS.OPENAI_SEARCH,
|
||||
API_FORMATS.OPENAI_EMBEDDING,
|
||||
API_FORMATS.OPENAI_RERANK,
|
||||
API_FORMATS.OPENAI_IMAGE,
|
||||
@@ -140,6 +146,7 @@ export const API_FORMAT_KIND_LABELS: Record<string, string> = {
|
||||
chat: 'Chat',
|
||||
responses: 'Responses',
|
||||
'responses:compact': 'Responses Compact',
|
||||
search: 'Search',
|
||||
messages: 'Messages',
|
||||
generate_content: 'Generate Content',
|
||||
interactions: 'Interactions',
|
||||
@@ -174,6 +181,9 @@ export function normalizeApiFormatAlias(format: string | null | undefined): stri
|
||||
return API_FORMATS.OPENAI_RESPONSES
|
||||
case 'OPENAI_RESPONSES_COMPACT':
|
||||
return API_FORMATS.OPENAI_RESPONSES_COMPACT
|
||||
case 'OPENAI_SEARCH':
|
||||
case 'SEARCH':
|
||||
return API_FORMATS.OPENAI_SEARCH
|
||||
case 'OPENAI_IMAGE':
|
||||
return API_FORMATS.OPENAI_IMAGE
|
||||
case 'OPENAI_VIDEO':
|
||||
@@ -216,6 +226,18 @@ export function normalizeApiFormatAlias(format: string | null | undefined): stri
|
||||
}
|
||||
}
|
||||
|
||||
export function apiFormatPermissionCovers(
|
||||
allowedFormat: string | null | undefined,
|
||||
requestedFormat: string | null | undefined,
|
||||
): boolean {
|
||||
const allowed = normalizeApiFormatAlias(allowedFormat)
|
||||
const requested = normalizeApiFormatAlias(requestedFormat)
|
||||
return Boolean(allowed)
|
||||
&& Boolean(requested)
|
||||
&& (allowed === requested
|
||||
|| (allowed === API_FORMATS.OPENAI_RESPONSES && requested === API_FORMATS.OPENAI_SEARCH))
|
||||
}
|
||||
|
||||
// 工具函数:按 family 分组并排序 API 格式数组
|
||||
export interface ApiFormatGroup {
|
||||
family: string
|
||||
@@ -272,14 +294,16 @@ export function formatApiFormatShort(format: string | null | undefined): string
|
||||
|
||||
// 工具函数:按标准顺序排序 API 格式数组
|
||||
export function sortApiFormats(formats: string[]): string[] {
|
||||
return [...formats].sort((a, b) => {
|
||||
const aIdx = API_FORMAT_ORDER.indexOf(normalizeApiFormatAlias(a))
|
||||
const bIdx = API_FORMAT_ORDER.indexOf(normalizeApiFormatAlias(b))
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
return [...formats].sort(compareApiFormats)
|
||||
}
|
||||
|
||||
export function compareApiFormats(a: string, b: string): number {
|
||||
const aIdx = API_FORMAT_ORDER.indexOf(normalizeApiFormatAlias(a))
|
||||
const bIdx = API_FORMAT_ORDER.indexOf(normalizeApiFormatAlias(b))
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
}
|
||||
|
||||
// openai family 格式只支持 bearer(Authorization header),不允许覆盖认证方式
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ProviderModelMapping } from './provider'
|
||||
export interface CacheTTLPricing {
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 单个价格阶梯配置 */
|
||||
@@ -16,22 +17,39 @@ export interface PricingTier {
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: CacheTTLPricing[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface ImageOutputQualityPricing extends Partial<Record<ImageOutputQuality, number>> {
|
||||
[quality: string]: unknown
|
||||
}
|
||||
|
||||
export interface ImageOutputPriceRange {
|
||||
up_to_pixels: number | null
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
prices: ImageOutputQualityPricing
|
||||
label?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
|
||||
export interface ProcessingTierPricingConfig {
|
||||
tiers?: PricingTier[]
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 阶梯计费配置 */
|
||||
export interface TieredPricingConfig {
|
||||
tiers: PricingTier[]
|
||||
image_output_prices?: Record<string, Record<string, number>> | null
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
processing_tiers?: Record<string, ProcessingTierPricingConfig> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
@@ -270,6 +288,8 @@ export interface UpstreamModel {
|
||||
id: string
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
visibility?: string
|
||||
supported_in_api?: boolean
|
||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface UsageRecordDetail {
|
||||
model: string
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -369,6 +370,7 @@ export const meApi = {
|
||||
target_model?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { PricingTier, TieredPricingConfig } from './endpoints/types'
|
||||
|
||||
export interface ModelsDevTokenCost {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache_read?: number
|
||||
cache_write?: number
|
||||
input_audio?: number
|
||||
output_audio?: number
|
||||
}
|
||||
|
||||
export interface ModelsDevCostTier extends ModelsDevTokenCost {
|
||||
tier: {
|
||||
type: 'context'
|
||||
size: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelsDevCost extends ModelsDevTokenCost {
|
||||
tiers?: ModelsDevCostTier[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPrice(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function parseTokenPrices(value: unknown): Omit<PricingTier, 'up_to'> | null {
|
||||
if (!isRecord(value) || !isPrice(value.input) || !isPrice(value.output)) return null
|
||||
if (value.cache_write !== undefined && !isPrice(value.cache_write)) return null
|
||||
if (value.cache_read !== undefined && !isPrice(value.cache_read)) return null
|
||||
|
||||
return {
|
||||
input_price_per_1m: value.input,
|
||||
output_price_per_1m: value.output,
|
||||
...(value.cache_write === undefined
|
||||
? {}
|
||||
: { cache_creation_price_per_1m: value.cache_write }),
|
||||
...(value.cache_read === undefined
|
||||
? {}
|
||||
: { cache_read_price_per_1m: value.cache_read }),
|
||||
}
|
||||
}
|
||||
|
||||
function parseContextTier(value: unknown): { size: number; prices: Omit<PricingTier, 'up_to'> } | null {
|
||||
if (!isRecord(value) || !isRecord(value.tier)) return null
|
||||
if (
|
||||
value.tier.type !== 'context'
|
||||
|| typeof value.tier.size !== 'number'
|
||||
|| !Number.isSafeInteger(value.tier.size)
|
||||
|| value.tier.size < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const prices = parseTokenPrices(value)
|
||||
return prices ? { size: value.tier.size, prices } : null
|
||||
}
|
||||
|
||||
export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig | null {
|
||||
const basePrices = parseTokenPrices(cost)
|
||||
if (!basePrices || !isRecord(cost)) return null
|
||||
|
||||
const rawTiers = cost.tiers
|
||||
if (rawTiers !== undefined && !Array.isArray(rawTiers)) return null
|
||||
const contextTiers = (rawTiers ?? []).map(parseContextTier)
|
||||
if (contextTiers.some(tier => tier === null)) return null
|
||||
|
||||
const sortedTiers = contextTiers
|
||||
.filter((tier): tier is NonNullable<typeof tier> => tier !== null)
|
||||
.sort((a, b) => a.size - b.size)
|
||||
if (sortedTiers.some((tier, index) => index > 0 && tier.size === sortedTiers[index - 1].size)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = []
|
||||
if (sortedTiers[0]?.size !== 0) {
|
||||
tiers.push({
|
||||
...basePrices,
|
||||
up_to: sortedTiers[0] ? sortedTiers[0].size - 1 : null,
|
||||
})
|
||||
}
|
||||
tiers.push(...sortedTiers.map((tier, index) => ({
|
||||
...tier.prices,
|
||||
up_to: sortedTiers[index + 1] ? sortedTiers[index + 1].size - 1 : null,
|
||||
})))
|
||||
|
||||
return { tiers }
|
||||
}
|
||||
|
||||
export function resolveModelsDevTieredPricing(
|
||||
_providerId: string,
|
||||
_modelId: string,
|
||||
cost: unknown,
|
||||
): TieredPricingConfig | null {
|
||||
// Provider/model identities must never inject local prices over the fetched catalog.
|
||||
return buildModelsDevTieredPricing(cost)
|
||||
}
|
||||
@@ -4,19 +4,19 @@
|
||||
*/
|
||||
|
||||
import api from './client'
|
||||
import {
|
||||
resolveModelsDevTieredPricing,
|
||||
type ModelsDevCost,
|
||||
} from './models-dev-pricing'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
|
||||
export type { ModelsDevCost, ModelsDevCostTier, ModelsDevTokenCost } from './models-dev-pricing'
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_KEY = 'models_dev_cache'
|
||||
const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟
|
||||
|
||||
// Models.dev API 数据结构
|
||||
export interface ModelsDevCost {
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cache_read?: number
|
||||
}
|
||||
|
||||
export interface ModelsDevLimit {
|
||||
context?: number
|
||||
output?: number
|
||||
@@ -64,6 +64,7 @@ export interface ModelsDevModelItem {
|
||||
family?: string
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
tieredPricing?: TieredPricingConfig
|
||||
contextLimit?: number
|
||||
outputLimit?: number
|
||||
supportsVision?: boolean
|
||||
@@ -165,14 +166,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
if (!provider.models) continue
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const tieredPricing = resolveModelsDevTieredPricing(providerId, modelId, model.cost)
|
||||
const basePricingTier = tieredPricing?.tiers[0]
|
||||
items.push({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
modelId,
|
||||
modelName: model.name || modelId,
|
||||
family: model.family,
|
||||
inputPrice: model.cost?.input,
|
||||
outputPrice: model.cost?.output,
|
||||
inputPrice: basePricingTier?.input_price_per_1m ?? model.cost?.input,
|
||||
outputPrice: basePricingTier?.output_price_per_1m ?? model.cost?.output,
|
||||
tieredPricing: tieredPricing ?? undefined,
|
||||
contextLimit: model.limit?.context,
|
||||
outputLimit: model.limit?.output,
|
||||
supportsVision: model.input?.includes('image'),
|
||||
|
||||
+28
-15
@@ -1,6 +1,8 @@
|
||||
import apiClient from './client'
|
||||
import { buildCacheKey, cache, cachedRequest } from '@/utils/cache'
|
||||
|
||||
const MODULE_MANAGEMENT_ORDER_CONFIG_KEY = 'module_management.extension_order'
|
||||
const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs'
|
||||
|
||||
export interface ModuleStatus {
|
||||
name: string
|
||||
@@ -160,19 +162,30 @@ function normalizePlaceholderPrefix(value: unknown): string {
|
||||
: CHAT_PII_REDACTION_DEFAULT_CONFIG.placeholder_prefix
|
||||
}
|
||||
|
||||
async function getSystemConfigValue(key: string): Promise<unknown> {
|
||||
const response = await apiClient.get<{ key: string; value: unknown }>(`/api/admin/system/configs/${key}`)
|
||||
return response.data.value
|
||||
}
|
||||
|
||||
async function updateSystemConfigValue(key: string, value: unknown, description: string) {
|
||||
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
|
||||
`/api/admin/system/configs/${key}`,
|
||||
{ value, description },
|
||||
)
|
||||
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
|
||||
cache.delete(buildCacheKey('admin:system:config', { key }))
|
||||
return response.data.value
|
||||
}
|
||||
|
||||
async function getAllSystemConfigValues(): Promise<Map<string, unknown>> {
|
||||
const configs = await cachedRequest(
|
||||
ALL_SYSTEM_CONFIGS_CACHE_KEY,
|
||||
async () => {
|
||||
const response = await apiClient.get<Array<{ key: string; value: unknown }>>(
|
||||
'/api/admin/system/configs'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
return new Map(configs.map(config => [config.key, config.value]))
|
||||
}
|
||||
|
||||
export const modulesApi = {
|
||||
/**
|
||||
* 获取所有模块状态(管理员)
|
||||
@@ -202,6 +215,7 @@ export const modulesApi = {
|
||||
`/api/admin/modules/status/${moduleName}/enabled`,
|
||||
{ enabled }
|
||||
)
|
||||
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -227,22 +241,21 @@ export const modulesApi = {
|
||||
description: '模块管理扩展模块展示顺序',
|
||||
},
|
||||
)
|
||||
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
|
||||
cache.delete(buildCacheKey('admin:system:config', {
|
||||
key: MODULE_MANAGEMENT_ORDER_CONFIG_KEY,
|
||||
}))
|
||||
return normalizeModuleManagementOrder(response.data.value)
|
||||
},
|
||||
|
||||
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
|
||||
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.rules),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix),
|
||||
])
|
||||
const configsByKey = await getAllSystemConfigValues()
|
||||
|
||||
return normalizeChatPiiRedactionConfig({
|
||||
enabled,
|
||||
rules,
|
||||
cache_ttl_seconds: cacheTtlSeconds,
|
||||
placeholder_prefix: placeholderPrefix,
|
||||
enabled: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
|
||||
rules: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.rules),
|
||||
cache_ttl_seconds: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
|
||||
placeholder_prefix: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix),
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -332,10 +332,22 @@ export interface GatewayUsageRuntimeMetrics {
|
||||
workerProcessFailuresTotal: number | null
|
||||
workerReadFailuresTotal: number | null
|
||||
workerReclaimFailuresTotal: number | null
|
||||
terminalSubmissionLimit: number | null
|
||||
terminalSubmissionInFlight: number | null
|
||||
terminalSubmissionMaxInFlight: number | null
|
||||
terminalSubmissionRejectedTotal: number | null
|
||||
terminalEnqueueInFlight: number | null
|
||||
terminalEnqueueDeferredTotal: number | null
|
||||
terminalEnqueueDeferredDirectWriteTotal: number | null
|
||||
terminalEnqueueDeferredDroppedTotal: number | null
|
||||
terminalEnqueueDeferredRetryTotal: number | null
|
||||
terminalEnqueueFailedTotal: number | null
|
||||
terminalDirectFallbackLimit: number | null
|
||||
terminalDirectFallbackInFlight: number | null
|
||||
terminalDirectFallbackMaxInFlight: number | null
|
||||
terminalDirectFallbackSucceededTotal: number | null
|
||||
terminalDirectFallbackFailedTotal: number | null
|
||||
terminalDirectFallbackRejectedTotal: number | null
|
||||
lifecycleEnqueueInFlight: number | null
|
||||
lifecycleEnqueueDeferredTotal: number | null
|
||||
lifecycleEnqueueDeferredDroppedTotal: number | null
|
||||
@@ -673,10 +685,22 @@ function buildUsageRuntimeMetrics(samples: PrometheusSample[]): GatewayUsageRunt
|
||||
workerProcessFailuresTotal: findMetricValueNumber(samples, 'usage_runtime_queue_worker_process_failures_total'),
|
||||
workerReadFailuresTotal: findMetricValueNumber(samples, 'usage_runtime_queue_worker_read_failures_total'),
|
||||
workerReclaimFailuresTotal: findMetricValueNumber(samples, 'usage_runtime_queue_worker_reclaim_failures_total'),
|
||||
terminalSubmissionLimit: findMetricValueNumber(samples, 'usage_runtime_terminal_submission_limit'),
|
||||
terminalSubmissionInFlight: findMetricValueNumber(samples, 'usage_runtime_terminal_submission_in_flight'),
|
||||
terminalSubmissionMaxInFlight: findMetricValueNumber(samples, 'usage_runtime_terminal_submission_max_in_flight'),
|
||||
terminalSubmissionRejectedTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_submission_rejected_total'),
|
||||
terminalEnqueueInFlight: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_in_flight'),
|
||||
terminalEnqueueDeferredTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_deferred_total'),
|
||||
terminalEnqueueDeferredDirectWriteTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_deferred_direct_write_total'),
|
||||
terminalEnqueueDeferredDroppedTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_deferred_dropped_total'),
|
||||
terminalEnqueueDeferredRetryTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_deferred_retry_total'),
|
||||
terminalEnqueueFailedTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_enqueue_failed_total'),
|
||||
terminalDirectFallbackLimit: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_limit'),
|
||||
terminalDirectFallbackInFlight: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_in_flight'),
|
||||
terminalDirectFallbackMaxInFlight: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_max_in_flight'),
|
||||
terminalDirectFallbackSucceededTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_succeeded_total'),
|
||||
terminalDirectFallbackFailedTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_failed_total'),
|
||||
terminalDirectFallbackRejectedTotal: findMetricValueNumber(samples, 'usage_runtime_terminal_direct_fallback_rejected_total'),
|
||||
lifecycleEnqueueInFlight: findMetricValueNumber(samples, 'usage_runtime_lifecycle_enqueue_in_flight'),
|
||||
lifecycleEnqueueDeferredTotal: findMetricValueNumber(samples, 'usage_runtime_lifecycle_enqueue_deferred_total'),
|
||||
lifecycleEnqueueDeferredDroppedTotal: findMetricValueNumber(samples, 'usage_runtime_lifecycle_enqueue_deferred_dropped_total'),
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface UsageRecord {
|
||||
model: string
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
@@ -43,6 +44,8 @@ export interface UsageStats {
|
||||
total_cost: number
|
||||
total_actual_cost?: number
|
||||
avg_response_time: number
|
||||
error_count?: number
|
||||
error_rate?: number
|
||||
today?: {
|
||||
requests: number
|
||||
tokens: number
|
||||
@@ -567,6 +570,7 @@ export const usageApi = {
|
||||
target_model?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
image_progress?: ImageProgress | null
|
||||
}>
|
||||
}> {
|
||||
|
||||
Reference in New Issue
Block a user