Merge pull request #2 from fawney19/dev

Dev
This commit is contained in:
fawney19
2025-12-11 11:01:23 +08:00
committed by GitHub
19 changed files with 3460 additions and 166 deletions

View File

@@ -61,6 +61,16 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
./deploy.sh # 自动构建、启动、迁移 ./deploy.sh # 自动构建、启动、迁移
``` ```
### 更新
```bash
# 拉取最新代码
git pull
# 自动部署脚本
./deploy.sh
```
### 本地开发 ### 本地开发
```bash ```bash

View File

@@ -1,6 +1,8 @@
import axios from 'axios' import axios, { getAdapter } from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants' import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { isDemoMode } from '@/config/demo'
import { handleMockRequest, setMockUserToken } from '@/mocks'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
// 在开发环境下使用代理,生产环境使用环境变量 // 在开发环境下使用代理,生产环境使用环境变量
@@ -42,6 +44,39 @@ function isRefreshableAuthError(errorDetail: string): boolean {
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg)) return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
} }
/**
* 创建 Demo 模式的自定义 adapter
* 在 Demo 模式下拦截请求并返回 mock 数据
*/
function createDemoAdapter(defaultAdapter: AxiosAdapter) {
return async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
if (isDemoMode()) {
try {
const mockResponse = await handleMockRequest({
method: config.method?.toUpperCase(),
url: config.url,
data: config.data,
params: config.params,
})
if (mockResponse) {
// 确保响应包含 config
mockResponse.config = config
return mockResponse
}
} catch (error: any) {
// Mock 错误需要附加 config否则 handleResponseError 会崩溃
if (error.response) {
error.config = config
error.response.config = config
}
throw error
}
}
// 非 Demo 模式或没有 mock 响应时,使用默认 adapter
return defaultAdapter(config)
}
}
class ApiClient { class ApiClient {
private client: AxiosInstance private client: AxiosInstance
private token: string | null = null private token: string | null = null
@@ -57,6 +92,10 @@ class ApiClient {
}, },
}) })
// 设置自定义 adapter 处理 Demo 模式
const defaultAdapter = getAdapter(this.client.defaults.adapter)
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
this.setupInterceptors() this.setupInterceptors()
} }
@@ -64,7 +103,7 @@ class ApiClient {
* 配置请求和响应拦截器 * 配置请求和响应拦截器
*/ */
private setupInterceptors(): void { private setupInterceptors(): void {
// 请求拦截器 // 请求拦截器 - 仅处理认证
this.client.interceptors.request.use( this.client.interceptors.request.use(
(config) => { (config) => {
const requiresAuth = !isPublicEndpoint(config.url, config.method) && const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
@@ -207,11 +246,19 @@ class ApiClient {
setToken(token: string): void { setToken(token: string): void {
this.token = token this.token = token
localStorage.setItem('access_token', token) localStorage.setItem('access_token', token)
// 同步到 mock handler
if (isDemoMode()) {
setMockUserToken(token)
}
} }
getToken(): string | null { getToken(): string | null {
if (!this.token) { if (!this.token) {
this.token = localStorage.getItem('access_token') this.token = localStorage.getItem('access_token')
// 页面刷新时,从 localStorage 恢复 token 到 mock handler
if (this.token && isDemoMode()) {
setMockUserToken(this.token)
}
} }
return this.token return this.token
} }
@@ -220,12 +267,18 @@ class ApiClient {
this.token = null this.token = null
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token') localStorage.removeItem('refresh_token')
// 同步清除 mock token
if (isDemoMode()) {
setMockUserToken(null)
}
} }
async refreshToken(refreshToken: string): Promise<AxiosResponse> { async refreshToken(refreshToken: string): Promise<AxiosResponse> {
// refreshToken 会通过 adapter 处理 Demo 模式
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken }) return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
} }
// 以下方法直接委托给 axios clientDemo 模式由 adapter 统一处理
async request<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> { async request<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.request<T>(config) return this.client.request<T>(config)
} }

View File

@@ -81,6 +81,7 @@ export interface EndpointHealthDetail {
api_format: string api_format: string
health_score: number health_score: number
is_active: boolean is_active: boolean
active_keys?: number
} }
export interface EndpointHealthEvent { export interface EndpointHealthEvent {

View File

@@ -0,0 +1,37 @@
/**
* Demo Mode Configuration
* 用于 GitHub Pages 等静态托管环境的演示模式
*/
// 检测是否为演示模式环境
export function isDemoMode(): boolean {
const hostname = window.location.hostname
return (
hostname.includes('github.io') ||
hostname.includes('vercel.app') ||
hostname.includes('netlify.app') ||
hostname.includes('pages.dev') ||
import.meta.env.VITE_DEMO_MODE === 'true'
)
}
// Demo 账号配置
export const DEMO_ACCOUNTS = {
admin: {
email: 'admin@demo.aether.io',
password: 'demo123',
hint: '管理员账号'
},
user: {
email: 'user@demo.aether.io',
password: 'demo123',
hint: '普通用户'
}
} as const
// Demo 模式提示信息
export const DEMO_MODE_INFO = {
title: '演示模式',
description: '当前处于演示模式,所有数据均为模拟数据,不会产生实际调用。',
accountHint: '可使用以下演示账号登录:'
} as const

View File

@@ -11,6 +11,43 @@
</h2> </h2>
</div> </div>
<!-- Demo 模式提示 -->
<div v-if="isDemo" class="rounded-lg border border-primary/20 dark:border-primary/30 bg-primary/5 dark:bg-primary/10 p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 text-primary dark:text-primary/90">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">
演示模式
</p>
<p class="mt-1 text-xs text-muted-foreground">
当前处于演示模式所有数据均为模拟数据
</p>
<div class="mt-3 space-y-2">
<button
type="button"
@click="fillDemoAccount('admin')"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
>
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-primary/20 dark:bg-primary/30 text-primary text-[10px] font-bold group-hover:bg-primary/30 dark:group-hover:bg-primary/40 transition-colors">A</span>
<span>管理员admin@demo.aether.io / demo123</span>
</button>
<button
type="button"
@click="fillDemoAccount('user')"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
>
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-muted text-muted-foreground text-[10px] font-bold group-hover:bg-muted/80 transition-colors">U</span>
<span>普通用户user@demo.aether.io / demo123</span>
</button>
</div>
</div>
</div>
</div>
<!-- 登录表单 --> <!-- 登录表单 -->
<form @submit.prevent="handleLogin" class="space-y-4"> <form @submit.prevent="handleLogin" class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
@@ -32,14 +69,14 @@
v-model="form.password" v-model="form.password"
type="password" type="password"
required required
placeholder="••••••••" placeholder="********"
autocomplete="off" autocomplete="off"
@keyup.enter="handleLogin" @keyup.enter="handleLogin"
/> />
</div> </div>
<!-- 提示信息 --> <!-- 提示信息 -->
<p class="text-xs text-slate-400 dark:text-muted-foreground/80"> <p v-if="!isDemo" class="text-xs text-slate-400 dark:text-muted-foreground/80">
如需开通账户请联系管理员配置访问权限 如需开通账户请联系管理员配置访问权限
</p> </p>
</form> </form>
@@ -66,7 +103,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Dialog } from '@/components/ui' import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -74,6 +111,7 @@ import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue' import Label from '@/components/ui/label.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -88,6 +126,7 @@ const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast() const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const isOpen = ref(props.modelValue) const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode())
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
isOpen.value = val isOpen.value = val
@@ -109,6 +148,12 @@ const form = ref({
password: '' password: ''
}) })
function fillDemoAccount(type: 'admin' | 'user') {
const account = DEMO_ACCOUNTS[type]
form.value.email = account.email
form.value.password = account.password
}
async function handleLogin() { async function handleLogin() {
if (!form.value.email || !form.value.password) { if (!form.value.email || !form.value.password) {
showWarning('请输入邮箱和密码') showWarning('请输入邮箱和密码')

View File

@@ -21,19 +21,20 @@
{{ model.is_active ? '活跃' : '停用' }} {{ model.is_active ? '活跃' : '停用' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span class="text-sm text-muted-foreground font-mono">{{ model.name }}</span> <span class="font-mono shrink-0">{{ model.name }}</span>
<button <button
class="p-0.5 rounded hover:bg-muted transition-colors" class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
title="复制模型 ID" title="复制模型 ID"
@click="copyToClipboard(model.name)" @click="copyToClipboard(model.name)"
> >
<Copy class="w-3 h-3 text-muted-foreground" /> <Copy class="w-3 h-3" />
</button> </button>
<template v-if="model.description">
<span class="shrink-0">·</span>
<span class="text-xs truncate" :title="model.description">{{ model.description }}</span>
</template>
</div> </div>
<p v-if="model.description" class="text-xs text-muted-foreground">
{{ model.description }}
</p>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" @click="$emit('edit-model', model)" title="编辑模型"> <Button variant="ghost" size="icon" @click="$emit('edit-model', model)" title="编辑模型">

View File

@@ -101,6 +101,12 @@
</div> </div>
</div> </div>
<!-- Demo Mode Badge (center) -->
<div v-if="isDemo" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium">
<AlertTriangle class="w-3.5 h-3.5" />
<span>演示模式</span>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Theme Toggle --> <!-- Theme Toggle -->
<button <button
@@ -125,6 +131,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
import { isDemoMode } from '@/config/demo'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue' import SidebarNav from '@/components/layout/SidebarNav.vue'
@@ -157,6 +164,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const { isDark, themeMode, toggleDarkMode } = useDarkMode() const { isDark, themeMode, toggleDarkMode } = useDarkMode()
const isDemo = computed(() => isDemoMode())
const showAuthError = ref(false) const showAuthError = ref(false)
let authCheckInterval: number | null = null let authCheckInterval: number | null = null

868
frontend/src/mocks/data.ts Normal file
View File

@@ -0,0 +1,868 @@
/**
* Demo Mode Mock Data
* 演示模式的模拟数据
*/
import type { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser, ApiKey } from '@/api/users'
import type { AdminApiKey, AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
// ========== 用户数据 ==========
export const MOCK_ADMIN_USER: User = {
id: 'demo-admin-uuid-0001',
username: 'Demo Admin',
email: 'admin@demo.aether.io',
role: 'admin',
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-01-01T00:00:00Z',
last_login_at: new Date().toISOString()
}
export const MOCK_NORMAL_USER: User = {
id: 'demo-user-uuid-0002',
username: 'Demo User',
email: 'user@demo.aether.io',
role: 'user',
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-06-01T00:00:00Z',
last_login_at: new Date().toISOString()
}
export const MOCK_LOGIN_RESPONSE_ADMIN: LoginResponse = {
access_token: 'demo-access-token-admin',
refresh_token: 'demo-refresh-token-admin',
token_type: 'bearer',
expires_in: 3600,
user_id: MOCK_ADMIN_USER.id,
email: MOCK_ADMIN_USER.email,
username: MOCK_ADMIN_USER.username,
role: 'admin'
}
export const MOCK_LOGIN_RESPONSE_USER: LoginResponse = {
access_token: 'demo-access-token-user',
refresh_token: 'demo-refresh-token-user',
token_type: 'bearer',
expires_in: 3600,
user_id: MOCK_NORMAL_USER.id,
email: MOCK_NORMAL_USER.email,
username: MOCK_NORMAL_USER.username,
role: 'user'
}
// ========== Profile 数据 ==========
export const MOCK_ADMIN_PROFILE: Profile = {
id: MOCK_ADMIN_USER.id!,
email: MOCK_ADMIN_USER.email!,
username: MOCK_ADMIN_USER.username,
role: 'admin',
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
preferences: {
theme: 'auto',
language: 'zh-CN'
}
}
export const MOCK_USER_PROFILE: Profile = {
id: MOCK_NORMAL_USER.id!,
email: MOCK_NORMAL_USER.email!,
username: MOCK_NORMAL_USER.username,
role: 'user',
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
created_at: '2024-06-01T00:00:00Z',
updated_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
preferences: {
theme: 'auto',
language: 'zh-CN'
}
}
// ========== Dashboard 数据 ==========
export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
stats: [
{
name: '今日请求',
value: '1,234',
subValue: '成功率 99.2%',
change: '+12.5%',
changeType: 'increase',
icon: 'Activity'
},
{
name: '今日 Token',
value: '2.5M',
subValue: '输入 1.8M / 输出 0.7M',
change: '+8.3%',
changeType: 'increase',
icon: 'Zap'
},
{
name: '今日费用',
value: '$45.67',
subValue: '节省 $12.34 (21%)',
change: '-5.2%',
changeType: 'decrease',
icon: 'DollarSign'
},
{
name: '活跃用户',
value: '28',
subValue: '总用户 156',
change: '+3',
changeType: 'increase',
icon: 'Users'
}
],
today: {
requests: 1234,
tokens: 2500000,
cost: 45.67,
actual_cost: 33.33,
cache_creation_tokens: 50000,
cache_read_tokens: 200000
},
api_keys: {
total: 45,
active: 38
},
tokens: {
month: 75000000
},
system_health: {
avg_response_time: 1.23,
error_rate: 0.8,
error_requests: 10,
fallback_count: 5,
total_requests: 1234
},
cost_stats: {
total_cost: 45.67,
total_actual_cost: 33.33,
cost_savings: 12.34
},
cache_stats: {
cache_creation_tokens: 50000,
cache_read_tokens: 200000,
cache_creation_cost: 0.25,
cache_read_cost: 0.10,
cache_hit_rate: 0.35,
total_cache_tokens: 250000
},
users: {
total: 156,
active: 28
},
token_breakdown: {
input: 1800000,
output: 700000,
cache_creation: 50000,
cache_read: 200000
}
}
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
{ name: 'Anthropic Official', status: 'active', requests: 456 },
{ name: 'OpenAI Official', status: 'active', requests: 389 },
{ name: 'Google AI', status: 'active', requests: 234 },
{ name: 'AWS Bedrock', status: 'active', requests: 89 },
{ name: 'Azure OpenAI', status: 'inactive', requests: 0 },
{ name: 'Vertex AI', status: 'active', requests: 66 }
]
// 生成过去7天的每日统计数据
function generateDailyStats(): DailyStatsResponse {
const dailyStats = []
const now = new Date()
for (let i = 6; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
const baseRequests = 800 + Math.floor(Math.random() * 600)
const baseTokens = 1500000 + Math.floor(Math.random() * 1500000)
const baseCost = 30 + Math.random() * 30
dailyStats.push({
date: dateStr,
requests: baseRequests,
tokens: baseTokens,
cost: Number(baseCost.toFixed(2)),
avg_response_time: 0.8 + Math.random() * 0.8,
unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
]
})
}
return {
daily_stats: dailyStats,
model_summary: [
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
],
period: {
start_date: dailyStats[0].date,
end_date: dailyStats[dailyStats.length - 1].date,
days: 7
}
}
}
export const MOCK_DAILY_STATS = generateDailyStats()
// ========== 用户管理数据 ==========
export const MOCK_ALL_USERS: AdminUser[] = [
{
id: 'demo-admin-uuid-0001',
username: 'Demo Admin',
email: 'admin@demo.aether.io',
role: 'admin',
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'demo-user-uuid-0002',
username: 'Demo User',
email: 'user@demo.aether.io',
role: 'user',
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-06-01T00:00:00Z'
},
{
id: 'demo-user-uuid-0003',
username: 'Alice Wang',
email: 'alice@example.com',
role: 'user',
is_active: true,
quota_usd: 50,
used_usd: 23.45,
total_usd: 123.45,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-03-15T00:00:00Z'
},
{
id: 'demo-user-uuid-0004',
username: 'Bob Zhang',
email: 'bob@example.com',
role: 'user',
is_active: true,
quota_usd: 200,
used_usd: 89.12,
total_usd: 589.12,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-02-20T00:00:00Z'
},
{
id: 'demo-user-uuid-0005',
username: 'Charlie Li',
email: 'charlie@example.com',
role: 'user',
is_active: false,
quota_usd: 30,
used_usd: 30.00,
total_usd: 30.00,
allowed_providers: null,
allowed_endpoints: null,
allowed_models: null,
created_at: '2024-04-10T00:00:00Z'
}
]
// ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [
{
id: 'key-uuid-001',
key_display: 'sk-ae...x7f9',
name: '开发环境',
created_at: '2024-06-15T00:00:00Z',
last_used_at: new Date().toISOString(),
is_active: true,
is_standalone: false,
total_requests: 1234,
total_cost_usd: 45.67
},
{
id: 'key-uuid-002',
key_display: 'sk-ae...m2k8',
name: '生产环境',
created_at: '2024-07-01T00:00:00Z',
last_used_at: new Date().toISOString(),
is_active: true,
is_standalone: false,
total_requests: 5678,
total_cost_usd: 123.45
},
{
id: 'key-uuid-003',
key_display: 'sk-ae...p9q1',
name: '测试用途',
created_at: '2024-08-01T00:00:00Z',
is_active: false,
is_standalone: false,
total_requests: 100,
total_cost_usd: 2.34
}
]
export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
api_keys: [
{
id: 'standalone-key-001',
user_id: 'demo-user-uuid-0002',
user_email: 'user@demo.aether.io',
username: 'Demo User',
name: '独立余额 Key #1',
key_display: 'sk-sa...abc1',
is_active: true,
is_standalone: true,
balance_used_usd: 25.50,
current_balance_usd: 74.50,
total_requests: 500,
total_tokens: 1500000,
total_cost_usd: 25.50,
created_at: '2024-09-01T00:00:00Z',
last_used_at: new Date().toISOString()
},
{
id: 'standalone-key-002',
user_id: 'demo-user-uuid-0003',
user_email: 'alice@example.com',
username: 'Alice Wang',
name: '独立余额 Key #2',
key_display: 'sk-sa...def2',
is_active: true,
is_standalone: true,
balance_used_usd: 45.00,
current_balance_usd: 55.00,
total_requests: 800,
total_tokens: 2400000,
total_cost_usd: 45.00,
rate_limit: 60,
created_at: '2024-08-15T00:00:00Z',
last_used_at: new Date().toISOString()
}
],
total: 2,
limit: 20,
skip: 0
}
// ========== Provider 数据 ==========
export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
{
id: 'provider-001',
name: 'duck_coding_free',
display_name: 'DuckCodingFree',
description: '',
website: 'https://duckcoding.com',
provider_priority: 1,
billing_type: 'free_tier',
monthly_used_usd: 0.0,
is_active: true,
total_endpoints: 3,
active_endpoints: 3,
total_keys: 3,
active_keys: 3,
total_models: 7,
active_models: 7,
avg_health_score: 0.91,
unhealthy_endpoints: 0,
api_formats: ['CLAUDE_CLI', 'GEMINI_CLI', 'OPENAI_CLI'],
endpoint_health_details: [
{ api_format: 'CLAUDE_CLI', health_score: 0.73, is_active: true, active_keys: 1 },
{ api_format: 'GEMINI_CLI', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'OPENAI_CLI', health_score: 1.0, is_active: true, active_keys: 1 }
],
created_at: '2024-12-09T14:10:36.446217+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-002',
name: 'open_claude_code',
display_name: 'OpenClaudeCode',
description: '',
website: 'https://www.openclaudecode.cn',
provider_priority: 2,
billing_type: 'pay_as_you_go',
monthly_used_usd: 545.18,
is_active: true,
total_endpoints: 2,
active_endpoints: 2,
total_keys: 3,
active_keys: 3,
total_models: 3,
active_models: 1,
avg_health_score: 0.825,
unhealthy_endpoints: 0,
api_formats: ['CLAUDE', 'CLAUDE_CLI'],
endpoint_health_details: [
{ api_format: 'CLAUDE', health_score: 1.0, is_active: true, active_keys: 2 },
{ api_format: 'CLAUDE_CLI', health_score: 0.65, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T22:58:15.044538+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-003',
name: '88_code',
display_name: '88Code',
description: '',
website: 'https://www.88code.org/',
provider_priority: 3,
billing_type: 'pay_as_you_go',
monthly_used_usd: 33.36,
is_active: true,
total_endpoints: 2,
active_endpoints: 2,
total_keys: 2,
active_keys: 2,
total_models: 5,
active_models: 5,
avg_health_score: 1.0,
unhealthy_endpoints: 0,
api_formats: ['CLAUDE_CLI', 'OPENAI_CLI'],
endpoint_health_details: [
{ api_format: 'CLAUDE_CLI', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'OPENAI_CLI', health_score: 1.0, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T22:56:46.361092+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-004',
name: 'ikun_code',
display_name: 'IKunCode',
description: '',
website: 'https://api.ikuncode.cc',
provider_priority: 4,
billing_type: 'pay_as_you_go',
monthly_used_usd: 268.65,
is_active: true,
total_endpoints: 4,
active_endpoints: 4,
total_keys: 3,
active_keys: 3,
total_models: 7,
active_models: 7,
avg_health_score: 1.0,
unhealthy_endpoints: 0,
api_formats: ['CLAUDE_CLI', 'GEMINI', 'GEMINI_CLI', 'OPENAI_CLI'],
endpoint_health_details: [
{ api_format: 'CLAUDE_CLI', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'GEMINI', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'GEMINI_CLI', health_score: 1.0, is_active: true, active_keys: 1 },
{ api_format: 'OPENAI_CLI', health_score: 1.0, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T15:16:55.807595+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-005',
name: 'duck_coding',
display_name: 'DuckCoding',
description: '',
website: 'https://duckcoding.com',
provider_priority: 5,
billing_type: 'pay_as_you_go',
monthly_used_usd: 5.29,
is_active: true,
total_endpoints: 6,
active_endpoints: 6,
total_keys: 11,
active_keys: 11,
total_models: 8,
active_models: 8,
avg_health_score: 0.863,
unhealthy_endpoints: 1,
api_formats: ['CLAUDE', 'CLAUDE_CLI', 'GEMINI', 'GEMINI_CLI', 'OPENAI', 'OPENAI_CLI'],
endpoint_health_details: [
{ api_format: 'CLAUDE', health_score: 1.0, is_active: true, active_keys: 2 },
{ api_format: 'CLAUDE_CLI', health_score: 0.48, is_active: true, active_keys: 2 },
{ api_format: 'GEMINI', health_score: 1.0, is_active: true, active_keys: 2 },
{ api_format: 'GEMINI_CLI', health_score: 0.85, is_active: true, active_keys: 2 },
{ api_format: 'OPENAI', health_score: 0.85, is_active: true, active_keys: 2 },
{ api_format: 'OPENAI_CLI', health_score: 1.0, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T22:56:09.712806+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-006',
name: 'privnode',
display_name: 'Privnode',
description: '',
website: 'https://privnode.com',
provider_priority: 6,
billing_type: 'pay_as_you_go',
monthly_used_usd: 0.0,
is_active: true,
total_endpoints: 0,
active_endpoints: 0,
total_keys: 0,
active_keys: 0,
total_models: 6,
active_models: 6,
avg_health_score: 1.0,
unhealthy_endpoints: 0,
api_formats: [],
endpoint_health_details: [],
created_at: '2024-12-07T22:57:18.069024+08:00',
updated_at: new Date().toISOString()
},
{
id: 'provider-007',
name: 'undying_api',
display_name: 'UndyingAPI',
description: '',
website: 'https://vip.undyingapi.com',
provider_priority: 7,
billing_type: 'pay_as_you_go',
monthly_used_usd: 6.6,
is_active: true,
total_endpoints: 1,
active_endpoints: 1,
total_keys: 1,
active_keys: 1,
total_models: 1,
active_models: 1,
avg_health_score: 1.0,
unhealthy_endpoints: 0,
api_formats: ['GEMINI'],
endpoint_health_details: [
{ api_format: 'GEMINI', health_score: 1.0, is_active: true, active_keys: 1 }
],
created_at: '2024-12-07T23:00:42.559105+08:00',
updated_at: new Date().toISOString()
}
]
// ========== GlobalModel 数据 ==========
export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
{
id: 'gm-001',
name: 'claude-haiku-4-5-20251001',
display_name: 'claude-haiku-4-5',
description: 'Anthropic 最快速的 Claude 4 系列模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.00, output_price_per_1m: 5.00, cache_creation_price_per_1m: 1.25, cache_read_price_per_1m: 0.1 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-002',
name: 'claude-opus-4-5-20251101',
display_name: 'claude-opus-4-5',
description: 'Anthropic 最强大的模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 5.00, output_price_per_1m: 25.00, cache_creation_price_per_1m: 6.25, cache_read_price_per_1m: 0.5 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-003',
name: 'claude-sonnet-4-5-20250929',
display_name: 'claude-sonnet-4-5',
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文',
is_active: true,
default_tiered_pricing: {
tiers: [
{
"up_to": 200000,
"input_price_per_1m": 3,
"output_price_per_1m": 15,
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.3,
"cache_ttl_pricing": [
{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 6
}
]
},
{
"up_to": null,
"input_price_per_1m": 6,
"output_price_per_1m": 22.5,
"cache_creation_price_per_1m": 7.5,
"cache_read_price_per_1m": 0.6,
"cache_ttl_pricing": [
{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 12
}
]
}
]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
supported_capabilities: ['cache_1h', 'cli_1m'],
provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-004',
name: 'gemini-3-pro-image-preview',
display_name: 'gemini-3-pro-image-preview',
description: 'Google Gemini 3 Pro 图像生成预览版',
is_active: true,
default_price_per_request: 0.300,
default_tiered_pricing: {
tiers: []
},
default_supports_vision: true,
default_supports_function_calling: false,
default_supports_streaming: true,
default_supports_image_generation: true,
provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-005',
name: 'gemini-3-pro-preview',
display_name: 'gemini-3-pro-preview',
description: 'Google Gemini 3 Pro 预览版',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 2.00, output_price_per_1m: 12.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-006',
name: 'gpt-5.1',
display_name: 'gpt-5.1',
description: 'OpenAI GPT-5.1 模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-007',
name: 'gpt-5.1-codex',
display_name: 'gpt-5.1-codex',
description: 'OpenAI GPT-5.1 Codex 代码专用模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-008',
name: 'gpt-5.1-codex-max',
display_name: 'gpt-5.1-codex-max',
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-009',
name: 'gpt-5.1-codex-mini',
display_name: 'gpt-5.1-codex-mini',
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
}
]
// ========== Usage 数据 ==========
export const MOCK_USAGE_RESPONSE: UsageResponse = {
total_requests: 1234,
total_input_tokens: 1800000,
total_output_tokens: 700000,
total_tokens: 2500000,
total_cost: 45.67,
total_actual_cost: 33.33,
avg_response_time: 1.23,
quota_usd: 100,
used_usd: 45.32,
summary_by_model: [
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
],
records: [
{
id: 'usage-001',
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
input_tokens: 1500,
output_tokens: 800,
total_tokens: 2300,
cost: 0.0165,
response_time_ms: 1234,
is_stream: true,
created_at: new Date().toISOString(),
status_code: 200,
input_price_per_1m: 3,
output_price_per_1m: 15
},
{
id: 'usage-002',
provider: 'openai',
model: 'gpt-4o',
input_tokens: 2000,
output_tokens: 500,
total_tokens: 2500,
cost: 0.01,
response_time_ms: 890,
is_stream: false,
created_at: new Date(Date.now() - 300000).toISOString(),
status_code: 200,
input_price_per_1m: 2.5,
output_price_per_1m: 10
}
]
}
// ========== 系统配置 ==========
export const MOCK_SYSTEM_CONFIGS = [
{ key: 'rate_limit_enabled', value: true, description: '是否启用速率限制' },
{ key: 'default_rate_limit', value: 60, description: '默认速率限制(请求/分钟)' },
{ key: 'cache_enabled', value: true, description: '是否启用缓存' },
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL' },
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' }
]
// ========== API 格式 ==========
export const MOCK_API_FORMATS = {
formats: [
{ value: 'claude', label: 'Claude API', default_path: '/v1/messages', aliases: [] },
{ value: 'claude_cli', label: 'Claude CLI', default_path: '/v1/messages', aliases: [] },
{ value: 'openai', label: 'OpenAI API', default_path: '/v1/chat/completions', aliases: [] },
{ value: 'openai_cli', label: 'OpenAI Responses API', default_path: '/v1/responses', aliases: [] },
{ value: 'gemini', label: 'Gemini API', default_path: '/v1beta/models', aliases: [] },
{ value: 'gemini_cli', label: 'Gemini CLI', default_path: '/v1beta/models', aliases: [] }
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
/**
* Mock Module Index
*/
export { handleMockRequest, setMockUserToken, getMockUserToken } from './handler'
export * from './data'

View File

@@ -171,7 +171,14 @@ const filteredRecords = computed(() => {
} else if (filterStatus.value === 'completed') { } else if (filterStatus.value === 'completed') {
records = records.filter(record => record.status === 'completed') records = records.filter(record => record.status === 'completed')
} else if (filterStatus.value === 'failed') { } else if (filterStatus.value === 'failed') {
records = records.filter(record => record.status === 'failed') // 失败请求需要同时考虑新旧两种判断方式:
// 1. 新方式status = "failed"
// 2. 旧方式status_code >= 400 或 error_message 不为空
records = records.filter(record =>
record.status === 'failed' ||
(record.status_code && record.status_code >= 400) ||
record.error_message
)
} }
} }

View File

@@ -95,6 +95,8 @@ warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
disallow_untyped_defs = true disallow_untyped_defs = true
exclude = "tools/debug" exclude = "tools/debug"
# 忽略项目内部模块的 import-untyped 警告
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -530,9 +530,18 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
query = query.filter( query = query.filter(
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)) (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
) )
elif self.status in ("pending", "streaming", "completed", "failed"): elif self.status in ("pending", "streaming", "completed"):
# 新的状态筛选:直接按 status 字段过滤 # 新的状态筛选:直接按 status 字段过滤
query = query.filter(Usage.status == self.status) query = query.filter(Usage.status == self.status)
elif self.status == "failed":
# 失败请求需要同时考虑新旧两种判断方式:
# 1. 新方式status = "failed"
# 2. 旧方式status_code >= 400 或 error_message 不为空
query = query.filter(
(Usage.status == "failed") |
(Usage.status_code >= 400) |
(Usage.error_message.isnot(None))
)
elif self.status == "active": elif self.status == "active":
# 活跃请求pending 或 streaming 状态 # 活跃请求pending 或 streaming 状态
query = query.filter(Usage.status.in_(["pending", "streaming"])) query = query.filter(Usage.status.in_(["pending", "streaming"]))
@@ -651,42 +660,17 @@ class AdminActiveRequestsAdapter(AdminApiAdapter):
self.ids = ids self.ids = ids
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
db = context.db from src.services.usage import UsageService
db = context.db
id_list = None
if self.ids: if self.ids:
# 查询指定 ID 的请求状态
id_list = [id.strip() for id in self.ids.split(",") if id.strip()] id_list = [id.strip() for id in self.ids.split(",") if id.strip()]
if not id_list: if not id_list:
return {"requests": []} return {"requests": []}
records = ( requests = UsageService.get_active_requests_status(db=db, ids=id_list)
db.query(Usage.id, Usage.status, Usage.input_tokens, Usage.output_tokens, Usage.total_cost_usd, Usage.response_time_ms) return {"requests": requests}
.filter(Usage.id.in_(id_list))
.all()
)
else:
# 查询所有活跃请求pending 或 streaming
records = (
db.query(Usage.id, Usage.status, Usage.input_tokens, Usage.output_tokens, Usage.total_cost_usd, Usage.response_time_ms)
.filter(Usage.status.in_(["pending", "streaming"]))
.order_by(Usage.created_at.desc())
.limit(50)
.all()
)
return {
"requests": [
{
"id": r.id,
"status": r.status,
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"cost": float(r.total_cost_usd) if r.total_cost_usd else 0,
"response_time_ms": r.response_time_ms,
}
for r in records
]
}
@dataclass @dataclass

View File

@@ -50,7 +50,9 @@ class MessageTelemetry:
负责记录 Usage/Audit避免处理器里重复代码。 负责记录 Usage/Audit避免处理器里重复代码。
""" """
def __init__(self, db: Session, user, api_key, request_id: str, client_ip: str): def __init__(
self, db: Session, user: Any, api_key: Any, request_id: str, client_ip: str
) -> None:
self.db = db self.db = db
self.user = user self.user = user
self.api_key = api_key self.api_key = api_key
@@ -187,7 +189,7 @@ class MessageTelemetry:
response_body: Optional[Dict[str, Any]] = None, response_body: Optional[Dict[str, Any]] = None,
# 模型映射信息 # 模型映射信息
target_model: Optional[str] = None, target_model: Optional[str] = None,
): ) -> None:
""" """
记录失败请求 记录失败请求
@@ -283,15 +285,15 @@ class BaseMessageHandler:
self, self,
*, *,
db: Session, db: Session,
user, user: Any,
api_key, api_key: Any,
request_id: str, request_id: str,
client_ip: str, client_ip: str,
user_agent: str, user_agent: str,
start_time: float, start_time: float,
allowed_api_formats: Optional[list[str]] = None, allowed_api_formats: Optional[list[str]] = None,
adapter_detector: Optional[AdapterDetectorType] = None, adapter_detector: Optional[AdapterDetectorType] = None,
): ) -> None:
self.db = db self.db = db
self.user = user self.user = user
self.api_key = api_key self.api_key = api_key
@@ -304,7 +306,7 @@ class BaseMessageHandler:
self.adapter_detector = adapter_detector self.adapter_detector = adapter_detector
redis_client = get_redis_client_sync() redis_client = get_redis_client_sync()
self.orchestrator = FallbackOrchestrator(db, redis_client) self.orchestrator = FallbackOrchestrator(db, redis_client) # type: ignore[arg-type]
self.telemetry = MessageTelemetry(db, user, api_key, request_id, client_ip) self.telemetry = MessageTelemetry(db, user, api_key, request_id, client_ip)
def elapsed_ms(self) -> int: def elapsed_ms(self) -> int:
@@ -347,7 +349,8 @@ class BaseMessageHandler:
def get_api_format(self, provider_type: Optional[str] = None) -> APIFormat: def get_api_format(self, provider_type: Optional[str] = None) -> APIFormat:
"""根据 provider_type 解析 API 格式,未知类型默认 OPENAI""" """根据 provider_type 解析 API 格式,未知类型默认 OPENAI"""
if provider_type: if provider_type:
return resolve_api_format(provider_type, default=APIFormat.OPENAI) result = resolve_api_format(provider_type, default=APIFormat.OPENAI)
return result or APIFormat.OPENAI
return self.primary_api_format return self.primary_api_format
def build_provider_payload( def build_provider_payload(
@@ -361,3 +364,34 @@ class BaseMessageHandler:
if mapped_model: if mapped_model:
payload["model"] = mapped_model payload["model"] = mapped_model
return payload return payload
def _update_usage_to_streaming(self, request_id: Optional[str] = None) -> None:
"""更新 Usage 状态为 streaming流式传输开始时调用
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
Args:
request_id: 请求 ID如果不传则使用 self.request_id
"""
import asyncio
from src.database.database import get_db
target_request_id = request_id or self.request_id
async def _do_update() -> None:
try:
db_gen = get_db()
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
)
finally:
db.close()
except Exception as e:
logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}")
# 创建后台任务,不阻塞当前流
asyncio.create_task(_do_update())

View File

@@ -116,7 +116,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# ==================== 抽象方法 ==================== # ==================== 抽象方法 ====================
@abstractmethod @abstractmethod
async def _convert_request(self, request): async def _convert_request(self, request: Any) -> Any:
""" """
将请求转换为目标格式 将请求转换为目标格式
@@ -261,7 +261,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
mapping = await mapper.get_mapping(source_model, provider_id) mapping = await mapper.get_mapping(source_model, provider_id)
if mapping and mapping.model: if mapping and mapping.model:
mapped_name = mapping.model.provider_model_name mapped_name = str(mapping.model.provider_model_name)
logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}") logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}")
return mapped_name return mapped_name
@@ -271,10 +271,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
async def process_stream( async def process_stream(
self, self,
request, request: Any,
http_request: Request, http_request: Request,
original_headers: Dict, original_headers: Dict[str, Any],
original_request_body: Dict, original_request_body: Dict[str, Any],
query_params: Optional[Dict[str, str]] = None, query_params: Optional[Dict[str, str]] = None,
) -> StreamingResponse: ) -> StreamingResponse:
"""处理流式响应""" """处理流式响应"""
@@ -315,7 +315,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider: Provider, provider: Provider,
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
key: ProviderAPIKey, key: ProviderAPIKey,
): ) -> AsyncGenerator[bytes, None]:
return await self._execute_stream_request( return await self._execute_stream_request(
ctx, ctx,
provider, provider,
@@ -401,16 +401,16 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx["cached_tokens"] = 0 ctx["cached_tokens"] = 0
ctx["cache_creation_tokens"] = 0 ctx["cache_creation_tokens"] = 0
ctx["provider_name"] = provider.name ctx["provider_name"] = str(provider.name)
ctx["provider_id"] = provider.id ctx["provider_id"] = str(provider.id)
ctx["endpoint_id"] = endpoint.id ctx["endpoint_id"] = str(endpoint.id)
ctx["key_id"] = key.id ctx["key_id"] = str(key.id)
ctx["provider_api_format"] = endpoint.api_format # Provider 的响应格式 ctx["provider_api_format"] = str(endpoint.api_format) if endpoint.api_format else ""
# 获取模型映射 # 获取模型映射
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=ctx["model"], source_model=ctx["model"],
provider_id=provider.id, provider_id=str(provider.id),
) )
# 应用模型映射到请求体 # 应用模型映射到请求体
@@ -514,14 +514,20 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
"""创建响应流生成器""" """创建响应流生成器"""
try: try:
sse_parser = SSEEventParser() sse_parser = SSEEventParser()
streaming_status_updated = False
async for line in stream_response.aiter_lines(): async for line in stream_response.aiter_lines():
# 在第一次输出数据前更新状态为 streaming
if not streaming_status_updated:
self._update_usage_to_streaming()
streaming_status_updated = True
normalized_line = line.rstrip("\r") normalized_line = line.rstrip("\r")
events = sse_parser.feed_line(normalized_line) events = sse_parser.feed_line(normalized_line)
if normalized_line == "": if normalized_line == "":
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
yield b"\n" yield b"\n"
continue continue
@@ -530,11 +536,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
# 处理剩余事件 # 处理剩余事件
for event in sse_parser.flush(): for event in sse_parser.flush():
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
except GeneratorExit: except GeneratorExit:
raise raise
@@ -618,7 +624,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
f"error_type={parsed.error_type}, " f"error_type={parsed.error_type}, "
f"message={parsed.error_message}") f"message={parsed.error_message}")
raise EmbeddedErrorException( raise EmbeddedErrorException(
provider_name=provider.name, provider_name=str(provider.name),
error_code=( error_code=(
int(parsed.error_type) int(parsed.error_type)
if parsed.error_type and parsed.error_type.isdigit() if parsed.error_type and parsed.error_type.isdigit()
@@ -652,6 +658,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
try: try:
sse_parser = SSEEventParser() sse_parser = SSEEventParser()
# 在第一次输出数据前更新状态为 streaming
if prefetched_lines:
self._update_usage_to_streaming()
# 先输出预读的数据 # 先输出预读的数据
for line in prefetched_lines: for line in prefetched_lines:
normalized_line = line.rstrip("\r") normalized_line = line.rstrip("\r")
@@ -659,7 +669,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
if normalized_line == "": if normalized_line == "":
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
yield b"\n" yield b"\n"
continue continue
@@ -667,7 +677,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
# 继续输出剩余的流数据(使用同一个迭代器) # 继续输出剩余的流数据(使用同一个迭代器)
async for line in line_iterator: async for line in line_iterator:
@@ -676,7 +686,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
if normalized_line == "": if normalized_line == "":
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
yield b"\n" yield b"\n"
continue continue
@@ -684,11 +694,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
yield (line + "\n").encode("utf-8") yield (line + "\n").encode("utf-8")
for event in events: for event in events:
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
# 处理剩余事件 # 处理剩余事件
for event in sse_parser.flush(): for event in sse_parser.flush():
self._handle_sse_event(ctx, event.get("event"), event.get("data", "")) self._handle_sse_event(ctx, event.get("event"), event.get("data") or "")
except GeneratorExit: except GeneratorExit:
raise raise
@@ -853,8 +863,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 根据状态码决定记录成功还是失败 # 根据状态码决定记录成功还是失败
# 499 = 客户端断开连接503 = 服务不可用(如流中断) # 499 = 客户端断开连接503 = 服务不可用(如流中断)
status_code = ctx.get("status_code") status_code: int = ctx.get("status_code") or 200
if status_code and status_code >= 400: if status_code >= 400:
# 记录失败的 Usage但使用已收到的预估 token 信息(来自 message_start # 记录失败的 Usage但使用已收到的预估 token 信息(来自 message_start
# 这样即使请求中断,也能记录预估成本 # 这样即使请求中断,也能记录预估成本
await bg_telemetry.record_failure( await bg_telemetry.record_failure(
@@ -955,6 +965,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_message=f"记录统计信息失败: {str(e)[:200]}", error_message=f"记录统计信息失败: {str(e)[:200]}",
) )
# _update_usage_to_streaming 方法已移至基类 BaseMessageHandler
async def _update_usage_status_on_error( async def _update_usage_status_on_error(
self, self,
response_time_ms: int, response_time_ms: int,
@@ -991,11 +1003,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
usage = db.query(Usage).filter(Usage.request_id == self.request_id).first() usage = db.query(Usage).filter(Usage.request_id == self.request_id).first()
if usage: if usage:
usage.status = status setattr(usage, "status", status)
usage.status_code = status_code setattr(usage, "status_code", status_code)
usage.response_time_ms = response_time_ms setattr(usage, "response_time_ms", response_time_ms)
if error_message: if error_message:
usage.error_message = error_message setattr(usage, "error_message", error_message)
db.commit() db.commit()
logger.debug(f"[{self.request_id}] Usage 状态已更新: {status}") logger.debug(f"[{self.request_id}] Usage 状态已更新: {status}")
except Exception as e: except Exception as e:
@@ -1040,10 +1052,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
async def process_sync( async def process_sync(
self, self,
request, request: Any,
http_request: Request, http_request: Request,
original_headers: Dict, original_headers: Dict[str, Any],
original_request_body: Dict, original_request_body: Dict[str, Any],
query_params: Optional[Dict[str, str]] = None, query_params: Optional[Dict[str, str]] = None,
) -> JSONResponse: ) -> JSONResponse:
"""处理非流式响应""" """处理非流式响应"""
@@ -1055,31 +1067,31 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
api_format = self.allowed_api_formats[0] api_format = self.allowed_api_formats[0]
# 用于跟踪的变量 # 用于跟踪的变量
provider_name = None provider_name: Optional[str] = None
response_json = None response_json: Optional[Dict[str, Any]] = None
status_code = 200 status_code = 200
response_headers = {} response_headers: Dict[str, str] = {}
provider_request_headers = {} provider_request_headers: Dict[str, str] = {}
provider_request_body = None provider_request_body: Optional[Dict[str, Any]] = None
provider_id = None # Provider ID用于失败记录 provider_id: Optional[str] = None # Provider ID用于失败记录
endpoint_id = None # Endpoint ID用于失败记录 endpoint_id: Optional[str] = None # Endpoint ID用于失败记录
key_id = None # Key ID用于失败记录 key_id: Optional[str] = None # Key ID用于失败记录
mapped_model_result = None # 映射后的目标模型名(用于 Usage 记录) mapped_model_result: Optional[str] = None # 映射后的目标模型名(用于 Usage 记录)
async def sync_request_func( async def sync_request_func(
provider: Provider, provider: Provider,
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
key: ProviderAPIKey, key: ProviderAPIKey,
): ) -> Dict[str, Any]:
nonlocal provider_name, response_json, status_code, response_headers nonlocal provider_name, response_json, status_code, response_headers
nonlocal provider_request_headers, provider_request_body, mapped_model_result nonlocal provider_request_headers, provider_request_body, mapped_model_result
provider_name = provider.name provider_name = str(provider.name)
# 获取模型映射 # 获取模型映射
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=model, source_model=model,
provider_id=provider.id, provider_id=str(provider.id),
) )
# 应用模型映射 # 应用模型映射
@@ -1131,7 +1143,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
elif resp.status_code == 429: elif resp.status_code == 429:
raise ProviderRateLimitException( raise ProviderRateLimitException(
f"提供商速率限制: {provider.name}", f"提供商速率限制: {provider.name}",
provider_name=provider.name, provider_name=str(provider.name),
response_headers=response_headers, response_headers=response_headers,
) )
elif resp.status_code >= 500: elif resp.status_code >= 500:
@@ -1142,7 +1154,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
response_json = resp.json() response_json = resp.json()
return response_json return response_json if isinstance(response_json, dict) else {}
try: try:
# 解析能力需求 # 解析能力需求
@@ -1170,6 +1182,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_name = actual_provider_name provider_name = actual_provider_name
response_time_ms = self.elapsed_ms() response_time_ms = self.elapsed_ms()
# 确保 response_json 不为 None
if response_json is None:
response_json = {}
# 规范化响应 # 规范化响应
response_json = self._normalize_response(response_json) response_json = self._normalize_response(response_json)

View File

@@ -207,7 +207,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}") logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}")
if mapping and mapping.model: if mapping and mapping.model:
mapped_name = mapping.model.provider_model_name mapped_name = str(mapping.model.provider_model_name)
logger.debug(f"[CLI] 模型映射: {source_model} -> {mapped_name} (provider={provider_id[:8]}...)") logger.debug(f"[CLI] 模型映射: {source_model} -> {mapped_name} (provider={provider_id[:8]}...)")
return mapped_name return mapped_name
@@ -351,7 +351,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider: Provider, provider: Provider,
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
key: ProviderAPIKey, key: ProviderAPIKey,
): ) -> AsyncGenerator[bytes, None]:
return await self._execute_stream_request( return await self._execute_stream_request(
ctx, ctx,
provider, provider,
@@ -451,19 +451,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.response_metadata = {} # 重置 Provider 响应元数据 ctx.response_metadata = {} # 重置 Provider 响应元数据
# 记录 Provider 信息 # 记录 Provider 信息
ctx.provider_name = provider.name ctx.provider_name = str(provider.name)
ctx.provider_id = provider.id ctx.provider_id = str(provider.id)
ctx.endpoint_id = endpoint.id ctx.endpoint_id = str(endpoint.id)
ctx.key_id = key.id ctx.key_id = str(key.id)
# 记录格式转换信息 # 记录格式转换信息
ctx.provider_api_format = endpoint.api_format if endpoint.api_format else "" ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置 ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
# 获取模型映射(别名/映射 → 实际模型名) # 获取模型映射(别名/映射 → 实际模型名)
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=ctx.model, source_model=ctx.model,
provider_id=provider.id, provider_id=str(provider.id),
) )
# 应用模型映射到请求体(子类可覆盖此方法处理不同格式) # 应用模型映射到请求体(子类可覆盖此方法处理不同格式)
@@ -575,11 +575,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
try: try:
sse_parser = SSEEventParser() sse_parser = SSEEventParser()
last_data_time = time.time() last_data_time = time.time()
streaming_status_updated = False
# 检查是否需要格式转换 # 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx) needs_conversion = self._needs_format_conversion(ctx)
async for line in stream_response.aiter_lines(): async for line in stream_response.aiter_lines():
# 在第一次输出数据前更新状态为 streaming
if not streaming_status_updated:
self._update_usage_to_streaming(ctx.request_id)
streaming_status_updated = True
normalized_line = line.rstrip("\r") normalized_line = line.rstrip("\r")
events = sse_parser.feed_line(normalized_line) events = sse_parser.feed_line(normalized_line)
@@ -588,7 +594,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
yield b"\n" yield b"\n"
continue continue
@@ -622,7 +628,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
if ctx.data_count > 0: if ctx.data_count > 0:
@@ -633,7 +639,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
# 检查是否收到数据 # 检查是否收到数据
@@ -781,7 +787,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
f"error_type={parsed.error_type}, " f"error_type={parsed.error_type}, "
f"message={parsed.error_message}") f"message={parsed.error_message}")
raise EmbeddedErrorException( raise EmbeddedErrorException(
provider_name=provider.name, provider_name=str(provider.name),
error_code=( error_code=(
int(parsed.error_type) int(parsed.error_type)
if parsed.error_type and parsed.error_type.isdigit() if parsed.error_type and parsed.error_type.isdigit()
@@ -819,6 +825,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 检查是否需要格式转换 # 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx) needs_conversion = self._needs_format_conversion(ctx)
# 在第一次输出数据前更新状态为 streaming
if prefetched_lines:
self._update_usage_to_streaming(ctx.request_id)
# 先处理预读的数据 # 先处理预读的数据
for line in prefetched_lines: for line in prefetched_lines:
normalized_line = line.rstrip("\r") normalized_line = line.rstrip("\r")
@@ -829,7 +839,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
yield b"\n" yield b"\n"
continue continue
@@ -848,7 +858,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
if ctx.data_count > 0: if ctx.data_count > 0:
@@ -864,7 +874,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
yield b"\n" yield b"\n"
continue continue
@@ -898,7 +908,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
if ctx.data_count > 0: if ctx.data_count > 0:
@@ -910,7 +920,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
self._handle_sse_event( self._handle_sse_event(
ctx, ctx,
event.get("event"), event.get("event"),
event.get("data", ""), event.get("data") or "",
) )
# 检查是否收到数据 # 检查是否收到数据
@@ -1270,6 +1280,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_model=ctx.mapped_model, target_model=ctx.mapped_model,
) )
# _update_usage_to_streaming 方法已移至基类 BaseMessageHandler
async def process_sync( async def process_sync(
self, self,
original_request_body: Dict[str, Any], original_request_body: Dict[str, Any],
@@ -1309,15 +1321,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider: Provider, provider: Provider,
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
key: ProviderAPIKey, key: ProviderAPIKey,
): ) -> Dict[str, Any]:
nonlocal provider_name, response_json, status_code, response_headers, provider_api_format, provider_request_headers, provider_request_body, mapped_model_result, response_metadata_result nonlocal provider_name, response_json, status_code, response_headers, provider_api_format, provider_request_headers, provider_request_body, mapped_model_result, response_metadata_result
provider_name = provider.name provider_name = str(provider.name)
provider_api_format = endpoint.api_format if endpoint.api_format else "" provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
# 获取模型映射(别名/映射 → 实际模型名) # 获取模型映射(别名/映射 → 实际模型名)
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=model, source_model=model,
provider_id=provider.id, provider_id=str(provider.id),
) )
# 应用模型映射到请求体(子类可覆盖此方法处理不同格式) # 应用模型映射到请求体(子类可覆盖此方法处理不同格式)
@@ -1373,7 +1385,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
elif resp.status_code == 429: elif resp.status_code == 429:
raise ProviderRateLimitException( raise ProviderRateLimitException(
f"提供商速率限制: {provider.name}", f"提供商速率限制: {provider.name}",
provider_name=provider.name, provider_name=str(provider.name),
response_headers=response_headers, response_headers=response_headers,
retry_after=int(resp.headers.get("retry-after", 0)) or None, retry_after=int(resp.headers.get("retry-after", 0)) or None,
) )
@@ -1409,7 +1421,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 提取 Provider 响应元数据(子类可覆盖) # 提取 Provider 响应元数据(子类可覆盖)
response_metadata_result = self._extract_response_metadata(response_json) response_metadata_result = self._extract_response_metadata(response_json)
return response_json return response_json if isinstance(response_json, dict) else {}
try: try:
# 解析能力需求 # 解析能力需求
@@ -1437,6 +1449,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_name = actual_provider_name provider_name = actual_provider_name
response_time_ms = int((time.time() - sync_start_time) * 1000) response_time_ms = int((time.time() - sync_start_time) * 1000)
# 确保 response_json 不为 None
if response_json is None:
response_json = {}
# 检查是否需要格式转换 # 检查是否需要格式转换
if ( if (
provider_api_format provider_api_format

View File

@@ -662,60 +662,18 @@ class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
ids: Optional[str] = None ids: Optional[str] = None
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from src.services.usage import UsageService
db = context.db db = context.db
user = context.user user = context.user
id_list = None
if self.ids: if self.ids:
# 查询指定 ID 的请求状态(只能查询自己的)
id_list = [id.strip() for id in self.ids.split(",") if id.strip()] id_list = [id.strip() for id in self.ids.split(",") if id.strip()]
if not id_list: if not id_list:
return {"requests": []} return {"requests": []}
records = ( requests = UsageService.get_active_requests_status(db=db, ids=id_list, user_id=user.id)
db.query( return {"requests": requests}
Usage.id,
Usage.status,
Usage.input_tokens,
Usage.output_tokens,
Usage.total_cost_usd,
Usage.response_time_ms,
)
.filter(Usage.id.in_(id_list), Usage.user_id == user.id)
.all()
)
else:
# 查询所有活跃请求pending 或 streaming
records = (
db.query(
Usage.id,
Usage.status,
Usage.input_tokens,
Usage.output_tokens,
Usage.total_cost_usd,
Usage.response_time_ms,
)
.filter(
Usage.user_id == user.id,
Usage.status.in_(["pending", "streaming"]),
)
.order_by(Usage.created_at.desc())
.limit(50)
.all()
)
return {
"requests": [
{
"id": r.id,
"status": r.status,
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"cost": float(r.total_cost_usd) if r.total_cost_usd else 0,
"response_time_ms": r.response_time_ms,
}
for r in records
]
}
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter): class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):

View File

@@ -76,6 +76,7 @@ class ErrorClassifier:
"content_policy_violation", # 内容违规 "content_policy_violation", # 内容违规
"invalid_api_key", # 无效的 API Key不同于认证失败 "invalid_api_key", # 无效的 API Key不同于认证失败
"context_length_exceeded", # 上下文长度超限 "context_length_exceeded", # 上下文长度超限
"content_length_limit", # 请求内容长度超限 (Claude API)
"max_tokens", # token 数超限 "max_tokens", # token 数超限
"invalid_prompt", # 无效的提示词 "invalid_prompt", # 无效的提示词
"content too long", # 内容过长 "content too long", # 内容过长

View File

@@ -1304,3 +1304,93 @@ class UsageService:
) )
.count() .count()
) )
@classmethod
def get_active_requests_status(
cls,
db: Session,
ids: Optional[List[str]] = None,
user_id: Optional[str] = None,
default_timeout_seconds: int = 300,
) -> List[Dict[str, Any]]:
"""
获取活跃请求状态(用于前端轮询),并自动清理超时的 pending 请求
与 get_active_requests 不同,此方法:
1. 返回轻量级的状态字典而非完整 Usage 对象
2. 自动检测并清理超时的 pending 请求
3. 支持按 ID 列表查询特定请求
Args:
db: 数据库会话
ids: 指定要查询的请求 ID 列表(可选)
user_id: 限制只查询该用户的请求(可选,用于普通用户接口)
default_timeout_seconds: 默认超时时间(秒),当端点未配置时使用
Returns:
请求状态列表
"""
from src.models.database import ProviderEndpoint
now = datetime.now(timezone.utc)
# 构建基础查询,包含端点的 timeout 配置
query = db.query(
Usage.id,
Usage.status,
Usage.input_tokens,
Usage.output_tokens,
Usage.total_cost_usd,
Usage.response_time_ms,
Usage.created_at,
Usage.provider_endpoint_id,
ProviderEndpoint.timeout.label("endpoint_timeout"),
).outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
if ids:
query = query.filter(Usage.id.in_(ids))
if user_id:
query = query.filter(Usage.user_id == user_id)
else:
# 查询所有活跃请求
query = query.filter(Usage.status.in_(["pending", "streaming"]))
if user_id:
query = query.filter(Usage.user_id == user_id)
query = query.order_by(Usage.created_at.desc()).limit(50)
records = query.all()
# 检查超时的 pending 请求
timeout_ids = []
for r in records:
if r.status == "pending" and r.created_at:
# 使用端点配置的超时时间,若无则使用默认值
timeout_seconds = r.endpoint_timeout or default_timeout_seconds
# 处理时区:如果 created_at 没有时区信息,假定为 UTC
created_at = r.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
elapsed = (now - created_at).total_seconds()
if elapsed > timeout_seconds:
timeout_ids.append(r.id)
# 批量更新超时的请求
if timeout_ids:
db.query(Usage).filter(Usage.id.in_(timeout_ids)).update(
{"status": "failed", "error_message": "请求超时(服务器可能已重启)"},
synchronize_session=False,
)
db.commit()
return [
{
"id": r.id,
"status": "failed" if r.id in timeout_ids else r.status,
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"cost": float(r.total_cost_usd) if r.total_cost_usd else 0,
"response_time_ms": r.response_time_ms,
}
for r in records
]