15 Commits

Author SHA1 Message Date
fawney19
b89a4af0cf refactor: 统一 HTTP 客户端超时配置
将 HTTPClientPool 中硬编码的超时参数改为使用可配置的环境变量,提高系统的灵活性和可维护性。

- 添加 HTTP_READ_TIMEOUT 环境变量配置(默认 300 秒)
- 统一所有 HTTP 客户端创建逻辑使用配置化超时
- 改进变量命名清晰性(config -> default_config 或 client_config)
2025-12-30 15:06:55 +08:00
fawney19
a56854af43 feat: 为 GlobalModel 添加关联提供商查询 API
添加新的 API 端点 GET /api/admin/models/global/{global_model_id}/providers,用于获取 GlobalModel 的所有关联提供商(包括非活跃的)。

- 后端:实现 AdminGetGlobalModelProvidersAdapter 适配器
- 前端:使用新 API 替换原有的 ModelCatalog 获取方式
- 数据库:改进初始化时的错误提示和连接异常处理
2025-12-30 14:47:35 +08:00
fawney19
4a35d78c8d fix: 修正 README 中的图片文件扩展名 2025-12-30 09:44:45 +08:00
fawney19
26b281271e docs: 更新许可证说明、README 文档和模拟数据;调整模型版本至最新 2025-12-30 09:41:03 +08:00
fawney19
96094cfde2 fix: 调整仪表盘普通用户月度统计显示,添加月度费用字段 2025-12-29 18:28:37 +08:00
fawney19
7e26af5476 fix: 修复仪表盘缓存命中率和配额显示逻辑
- 本月缓存命中率改为使用本月数据计算(monthly_input_tokens + cache_read_tokens),而非全时数据
- 修复配额显示:有配额时显示实际金额,无配额时显示为 /bin/zsh 并标记为高警告状态
2025-12-29 18:12:33 +08:00
fawney19
c8dfb784bc fix: 修复仪表盘月份统计逻辑,改为自然月而非过去30天 2025-12-29 17:55:42 +08:00
fawney19
fd3a5a5afe refactor: 将模型测试功能从 ModelsTab 移到 KeyAllowedModelsDialog 2025-12-29 17:44:02 +08:00
fawney19
599b3d4c95 feat: 添加 Provider API Key 查看和复制功能
- 后端添加 GET /api/admin/endpoints/keys/{key_id}/reveal 接口
- 前端密钥列表添加眼睛按钮(显示/隐藏完整密钥)和复制按钮
- 关闭抽屉时自动清除已显示的密钥(安全考虑)

Fixes #53
2025-12-29 17:14:26 +08:00
fawney19
41719a00e7 refactor: 改进分布式任务锁的清理策略
实现两种锁清理模式:
- 单实例模式(默认):启动时使用 Lua 脚本原子性清理旧锁,解决 worker 重启时���锁残留问题
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出

可通过 SINGLE_INSTANCE_MODE 环境变量控制模式选择。
2025-12-28 21:34:43 +08:00
fawney19
b5c0f85dca refactor: 统一剪贴板复制功能到 useClipboard 组合式函数
将各个组件和视图中重复的剪贴板复制逻辑提取到 useClipboard 组合式函数。
增加 showToast 参数支持静默复制,减少代码重复,提高维护性。
2025-12-28 20:41:52 +08:00
fawney19
7d6d262ed3 feat: 增加用户密码修改时的确认验证
在编辑用户时,如果填写了新密码,需要进行密码确认,确保两次输入一致。
同时更新后端请求模型以支持密码字段。
2025-12-28 20:00:25 +08:00
fawney19
e21acd73eb fix: 修复模型映射中重复关联的问题
在批量分配模型和编辑模型映射时,需要检查不仅是主模型名是否已关联,
还要检查其映射名称是否已存在,防止同一个上游模型被重复关联。
2025-12-28 19:40:07 +08:00
fawney19
702f9bc5f1 fix: 修复缓存监控页面TTL分析时间段选择器点击无响应
为 Select 组件添加 v-model:open 绑定,解决 radix-vue Select 组件
在某些情况下点击无响应的问题。

Fixes #55
2025-12-28 19:14:49 +08:00
fawney19
d0ce798881 fix: TTL=0时启用Key随机轮换模式
- 当所有Key的cache_ttl_minutes都为0时,使用随机排序代替确定性哈希
- 将hashlib和random的import移到文件顶部
- 简化单Key场景的处理逻辑

Closes #57
2025-12-28 19:07:25 +08:00
37 changed files with 686 additions and 301 deletions

15
LICENSE
View File

@@ -5,12 +5,17 @@ Aether 非商业开源许可证
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
1. 仅限非商业用途
本软件不得用于商业目的。商业目的包括但不限于:
1. 仅限非盈利用途
本软件不得用于盈利目的。盈利目的包括但不限于:
- 出售本软件或任何衍生作品
- 使用本软件提供付费服务
- 将本软件用于商业产品或服务
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
- 将本软件用于以盈利为目的的商业产品或服务
以下用途被明确允许:
- 个人学习和研究
- 教育机构的教学和研究
- 非盈利组织的内部使用
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
2. 署名要求
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
您不得以不同的条款将本软件再许可给他人。
5. 商业许可
如需商业使用,请联系版权持有人以获取单独的商业许可。
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何

View File

@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡?
@@ -162,4 +162,16 @@ cd frontend && npm install && npm run dev
## 许可证
本项目采用 [Aether 非商业开源许可证](LICENSE)。
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
## 联系作者
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
</p>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)

BIN
docs/author/qq_qrcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {

View File

@@ -4,7 +4,8 @@ import type {
GlobalModelUpdate,
GlobalModelResponse,
GlobalModelWithStats,
GlobalModelListResponse
GlobalModelListResponse,
ModelCatalogProviderDetail,
} from './types'
/**
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
)
return response.data
}
/**
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
*/
export async function getGlobalModelProviders(globalModelId: string): Promise<{
providers: ModelCatalogProviderDetail[]
total: number
}> {
const response = await client.get(
`/api/admin/models/global/${globalModelId}/providers`
)
return response.data
}

View File

@@ -110,6 +110,14 @@ export async function updateEndpointKey(
return response.data
}
/**
* 获取完整的 API Key用于查看和复制
*/
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
return response.data
}
/**
* 删除 Endpoint Key
*/

View File

@@ -20,4 +20,5 @@ export {
updateGlobalModel,
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
} from './endpoints/global-models'

View File

@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
export function useClipboard() {
const { success, error: showError } = useToast()
async function copyToClipboard(text: string): Promise<boolean> {
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
if (showToast) success('已复制到剪贴板')
return true
}
@@ -25,17 +25,17 @@ export function useClipboard() {
try {
const successful = document.execCommand('copy')
if (successful) {
success('已复制到剪贴板')
if (showToast) success('已复制到剪贴板')
return true
}
showError('复制失败,请手动复制')
if (showToast) showError('复制失败,请手动复制')
return false
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
log.error('复制失败:', err)
showError('复制失败,请手动选择文本进行复制')
if (showToast) showError('复制失败,请手动选择文本进行复制')
return false
}
}

View File

@@ -700,6 +700,7 @@ import {
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
@@ -731,6 +732,7 @@ const emit = defineEmits<{
'refreshProviders': []
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props {
model: GlobalModelResponse | null
@@ -763,16 +765,6 @@ function handleClose() {
}
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
// 格式化日期
function formatDate(dateStr: string): string {
if (!dateStr) return '-'

View File

@@ -433,11 +433,17 @@ const availableGlobalModels = computed(() => {
)
})
// 计算可添加的上游模型(排除已关联的)
// 计算可添加的上游模型(排除已关联的,包括主模型名和映射名称
const availableUpstreamModelsBase = computed(() => {
const existingModelNames = new Set(
existingModels.value.map(m => m.provider_model_name)
)
const existingModelNames = new Set<string>()
for (const m of existingModels.value) {
// 主模型名
existingModelNames.add(m.provider_model_name)
// 映射名称
for (const mapping of m.provider_model_mappings ?? []) {
if (mapping.name) existingModelNames.add(mapping.name)
}
}
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
})

View File

@@ -116,6 +116,19 @@
{{ model.global_model_name }}
</div>
</div>
<!-- 测试按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试模型连接"
:disabled="testingModelName === model.global_model_name"
@click.stop="testModelConnection(model)"
>
<Loader2 v-if="testingModelName === model.global_model_name" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
@@ -148,16 +161,17 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import {
updateEndpointKey,
getProviderAvailableSourceModels,
testModel,
type EndpointAPIKey,
type ProviderAvailableSourceModel
} from '@/api/endpoints'
@@ -181,6 +195,7 @@ const loadingModels = ref(false)
const availableModels = ref<ProviderAvailableSourceModel[]>([])
const selectedModels = ref<string[]>([])
const initialModels = ref<string[]>([])
const testingModelName = ref<string | null>(null)
// 监听对话框打开
watch(() => props.open, (open) => {
@@ -268,6 +283,32 @@ function clearModels() {
selectedModels.value = []
}
// 测试模型连接
async function testModelConnection(model: ProviderAvailableSourceModel) {
if (!props.providerId || !props.apiKey || testingModelName.value) return
testingModelName.value = model.global_model_name
try {
const result = await testModel({
provider_id: props.providerId,
model_name: model.provider_model_name,
api_key_id: props.apiKey.id,
message: "hello"
})
if (result.success) {
success(`模型 "${model.display_name}" 测试成功`)
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelName.value = null
}
}
function areArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort()

View File

@@ -449,7 +449,17 @@ interface UpstreamModelGroup {
}
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
// 收集当前表单已添加的名称
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
// 收集所有已存在的映射名称(包括主模型名和映射名称)
for (const m of props.models) {
addedNames.add(m.provider_model_name)
for (const mapping of m.provider_model_mappings ?? []) {
if (mapping.name) addedNames.add(mapping.name)
}
}
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
const groups = new Map<string, UpstreamModelGroup>()

View File

@@ -337,8 +337,40 @@
{{ key.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<div class="text-[10px] font-mono text-muted-foreground truncate">
{{ key.api_key_masked }}
<div class="flex items-center gap-1">
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
:disabled="revealingKeyId === key.id"
@click.stop="toggleKeyReveal(key)"
>
<Loader2
v-if="revealingKeyId === key.id"
class="w-3 h-3 animate-spin"
/>
<EyeOff
v-else-if="revealedKeys.has(key.id)"
class="w-3 h-3"
/>
<Eye
v-else
class="w-3 h-3"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-3 h-3" />
</Button>
</div>
</div>
<div class="flex items-center gap-1.5 ml-auto shrink-0">
@@ -654,13 +686,16 @@ import {
Power,
Layers,
GripVertical,
Copy
Copy,
Eye,
EyeOff
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
import {
KeyFormDialog,
@@ -680,6 +715,7 @@ import {
updateEndpoint,
updateEndpointKey,
batchUpdateKeyPriority,
revealEndpointKey,
type ProviderEndpoint,
type EndpointAPIKey,
type Model
@@ -706,6 +742,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
const loading = ref(false)
const provider = ref<any>(null)
@@ -729,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
const revealingKeyId = ref<string | null>(null)
// 模型相关状态
const modelFormDialogOpen = ref(false)
const editingModel = ref<Model | null>(null)
@@ -798,6 +839,9 @@ watch(() => props.open, (newOpen) => {
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
}
})
@@ -886,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
keyAllowedModelsDialogOpen.value = true
}
// 切换密钥显示/隐藏
async function toggleKeyReveal(key: EndpointAPIKey) {
if (revealedKeys.value.has(key.id)) {
// 已显示,隐藏它
revealedKeys.value.delete(key.id)
return
}
// 未显示,调用 API 获取完整密钥
revealingKeyId.value = key.id
try {
const result = await revealEndpointKey(key.id)
revealedKeys.value.set(key.id, result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
} finally {
revealingKeyId.value = null
}
}
// 复制完整密钥
async function copyFullKey(key: EndpointAPIKey) {
// 如果已经显示了,直接复制
if (revealedKeys.value.has(key.id)) {
copyToClipboard(revealedKeys.value.get(key.id)!)
return
}
// 否则先获取再复制
try {
const result = await revealEndpointKey(key.id)
copyToClipboard(result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
}
}
function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key
deleteKeyConfirmOpen.value = true
@@ -1250,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400'
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
}
// 加载 Provider 信息
async function loadProvider() {
if (!props.providerId) return

View File

@@ -156,17 +156,6 @@
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
@click="testModelConnection(model)"
>
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -220,13 +209,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image, Loader2, Play } from 'lucide-vue-next'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
import { useClipboard } from '@/composables/useClipboard'
import { getProviderModels, type Model } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser'
const props = defineProps<{
provider: any
@@ -239,12 +228,12 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
const models = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
// 按名称排序的模型列表
const sortedModels = computed(() => {
@@ -257,12 +246,7 @@ const sortedModels = computed(() => {
// 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) {
try {
await navigator.clipboard.writeText(modelId)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
await copyToClipboard(modelId)
}
// 加载模型
@@ -393,39 +377,6 @@ async function toggleModelActive(model: Model) {
}
}
// 测试模型连接性
async function testModelConnection(model: Model) {
if (testingModelId.value) return
testingModelId.value = model.id
try {
const result = await testModel({
provider_id: props.provider.id,
model_name: model.provider_model_name,
message: "hello"
})
if (result.success) {
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
// 如果有响应内容,可以显示更多信息
if (result.data?.response?.choices?.[0]?.message?.content) {
const content = result.data.response.choices[0].message.content
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
} else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
}
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelId.value = null
}
}
onMounted(() => {
loadModels()
})

View File

@@ -473,6 +473,7 @@
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Separator from '@/components/ui/separator.vue'
@@ -505,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
const currentExpandDepth = ref(1)
const dataSource = ref<'client' | 'provider'>('client')
const { copyToClipboard } = useClipboard()
const historicalPricing = ref<{
input_price: string
output_price: string
@@ -784,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
}
if (data) {
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
copyToClipboard(JSON.stringify(data, null, 2), false)
copiedStates.value[tabName] = true
setTimeout(() => {
copiedStates.value[tabName] = false

View File

@@ -86,6 +86,34 @@
</p>
</div>
<div
v-if="isEditMode && form.password.length > 0"
class="space-y-2"
>
<Label class="text-sm font-medium">
确认新密码 <span class="text-muted-foreground">*</span>
</Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
:name="`confirm-${formNonce}`"
required
minlength="6"
placeholder="再次输入新密码"
class="h-10"
/>
<p
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
class="text-xs text-destructive"
>
两次输入的密码不一致
</p>
</div>
<div class="space-y-2">
<Label
for="form-email"
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
const form = ref({
username: '',
password: '',
confirmPassword: '',
email: '',
quota: 10,
role: 'user' as 'admin' | 'user',
@@ -443,6 +472,7 @@ function resetForm() {
form.value = {
username: '',
password: '',
confirmPassword: '',
email: '',
quota: 10,
role: 'user',
@@ -461,6 +491,7 @@ function loadUserData() {
form.value = {
username: props.user.username,
password: '',
confirmPassword: '',
email: props.user.email || '',
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
role: props.user.role,
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0
const hasEmail = form.value.email.trim().length > 0
const hasPassword = isEditMode.value || form.value.password.length >= 6
return hasUsername && hasEmail && hasPassword
// 编辑模式下如果填写了密码,必须确认密码一致
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
return hasUsername && hasEmail && hasPassword && passwordConfirmed
})
// 加载访问控制选项

View File

@@ -5,7 +5,7 @@
import type { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser, ApiKey } from '@/api/users'
import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
output: 700000,
cache_creation: 50000,
cache_read: 200000
}
},
// 普通用户专用字段
monthly_cost: 45.67
}
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
]
})
}
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
return {
daily_stats: dailyStats,
model_summary: [
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
],
period: {
start_date: dailyStats[0].date,
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
// ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [
export const MOCK_USER_API_KEYS = [
{
id: 'key-uuid-001',
key_display: 'sk-ae...x7f9',
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 1234,
total_cost_usd: 45.67
total_cost_usd: 45.67,
force_capabilities: null
},
{
id: 'key-uuid-002',
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 5678,
total_cost_usd: 123.45
total_cost_usd: 123.45,
force_capabilities: { cache_1h: true }
},
{
id: 'key-uuid-003',
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: false,
is_standalone: false,
total_requests: 100,
total_cost_usd: 2.34
total_cost_usd: 2.34,
force_capabilities: null
}
]
@@ -813,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
quota_usd: 100,
used_usd: 45.32,
summary_by_model: [
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
],
records: [
{
id: 'usage-001',
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500,
output_tokens: 800,
total_tokens: 2300,
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
{
id: 'usage-002',
provider: 'openai',
model: 'gpt-4o',
model: 'gpt-5.1',
input_tokens: 2000,
output_tokens: 500,
total_tokens: 2500,

View File

@@ -405,10 +405,10 @@ function getUsageRecords() {
// Mock 映射数据
const MOCK_ALIASES = [
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
]
// Mock Endpoint Keys
@@ -2172,10 +2172,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色)
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-opus-4-20250514'
'claude-sonnet-4-5-20250929',
'claude-haiku-4-5-20251001',
'claude-opus-4-5-20251101',
'gpt-5.1'
]
// 生成模拟的请求间隔数据

View File

@@ -650,6 +650,7 @@
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import {
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const apiKeys = ref<AdminApiKey[]>([])
const loading = ref(false)
@@ -927,20 +929,14 @@ function selectKey() {
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value)
success('API Key 已复制到剪贴板')
} catch {
error('复制失败,请手动复制')
}
await copyToClipboard(newKeyValue.value)
}
async function copyKeyPrefix(apiKey: AdminApiKey) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
await copyToClipboard(response.key)
} catch (err) {
log.error('复制密钥失败:', err)
error('复制失败,请重试')

View File

@@ -46,6 +46,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000))
const analysisHoursSelectOpen = ref(false)
// ==================== 模型映射缓存 ====================
@@ -1056,7 +1057,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours">
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />
</SelectTrigger>

View File

@@ -713,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
import type { Model } from '@/api/endpoints'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useRowClick } from '@/composables/useRowClick'
import { parseApiError } from '@/utils/errorParser'
import {
@@ -736,6 +737,7 @@ import {
updateGlobalModel,
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
type GlobalModelResponse,
} from '@/api/global-models'
import { log } from '@/utils/logger'
@@ -743,6 +745,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
@@ -1066,16 +1069,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
selectModel(model)
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
success('已复制')
} catch {
showError('复制失败')
}
}
async function selectModel(model: GlobalModelResponse) {
selectedModel.value = model
detailTab.value = 'basic'
@@ -1088,18 +1081,11 @@ async function selectModel(model: GlobalModelResponse) {
async function loadModelProviders(_globalModelId: string) {
loadingModelProviders.value = true
try {
// 使用 ModelCatalog API 获取详细的关联提供商信息
const { getModelCatalog } = await import('@/api/endpoints')
const catalogResponse = await getModelCatalog()
// 使用新的 API 获取所有关联提供商(包括非活跃的)
const response = await getGlobalModelProviders(_globalModelId)
// 查找当前 GlobalModel 对应的 catalog item
const catalogItem = catalogResponse.models.find(
m => m.global_model_name === selectedModel.value?.name
)
if (catalogItem) {
// 转换为展示格式,包含完整的模型实现信息
selectedModelProviders.value = catalogItem.providers.map(p => ({
// 转换为展示格式
selectedModelProviders.value = response.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
@@ -1121,9 +1107,6 @@ async function loadModelProviders(_globalModelId: string) {
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
}))
} else {
selectedModelProviders.value = []
}
} catch (err: any) {
log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误')

View File

@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage'
import { adminApi } from '@/api/admin'
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
const { success, error } = useToast()
const { confirmDanger, confirmWarning } = useConfirm()
const { copyToClipboard } = useClipboard()
const usersStore = useUsersStore()
// 用户表单对话框状态
@@ -1001,12 +1003,7 @@ function selectApiKey() {
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newApiKey.value)
success('API Key已复制到剪贴板')
} catch {
error('复制失败,请手动复制')
}
await copyToClipboard(newApiKey.value)
}
async function closeNewApiKeyDialog() {
@@ -1035,8 +1032,7 @@ async function copyFullKey(apiKey: any) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
await copyToClipboard(response.key)
} catch (err: any) {
log.error('复制密钥失败:', err)
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')

View File

@@ -145,10 +145,10 @@
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
实际成
月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatCurrency(costStats.total_actual_cost) }}
{{ formatCurrency(costStats.total_cost) }}
</p>
<Badge
v-if="costStats.cost_savings > 0"
@@ -162,14 +162,14 @@
</div>
</div>
<!-- 普通用户缓存统计 -->
<!-- 普通用户月度统计 -->
<div
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
class="mt-6"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium text-foreground">
本月缓存使用
本月统计
</h3>
<Badge
variant="outline"
@@ -178,8 +178,16 @@
Monthly
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
<div
:class="[
'grid gap-2 sm:gap-3',
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]"
>
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/30"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -190,7 +198,10 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-kraft/30">
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-kraft/30"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -201,7 +212,10 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/25"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -213,19 +227,16 @@
</div>
</Card>
<Card
v-if="tokenBreakdown"
v-if="userMonthlyCost !== null"
class="relative p-3 sm:p-4 border-manilla/40"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
总Token
本月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
</p>
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
{{ formatCurrency(userMonthlyCost) }}
</p>
</div>
</Card>
@@ -831,6 +842,12 @@ const cacheStats = ref<{
total_cache_tokens: number
} | null>(null)
const userMonthlyCost = ref<number | null>(null)
const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0
)
const tokenBreakdown = ref<{
input: number
output: number
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
} else {
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
}
} finally {
loading.value = false

View File

@@ -342,6 +342,7 @@ import {
Plus,
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import {
Card,
Table,
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
import { log } from '@/utils/logger'
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
return (tiered?.tiers?.length || 0) > 1
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
success('已复制')
} catch (err) {
log.error('复制失败:', err)
showError('复制失败')
}
}
onMounted(() => {
refreshData()
})

View File

@@ -352,6 +352,7 @@ import {
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
@@ -375,6 +376,7 @@ const emit = defineEmits<{
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props {
model: PublicGlobalModel | null
@@ -408,15 +410,6 @@ function handleClose() {
emit('update:open', false)
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
function getFirstTierPrice(
tieredPricing: TieredPricingConfig | undefined | null,
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'

View File

@@ -13,7 +13,7 @@ authors = [
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"License :: Other/Proprietary License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",

View File

@@ -80,6 +80,17 @@ async def get_keys_grouped_by_format(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/keys/{key_id}/reveal")
async def reveal_endpoint_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""获取完整的 API Key用于查看和复制"""
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/keys/{key_id}")
async def delete_endpoint_key(
key_id: str,
@@ -293,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
return EndpointAPIKeyResponse(**response_dict)
@dataclass
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"""获取完整的 API Key用于查看和复制"""
key_id: str
async def handle(self, context): # type: ignore[override]
db = context.db
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
if not key:
raise NotFoundException(f"Key {self.key_id} 不存在")
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
return {"api_key": decrypted_key}
@dataclass
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
key_id: str

View File

@@ -5,7 +5,7 @@ GlobalModel Admin API
"""
from dataclasses import dataclass
from typing import List, Optional
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
BatchAssignToProvidersResponse,
GlobalModelCreate,
GlobalModelListResponse,
GlobalModelProvidersResponse,
GlobalModelResponse,
GlobalModelUpdate,
GlobalModelWithStats,
ModelCatalogProviderDetail,
)
from src.services.model.global_model import GlobalModelService
@@ -108,6 +110,17 @@ async def batch_assign_to_providers(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
async def get_global_model_providers(
request: Request,
global_model_id: str,
db: Session = Depends(get_db),
) -> GlobalModelProvidersResponse:
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ========== Adapters ==========
@@ -275,3 +288,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
return BatchAssignToProvidersResponse(**result)
@dataclass
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
global_model_id: str
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import joinedload
from src.models.database import Model
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
# 获取所有关联的 Model包括非活跃的
models = (
context.db.query(Model)
.options(joinedload(Model.provider), joinedload(Model.global_model))
.filter(Model.global_model_id == global_model.id)
.all()
)
provider_entries = []
for model in models:
provider = model.provider
if not provider:
continue
effective_tiered = model.get_effective_tiered_pricing()
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
provider_entries.append(
ModelCatalogProviderDetail(
provider_id=provider.id,
provider_name=provider.name,
provider_display_name=provider.display_name,
model_id=model.id,
target_model=model.provider_model_name,
input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
cache_read_price_per_1m=model.get_effective_cache_read_price(),
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
price_per_request=model.get_effective_price_per_request(),
effective_tiered_pricing=effective_tiered,
tier_count=tier_count,
supports_vision=model.get_effective_supports_vision(),
supports_function_calling=model.get_effective_supports_function_calling(),
supports_streaming=model.get_effective_supports_streaming(),
is_active=bool(model.is_active),
)
)
return GlobalModelProvidersResponse(
providers=provider_entries,
total=len(provider_entries),
)

View File

@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据 ====================
# 从 stats_summary + 今日实时数据获取全局统计
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
func.sum(StatsDaily.fallback_count).label("fallback_count"),
)
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
.first()
)
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
else:
# 回退到实时查询(没有预聚合数据时)
total_requests = (
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= last_month).scalar() or 0
.filter(Usage.created_at >= month_start).scalar() or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= last_month,
Usage.created_at >= month_start,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0
)
total_tokens = (
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
)
cache_stats = (
db.query(
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
)
.filter(Usage.created_at >= last_month)
.filter(Usage.created_at >= month_start)
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
)
.filter(
RequestCandidate.created_at >= last_month,
RequestCandidate.created_at >= month_start,
RequestCandidate.status.in_(["success", "failed"]),
)
.group_by(RequestCandidate.request_id)
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = (
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 本月请求统计
user_requests = (
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
or 0
)
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
# 计算缓存命中率cache_read / (input_tokens + cache_read)
# 计算本月缓存命中率cache_read / (input_tokens + cache_read)
# input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的
# 总输入 = input_tokens + cache_read缓存命中率 = cache_read / 总输入
total_input_with_cache = all_time_input_tokens + all_time_cache_read
total_input_with_cache = monthly_input_tokens + cache_read_tokens
cache_hit_rate = (
round((all_time_cache_read / total_input_with_cache) * 100, 1)
round((cache_read_tokens / total_input_with_cache) * 100, 1)
if total_input_with_cache > 0
else 0
)
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd and user.quota_usd > 0:
elif user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = "无限制"
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
else:
quota_value = "0%"
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
quota_high = True
return {
"stats": [
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "TrendingUp",
},
{
"name": "本月费用",
"value": f"${user_cost:.2f}",
"icon": "DollarSign",
"name": "总Token",
"value": format_tokens(
all_time_input_tokens
+ all_time_output_tokens
+ all_time_cache_creation
+ all_time_cache_read
),
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
"icon": "Hash",
},
],
"today": {
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"cache_hit_rate": cache_hit_rate,
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
},
# 本月费用(用于下方缓存区域显示)
"monthly_cost": float(user_cost),
}

View File

@@ -9,6 +9,7 @@ from urllib.parse import quote, urlparse
import httpx
from src.config import config
from src.core.logger import logger
@@ -83,10 +84,10 @@ class HTTPClientPool:
http2=False, # 暂时禁用HTTP/2以提高兼容性
verify=True, # 启用SSL验证
timeout=httpx.Timeout(
connect=10.0, # 连接超时
read=300.0, # 读取超时(5分钟,适合流式响应)
write=60.0, # 写入超时(60秒,支持大请求体)
pool=5.0, # 连接池超时
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
limits=httpx.Limits(
max_connections=100, # 最大连接数
@@ -111,15 +112,20 @@ class HTTPClientPool:
"""
if name not in cls._clients:
# 合并默认配置和自定义配置
config = {
default_config = {
"http2": False,
"verify": True,
"timeout": httpx.Timeout(10.0, read=300.0),
"timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
"follow_redirects": True,
}
config.update(kwargs)
default_config.update(kwargs)
cls._clients[name] = httpx.AsyncClient(**config)
cls._clients[name] = httpx.AsyncClient(**default_config)
logger.debug(f"创建命名HTTP客户端: {name}")
return cls._clients[name]
@@ -151,14 +157,19 @@ class HTTPClientPool:
async with HTTPClientPool.get_temp_client() as client:
response = await client.get('https://example.com')
"""
config = {
default_config = {
"http2": False,
"verify": True,
"timeout": httpx.Timeout(10.0),
"timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
}
config.update(kwargs)
default_config.update(kwargs)
client = httpx.AsyncClient(**config)
client = httpx.AsyncClient(**default_config)
try:
yield client
finally:
@@ -182,25 +193,30 @@ class HTTPClientPool:
Returns:
配置好的 httpx.AsyncClient 实例
"""
config: Dict[str, Any] = {
client_config: Dict[str, Any] = {
"http2": False,
"verify": True,
"follow_redirects": True,
}
if timeout:
config["timeout"] = timeout
client_config["timeout"] = timeout
else:
config["timeout"] = httpx.Timeout(10.0, read=300.0)
client_config["timeout"] = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
# 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
if proxy_url:
config["proxy"] = proxy_url
client_config["proxy"] = proxy_url
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
config.update(kwargs)
return httpx.AsyncClient(**config)
client_config.update(kwargs)
return httpx.AsyncClient(**client_config)
# 便捷访问函数

View File

@@ -148,6 +148,7 @@ class Config:
# HTTP 请求超时配置(秒)
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0"))
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "300.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))

View File

@@ -360,6 +360,9 @@ def init_db():
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
"""
import sys
from sqlalchemy.exc import OperationalError
logger.info("初始化数据库...")
# 确保引擎已创建
@@ -382,6 +385,38 @@ def init_db():
db.commit()
logger.info("数据库初始化完成")
except OperationalError as e:
db.rollback()
# 提取数据库连接信息用于提示
db_url = config.database_url
# 隐藏密码,只显示 host:port/database
if "@" in db_url:
db_info = db_url.split("@")[-1]
else:
db_info = db_url
import os
# 直接打印到 stderr确保消息显示
print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print("数据库连接失败", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print("", file=sys.stderr)
print(f"无法连接到数据库: {db_info}", file=sys.stderr)
print("", file=sys.stderr)
print("请检查以下事项:", file=sys.stderr)
print(" 1. PostgreSQL 服务是否正在运行", file=sys.stderr)
print(" 2. 数据库连接配置是否正确 (DATABASE_URL)", file=sys.stderr)
print(" 3. 数据库用户名和密码是否正确", file=sys.stderr)
print("", file=sys.stderr)
print("如果使用 Docker请先运行:", file=sys.stderr)
print(" docker-compose up -d postgres redis", file=sys.stderr)
print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
# 使用 os._exit 直接退出,避免 uvicorn 捕获并打印堆栈
os._exit(1)
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
db.rollback()

View File

@@ -317,6 +317,7 @@ class UpdateUserRequest(BaseModel):
username: Optional[str] = Field(None, min_length=1, max_length=50)
email: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=6, max_length=128, description="新密码(留空保持不变)")
quota_usd: Optional[float] = Field(None, ge=0)
is_active: Optional[bool] = None
role: Optional[str] = None

View File

@@ -274,6 +274,13 @@ class GlobalModelListResponse(BaseModel):
total: int
class GlobalModelProvidersResponse(BaseModel):
"""GlobalModel 关联提供商列表响应"""
providers: List[ModelCatalogProviderDetail]
total: int
class BatchAssignToProvidersRequest(BaseModel):
"""批量为 Provider 添加 GlobalModel 实现"""

View File

@@ -30,6 +30,8 @@
from __future__ import annotations
import hashlib
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
@@ -956,7 +958,16 @@ class CacheAwareScheduler:
# 获取活跃的 Key 并按 internal_priority + 负载均衡排序
active_keys = [key for key in endpoint.api_keys if key.is_active]
keys = self._shuffle_keys_by_internal_priority(active_keys, affinity_key)
# 检查是否所有 Key 都是 TTL=0轮换模式
# 如果所有 Key 的 cache_ttl_minutes 都是 0 或 None则使用随机排序
use_random = all(
(key.cache_ttl_minutes or 0) == 0 for key in active_keys
) if active_keys else False
if use_random and len(active_keys) > 1:
logger.debug(
f" Endpoint {endpoint.id[:8]}... 启用 Key 轮换模式 (TTL=0, {len(active_keys)} keys)"
)
keys = self._shuffle_keys_by_internal_priority(active_keys, affinity_key, use_random)
for key in keys:
# Key 级别的能力检查(模型级别的能力检查已在上面完成)
@@ -1170,6 +1181,7 @@ class CacheAwareScheduler:
self,
keys: List[ProviderAPIKey],
affinity_key: Optional[str] = None,
use_random: bool = False,
) -> List[ProviderAPIKey]:
"""
对 API Key 按 internal_priority 分组,同优先级内部基于 affinity_key 进行确定性打乱
@@ -1178,10 +1190,12 @@ class CacheAwareScheduler:
- 数字越小越优先使用
- 同优先级 Key 之间实现负载均衡
- 使用 affinity_key 哈希确保同一请求 Key 的请求稳定(避免破坏缓存亲和性)
- 当 use_random=True 时,使用随机排序实现轮换(用于 TTL=0 的场景)
Args:
keys: API Key 列表
affinity_key: 亲和性标识符(通常为 API Key ID用于确定性打乱
use_random: 是否使用随机排序TTL=0 时为 True
Returns:
排序后的 Key 列表
@@ -1198,15 +1212,19 @@ class CacheAwareScheduler:
priority = key.internal_priority if key.internal_priority is not None else 999999
priority_groups[priority].append(key)
# 对每个优先级组内的 Key 进行确定性打乱
# 对每个优先级组内的 Key 进行打乱
result = []
for priority in sorted(priority_groups.keys()): # 数字小的优先级高,排前面
group_keys = priority_groups[priority]
if len(group_keys) > 1 and affinity_key:
# 改进的哈希策略:为每个 key 计算独立的哈希值
import hashlib
if len(group_keys) > 1:
if use_random:
# TTL=0 模式:使用随机排序实现 Key 轮换
shuffled = list(group_keys)
random.shuffle(shuffled)
result.extend(shuffled)
elif affinity_key:
# 正常模式:使用哈希确定性打乱(保持缓存亲和性)
key_scores = []
for key in group_keys:
# 使用 affinity_key + key.id 的组合哈希
@@ -1218,8 +1236,11 @@ class CacheAwareScheduler:
sorted_group = [key for _, key in sorted(key_scores)]
result.extend(sorted_group)
else:
# 单个 Key 或没有 affinity_key 时保持原顺序
# 没有 affinity_key 时按 ID 排序保持稳定性
result.extend(sorted(group_keys, key=lambda k: k.id))
else:
# 单个 Key 直接添加
result.extend(group_keys)
return result

View File

@@ -1,8 +1,16 @@
"""分布式任务协调器,确保仅有一个 worker 执行特定任务"""
"""分布式任务协调器,确保仅有一个 worker 执行特定任务
锁清理策略:
- 单实例模式(默认):启动时使用原子操作清理旧锁并获取新锁
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出
使用方式:
- 默认行为:启动时清理旧锁(适用于单机部署)
- 多实例部署:设置 SINGLE_INSTANCE_MODE=false 禁用启动清理
"""
from __future__ import annotations
import asyncio
import os
import pathlib
import uuid
@@ -19,6 +27,10 @@ except ImportError: # pragma: no cover - Windows 环境
class StartupTaskCoordinator:
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
# 类级别标记:在当前进程中是否已尝试过启动清理
# 注意:这在 fork 模式下每个 worker 都是独立的
_startup_cleanup_attempted = False
def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
self.redis = redis_client
self._tokens: Dict[str, str] = {}
@@ -26,6 +38,8 @@ class StartupTaskCoordinator:
self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
if not self._lock_dir.exists():
self._lock_dir.mkdir(parents=True, exist_ok=True)
# 单实例模式:启动时清理旧锁(适用于单机部署,避免残留锁问题)
self._single_instance_mode = os.getenv("SINGLE_INSTANCE_MODE", "true").lower() == "true"
def _redis_key(self, name: str) -> str:
return f"task_lock:{name}"
@@ -36,7 +50,46 @@ class StartupTaskCoordinator:
if self.redis:
token = str(uuid.uuid4())
try:
acquired = await self.redis.set(self._redis_key(name), token, nx=True, ex=ttl)
if self._single_instance_mode:
# 单实例模式:使用 Lua 脚本原子性地"清理旧锁 + 竞争获取"
# 只有当锁不存在或成功获取时才返回 1
# 这样第一个执行的 worker 会清理旧锁并获取,后续 worker 会正常竞争
script = """
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
local startup_key = KEYS[1] .. ':startup'
-- 检查是否已有 worker 执行过启动清理
local cleaned = redis.call('GET', startup_key)
if not cleaned then
-- 第一个 worker删除旧锁标记已清理
redis.call('DEL', key)
redis.call('SET', startup_key, '1', 'EX', 60)
end
-- 尝试获取锁NX 模式)
local result = redis.call('SET', key, token, 'NX', 'EX', ttl)
if result then
return 1
end
return 0
"""
result = await self.redis.eval(
script, 2,
self._redis_key(name), self._redis_key(name),
token, ttl
)
if result == 1:
self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
return True
return False
else:
# 多实例模式:直接使用 NX 选项竞争锁
acquired = await self.redis.set(
self._redis_key(name), token, nx=True, ex=ttl
)
if acquired:
self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行")