mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e | ||
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 | ||
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 |
15
LICENSE
15
LICENSE
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
|||||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||||
|
|
||||||
1. 仅限非商业用途
|
1. 仅限非盈利用途
|
||||||
本软件不得用于商业目的。商业目的包括但不限于:
|
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||||
- 出售本软件或任何衍生作品
|
- 出售本软件或任何衍生作品
|
||||||
- 使用本软件提供付费服务
|
- 使用本软件提供付费服务
|
||||||
- 将本软件用于商业产品或服务
|
- 将本软件用于以盈利为目的的商业产品或服务
|
||||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
|
||||||
|
以下用途被明确允许:
|
||||||
|
- 个人学习和研究
|
||||||
|
- 教育机构的教学和研究
|
||||||
|
- 非盈利组织的内部使用
|
||||||
|
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||||
|
|
||||||
2. 署名要求
|
2. 署名要求
|
||||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
|||||||
您不得以不同的条款将本软件再许可给他人。
|
您不得以不同的条款将本软件再许可给他人。
|
||||||
|
|
||||||
5. 商业许可
|
5. 商业许可
|
||||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||||
|
|
||||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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
|
||||||
|
|
||||||
|
[](https://star-history.com/#fawney19/Aether&Date)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
docs/author/qq_qrcode.jpg
Normal file
BIN
docs/author/qq_qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/author/wechat_payment.jpg
Normal file
BIN
docs/author/wechat_payment.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export {
|
|||||||
updateGlobalModel,
|
updateGlobalModel,
|
||||||
deleteGlobalModel,
|
deleteGlobalModel,
|
||||||
batchAssignToProviders,
|
batchAssignToProviders,
|
||||||
|
getGlobalModelProviders,
|
||||||
} from './endpoints/global-models'
|
} from './endpoints/global-models'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '-'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载访问控制选项
|
// 加载访问控制选项
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 生成模拟的请求间隔数据
|
// 生成模拟的请求间隔数据
|
||||||
|
|||||||
@@ -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('复制失败,请重试')
|
||||||
|
|||||||
@@ -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, '加载关联提供商失败'), '错误')
|
||||||
|
|||||||
@@ -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 || '未知错误', '复制密钥失败')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# 便捷访问函数
|
# 便捷访问函数
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 实现"""
|
||||||
|
|
||||||
|
|||||||
@@ -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 锁独占执行")
|
||||||
|
|||||||
Reference in New Issue
Block a user