12 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
33 changed files with 626 additions and 279 deletions

15
LICENSE
View File

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

View File

@@ -143,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略 - **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略 - **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型 > **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡? ### 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 cache_stats?: CacheStats
users?: UserStats users?: UserStats
token_breakdown?: TokenBreakdown token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
} }
export interface RecentRequestsResponse { export interface RecentRequestsResponse {

View File

@@ -4,7 +4,8 @@ import type {
GlobalModelUpdate, GlobalModelUpdate,
GlobalModelResponse, GlobalModelResponse,
GlobalModelWithStats, GlobalModelWithStats,
GlobalModelListResponse GlobalModelListResponse,
ModelCatalogProviderDetail,
} from './types' } from './types'
/** /**
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
) )
return response.data 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 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 * 删除 Endpoint Key
*/ */

View File

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

View File

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

View File

@@ -700,6 +700,7 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey' import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -731,6 +732,7 @@ const emit = defineEmits<{
'refreshProviders': [] 'refreshProviders': []
}>() }>()
const { success: showSuccess, error: showError } = useToast() const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props { interface Props {
model: GlobalModelResponse | null 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 { function formatDate(dateStr: string): string {
if (!dateStr) return '-' if (!dateStr) return '-'

View File

@@ -116,6 +116,19 @@
{{ model.global_model_name }} {{ model.global_model_name }}
</div> </div>
</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> </div>
</div> </div>
@@ -148,16 +161,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' 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 { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue' import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { import {
updateEndpointKey, updateEndpointKey,
getProviderAvailableSourceModels, getProviderAvailableSourceModels,
testModel,
type EndpointAPIKey, type EndpointAPIKey,
type ProviderAvailableSourceModel type ProviderAvailableSourceModel
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -181,6 +195,7 @@ const loadingModels = ref(false)
const availableModels = ref<ProviderAvailableSourceModel[]>([]) const availableModels = ref<ProviderAvailableSourceModel[]>([])
const selectedModels = ref<string[]>([]) const selectedModels = ref<string[]>([])
const initialModels = ref<string[]>([]) const initialModels = ref<string[]>([])
const testingModelName = ref<string | null>(null)
// 监听对话框打开 // 监听对话框打开
watch(() => props.open, (open) => { watch(() => props.open, (open) => {
@@ -268,6 +283,32 @@ function clearModels() {
selectedModels.value = [] 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 { function areArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false if (a.length !== b.length) return false
const sortedA = [...a].sort() const sortedA = [...a].sort()

View File

@@ -337,8 +337,40 @@
{{ key.is_active ? '活跃' : '禁用' }} {{ key.is_active ? '活跃' : '禁用' }}
</Badge> </Badge>
</div> </div>
<div class="text-[10px] font-mono text-muted-foreground truncate"> <div class="flex items-center gap-1">
{{ key.api_key_masked }} <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> </div>
<div class="flex items-center gap-1.5 ml-auto shrink-0"> <div class="flex items-center gap-1.5 ml-auto shrink-0">
@@ -654,13 +686,16 @@ import {
Power, Power,
Layers, Layers,
GripVertical, GripVertical,
Copy Copy,
Eye,
EyeOff
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey' import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProvider, getProviderEndpoints } from '@/api/endpoints' import { getProvider, getProviderEndpoints } from '@/api/endpoints'
import { import {
KeyFormDialog, KeyFormDialog,
@@ -680,6 +715,7 @@ import {
updateEndpoint, updateEndpoint,
updateEndpointKey, updateEndpointKey,
batchUpdateKeyPriority, batchUpdateKeyPriority,
revealEndpointKey,
type ProviderEndpoint, type ProviderEndpoint,
type EndpointAPIKey, type EndpointAPIKey,
type Model type Model
@@ -706,6 +742,7 @@ const emit = defineEmits<{
}>() }>()
const { error: showError, success: showSuccess } = useToast() const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
const loading = ref(false) const loading = ref(false)
const provider = ref<any>(null) const provider = ref<any>(null)
@@ -729,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null) const togglingEndpointId = ref<string | null>(null)
const togglingKeyId = 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 modelFormDialogOpen = ref(false)
const editingModel = ref<Model | null>(null) const editingModel = ref<Model | null>(null)
@@ -798,6 +839,9 @@ watch(() => props.open, (newOpen) => {
currentEndpoint.value = null currentEndpoint.value = null
editingKey.value = null editingKey.value = null
keyToDelete.value = null keyToDelete.value = null
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
} }
}) })
@@ -886,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
keyAllowedModelsDialogOpen.value = true 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) { function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key keyToDelete.value = key
deleteKeyConfirmOpen.value = true deleteKeyConfirmOpen.value = true
@@ -1250,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400' return 'bg-red-500 dark:bg-red-400'
} }
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
}
// 加载 Provider 信息 // 加载 Provider 信息
async function loadProvider() { async function loadProvider() {
if (!props.providerId) return if (!props.providerId) return

View File

@@ -156,17 +156,6 @@
</td> </td>
<td class="align-top px-4 py-3"> <td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -220,13 +209,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' 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 Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast' 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 { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser'
const props = defineProps<{ const props = defineProps<{
provider: any provider: any
@@ -239,12 +228,12 @@ const emit = defineEmits<{
}>() }>()
const { error: showError, success: showSuccess } = useToast() const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
// 状态 // 状态
const loading = ref(false) const loading = ref(false)
const models = ref<Model[]>([]) const models = ref<Model[]>([])
const togglingModelId = ref<string | null>(null) const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
// 按名称排序的模型列表 // 按名称排序的模型列表
const sortedModels = computed(() => { const sortedModels = computed(() => {
@@ -257,12 +246,7 @@ const sortedModels = computed(() => {
// 复制模型 ID 到剪贴板 // 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) { async function copyModelId(modelId: string) {
try { await copyToClipboard(modelId)
await navigator.clipboard.writeText(modelId)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
} }
// 加载模型 // 加载模型
@@ -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(() => { onMounted(() => {
loadModels() loadModels()
}) })

View File

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

View File

@@ -86,6 +86,34 @@
</p> </p>
</div> </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"> <div class="space-y-2">
<Label <Label
for="form-email" for="form-email"
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
const form = ref({ const form = ref({
username: '', username: '',
password: '', password: '',
confirmPassword: '',
email: '', email: '',
quota: 10, quota: 10,
role: 'user' as 'admin' | 'user', role: 'user' as 'admin' | 'user',
@@ -443,6 +472,7 @@ function resetForm() {
form.value = { form.value = {
username: '', username: '',
password: '', password: '',
confirmPassword: '',
email: '', email: '',
quota: 10, quota: 10,
role: 'user', role: 'user',
@@ -461,6 +491,7 @@ function loadUserData() {
form.value = { form.value = {
username: props.user.username, username: props.user.username,
password: '', password: '',
confirmPassword: '',
email: props.user.email || '', email: props.user.email || '',
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd, quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
role: props.user.role, role: props.user.role,
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0 const hasUsername = form.value.username.trim().length > 0
const hasEmail = form.value.email.trim().length > 0 const hasEmail = form.value.email.trim().length > 0
const hasPassword = isEditMode.value || form.value.password.length >= 6 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 { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard' 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 { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me' import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types' import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
output: 700000, output: 700000,
cache_creation: 50000, cache_creation: 50000,
cache_read: 200000 cache_read: 200000
} },
// 普通用户专用字段
monthly_cost: 45.67
} }
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [ export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' }, { id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' }, { id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' }, { id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' }, { id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' }, { id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' }, { id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' }, { id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' } { id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
] ]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [ export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
unique_models: 8 + Math.floor(Math.random() * 5), unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3), unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [ 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: '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-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).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-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).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-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).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-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), 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 { return {
daily_stats: dailyStats, daily_stats: dailyStats,
model_summary: [ 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: '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-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 }, { 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-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 }, { 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-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: '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-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-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: { period: {
start_date: dailyStats[0].date, start_date: dailyStats[0].date,
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
// ========== API Key 数据 ========== // ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [ export const MOCK_USER_API_KEYS = [
{ {
id: 'key-uuid-001', id: 'key-uuid-001',
key_display: 'sk-ae...x7f9', key_display: 'sk-ae...x7f9',
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true, is_active: true,
is_standalone: false, is_standalone: false,
total_requests: 1234, total_requests: 1234,
total_cost_usd: 45.67 total_cost_usd: 45.67,
force_capabilities: null
}, },
{ {
id: 'key-uuid-002', id: 'key-uuid-002',
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true, is_active: true,
is_standalone: false, is_standalone: false,
total_requests: 5678, total_requests: 5678,
total_cost_usd: 123.45 total_cost_usd: 123.45,
force_capabilities: { cache_1h: true }
}, },
{ {
id: 'key-uuid-003', id: 'key-uuid-003',
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: false, is_active: false,
is_standalone: false, is_standalone: false,
total_requests: 100, 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, quota_usd: 100,
used_usd: 45.32, used_usd: 45.32,
summary_by_model: [ 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: '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-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 }, { 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-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: '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-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: '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: [ records: [
{ {
id: 'usage-001', id: 'usage-001',
provider: 'anthropic', provider: 'anthropic',
model: 'claude-sonnet-4-20250514', model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500, input_tokens: 1500,
output_tokens: 800, output_tokens: 800,
total_tokens: 2300, total_tokens: 2300,
@@ -837,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
{ {
id: 'usage-002', id: 'usage-002',
provider: 'openai', provider: 'openai',
model: 'gpt-4o', model: 'gpt-5.1',
input_tokens: 2000, input_tokens: 2000,
output_tokens: 500, output_tokens: 500,
total_tokens: 2500, total_tokens: 2500,

View File

@@ -405,10 +405,10 @@ function getUsageRecords() {
// Mock 映射数据 // Mock 映射数据
const MOCK_ALIASES = [ 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-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-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-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: '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-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-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-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 // Mock Endpoint Keys
@@ -2172,10 +2172,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色) // 模型列表(用于按模型区分颜色)
const models = [ const models = [
'claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet-20241022', 'claude-haiku-4-5-20251001',
'claude-3-5-haiku-20241022', 'claude-opus-4-5-20251101',
'claude-opus-4-20250514' 'gpt-5.1'
] ]
// 生成模拟的请求间隔数据 // 生成模拟的请求间隔数据

View File

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

View File

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

View File

@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage' import { usageApi, type UsageByUser } from '@/api/usage'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
const { success, error } = useToast() const { success, error } = useToast()
const { confirmDanger, confirmWarning } = useConfirm() const { confirmDanger, confirmWarning } = useConfirm()
const { copyToClipboard } = useClipboard()
const usersStore = useUsersStore() const usersStore = useUsersStore()
// 用户表单对话框状态 // 用户表单对话框状态
@@ -1001,12 +1003,7 @@ function selectApiKey() {
} }
async function copyApiKey() { async function copyApiKey() {
try { await copyToClipboard(newApiKey.value)
await navigator.clipboard.writeText(newApiKey.value)
success('API Key已复制到剪贴板')
} catch {
error('复制失败,请手动复制')
}
} }
async function closeNewApiKeyDialog() { async function closeNewApiKeyDialog() {
@@ -1035,8 +1032,7 @@ async function copyFullKey(apiKey: any) {
try { try {
// 调用后端 API 获取完整密钥 // 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id) const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key) await copyToClipboard(response.key)
success('完整密钥已复制到剪贴板')
} catch (err: any) { } catch (err: any) {
log.error('复制密钥失败:', err) log.error('复制密钥失败:', err)
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败') 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" /> <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"> <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 class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
实际成 月费用
</p> </p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground"> <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> </p>
<Badge <Badge
v-if="costStats.cost_savings > 0" v-if="costStats.cost_savings > 0"
@@ -162,14 +162,14 @@
</div> </div>
</div> </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" class="mt-6"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium text-foreground"> <h3 class="text-sm font-medium text-foreground">
本月缓存使用 本月统计
</h3> </h3>
<Badge <Badge
variant="outline" variant="outline"
@@ -178,8 +178,16 @@
Monthly Monthly
</Badge> </Badge>
</div> </div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4"> <div
<Card class="relative p-3 sm:p-4 border-book-cloth/30"> :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" /> <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"> <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 class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -190,7 +198,10 @@
</p> </p>
</div> </div>
</Card> </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" /> <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"> <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 class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -201,7 +212,10 @@
</p> </p>
</div> </div>
</Card> </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" /> <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"> <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 class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
@@ -213,19 +227,16 @@
</div> </div>
</Card> </Card>
<Card <Card
v-if="tokenBreakdown" v-if="userMonthlyCost !== null"
class="relative p-3 sm:p-4 border-manilla/40" 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"> <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 class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
总Token 本月费用
</p> </p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }} {{ formatCurrency(userMonthlyCost) }}
</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) }}
</p> </p>
</div> </div>
</Card> </Card>
@@ -831,6 +842,12 @@ const cacheStats = ref<{
total_cache_tokens: number total_cache_tokens: number
} | null>(null) } | null>(null)
const userMonthlyCost = ref<number | null>(null)
const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0
)
const tokenBreakdown = ref<{ const tokenBreakdown = ref<{
input: number input: number
output: number output: number
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
} else { } else {
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
} }
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -342,6 +342,7 @@ import {
Plus, Plus,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { import {
Card, Card,
Table, Table,
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 状态 // 状态
const loading = ref(false) const loading = ref(false)
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
return (tiered?.tiers?.length || 0) > 1 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(() => { onMounted(() => {
refreshData() refreshData()
}) })

View File

@@ -352,6 +352,7 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey' import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -375,6 +376,7 @@ const emit = defineEmits<{
}>() }>()
const { success: showSuccess, error: showError } = useToast() const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props { interface Props {
model: PublicGlobalModel | null model: PublicGlobalModel | null
@@ -408,15 +410,6 @@ function handleClose() {
emit('update:open', false) emit('update:open', false)
} }
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
function getFirstTierPrice( function getFirstTierPrice(
tieredPricing: TieredPricingConfig | undefined | null, tieredPricing: TieredPricingConfig | undefined | null,
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m' 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 = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: Other/Proprietary License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "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) 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}") @router.delete("/keys/{key_id}")
async def delete_endpoint_key( async def delete_endpoint_key(
key_id: str, key_id: str,
@@ -293,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
return EndpointAPIKeyResponse(**response_dict) 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 @dataclass
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter): class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
key_id: str key_id: str

View File

@@ -5,7 +5,7 @@ GlobalModel Admin API
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import Optional
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
BatchAssignToProvidersResponse, BatchAssignToProvidersResponse,
GlobalModelCreate, GlobalModelCreate,
GlobalModelListResponse, GlobalModelListResponse,
GlobalModelProvidersResponse,
GlobalModelResponse, GlobalModelResponse,
GlobalModelUpdate, GlobalModelUpdate,
GlobalModelWithStats, GlobalModelWithStats,
ModelCatalogProviderDetail,
) )
from src.services.model.global_model import GlobalModelService 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) 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 ========== # ========== 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'])}") logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
return BatchAssignToProvidersResponse(**result) 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 开始时间) # 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc) today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).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 + 今日实时数据获取全局统计 # 从 stats_summary + 今日实时数据获取全局统计
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"), func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
func.sum(StatsDaily.fallback_count).label("fallback_count"), 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() .first()
) )
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
else: else:
# 回退到实时查询(没有预聚合数据时) # 回退到实时查询(没有预聚合数据时)
total_requests = ( 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 = ( 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 = ( total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd)) 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 = ( error_requests = (
db.query(func.count(Usage.id)) db.query(func.count(Usage.id))
.filter( .filter(
Usage.created_at >= last_month, Usage.created_at >= month_start,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)), (Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0 ).scalar() or 0
) )
total_tokens = ( 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 = ( cache_stats = (
db.query( db.query(
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"), func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_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() .first()
) )
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0 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") RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
) )
.filter( .filter(
RequestCandidate.created_at >= last_month, RequestCandidate.created_at >= month_start,
RequestCandidate.status.in_(["success", "failed"]), RequestCandidate.status.in_(["success", "failed"]),
) )
.group_by(RequestCandidate.request_id) .group_by(RequestCandidate.request_id)
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 转换为 UTC 用于数据库查询 # 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc) today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).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() user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = ( active_keys = (
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 本月请求统计 # 本月请求统计
user_requests = ( user_requests = (
db.query(func.count(Usage.id)) 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() .scalar()
) )
user_cost = ( user_cost = (
db.query(func.sum(Usage.total_cost_usd)) 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() .scalar()
or 0 or 0
) )
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"), func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_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() .first()
) )
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0 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 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 是从缓存读取的
# 总输入 = input_tokens + cache_read缓存命中率 = 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 = ( 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 if total_input_with_cache > 0
else 0 else 0
) )
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
quota_value = "无限制" quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}" quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False 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)) 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_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80 quota_high = percent > 80
else: else:
quota_value = "0%" quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}" quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False quota_high = True
return { return {
"stats": [ "stats": [
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "TrendingUp", "icon": "TrendingUp",
}, },
{ {
"name": "本月费用", "name": "总Token",
"value": f"${user_cost:.2f}", "value": format_tokens(
"icon": "DollarSign", 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": { "today": {
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"cache_hit_rate": cache_hit_rate, "cache_hit_rate": cache_hit_rate,
"total_cache_tokens": cache_creation_tokens + cache_read_tokens, "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 import httpx
from src.config import config
from src.core.logger import logger from src.core.logger import logger
@@ -83,10 +84,10 @@ class HTTPClientPool:
http2=False, # 暂时禁用HTTP/2以提高兼容性 http2=False, # 暂时禁用HTTP/2以提高兼容性
verify=True, # 启用SSL验证 verify=True, # 启用SSL验证
timeout=httpx.Timeout( timeout=httpx.Timeout(
connect=10.0, # 连接超时 connect=config.http_connect_timeout,
read=300.0, # 读取超时(5分钟,适合流式响应) read=config.http_read_timeout,
write=60.0, # 写入超时(60秒,支持大请求体) write=config.http_write_timeout,
pool=5.0, # 连接池超时 pool=config.http_pool_timeout,
), ),
limits=httpx.Limits( limits=httpx.Limits(
max_connections=100, # 最大连接数 max_connections=100, # 最大连接数
@@ -111,15 +112,20 @@ class HTTPClientPool:
""" """
if name not in cls._clients: if name not in cls._clients:
# 合并默认配置和自定义配置 # 合并默认配置和自定义配置
config = { default_config = {
"http2": False, "http2": False,
"verify": True, "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, "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}") logger.debug(f"创建命名HTTP客户端: {name}")
return cls._clients[name] return cls._clients[name]
@@ -151,14 +157,19 @@ class HTTPClientPool:
async with HTTPClientPool.get_temp_client() as client: async with HTTPClientPool.get_temp_client() as client:
response = await client.get('https://example.com') response = await client.get('https://example.com')
""" """
config = { default_config = {
"http2": False, "http2": False,
"verify": True, "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: try:
yield client yield client
finally: finally:
@@ -182,25 +193,30 @@ class HTTPClientPool:
Returns: Returns:
配置好的 httpx.AsyncClient 实例 配置好的 httpx.AsyncClient 实例
""" """
config: Dict[str, Any] = { client_config: Dict[str, Any] = {
"http2": False, "http2": False,
"verify": True, "verify": True,
"follow_redirects": True, "follow_redirects": True,
} }
if timeout: if timeout:
config["timeout"] = timeout client_config["timeout"] = timeout
else: 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 proxy_url = build_proxy_url(proxy_config) if proxy_config else None
if proxy_url: if proxy_url:
config["proxy"] = proxy_url client_config["proxy"] = proxy_url
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}") logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
config.update(kwargs) client_config.update(kwargs)
return httpx.AsyncClient(**config) return httpx.AsyncClient(**client_config)
# 便捷访问函数 # 便捷访问函数

View File

@@ -148,6 +148,7 @@ class Config:
# HTTP 请求超时配置(秒) # HTTP 请求超时配置(秒)
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0")) 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_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.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 注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
""" """
import sys
from sqlalchemy.exc import OperationalError
logger.info("初始化数据库...") logger.info("初始化数据库...")
# 确保引擎已创建 # 确保引擎已创建
@@ -382,6 +385,38 @@ def init_db():
db.commit() db.commit()
logger.info("数据库初始化完成") 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: except Exception as e:
logger.error(f"数据库初始化失败: {e}") logger.error(f"数据库初始化失败: {e}")
db.rollback() db.rollback()

View File

@@ -317,6 +317,7 @@ class UpdateUserRequest(BaseModel):
username: Optional[str] = Field(None, min_length=1, max_length=50) username: Optional[str] = Field(None, min_length=1, max_length=50)
email: Optional[str] = Field(None, max_length=100) 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) quota_usd: Optional[float] = Field(None, ge=0)
is_active: Optional[bool] = None is_active: Optional[bool] = None
role: Optional[str] = None role: Optional[str] = None

View File

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

View File

@@ -1,8 +1,16 @@
"""分布式任务协调器,确保仅有一个 worker 执行特定任务""" """分布式任务协调器,确保仅有一个 worker 执行特定任务
锁清理策略:
- 单实例模式(默认):启动时使用原子操作清理旧锁并获取新锁
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出
使用方式:
- 默认行为:启动时清理旧锁(适用于单机部署)
- 多实例部署:设置 SINGLE_INSTANCE_MODE=false 禁用启动清理
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
import pathlib import pathlib
import uuid import uuid
@@ -19,6 +27,10 @@ except ImportError: # pragma: no cover - Windows 环境
class StartupTaskCoordinator: class StartupTaskCoordinator:
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行""" """利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
# 类级别标记:在当前进程中是否已尝试过启动清理
# 注意:这在 fork 模式下每个 worker 都是独立的
_startup_cleanup_attempted = False
def __init__(self, redis_client=None, lock_dir: Optional[str] = None): def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
self.redis = redis_client self.redis = redis_client
self._tokens: Dict[str, str] = {} 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")) self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
if not self._lock_dir.exists(): if not self._lock_dir.exists():
self._lock_dir.mkdir(parents=True, exist_ok=True) 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: def _redis_key(self, name: str) -> str:
return f"task_lock:{name}" return f"task_lock:{name}"
@@ -36,7 +50,46 @@ class StartupTaskCoordinator:
if self.redis: if self.redis:
token = str(uuid.uuid4()) token = str(uuid.uuid4())
try: 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: if acquired:
self._tokens[name] = token self._tokens[name] = token
logger.info(f"任务 {name} 通过 Redis 锁独占执行") logger.info(f"任务 {name} 通过 Redis 锁独占执行")