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:
MMEXA
2026-07-16 23:43:04 +08:00
1257 changed files with 80521 additions and 35495 deletions
@@ -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 })
})
})
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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 事件,这里主动广播一次。
+16 -2
View File
@@ -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
)
},
+46 -8
View File
@@ -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')
+32 -8
View File
@@ -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 格式只支持 bearerAuthorization header),不允许覆盖认证方式
+22 -2
View File
@@ -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
}
+2
View File
@@ -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 } : {}
+101
View File
@@ -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)
}
+13 -9
View File
@@ -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
View File
@@ -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),
})
},
+24
View File
@@ -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'),
+4
View File
@@ -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
}>
}> {