13 Commits

Author SHA1 Message Date
fawney19
c8033fb6ab chore: bump version to 0.2.5 2026-01-07 20:19:49 +08:00
fawney19
e33d5b952c feat: 所有计费模板支持按次计费,调整端点默认重试次数为 2
- 为 Claude、OpenAI、豆包、Gemini 计费模板添加 request 维度
- 支持通过 price_per_request 配置按次计费(如图片生成模型)
- 将端点 max_retries 默认值从 3 改为 2(请求一次 + 重试一次)
2026-01-07 20:15:30 +08:00
fawney19
4345ac2ba2 fix: 添加系统配置项默认值,避免前端获取配置时报错
添加以下配置项到 DEFAULT_CONFIGS:
- email_suffix_mode/email_suffix_list (邮箱后缀限制)
- audit_log_retention_days (审计日志保留天数)
- smtp_* (SMTP 邮件服务器配置)
2026-01-07 20:01:17 +08:00
fawney19
a12b43ce5c refactor: 清理数据库字段命名歧义
- users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
- api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
- providers 表:删除 rate_limit 字段(与 rpm_limit 重复)
- usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)

同步更新前后端所有相关代码
2026-01-07 19:53:32 +08:00
fawney19
6885cf1f6d refactor: 使用 asyncio.wait_for 控制请求整体超时
将 endpoint.timeout 从 httpx 的 read 超时改为 asyncio.wait_for 控制,
更精确地管理"建立连接 + 获取首字节"阶段的整体超时。

主要改动:
- HTTP 超时配置改用全局 config 参数
- endpoint.timeout 作为 asyncio.wait_for 的整体超时
- 增加 asyncio.TimeoutError 处理和连接清理逻辑
- 增加防御性空值检查
2026-01-07 18:17:35 +08:00
fawney19
00f6fafcfc feat: 添加 API 文档路由、扩展用户列表字段、修复 CORS 配置
- Dockerfile.app: 添加 /docs、/redoc、/openapi.json 的 nginx 代理规则
- routes.py: 管理员用户列表接口增加 allowed_providers/endpoints/models 字段
- main.py: 修复 CORS_ORIGINS=* 时 credentials 配置冲突问题
2026-01-07 17:31:31 +08:00
fawney19
42dc64246c feat: 添加 GUNICORN_WORKERS 环境变量配置 2026-01-07 16:38:46 +08:00
fawney19
fbe303a3cd fix: 补充 cost.py 中 Provider 未实现模型时的 else 分支处理 2026-01-07 16:13:53 +08:00
fawney19
373845450b refactor: 简化 docker-compose 环境变量配置
使用 env_file 加载 .env 文件,移除冗余的环境变量声明,
仅保留需要组合的变量和容器级别设置
2026-01-07 15:19:55 +08:00
fawney19
084bbc0bef refactor: 将 nginx gzip 压缩配置移至 server 块内部 2026-01-07 15:10:11 +08:00
fawney19
0061fc04b7 feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
2026-01-07 14:55:07 +08:00
fawney19
f6a6410626 feat: 添加 GitHub 仓库链接到页面头部
- 新增 GithubIcon 组件复用 GitHub 图标
- MainLayout 和 Home 页面头部添加 GitHub 链接按钮
- 重构 Home 页面 header 布局结构
2026-01-06 18:27:28 +08:00
fawney19
835be3d329 refactor: nginx 透传外层代理 IP 头并禁用审计日志页面的审计记录
- Dockerfile.app/local: 使用 map 指令智能处理 X-Real-IP 和 X-Forwarded-For,
  有外层代理头则透传,否则使用 remote_addr
- audit.py: 查看审计日志不再产生审计记录,避免刷新页面时产生大量无意义日志
2026-01-06 17:23:08 +08:00
84 changed files with 6841 additions and 911 deletions

View File

@@ -39,7 +39,18 @@ COPY alembic.ini ./
COPY alembic/ ./alembic/
# Nginx 配置模板
# 智能处理 IP有外层代理头就透传没有就用直连 IP
RUN printf '%s\n' \
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -47,6 +58,15 @@ RUN printf '%s\n' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
@@ -62,6 +82,15 @@ RUN printf '%s\n' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
@@ -70,8 +99,8 @@ RUN printf '%s\n' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
@@ -124,7 +153,8 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
PORT=8084 \
GUNICORN_WORKERS=4
EXPOSE 80

View File

@@ -40,7 +40,18 @@ COPY alembic.ini ./
COPY alembic/ ./alembic/
# Nginx 配置模板
# 智能处理 IP有外层代理头就透传没有就用直连 IP
RUN printf '%s\n' \
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -48,6 +59,15 @@ RUN printf '%s\n' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
@@ -71,8 +91,8 @@ RUN printf '%s\n' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \

View File

@@ -0,0 +1,131 @@
"""add_management_tokens_table
Revision ID: ad55f1d008b7
Revises: c3d4e5f6g7h8
Create Date: 2026-01-06 15:24:10.660394+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'ad55f1d008b7'
down_revision = 'c3d4e5f6g7h8'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
indexes = inspector.get_indexes(table_name)
return any(idx["name"] == index_name for idx in indexes)
except Exception:
return False
def constraint_exists(table_name: str, constraint_name: str) -> bool:
"""检查约束是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
constraints = inspector.get_unique_constraints(table_name)
if any(c["name"] == constraint_name for c in constraints):
return True
# 也检查 check 约束
check_constraints = inspector.get_check_constraints(table_name)
if any(c["name"] == constraint_name for c in check_constraints):
return True
return False
except Exception:
return False
def upgrade() -> None:
"""应用迁移:创建 management_tokens 表"""
# 幂等性检查
if table_exists("management_tokens"):
# 表已存在,检查是否需要添加约束
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
return
op.create_table('management_tokens',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('token_prefix', sa.String(length=12), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('allowed_ips', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
# 添加用户名称唯一约束
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
# 注意JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
def downgrade() -> None:
"""回滚迁移:删除 management_tokens 表"""
# 幂等性检查
if not table_exists("management_tokens"):
return
# 删除约束
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
# 删除索引
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_user_id"):
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_is_active"):
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
# 删除表
op.drop_table('management_tokens')

View File

@@ -0,0 +1,73 @@
"""cleanup ambiguous database fields
Revision ID: 02a45b66b7c4
Revises: ad55f1d008b7
Create Date: 2026-01-07 11:20:12.684426+00:00
变更内容:
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats修正历史命名错误
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
4. usage 表:重命名 provider 为 provider_name避免与 provider_id 外键混淆)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '02a45b66b7c4'
down_revision = 'ad55f1d008b7'
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""
1. users.allowed_endpoints -> allowed_api_formats重命名
2. api_keys.allowed_endpoints 删除
3. providers.rate_limit 删除(与 rpm_limit 重复)
4. usage.provider -> provider_name重命名
"""
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
if _column_exists('users', 'allowed_endpoints'):
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
# 2. api_keys 表:删除 allowed_endpoints 字段
if _column_exists('api_keys', 'allowed_endpoints'):
op.drop_column('api_keys', 'allowed_endpoints')
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
if _column_exists('providers', 'rate_limit'):
op.drop_column('providers', 'rate_limit')
# 4. usage 表:重命名 provider 为 provider_name
if _column_exists('usage', 'provider'):
op.alter_column('usage', 'provider', new_column_name='provider_name')
def downgrade() -> None:
"""回滚:恢复原字段"""
# 4. usage 表:将 provider_name 改回 provider
if _column_exists('usage', 'provider_name'):
op.alter_column('usage', 'provider_name', new_column_name='provider')
# 3. providers 表:恢复 rate_limit 字段
if not _column_exists('providers', 'rate_limit'):
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
# 2. api_keys 表:恢复 allowed_endpoints 字段
if not _column_exists('api_keys', 'allowed_endpoints'):
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
if _column_exists('users', 'allowed_api_formats'):
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')

View File

@@ -17,7 +17,7 @@ services:
ports:
- "${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
@@ -32,7 +32,7 @@ services:
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 5s
timeout: 3s
retries: 5
@@ -44,20 +44,15 @@ services:
dockerfile: Dockerfile.app.local
image: aether-app:latest
container_name: aether-app
env_file:
- .env
environment:
# 需要组合的变量
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
PORT: 8084
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
JWT_ALGORITHM: HS256
JWT_EXPIRATION_DELTA: 86400
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
# Supervisor 需要的变量
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
# 容器级别设置
TZ: Asia/Shanghai
PYTHONIOENCODING: utf-8
LANG: C.UTF-8

View File

@@ -13,7 +13,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
@@ -26,7 +26,7 @@ services:
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 5s
timeout: 3s
retries: 5
@@ -35,20 +35,15 @@ services:
app:
image: ghcr.io/fawney19/aether:latest
container_name: aether-app
env_file:
- .env
environment:
# 需要组合的变量
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
PORT: 8084
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
JWT_ALGORITHM: HS256
JWT_EXPIRATION_DELTA: 86400
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
# Supervisor 需要的变量
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
# 容器级别设置
TZ: Asia/Shanghai
PYTHONIOENCODING: utf-8
LANG: C.UTF-8

View File

@@ -22,7 +22,7 @@ export interface UserExport {
password_hash: string
role: string
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
model_capability_settings?: any
quota_usd?: number | null
@@ -40,7 +40,6 @@ export interface UserApiKeyExport {
balance_used_usd?: number
current_balance_usd?: number | null
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制

View File

@@ -98,7 +98,7 @@ export interface User {
used_usd?: number
total_usd?: number
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_endpoints?: string[] | null // 允许使用的端点 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string
last_login_at?: string

View File

@@ -0,0 +1,203 @@
/**
* Management Token API
*/
import apiClient from './client'
// ============== 类型定义 ==============
export interface ManagementToken {
id: string
user_id: string
name: string
description?: string
token_display: string
allowed_ips?: string[] | null
expires_at?: string | null
last_used_at?: string | null
last_used_ip?: string | null
usage_count: number
is_active: boolean
created_at: string
updated_at: string
user?: {
id: string
email: string
username: string
role: string
}
}
export interface CreateManagementTokenRequest {
name: string
description?: string
allowed_ips?: string[]
expires_at?: string | null
}
export interface CreateManagementTokenResponse {
message: string
token: string
data: ManagementToken
}
export interface UpdateManagementTokenRequest {
name?: string
description?: string | null
allowed_ips?: string[] | null
expires_at?: string | null
is_active?: boolean
}
export interface ManagementTokenListResponse {
items: ManagementToken[]
total: number
skip: number
limit: number
quota?: {
used: number
max: number
}
}
// ============== 用户自助管理 API ==============
export const managementTokenApi = {
/**
* 列出当前用户的 Management Tokens
*/
async listTokens(params?: {
is_active?: boolean
skip?: number
limit?: number
}): Promise<ManagementTokenListResponse> {
const response = await apiClient.get<ManagementTokenListResponse>(
'/api/me/management-tokens',
{ params }
)
return response.data
},
/**
* 创建 Management Token
*/
async createToken(
data: CreateManagementTokenRequest
): Promise<CreateManagementTokenResponse> {
const response = await apiClient.post<CreateManagementTokenResponse>(
'/api/me/management-tokens',
data
)
return response.data
},
/**
* 获取 Token 详情
*/
async getToken(tokenId: string): Promise<ManagementToken> {
const response = await apiClient.get<ManagementToken>(
`/api/me/management-tokens/${tokenId}`
)
return response.data
},
/**
* 更新 Token
*/
async updateToken(
tokenId: string,
data: UpdateManagementTokenRequest
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}`,
data
)
return response.data
},
/**
* 删除 Token
*/
async deleteToken(tokenId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(
`/api/me/management-tokens/${tokenId}`
)
return response.data
},
/**
* 切换 Token 状态
*/
async toggleToken(
tokenId: string
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}/status`
)
return response.data
},
/**
* 重新生成 Token
*/
async regenerateToken(
tokenId: string
): Promise<{ token: string; data: ManagementToken }> {
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}/regenerate`
)
return response.data
}
}
// ============== 管理员 API ==============
export const adminManagementTokenApi = {
/**
* 列出所有 Management Tokens管理员
*/
async listAllTokens(params?: {
user_id?: string
is_active?: boolean
skip?: number
limit?: number
}): Promise<ManagementTokenListResponse> {
const response = await apiClient.get<ManagementTokenListResponse>(
'/api/admin/management-tokens',
{ params }
)
return response.data
},
/**
* 获取 Token 详情(管理员)
*/
async getToken(tokenId: string): Promise<ManagementToken> {
const response = await apiClient.get<ManagementToken>(
`/api/admin/management-tokens/${tokenId}`
)
return response.data
},
/**
* 删除任意 Token管理员
*/
async deleteToken(tokenId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(
`/api/admin/management-tokens/${tokenId}`
)
return response.data
},
/**
* 切换任意 Token 状态(管理员)
*/
async toggleToken(
tokenId: string
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
`/api/admin/management-tokens/${tokenId}/status`
)
return response.data
}
}

View File

@@ -10,7 +10,7 @@ export interface User {
used_usd: number
total_usd: number
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
allowed_endpoints: string[] | null // 允许使用的端点 ID 列表
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
allowed_models: string[] | null // 允许使用的模型名称列表
created_at: string
updated_at?: string
@@ -23,7 +23,7 @@ export interface CreateUserRequest {
role?: 'admin' | 'user'
quota_usd?: number | null
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
}
@@ -34,7 +34,7 @@ export interface UpdateUserRequest {
quota_usd?: number | null
password?: string
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
}

View File

@@ -0,0 +1,13 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
</template>

View File

@@ -18,7 +18,7 @@
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
@click="handleBackdropClick"
/>
</Transition>
@@ -106,6 +106,7 @@ const props = defineProps<{
iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding
persistent?: boolean // Prevent closing on backdrop click
}>()
// Emits 定义
@@ -138,6 +139,13 @@ function handleClose() {
}
}
// 处理背景点击
function handleBackdropClick() {
if (!props.persistent) {
handleClose()
}
}
const maxWidthClass = computed(() => {
const sizeValue = props.maxWidth || props.size || 'md'
const sizes = {
@@ -162,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10)
// 添加 ESC 键监听
useEscapeKey(() => {
if (isOpen.value) {
if (isOpen.value && !props.persistent) {
handleClose()
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
}

View File

@@ -73,14 +73,16 @@
>
<button
type="button"
:class="['auth-tab', authType === 'local' && 'active']"
class="auth-tab"
:class="[authType === 'local' && 'active']"
@click="authType = 'local'"
>
本地登录
</button>
<button
type="button"
:class="['auth-tab', authType === 'ldap' && 'active']"
class="auth-tab"
:class="[authType === 'ldap' && 'active']"
@click="authType = 'ldap'"
>
LDAP 登录

View File

@@ -273,8 +273,8 @@
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
@click="endpointDropdownOpen = !endpointDropdownOpen"
>
<span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length}` : '全部可用' }}
<span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length}` : '全部可用' }}
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
@@ -294,14 +294,14 @@
v-for="format in apiFormats"
:key="format.value"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
@click="toggleSelection('allowed_endpoints', format.value)"
@click="toggleSelection('allowed_api_formats', format.value)"
>
<input
type="checkbox"
:checked="form.allowed_endpoints.includes(format.value)"
:checked="form.allowed_api_formats.includes(format.value)"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="toggleSelection('allowed_endpoints', format.value)"
@change="toggleSelection('allowed_api_formats', format.value)"
>
<span class="text-sm">{{ format.label }}</span>
</div>
@@ -374,7 +374,7 @@ export interface UserFormData {
role: 'admin' | 'user'
is_active?: boolean
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
}
@@ -414,7 +414,7 @@ const form = ref({
unlimited: false,
is_active: true,
allowed_providers: [] as string[],
allowed_endpoints: [] as string[],
allowed_api_formats: [] as string[],
allowed_models: [] as string[]
})
@@ -435,7 +435,7 @@ function resetForm() {
unlimited: false,
is_active: true,
allowed_providers: [],
allowed_endpoints: [],
allowed_api_formats: [],
allowed_models: []
}
}
@@ -454,7 +454,7 @@ function loadUserData() {
unlimited: props.user.quota_usd == null,
is_active: props.user.is_active ?? true,
allowed_providers: props.user.allowed_providers || [],
allowed_endpoints: props.user.allowed_endpoints || [],
allowed_api_formats: props.user.allowed_api_formats || [],
allowed_models: props.user.allowed_models || []
}
}
@@ -495,7 +495,7 @@ async function loadAccessControlOptions() {
}
// 切换选择
function toggleSelection(field: 'allowed_providers' | 'allowed_endpoints' | 'allowed_models', value: string) {
function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'allowed_models', value: string) {
const arr = form.value[field]
const index = arr.indexOf(value)
if (index === -1) {
@@ -520,7 +520,7 @@ async function handleSubmit() {
quota_usd: form.value.unlimited ? null : form.value.quota,
role: form.value.role,
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
allowed_endpoints: form.value.allowed_endpoints.length > 0 ? form.value.allowed_endpoints : null,
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
}

View File

@@ -280,6 +280,16 @@
class="h-4 w-4"
/>
</button>
<!-- GitHub Link -->
<a
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
title="GitHub 仓库"
>
<GithubIcon class="h-4 w-4" />
</a>
</div>
</header>
</template>
@@ -302,6 +312,7 @@ import {
Home,
Users,
Key,
KeyRound,
BarChart3,
Cog,
Settings,
@@ -322,6 +333,7 @@ import {
X,
Mail,
} from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue'
const router = useRouter()
const route = useRoute()
@@ -387,6 +399,7 @@ const navigation = computed(() => {
items: [
{ name: '模型目录', href: '/dashboard/models', icon: Box },
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
{ name: '访问令牌', href: '/dashboard/management-tokens', icon: KeyRound },
]
},
{
@@ -412,6 +425,7 @@ const navigation = computed(() => {
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]
},

View File

@@ -22,7 +22,7 @@ export const MOCK_ADMIN_USER: User = {
used_usd: 156.78,
total_usd: 1234.56,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-01-01T00:00:00Z',
last_login_at: new Date().toISOString()
@@ -38,7 +38,7 @@ export const MOCK_NORMAL_USER: User = {
used_usd: 45.32,
total_usd: 245.32,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-06-01T00:00:00Z',
last_login_at: new Date().toISOString()
@@ -274,7 +274,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
used_usd: 156.78,
total_usd: 1234.56,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-01-01T00:00:00Z'
},
@@ -288,7 +288,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
used_usd: 45.32,
total_usd: 245.32,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-06-01T00:00:00Z'
},
@@ -302,7 +302,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
used_usd: 23.45,
total_usd: 123.45,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-03-15T00:00:00Z'
},
@@ -316,7 +316,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
used_usd: 89.12,
total_usd: 589.12,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-02-20T00:00:00Z'
},
@@ -330,7 +330,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
used_usd: 30.00,
total_usd: 30.00,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: '2024-04-10T00:00:00Z'
}

View File

@@ -690,7 +690,7 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
used_usd: 0,
total_usd: 0,
allowed_providers: null,
allowed_endpoints: null,
allowed_api_formats: null,
allowed_models: null,
created_at: new Date().toISOString()
}

View File

@@ -34,6 +34,11 @@ const routes: RouteRecordRaw[] = [
name: 'MyApiKeys',
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
},
{
path: 'management-tokens',
name: 'ManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
},
{
path: 'announcements',
name: 'Announcements',
@@ -81,6 +86,11 @@ const routes: RouteRecordRaw[] = [
name: 'ApiKeys',
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
},
{
path: 'providers',
name: 'ProviderManagement',

View File

@@ -32,7 +32,10 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="server-url" class="block text-sm font-medium">
<Label
for="server-url"
class="block text-sm font-medium"
>
服务器地址
</Label>
<Input
@@ -48,7 +51,10 @@
</div>
<div>
<Label for="bind-dn" class="block text-sm font-medium">
<Label
for="bind-dn"
class="block text-sm font-medium"
>
绑定 DN
</Label>
<Input
@@ -64,7 +70,10 @@
</div>
<div>
<Label for="bind-password" class="block text-sm font-medium">
<Label
for="bind-password"
class="block text-sm font-medium"
>
绑定密码
</Label>
<div class="relative mt-1">
@@ -80,10 +89,30 @@
v-if="hasPassword || ldapConfig.bind_password"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
@click="handleClearPassword"
title="清除密码"
@click="handleClearPassword"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line
x1="18"
y1="6"
x2="6"
y2="18"
/><line
x1="6"
y1="6"
x2="18"
y2="18"
/></svg>
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
@@ -92,7 +121,10 @@
</div>
<div>
<Label for="base-dn" class="block text-sm font-medium">
<Label
for="base-dn"
class="block text-sm font-medium"
>
基础 DN
</Label>
<Input
@@ -108,7 +140,10 @@
</div>
<div>
<Label for="user-search-filter" class="block text-sm font-medium">
<Label
for="user-search-filter"
class="block text-sm font-medium"
>
用户搜索过滤器
</Label>
<Input
@@ -124,7 +159,10 @@
</div>
<div>
<Label for="username-attr" class="block text-sm font-medium">
<Label
for="username-attr"
class="block text-sm font-medium"
>
用户名属性
</Label>
<Input
@@ -140,7 +178,10 @@
</div>
<div>
<Label for="email-attr" class="block text-sm font-medium">
<Label
for="email-attr"
class="block text-sm font-medium"
>
邮箱属性
</Label>
<Input
@@ -153,7 +194,10 @@
</div>
<div>
<Label for="display-name-attr" class="block text-sm font-medium">
<Label
for="display-name-attr"
class="block text-sm font-medium"
>
显示名称属性
</Label>
<Input
@@ -166,7 +210,10 @@
</div>
<div>
<Label for="connect-timeout" class="block text-sm font-medium">
<Label
for="connect-timeout"
class="block text-sm font-medium"
>
连接超时 ()
</Label>
<Input

View File

@@ -907,7 +907,7 @@ function editUser(user: any) {
role: user.role,
is_active: user.is_active,
allowed_providers: user.allowed_providers || [],
allowed_endpoints: user.allowed_endpoints || [],
allowed_api_formats: user.allowed_api_formats || [],
allowed_models: user.allowed_models || []
}
showUserFormDialog.value = true
@@ -929,7 +929,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
quota_usd: data.quota_usd,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_endpoints: data.allowed_endpoints,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
}
if (data.password) {
@@ -946,7 +946,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
quota_usd: data.quota_usd,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_endpoints: data.allowed_endpoints,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
})
// 如果创建时指定为禁用,则更新状态

View File

@@ -20,10 +20,11 @@
</nav>
<!-- Header -->
<header class="fixed top-0 left-0 right-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
<div class="mx-auto max-w-7xl px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo & Brand -->
<header class="sticky top-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
<div class="h-16 flex items-center">
<!-- Centered content container (max-w-7xl) -->
<div class="mx-auto max-w-7xl w-full px-6 flex items-center justify-between">
<!-- Left: Logo & Brand -->
<div
class="flex items-center gap-3 group/logo cursor-pointer"
@click="scrollToSection(0)"
@@ -40,7 +41,7 @@
</div>
</div>
<!-- Center Navigation -->
<!-- Center: Navigation -->
<nav class="hidden md:flex items-center gap-2">
<button
v-for="(section, index) in sections"
@@ -59,42 +60,54 @@
</button>
</nav>
<!-- Right Actions -->
<div class="flex items-center gap-3">
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
<!-- Right: Login/Dashboard Button -->
<RouterLink
v-if="authStore.isAuthenticated"
:to="dashboardPath"
class="min-w-[72px] text-center rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52] whitespace-nowrap"
>
控制台
</RouterLink>
<button
v-else
class="min-w-[72px] text-center rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f] whitespace-nowrap"
@click="showLoginDialog = true"
>
登录
</button>
</div>
<RouterLink
v-if="authStore.isAuthenticated"
:to="dashboardPath"
class="rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52]"
>
控制台
</RouterLink>
<button
<!-- Fixed right icons (px-8 to match dashboard) -->
<div class="absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-2">
<!-- Theme Toggle -->
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
@click="toggleDarkMode"
>
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<Sun
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f]"
@click="showLoginDialog = true"
>
登录
</button>
</div>
class="h-4 w-4"
/>
</button>
<!-- GitHub Link -->
<a
href="https://github.com/fawney19/Aether"
target="_blank"
rel="noopener noreferrer"
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
title="GitHub 仓库"
>
<GithubIcon class="h-4 w-4" />
</a>
</div>
</div>
</header>
@@ -336,31 +349,6 @@
</section>
</main>
<!-- Footer -->
<footer class="relative z-10 border-t border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-md py-8">
<div class="mx-auto max-w-7xl px-6">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p class="text-sm text-[#91918d] dark:text-muted-foreground">
© 2025 Aether. 团队内部使用
</p>
<div class="flex items-center gap-6 text-sm text-[#91918d] dark:text-muted-foreground">
<a
href="#"
class="transition hover:text-[#191919] dark:hover:text-white"
>使用条款</a>
<a
href="#"
class="transition hover:text-[#191919] dark:hover:text-white"
>隐私政策</a>
<a
href="#"
class="transition hover:text-[#191919] dark:hover:text-white"
>技术支持</a>
</div>
</div>
</div>
</footer>
<LoginDialog v-model="showLoginDialog" />
</div>
</template>
@@ -378,6 +366,7 @@ import {
SunMoon,
Terminal
} from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue'
import { useAuthStore } from '@/stores/auth'
import { useDarkMode } from '@/composables/useDarkMode'
import { useClipboard } from '@/composables/useClipboard'

View File

@@ -0,0 +1,859 @@
<template>
<div class="space-y-6 pb-8">
<!-- 访问令牌表格 -->
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和操作栏 -->
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h3 class="text-sm sm:text-base font-semibold">
访问令牌
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
<template v-if="quota">
已创建 {{ quota.used }}/{{ quota.max }} 个令牌
<span
v-if="quota.used >= quota.max"
class="text-destructive font-medium"
>已达上限</span>
</template>
<template v-else>
用于程序化访问管理 API 的令牌
</template>
</p>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-2">
<!-- 新增按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建新令牌"
:disabled="quota ? quota.used >= quota.max : false"
@click="showCreateDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="loadTokens"
/>
</div>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<LoadingState message="加载中..." />
</div>
<!-- 空状态 -->
<div
v-else-if="tokens.length === 0"
class="flex items-center justify-center py-12"
>
<EmptyState
title="暂无访问令牌"
description="创建你的第一个访问令牌开始使用管理 API"
:icon="KeyRound"
>
<template #actions>
<Button
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
>
<Plus class="mr-2 h-4 w-4" />
创建访问令牌
</Button>
</template>
</EmptyState>
</div>
<!-- 桌面端表格 -->
<div
v-else
class="hidden md:block overflow-x-auto"
>
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="min-w-[180px] h-12 font-semibold">
名称
</TableHead>
<TableHead class="min-w-[160px] h-12 font-semibold">
令牌
</TableHead>
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
使用次数
</TableHead>
<TableHead class="min-w-[70px] h-12 font-semibold text-center">
状态
</TableHead>
<TableHead class="min-w-[100px] h-12 font-semibold">
时间
</TableHead>
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="token in paginatedTokens"
:key="token.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<!-- 名称 -->
<TableCell class="py-4">
<div class="flex-1 min-w-0">
<div
class="text-sm font-semibold truncate"
:title="token.name"
>
{{ token.name }}
</div>
<div
v-if="token.description"
class="text-xs text-muted-foreground mt-0.5 truncate"
:title="token.description"
>
{{ token.description }}
</div>
</div>
</TableCell>
<!-- Token 显示 -->
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
{{ token.token_display }}
</code>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="重新生成令牌"
@click="confirmRegenerate(token)"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<!-- 使用次数 -->
<TableCell class="py-4 text-center">
<span class="text-sm font-medium">
{{ formatNumber(token.usage_count || 0) }}
</span>
</TableCell>
<!-- 状态 -->
<TableCell class="py-4 text-center">
<Badge
:variant="getStatusVariant(token)"
class="font-medium px-3 py-1"
>
{{ getStatusText(token) }}
</Badge>
</TableCell>
<!-- 时间 -->
<TableCell class="py-4 text-sm text-muted-foreground">
<div class="text-xs">
创建于 {{ formatDate(token.created_at) }}
</div>
<div class="text-xs mt-1">
{{ token.last_used_at ? `最后使用 ${formatRelativeTime(token.last_used_at)}` : '从未使用' }}
</div>
</TableCell>
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="openEditDialog(token)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="token.is_active ? '禁用' : '启用'"
@click="toggleToken(token)"
>
<Power class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="confirmDelete(token)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && tokens.length > 0"
class="md:hidden space-y-3 p-4"
>
<Card
v-for="token in paginatedTokens"
:key="token.id"
variant="default"
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
>
<div class="p-4">
<!-- 第一行名称状态操作 -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="text-sm font-semibold text-foreground truncate">
{{ token.name }}
</h3>
<Badge
:variant="getStatusVariant(token)"
class="text-xs px-1.5 py-0"
>
{{ getStatusText(token) }}
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑"
@click="openEditDialog(token)"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="token.is_active ? '禁用' : '启用'"
@click="toggleToken(token)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="confirmDelete(token)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Token 显示 -->
<div class="flex items-center gap-2 text-xs mb-2">
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
title="重新生成"
@click="confirmRegenerate(token)"
>
<RefreshCw class="h-3 w-3" />
</Button>
</div>
<!-- 统计信息 -->
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
<span>·</span>
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
</div>
</div>
</Card>
</div>
<!-- 分页 -->
<Pagination
v-if="totalTokens > 0"
:current="currentPage"
:total="totalTokens"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:page-size="handlePageSizeChange"
/>
</Card>
<!-- 创建/编辑 Token 对话框 -->
<Dialog
v-model="showCreateDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<KeyRound class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ editingToken ? '编辑访问令牌' : '创建访问令牌' }}
</h3>
<p class="text-xs text-muted-foreground">
{{ editingToken ? '修改令牌配置' : '创建一个新的令牌用于访问管理 API' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<!-- 名称 -->
<div class="space-y-2">
<Label
for="token-name"
class="text-sm font-semibold"
>名称 *</Label>
<Input
id="token-name"
v-model="formData.name"
placeholder="例如CI/CD 自动化"
class="h-11 border-border/60"
autocomplete="off"
required
/>
</div>
<!-- 描述 -->
<div class="space-y-2">
<Label
for="token-description"
class="text-sm font-semibold"
>描述</Label>
<Input
id="token-description"
v-model="formData.description"
placeholder="用途说明(可选)"
class="h-11 border-border/60"
autocomplete="off"
/>
</div>
<!-- IP 白名单 -->
<div class="space-y-2">
<Label
for="token-ips"
class="text-sm font-semibold"
>IP 白名单</Label>
<Input
id="token-ips"
v-model="formData.allowedIpsText"
placeholder="例如192.168.1.0/24, 10.0.0.1(逗号分隔,留空不限制)"
class="h-11 border-border/60"
autocomplete="off"
/>
<p class="text-xs text-muted-foreground">
限制只能从指定 IP 地址使用此令牌支持 CIDR 格式
</p>
</div>
<!-- 过期时间 -->
<div class="space-y-2">
<Label
for="token-expires"
class="text-sm font-semibold"
>过期时间</Label>
<Input
id="token-expires"
v-model="formData.expiresAt"
type="datetime-local"
class="h-11 border-border/60"
/>
<p class="text-xs text-muted-foreground">
留空表示永不过期
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-11 px-6"
@click="closeDialog"
>
取消
</Button>
<Button
class="h-11 px-6 shadow-lg shadow-primary/20"
:disabled="saving || !isFormValid"
@click="saveToken"
>
<Loader2
v-if="saving"
class="animate-spin h-4 w-4 mr-2"
/>
{{ saving ? '保存中...' : (editingToken ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
<!-- Token 创建成功对话框 -->
<Dialog
v-model="showTokenDialog"
size="lg"
persistent
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ isRegenerating ? '令牌已重新生成' : '创建成功' }}
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管此令牌只会显示一次
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-medium">访问令牌</Label>
<div class="flex items-center gap-2">
<Input
type="text"
:value="newTokenValue"
readonly
class="flex-1 font-mono text-sm bg-muted/50 h-11"
@click="($event.target as HTMLInputElement)?.select()"
/>
<Button
class="h-11"
@click="copyToken(newTokenValue)"
>
复制
</Button>
</div>
</div>
<div class="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
<div class="flex gap-2">
<AlertTriangle class="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p class="text-sm text-amber-800 dark:text-amber-200">
此令牌只会显示一次关闭后将无法再次查看请妥善保管
</p>
</div>
</div>
</div>
<template #footer>
<Button
class="h-10 px-5"
@click="showTokenDialog = false"
>
我已安全保存
</Button>
</template>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog
v-model="showDeleteDialog"
type="danger"
title="确认删除"
:description="`确定要删除令牌「${tokenToDelete?.name}」吗?此操作不可恢复。`"
confirm-text="删除"
:loading="deleting"
@confirm="deleteToken"
@cancel="showDeleteDialog = false"
/>
<!-- 重新生成确认对话框 -->
<AlertDialog
v-model="showRegenerateDialog"
type="warning"
title="确认重新生成"
:description="`重新生成后,原令牌将立即失效。确定要重新生成「${tokenToRegenerate?.name}」吗?`"
confirm-text="重新生成"
:loading="regenerating"
@confirm="regenerateToken"
@cancel="showRegenerateDialog = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue'
import {
managementTokenApi,
type ManagementToken
} from '@/api/management-tokens'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import { Dialog, Pagination } from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import {
Plus,
KeyRound,
Trash2,
Loader2,
CheckCircle,
Power,
Pencil,
RefreshCw,
AlertTriangle
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
const { success, error: showError } = useToast()
// 数据
const tokens = ref<ManagementToken[]>([])
const totalTokens = ref(0)
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const regenerating = ref(false)
// 配额信息
const quota = ref<{ used: number; max: number } | null>(null)
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const paginatedTokens = computed(() => tokens.value)
// 监听分页变化
watch([currentPage, pageSize], () => {
loadTokens()
})
function handlePageSizeChange(newSize: number) {
pageSize.value = newSize
currentPage.value = 1
}
// 对话框状态
const showCreateDialog = ref(false)
const showTokenDialog = ref(false)
const showDeleteDialog = ref(false)
const showRegenerateDialog = ref(false)
// 表单数据
const editingToken = ref<ManagementToken | null>(null)
const formData = reactive({
name: '',
description: '',
allowedIpsText: '',
expiresAt: ''
})
const newTokenValue = ref('')
const isRegenerating = ref(false)
const tokenToDelete = ref<ManagementToken | null>(null)
const tokenToRegenerate = ref<ManagementToken | null>(null)
// 表单验证
const isFormValid = computed(() => {
return formData.name.trim().length > 0
})
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
if (token.expires_at && isExpired(token.expires_at)) {
return 'destructive'
}
return token.is_active ? 'success' : 'secondary'
}
function getStatusText(token: ManagementToken): string {
if (token.expires_at && isExpired(token.expires_at)) {
return '已过期'
}
return token.is_active ? '活跃' : '禁用'
}
function isExpired(dateString: string): boolean {
return new Date(dateString) < new Date()
}
// 加载数据
onMounted(() => {
loadTokens()
})
async function loadTokens() {
loading.value = true
try {
const skip = (currentPage.value - 1) * pageSize.value
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
tokens.value = response.items
totalTokens.value = response.total
if (response.quota) {
quota.value = response.quota
}
// 如果当前页超出范围,重置到第一页
if (tokens.value.length === 0 && currentPage.value > 1) {
currentPage.value = 1
}
} catch (err: any) {
log.error('加载 Management Tokens 失败:', err)
if (!err.response) {
showError('无法连接到服务器')
} else {
showError(`加载失败:${err.response?.data?.detail || err.message}`)
}
} finally {
loading.value = false
}
}
// 打开编辑对话框
function openEditDialog(token: ManagementToken) {
editingToken.value = token
formData.name = token.name
formData.description = token.description || ''
formData.allowedIpsText = (token.allowed_ips && token.allowed_ips.length > 0)
? token.allowed_ips.join(', ')
: ''
formData.expiresAt = token.expires_at
? toLocalDatetimeString(new Date(token.expires_at))
: ''
showCreateDialog.value = true
}
// 关闭对话框
function closeDialog() {
showCreateDialog.value = false
editingToken.value = null
formData.name = ''
formData.description = ''
formData.allowedIpsText = ''
formData.expiresAt = ''
}
// 保存 Token
async function saveToken() {
if (!isFormValid.value) return
saving.value = true
try {
const allowedIps = formData.allowedIpsText
.split(',')
.map(ip => ip.trim())
.filter(ip => ip)
// 将本地时间转换为 UTC ISO 字符串
const expiresAtUtc = formData.expiresAt
? new Date(formData.expiresAt).toISOString()
: null
if (editingToken.value) {
// 更新
await managementTokenApi.updateToken(editingToken.value.id, {
name: formData.name,
description: formData.description.trim() || null,
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
expires_at: expiresAtUtc
})
success('令牌更新成功')
} else {
// 创建
const result = await managementTokenApi.createToken({
name: formData.name,
description: formData.description || undefined,
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
expires_at: expiresAtUtc
})
newTokenValue.value = result.token
isRegenerating.value = false
showTokenDialog.value = true
success('令牌创建成功')
}
closeDialog()
await loadTokens()
} catch (err: any) {
log.error('保存 Token 失败:', err)
const message = err.response?.data?.error?.message
|| err.response?.data?.detail
|| '保存失败'
showError(message)
} finally {
saving.value = false
}
}
// 切换状态
async function toggleToken(token: ManagementToken) {
try {
const result = await managementTokenApi.toggleToken(token.id)
const index = tokens.value.findIndex(t => t.id === token.id)
if (index !== -1) {
tokens.value[index] = result.data
}
success(result.data.is_active ? '令牌已启用' : '令牌已禁用')
} catch (err: any) {
log.error('切换状态失败:', err)
showError('操作失败')
}
}
// 删除
function confirmDelete(token: ManagementToken) {
tokenToDelete.value = token
showDeleteDialog.value = true
}
async function deleteToken() {
if (!tokenToDelete.value) return
deleting.value = true
try {
await managementTokenApi.deleteToken(tokenToDelete.value.id)
showDeleteDialog.value = false
success('令牌已删除')
await loadTokens()
} catch (err: any) {
log.error('删除 Token 失败:', err)
showError('删除失败')
} finally {
deleting.value = false
tokenToDelete.value = null
}
}
// 重新生成
function confirmRegenerate(token: ManagementToken) {
tokenToRegenerate.value = token
showRegenerateDialog.value = true
}
async function regenerateToken() {
if (!tokenToRegenerate.value) return
regenerating.value = true
try {
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
newTokenValue.value = result.token
isRegenerating.value = true
showRegenerateDialog.value = false
showTokenDialog.value = true
await loadTokens()
success('令牌已重新生成')
} catch (err: any) {
log.error('重新生成失败:', err)
showError('重新生成失败')
} finally {
regenerating.value = false
tokenToRegenerate.value = null
}
}
// 复制 Token
async function copyToken(text: string) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
} else {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
success('已复制到剪贴板')
}
} catch (err) {
log.error('复制失败:', err)
showError('复制失败')
}
}
// 格式化
function formatNumber(num: number): string {
return num.toLocaleString('zh-CN')
}
function toLocalDatetimeString(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
return formatDate(dateString)
}
</script>

View File

@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '0.2.3.dev0+g0f78d5cbf.d20260105'
__version_tuple__ = version_tuple = (0, 2, 3, 'dev0', 'g0f78d5cbf.d20260105')
__version__ = version = '0.2.5'
__version_tuple__ = version_tuple = (0, 2, 5)
__commit_id__ = commit_id = None

View File

@@ -6,6 +6,7 @@ from .adaptive import router as adaptive_router
from .api_keys import router as api_keys_router
from .endpoints import router as endpoints_router
from .ldap import router as ldap_router
from .management_tokens import router as management_tokens_router
from .models import router as models_router
from .monitoring import router as monitoring_router
from .provider_query import router as provider_query_router
@@ -30,5 +31,6 @@ router.include_router(models_router)
router.include_router(security_router)
router.include_router(provider_query_router)
router.include_router(ldap_router)
router.include_router(management_tokens_router)
__all__ = ["router"]

View File

@@ -73,7 +73,26 @@ async def list_standalone_api_keys(
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""列出所有独立余额API Keys"""
"""
列出所有独立余额 API Keys
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
有独立的余额限制,主要用于给非注册用户使用。
**查询参数**:
- `skip`: 跳过的记录数(分页偏移量),默认 0
- `limit`: 返回的记录数(分页限制),默认 100最大 500
- `is_active`: 可选根据启用状态筛选true/false
**返回字段**:
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
auto_delete_on_expiry 等字段
- `total`: 符合条件的总记录数
- `limit`: 当前分页限制
- `skip`: 当前分页偏移量
"""
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -84,7 +103,35 @@ async def create_standalone_api_key(
key_data: CreateApiKeyRequest,
db: Session = Depends(get_db),
):
"""创建独立余额API Key必须设置余额限制"""
"""
创建独立余额 API Key
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
**请求体字段**:
- `name`: API Key 的名称
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `rate_limit`: 可选,速率限制配置(请求数/秒)
- `expire_days`: 可选,过期天数(兼容旧版)
- `expires_at`: 可选过期时间ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
**返回字段**:
- `id`: API Key ID
- `key`: 完整的 API Key仅在创建时返回一次
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_standalone`: 是否为独立余额 Key始终为 true
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `created_at`: 创建时间
- `message`: 提示信息
"""
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -93,20 +140,72 @@ async def create_standalone_api_key(
async def update_api_key(
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
):
"""更新独立余额Key可修改名称、过期时间、余额限制等"""
"""
更新独立余额 API Key
更新指定 ID 的独立余额 API Key 的配置信息。
**路径参数**:
- `key_id`: API Key ID
**请求体字段**:
- `name`: 可选API Key 的名称
- `rate_limit`: 可选速率限制配置null 表示无限制)
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `expire_days`: 可选,过期天数(兼容旧版)
- `expires_at`: 可选过期时间ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_daysnull 或空字符串表示永不过期)
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
**返回字段**:
- `id`: API Key ID
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_active`: 是否启用
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `updated_at`: 更新时间
- `message`: 提示信息
"""
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}")
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""Toggle API key active status (PATCH with is_active in body)"""
"""
切换 API Key 启用状态
切换指定 API Key 的启用/禁用状态。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `is_active`: 新的启用状态
- `message`: 提示信息
"""
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{key_id}")
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
删除 API Key
删除指定的 API Key。此操作不可逆。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `message`: 提示信息
"""
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -117,7 +216,24 @@ async def add_balance_to_key(
request: Request,
db: Session = Depends(get_db),
):
"""Adjust balance for standalone API key (positive to add, negative to deduct)"""
"""
调整独立余额 API Key 的余额
为指定的独立余额 API Key 增加或扣除余额。
**路径参数**:
- `key_id`: API Key ID
**请求体字段**:
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
**返回字段**:
- `id`: API Key ID
- `name`: API Key 名称
- `current_balance_usd`: 调整后的当前余额
- `balance_used_usd`: 已使用余额
- `message`: 提示信息
"""
# 从请求体获取调整金额
body = await request.json()
amount_usd = body.get("amount_usd")
@@ -162,7 +278,24 @@ async def get_api_key_detail(
include_key: bool = Query(False, description="Include full decrypted key in response"),
db: Session = Depends(get_db),
):
"""Get API key detail, optionally include full key"""
"""
获取 API Key 详情
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
**路径参数**:
- `key_id`: API Key ID
**查询参数**:
- `include_key`: 是否包含完整的解密密钥,默认 false
**返回字段**:
- 当 include_key=false 时返回基本信息id, user_id, name, key_display, is_active,
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
expires_at, created_at, updated_at
- 当 include_key=true 时返回完整密钥key
"""
if include_key:
adapter = AdminGetFullKeyAdapter(key_id=key_id)
else:

View File

@@ -7,7 +7,7 @@ from .health import router as health_router
from .keys import router as keys_router
from .routes import router as routes_router
router = APIRouter(prefix="/api/admin/endpoints", tags=["Endpoint Management"])
router = APIRouter(prefix="/api/admin/endpoints", tags=["Admin - Endpoints"])
# Endpoint CRUD
router.include_router(routes_router)

View File

@@ -29,7 +29,19 @@ async def get_endpoint_concurrency(
request: Request,
db: Session = Depends(get_db),
) -> ConcurrencyStatusResponse:
"""获取 Endpoint 当前并发状态"""
"""
获取 Endpoint 当前并发状态
查询指定 Endpoint 的实时并发使用情况,包括当前并发数和最大并发限制。
**路径参数**:
- `endpoint_id`: Endpoint ID
**返回字段**:
- `endpoint_id`: Endpoint ID
- `endpoint_current_concurrency`: 当前并发数
- `endpoint_max_concurrent`: 最大并发限制
"""
adapter = AdminEndpointConcurrencyAdapter(endpoint_id=endpoint_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -40,7 +52,19 @@ async def get_key_concurrency(
request: Request,
db: Session = Depends(get_db),
) -> ConcurrencyStatusResponse:
"""获取 Key 当前并发状态"""
"""
获取 Key 当前并发状态
查询指定 API Key 的实时并发使用情况,包括当前并发数和最大并发限制。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `key_id`: API Key ID
- `key_current_concurrency`: 当前并发数
- `key_max_concurrent`: 最大并发限制
"""
adapter = AdminKeyConcurrencyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -51,7 +75,19 @@ async def reset_concurrency(
http_request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Reset concurrency counters (admin function, use with caution)"""
"""
重置并发计数器
重置指定 Endpoint 或 Key 的并发计数器,用于解决计数不准确的问题。
管理员功能,请谨慎使用。
**请求体字段**:
- `endpoint_id`: Endpoint ID可选
- `key_id`: API Key ID可选
**返回字段**:
- `message`: 操作结果消息
"""
adapter = AdminResetConcurrencyAdapter(endpoint_id=request.endpoint_id, key_id=request.key_id)
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)

View File

@@ -36,7 +36,20 @@ async def get_health_summary(
request: Request,
db: Session = Depends(get_db),
) -> HealthSummaryResponse:
"""获取健康状态摘要"""
"""
获取健康状态摘要
获取系统整体健康状态摘要,包括所有 Provider、Endpoint 和 Key 的健康状态统计。
**返回字段**:
- `total_providers`: Provider 总数
- `active_providers`: 活跃 Provider 数量
- `total_endpoints`: Endpoint 总数
- `active_endpoints`: 活跃 Endpoint 数量
- `total_keys`: Key 总数
- `active_keys`: 活跃 Key 数量
- `circuit_breaker_open_keys`: 熔断的 Key 数量
"""
adapter = AdminHealthSummaryAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -50,9 +63,21 @@ async def get_endpoint_health_status(
"""
获取端点健康状态(简化视图,与用户端点统一)
获取按 API 格式聚合的端点健康状态时间线,基于 Usage 表统计,
返回 50 个时间段的聚合状态,适用于快速查看整体健康趋势。
与 /health/api-formats 的区别:
- /health/status: 返回聚合的时间线状态50个时间段基于 Usage 表
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
**查询参数**:
- `lookback_hours`: 回溯的小时数1-72默认 6
**返回字段**:
- `api_format`: API 格式名称
- `timeline`: 时间线数据50个时间段
- `time_range_start`: 时间范围起始
- `time_range_end`: 时间范围结束
"""
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -65,7 +90,33 @@ async def get_api_format_health_monitor(
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
db: Session = Depends(get_db),
) -> ApiFormatHealthMonitorResponse:
"""获取按 API 格式聚合的健康监控时间线(详细事件列表)"""
"""
获取按 API 格式聚合的健康监控时间线(详细事件列表)
获取每个 API 格式的详细健康监控数据,包括请求事件列表、成功率统计、
时间线数据等,基于 RequestCandidate 表查询,适用于详细分析。
**查询参数**:
- `lookback_hours`: 回溯的小时数1-72默认 6
- `per_format_limit`: 每个 API 格式返回的事件数量10-200默认 60
**返回字段**:
- `generated_at`: 数据生成时间
- `formats`: API 格式健康监控数据列表
- `api_format`: API 格式名称
- `total_attempts`: 总请求数
- `success_count`: 成功请求数
- `failed_count`: 失败请求数
- `skipped_count`: 跳过请求数
- `success_rate`: 成功率
- `provider_count`: Provider 数量
- `key_count`: Key 数量
- `last_event_at`: 最后事件时间
- `events`: 事件列表
- `timeline`: 时间线数据
- `time_range_start`: 时间范围起始
- `time_range_end`: 时间范围结束
"""
adapter = AdminApiFormatHealthMonitorAdapter(
lookback_hours=lookback_hours,
per_format_limit=per_format_limit,
@@ -79,7 +130,26 @@ async def get_key_health(
request: Request,
db: Session = Depends(get_db),
) -> HealthStatusResponse:
"""获取 Key 健康状态"""
"""
获取 Key 健康状态
获取指定 API Key 的健康状态详情,包括健康分数、连续失败次数、
熔断器状态等信息。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `key_id`: API Key ID
- `key_health_score`: 健康分数0.0-1.0
- `key_consecutive_failures`: 连续失败次数
- `key_last_failure_at`: 最后失败时间
- `key_is_active`: 是否活跃
- `key_statistics`: 统计信息
- `circuit_breaker_open`: 熔断器是否打开
- `circuit_breaker_open_at`: 熔断器打开时间
- `next_probe_at`: 下次探测时间
"""
adapter = AdminKeyHealthAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -91,13 +161,20 @@ async def recover_key_health(
db: Session = Depends(get_db),
) -> dict:
"""
Recover key health status
恢复 Key 健康状态
Resets health_score to 1.0, closes circuit breaker,
cancels auto-disable, and resets all failure counts.
手动恢复指定 Key 的健康状态,将健康分数重置为 1.0,关闭熔断器,
取消自动禁用,并重置所有失败计数。
Parameters:
- key_id: Key ID (path parameter)
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `message`: 操作结果消息
- `details`: 详细信息
- `health_score`: 健康分数
- `circuit_breaker_open`: 熔断器状态
- `is_active`: 是否活跃
"""
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -109,12 +186,21 @@ async def recover_all_keys_health(
db: Session = Depends(get_db),
) -> dict:
"""
Batch recover all circuit-broken keys
批量恢复所有熔断 Key 的健康状态
Finds all keys with circuit_breaker_open=True and:
1. Resets health_score to 1.0
2. Closes circuit breaker
3. Resets failure counts
查找所有处于熔断状态的 Keycircuit_breaker_open=True
并批量执行以下操作:
1. 将健康分数重置为 1.0
2. 关闭熔断器
3. 重置失败计数
**返回字段**:
- `message`: 操作结果消息
- `recovered_count`: 恢复的 Key 数量
- `recovered_keys`: 恢复的 Key 列表
- `key_id`: Key ID
- `key_name`: Key 名称
- `endpoint_id`: Endpoint ID
"""
adapter = AdminRecoverAllKeysHealthAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -37,7 +37,33 @@ async def list_endpoint_keys(
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
db: Session = Depends(get_db),
) -> List[EndpointAPIKeyResponse]:
"""获取 Endpoint 的所有 Keys"""
"""
获取 Endpoint 的所有 Keys
获取指定 Endpoint 下的所有 API Key 列表,包括 Key 的配置、统计信息等。
结果按优先级和创建时间排序。
**路径参数**:
- `endpoint_id`: Endpoint ID
**查询参数**:
- `skip`: 跳过的记录数,用于分页(默认 0
- `limit`: 返回的最大记录数1-1000默认 100
**返回字段**:
- `id`: Key ID
- `name`: Key 名称
- `api_key_masked`: 脱敏后的 API Key
- `internal_priority`: 内部优先级
- `global_priority`: 全局优先级
- `rate_multiplier`: 速率倍数
- `max_concurrent`: 最大并发数null 表示自适应模式)
- `is_adaptive`: 是否为自适应并发模式
- `effective_limit`: 有效并发限制
- `success_rate`: 成功率
- `avg_response_time_ms`: 平均响应时间(毫秒)
- 其他配置和统计字段
"""
adapter = AdminListEndpointKeysAdapter(
endpoint_id=endpoint_id,
skip=skip,
@@ -53,7 +79,32 @@ async def add_endpoint_key(
request: Request,
db: Session = Depends(get_db),
) -> EndpointAPIKeyResponse:
"""为 Endpoint 添加 Key"""
"""
为 Endpoint 添加 Key
为指定 Endpoint 添加新的 API Key支持配置并发限制、速率倍数、
优先级、配额限制、能力限制等。
**路径参数**:
- `endpoint_id`: Endpoint ID
**请求体字段**:
- `endpoint_id`: Endpoint ID必须与路径参数一致
- `api_key`: API Key 原文(将被加密存储)
- `name`: Key 名称
- `note`: 备注(可选)
- `rate_multiplier`: 速率倍数(默认 1.0
- `internal_priority`: 内部优先级(默认 100
- `max_concurrent`: 最大并发数null 表示自适应模式)
- `rate_limit`: 每分钟请求限制(可选)
- `daily_limit`: 每日请求限制(可选)
- `monthly_limit`: 每月请求限制(可选)
- `allowed_models`: 允许的模型列表(可选)
- `capabilities`: 能力配置(可选)
**返回字段**:
- 包含完整的 Key 信息,其中 `api_key_plain` 为原文(仅在创建时返回)
"""
adapter = AdminCreateEndpointKeyAdapter(endpoint_id=endpoint_id, key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -65,7 +116,32 @@ async def update_endpoint_key(
request: Request,
db: Session = Depends(get_db),
) -> EndpointAPIKeyResponse:
"""更新 Endpoint Key"""
"""
更新 Endpoint Key
更新指定 Key 的配置,支持修改并发限制、速率倍数、优先级、
配额限制、能力限制等。支持部分更新。
**路径参数**:
- `key_id`: Key ID
**请求体字段**(均为可选):
- `api_key`: 新的 API Key 原文
- `name`: Key 名称
- `note`: 备注
- `rate_multiplier`: 速率倍数
- `internal_priority`: 内部优先级
- `max_concurrent`: 最大并发数(设置为 null 可切换到自适应模式)
- `rate_limit`: 每分钟请求限制
- `daily_limit`: 每日请求限制
- `monthly_limit`: 每月请求限制
- `allowed_models`: 允许的模型列表
- `capabilities`: 能力配置
- `is_active`: 是否活跃
**返回字段**:
- 包含更新后的完整 Key 信息
"""
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -75,7 +151,31 @@ async def get_keys_grouped_by_format(
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""获取按 API 格式分组的所有 Keys用于全局优先级管理"""
"""
获取按 API 格式分组的所有 Keys
获取所有活跃的 Key按 API 格式分组返回,用于全局优先级管理。
每个 Key 包含基本信息、健康度指标、能力标签等。
**返回字段**:
- 返回一个字典,键为 API 格式,值为该格式下的 Key 列表
- 每个 Key 包含:
- `id`: Key ID
- `name`: Key 名称
- `api_key_masked`: 脱敏后的 API Key
- `internal_priority`: 内部优先级
- `global_priority`: 全局优先级
- `rate_multiplier`: 速率倍数
- `is_active`: 是否活跃
- `circuit_breaker_open`: 熔断器状态
- `provider_name`: Provider 名称
- `endpoint_base_url`: Endpoint 基础 URL
- `api_format`: API 格式
- `capabilities`: 能力简称列表
- `success_rate`: 成功率
- `avg_response_time_ms`: 平均响应时间
- `request_count`: 请求总数
"""
adapter = AdminGetKeysGroupedByFormatAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -86,7 +186,18 @@ async def reveal_endpoint_key(
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""获取完整的 API Key用于查看和复制"""
"""
获取完整的 API Key
解密并返回指定 Key 的完整原文,用于查看和复制。
此操作会被记录到审计日志。
**路径参数**:
- `key_id`: Key ID
**返回字段**:
- `api_key`: 完整的 API Key 原文
"""
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -97,7 +208,17 @@ async def delete_endpoint_key(
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""删除 Endpoint Key"""
"""
删除 Endpoint Key
删除指定的 API Key。此操作不可逆请谨慎使用。
**路径参数**:
- `key_id`: Key ID
**返回字段**:
- `message`: 操作结果消息
"""
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -109,7 +230,24 @@ async def batch_update_key_priority(
priority_data: BatchUpdateKeyPriorityRequest,
db: Session = Depends(get_db),
) -> dict:
"""批量更新 Endpoint 下 Keys 的优先级(用于拖动排序)"""
"""
批量更新 Endpoint 下 Keys 的优先级
批量更新指定 Endpoint 下多个 Key 的内部优先级,用于拖动排序。
所有 Key 必须属于指定的 Endpoint。
**路径参数**:
- `endpoint_id`: Endpoint ID
**请求体字段**:
- `priorities`: 优先级列表
- `key_id`: Key ID
- `internal_priority`: 新的内部优先级
**返回字段**:
- `message`: 操作结果消息
- `updated_count`: 实际更新的 Key 数量
"""
adapter = AdminBatchUpdateKeyPriorityAdapter(endpoint_id=endpoint_id, priority_data=priority_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -45,7 +45,36 @@ async def list_provider_endpoints(
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
db: Session = Depends(get_db),
) -> List[ProviderEndpointResponse]:
"""获取指定 Provider 的所有 Endpoints"""
"""
获取指定 Provider 的所有 Endpoints
获取指定 Provider 下的所有 Endpoint 列表,包括配置、统计信息等。
结果按创建时间倒序排列。
**路径参数**:
- `provider_id`: Provider ID
**查询参数**:
- `skip`: 跳过的记录数,用于分页(默认 0
- `limit`: 返回的最大记录数1-1000默认 100
**返回字段**:
- `id`: Endpoint ID
- `provider_id`: Provider ID
- `provider_name`: Provider 名称
- `api_format`: API 格式
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `max_concurrent`: 最大并发数
- `rate_limit`: 速率限制
- `is_active`: 是否活跃
- `total_keys`: Key 总数
- `active_keys`: 活跃 Key 数量
- `proxy`: 代理配置(密码已脱敏)
- 其他配置字段
"""
adapter = AdminListProviderEndpointsAdapter(
provider_id=provider_id,
skip=skip,
@@ -61,7 +90,31 @@ async def create_provider_endpoint(
request: Request,
db: Session = Depends(get_db),
) -> ProviderEndpointResponse:
"""为 Provider 创建新的 Endpoint"""
"""
为 Provider 创建新的 Endpoint
为指定 Provider 创建新的 Endpoint每个 Provider 的每种 API 格式
只能创建一个 Endpoint。
**路径参数**:
- `provider_id`: Provider ID
**请求体字段**:
- `provider_id`: Provider ID必须与路径参数一致
- `api_format`: API 格式(如 claude、openai、gemini 等)
- `base_url`: 基础 URL
- `custom_path`: 自定义路径(可选)
- `headers`: 自定义请求头(可选)
- `timeout`: 超时时间(秒,默认 300
- `max_retries`: 最大重试次数(默认 2
- `max_concurrent`: 最大并发数(可选)
- `rate_limit`: 速率限制(可选)
- `config`: 额外配置(可选)
- `proxy`: 代理配置(可选)
**返回字段**:
- 包含完整的 Endpoint 信息
"""
adapter = AdminCreateProviderEndpointAdapter(
provider_id=provider_id,
endpoint_data=endpoint_data,
@@ -75,7 +128,31 @@ async def get_endpoint(
request: Request,
db: Session = Depends(get_db),
) -> ProviderEndpointResponse:
"""获取 Endpoint 详情"""
"""
获取 Endpoint 详情
获取指定 Endpoint 的详细信息,包括配置、统计信息等。
**路径参数**:
- `endpoint_id`: Endpoint ID
**返回字段**:
- `id`: Endpoint ID
- `provider_id`: Provider ID
- `provider_name`: Provider 名称
- `api_format`: API 格式
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `max_concurrent`: 最大并发数
- `rate_limit`: 速率限制
- `is_active`: 是否活跃
- `total_keys`: Key 总数
- `active_keys`: 活跃 Key 数量
- `proxy`: 代理配置(密码已脱敏)
- 其他配置字段
"""
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -87,7 +164,29 @@ async def update_endpoint(
request: Request,
db: Session = Depends(get_db),
) -> ProviderEndpointResponse:
"""更新 Endpoint"""
"""
更新 Endpoint
更新指定 Endpoint 的配置。支持部分更新。
**路径参数**:
- `endpoint_id`: Endpoint ID
**请求体字段**(均为可选):
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `headers`: 自定义请求头
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `max_concurrent`: 最大并发数
- `rate_limit`: 速率限制
- `is_active`: 是否活跃
- `config`: 额外配置
- `proxy`: 代理配置(设置为 null 可清除代理)
**返回字段**:
- 包含更新后的完整 Endpoint 信息
"""
adapter = AdminUpdateProviderEndpointAdapter(
endpoint_id=endpoint_id,
endpoint_data=endpoint_data,
@@ -101,7 +200,19 @@ async def delete_endpoint(
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""删除 Endpoint级联删除所有关联的 Keys"""
"""
删除 Endpoint
删除指定的 Endpoint同时级联删除所有关联的 API Keys。
此操作不可逆,请谨慎使用。
**路径参数**:
- `endpoint_id`: Endpoint ID
**返回字段**:
- `message`: 操作结果消息
- `deleted_keys_count`: 同时删除的 Key 数量
"""
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -166,21 +166,95 @@ class LDAPConfigTest(BaseModel):
@router.get("/config")
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
"""获取LDAP配置管理员"""
"""
获取 LDAP 配置
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
密码字段不会返回原文,仅返回是否已设置的标志。
**返回字段**:
- `server_url`: LDAP 服务器地址ldap://ldap.example.com:389
- `bind_dn`: 绑定 DNcn=admin,dc=example,dc=com
- `base_dn`: 搜索基准 DNou=users,dc=example,dc=com
- `has_bind_password`: 是否已设置绑定密码(布尔值)
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username})
- `username_attr`: 用户名属性默认uid
- `email_attr`: 邮箱属性默认mail
- `display_name_attr`: 显示名称属性默认cn
- `is_enabled`: 是否启用 LDAP 认证
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
- `use_starttls`: 是否使用 STARTTLS 加密连接
- `connect_timeout`: 连接超时时间1-60
"""
adapter = AdminGetLDAPConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/config")
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
"""更新LDAP配置管理员"""
"""
更新 LDAP 配置
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
**请求体字段**:
- `server_url`: LDAP 服务器地址必填1-255字符
- `bind_dn`: 绑定 DN必填1-255字符
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
- `base_dn`: 搜索基准 DN必填1-255字符
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username})
- `username_attr`: 用户名属性默认uid
- `email_attr`: 邮箱属性默认mail
- `display_name_attr`: 显示名称属性默认cn
- `is_enabled`: 是否启用 LDAP 认证
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP
- `use_starttls`: 是否使用 STARTTLS 加密连接
- `connect_timeout`: 连接超时时间1-60默认 10
**安全校验**:
- 启用 LDAP 时必须设置有效的绑定密码
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
- 独占模式要求先启用 LDAP 认证
- 搜索过滤器必须包含 {username} 占位符且括号匹配
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
**返回字段**:
- `message`: 操作结果消息
"""
adapter = AdminUpdateLDAPConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/test")
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
"""测试LDAP连接管理员"""
"""
测试 LDAP 连接
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
**请求体字段**(均为可选,用于临时覆盖):
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
- `bind_dn`: 绑定 DN覆盖已保存的配置
- `bind_password`: 绑定密码(覆盖已保存的密码)
- `base_dn`: 搜索基准 DN覆盖已保存的配置
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
- `username_attr`: 用户名属性(覆盖已保存的配置)
- `email_attr`: 邮箱属性(覆盖已保存的配置)
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
- `use_starttls`: 是否使用 STARTTLS覆盖已保存的配置
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
**测试逻辑**:
- 未提供的字段使用已保存的配置值
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
**返回字段**:
- `success`: 测试是否成功(布尔值)
- `message`: 测试结果消息(成功或失败原因)
"""
adapter = AdminTestLDAPConnectionAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,10 @@
"""Management Token 管理员路由模块"""
from fastapi import APIRouter
from .routes import router as management_tokens_router
router = APIRouter()
router.include_router(management_tokens_router)
__all__ = ["router"]

View File

@@ -0,0 +1,300 @@
"""管理员 Management Token 管理端点"""
from dataclasses import dataclass
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import NotFoundException
from src.database import get_db
from src.models.database import AuditEventType, ManagementToken, User
from src.services.management_token import ManagementTokenService, token_to_dict
router = APIRouter(prefix="/api/admin/management-tokens", tags=["Admin - Management Tokens"])
pipeline = ApiRequestPipeline()
# ============== 安全基类 ==============
class AdminManagementTokenApiAdapter(AdminApiAdapter):
"""管理员 Management Token 管理 API 的基类
安全限制:禁止使用 Management Token 调用这些接口。
"""
def authorize(self, context: ApiRequestContext) -> None:
# 先调用父类的认证和权限检查
super().authorize(context)
# 禁止使用 Management Token 调用 management-tokens 相关接口
if context.management_token is not None:
raise HTTPException(
status_code=403,
detail="不允许使用 Management Token 管理其他 Token请使用 Web 界面或 JWT 认证",
)
# ============== 路由 ==============
@router.get("")
async def list_all_management_tokens(
request: Request,
user_id: Optional[str] = Query(None, description="筛选用户 ID"),
is_active: Optional[bool] = Query(None, description="筛选激活状态"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
):
"""列出所有 Management Tokens管理员
管理员查看所有用户的 Management Tokens支持筛选和分页。
**查询参数**
- user_id (Optional[str]): 筛选指定用户 ID 的 tokens
- is_active (Optional[bool]): 筛选激活状态true/false
- skip (int): 分页偏移量,默认 0
- limit (int): 每页数量,范围 1-100默认 50
**返回字段**
- items (List[dict]): Token 列表
- id (str): Token ID
- user_id (str): 所属用户 ID
- user (dict): 用户信息(包含 id, username, email 等)
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值(不返回明文)
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间ISO 8601 格式)
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
- total (int): 总数量
- skip (int): 当前偏移量
- limit (int): 当前每页数量
"""
adapter = AdminListManagementTokensAdapter(
user_id=user_id, is_active=is_active, skip=skip, limit=limit
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{token_id}")
async def get_management_token(
token_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""获取 Management Token 详情(管理员)
管理员查看任意 Management Token 的详细信息。
**路径参数**
- token_id (str): Token ID
**返回字段**
- id (str): Token ID
- user_id (str): 所属用户 ID
- user (dict): 用户信息(包含 id, username, email 等)
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值(不返回明文)
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间ISO 8601 格式)
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = AdminGetManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{token_id}")
async def delete_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""删除任意 Management Token管理员
管理员可以删除任意用户的 Management Token。
**路径参数**
- token_id (str): 要删除的 Token ID
**返回字段**
- message (str): 操作结果消息
"""
adapter = AdminDeleteManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{token_id}/status")
async def toggle_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""切换任意 Management Token 状态(管理员)
管理员可以启用/禁用任意用户的 Management Token。
**路径参数**
- token_id (str): Token ID
**返回字段**
- message (str): 操作结果消息("Token 已启用""Token 已禁用"
- data (dict): 更新后的 Token 信息
- id (str): Token ID
- user_id (str): 所属用户 ID
- user (dict): 用户信息
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值
- is_active (bool): 是否激活(已切换后的状态)
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = AdminToggleManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 适配器 ==============
@dataclass
class AdminListManagementTokensAdapter(AdminManagementTokenApiAdapter):
"""列出所有 Management Tokens"""
name: str = "admin_list_management_tokens"
user_id: Optional[str] = None
is_active: Optional[bool] = None
skip: int = 0
limit: int = 50
async def handle(self, context: ApiRequestContext):
# 构建查询
query = context.db.query(ManagementToken)
if self.user_id:
query = query.filter(ManagementToken.user_id == self.user_id)
if self.is_active is not None:
query = query.filter(ManagementToken.is_active == self.is_active)
total = query.count()
tokens = (
query.order_by(ManagementToken.created_at.desc())
.offset(self.skip)
.limit(self.limit)
.all()
)
# 预加载用户信息
user_ids = list(set(t.user_id for t in tokens))
users = {u.id: u for u in context.db.query(User).filter(User.id.in_(user_ids)).all()}
for token in tokens:
token.user = users.get(token.user_id)
return JSONResponse(
content={
"items": [token_to_dict(t, include_user=True) for t in tokens],
"total": total,
"skip": self.skip,
"limit": self.limit,
}
)
@dataclass
class AdminGetManagementTokenAdapter(AdminManagementTokenApiAdapter):
"""获取 Management Token 详情"""
name: str = "admin_get_management_token"
token_id: str = ""
async def handle(self, context: ApiRequestContext):
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id
)
if not token:
raise NotFoundException("Management Token 不存在")
# 加载用户信息
token.user = context.db.query(User).filter(User.id == token.user_id).first()
return JSONResponse(content=token_to_dict(token, include_user=True))
@dataclass
class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
"""删除 Management Token"""
name: str = "admin_delete_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
async def handle(self, context: ApiRequestContext):
# 先获取 token 信息用于审计
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id
)
if not token:
raise NotFoundException("Management Token 不存在")
context.add_audit_metadata(
token_id=token.id,
token_name=token.name,
owner_user_id=token.user_id,
)
success = ManagementTokenService.delete_token(
db=context.db, token_id=self.token_id
)
if not success:
raise NotFoundException("Management Token 不存在")
return JSONResponse(content={"message": "删除成功"})
@dataclass
class AdminToggleManagementTokenAdapter(AdminManagementTokenApiAdapter):
"""切换 Management Token 状态"""
name: str = "admin_toggle_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
async def handle(self, context: ApiRequestContext):
token = ManagementTokenService.toggle_status(
db=context.db, token_id=self.token_id
)
if not token:
raise NotFoundException("Management Token 不存在")
# 加载用户信息
token.user = context.db.query(User).filter(User.id == token.user_id).first()
context.add_audit_metadata(
token_id=token.id,
token_name=token.name,
owner_user_id=token.user_id,
is_active=token.is_active,
)
return JSONResponse(
content={
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
"data": token_to_dict(token, include_user=True),
}
)

View File

@@ -8,7 +8,7 @@ from .catalog import router as catalog_router
from .external import router as external_router
from .global_models import router as global_models_router
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"])
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
# 挂载子路由
router.include_router(catalog_router)

View File

@@ -31,6 +31,22 @@ async def get_model_catalog(
request: Request,
db: Session = Depends(get_db),
) -> ModelCatalogResponse:
"""
获取统一模型目录
基于 GlobalModel 聚合所有活跃模型及其关联提供商的信息,返回完整的模型目录视图。
**返回字段**:
- `models`: 模型列表,每个模型包含:
- `global_model_name`: GlobalModel 名称
- `display_name`: 显示名称
- `description`: 模型描述
- `providers`: 提供商列表,包含提供商名称、价格、能力等详细信息
- `price_range`: 价格区间(基于 GlobalModel 第一阶梯价格)
- `total_providers`: 关联提供商数量
- `capabilities`: 模型能力标志(视觉、函数调用、流式输出)
- `total`: 模型总数
"""
adapter = AdminGetModelCatalogAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -82,9 +82,21 @@ def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
@router.get("/external")
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
"""
获取 models.dev 的模型数据(代理请求,解决跨域问题)
数据缓存 15 分钟(使用 Redis多 worker 共享)
每个提供商会标记 official 字段,前端可据此过滤
获取外部模型数据
从 models.dev 获取第三方模型数据,用于导入新模型或参考定价信息。
该接口作为代理请求解决跨域问题,并提供缓存优化。
**功能特性**:
- 代理 models.dev API解决前端跨域问题
- 使用 Redis 缓存 15 分钟,多 worker 共享缓存
- 自动标记官方提供商official 字段),前端可据此过滤第三方转售商
**返回字段**:
- 键为提供商 ID"anthropic""openai"
- 值为提供商详细信息,包含:
- `official`: 是否为官方提供商true/false
- 其他 models.dev 提供的原始字段(模型列表、定价等)
"""
# 检查缓存
cached = await _get_cached_data()
@@ -130,7 +142,16 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
@router.delete("/external/cache")
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
"""清除 models.dev 缓存"""
"""
清除外部模型数据缓存
手动清除 models.dev 的 Redis 缓存,强制下次请求重新获取最新数据。
通常用于需要立即更新外部模型数据的场景。
**返回字段**:
- `cleared`: 是否成功清除缓存true/false
- `message`: 提示信息(仅在 Redis 未启用时返回)
"""
redis = await get_redis_client()
if redis is None:
return {"cleared": False, "message": "Redis 未启用"}

View File

@@ -40,7 +40,27 @@ async def list_global_models(
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
) -> GlobalModelListResponse:
"""获取 GlobalModel 列表"""
"""
获取 GlobalModel 列表
查询系统中的全局模型列表,支持分页、过滤和搜索功能。
**查询参数**:
- `skip`: 跳过记录数,用于分页(默认 0
- `limit`: 返回记录数,用于分页(默认 100最大 1000
- `is_active`: 过滤活跃状态true/false/nullnull 表示不过滤)
- `search`: 搜索关键词,支持按名称或显示名称模糊搜索
**返回字段**:
- `models`: GlobalModel 列表,每个包含:
- `id`: GlobalModel ID
- `name`: 模型名称(唯一)
- `display_name`: 显示名称
- `is_active`: 是否活跃
- `provider_count`: 关联提供商数量
- 定价和能力配置等其他字段
- `total`: 返回的模型总数
"""
adapter = AdminListGlobalModelsAdapter(
skip=skip,
limit=limit,
@@ -56,7 +76,21 @@ async def get_global_model(
global_model_id: str,
db: Session = Depends(get_db),
) -> GlobalModelWithStats:
"""获取单个 GlobalModel 详情(含统计信息)"""
"""
获取单个 GlobalModel 详情
查询指定 GlobalModel 的详细信息,包含关联的提供商和价格统计数据。
**路径参数**:
- `global_model_id`: GlobalModel ID
**返回字段**:
- 基础字段:`id`, `name`, `display_name`, `is_active` 等
- 统计字段:
- `total_models`: 关联的 Model 实现数量
- `total_providers`: 关联的提供商数量
- `price_range`: 价格区间统计(最低/最高输入输出价格)
"""
adapter = AdminGetGlobalModelAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -67,7 +101,24 @@ async def create_global_model(
payload: GlobalModelCreate,
db: Session = Depends(get_db),
) -> GlobalModelResponse:
"""创建 GlobalModel"""
"""
创建 GlobalModel
创建一个新的全局模型定义,作为多个提供商实现的统一抽象。
**请求体字段**:
- `name`: 模型名称(唯一标识,如 "claude-3-5-sonnet-20241022"
- `display_name`: 显示名称(如 "Claude 3.5 Sonnet"
- `is_active`: 是否活跃(默认 true
- `default_price_per_request`: 默认按次计费价格(可选)
- `default_tiered_pricing`: 默认阶梯定价配置(包含多个价格阶梯)
- `supported_capabilities`: 支持的能力标志vision、function_calling、streaming
- `config`: 额外配置JSON 格式,如 description、context_window 等)
**返回字段**:
- `id`: 创建的 GlobalModel ID
- 其他请求体中的所有字段
"""
adapter = AdminCreateGlobalModelAdapter(payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -79,7 +130,26 @@ async def update_global_model(
payload: GlobalModelUpdate,
db: Session = Depends(get_db),
) -> GlobalModelResponse:
"""更新 GlobalModel"""
"""
更新 GlobalModel
更新指定 GlobalModel 的配置信息,支持部分字段更新。
更新后会自动失效相关缓存。
**路径参数**:
- `global_model_id`: GlobalModel ID
**请求体字段**(均为可选):
- `display_name`: 显示名称
- `is_active`: 是否活跃
- `default_price_per_request`: 默认按次计费价格
- `default_tiered_pricing`: 默认阶梯定价配置
- `supported_capabilities`: 支持的能力标志
- `config`: 额外配置
**返回字段**:
- 更新后的完整 GlobalModel 信息
"""
adapter = AdminUpdateGlobalModelAdapter(global_model_id=global_model_id, payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -90,7 +160,18 @@ async def delete_global_model(
global_model_id: str,
db: Session = Depends(get_db),
):
"""删除 GlobalModel级联删除所有关联的 Provider 模型实现)"""
"""
删除 GlobalModel
删除指定的 GlobalModel会级联删除所有关联的 Provider 模型实现。
删除后会自动失效相关缓存。
**路径参数**:
- `global_model_id`: GlobalModel ID
**返回**:
- 成功删除返回 204 状态码,无响应体
"""
adapter = AdminDeleteGlobalModelAdapter(global_model_id=global_model_id)
await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
return None
@@ -105,7 +186,29 @@ async def batch_assign_to_providers(
payload: BatchAssignToProvidersRequest,
db: Session = Depends(get_db),
) -> BatchAssignToProvidersResponse:
"""批量为多个 Provider 添加 GlobalModel 实现"""
"""
批量为提供商添加模型实现
为指定的 GlobalModel 批量创建多个 Provider 的模型实现Model 记录)。
用于快速将一个统一模型分配给多个提供商。
**路径参数**:
- `global_model_id`: GlobalModel ID
**请求体字段**:
- `provider_ids`: 提供商 ID 列表
- `create_models`: Model 创建配置列表,每个包含:
- `provider_id`: 提供商 ID
- `provider_model_name`: 提供商侧的模型名称(如 "claude-3-5-sonnet-20241022"
- 其他可选字段(价格覆盖、能力覆盖等)
**返回字段**:
- `success`: 成功创建的 Model 列表
- `errors`: 失败的提供商及错误信息列表
- `total_requested`: 请求处理的总数
- `total_success`: 成功创建的数量
- `total_errors`: 失败的数量
"""
adapter = AdminBatchAssignToProvidersAdapter(global_model_id=global_model_id, payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -116,7 +219,27 @@ async def get_global_model_providers(
global_model_id: str,
db: Session = Depends(get_db),
) -> GlobalModelProvidersResponse:
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
"""
获取 GlobalModel 的关联提供商
查询指定 GlobalModel 的所有关联提供商及其模型实现详情,包括非活跃的提供商。
用于查看某个统一模型在各个提供商上的具体配置。
**路径参数**:
- `global_model_id`: GlobalModel ID
**返回字段**:
- `providers`: 提供商列表,每个包含:
- `provider_id`: 提供商 ID
- `provider_name`: 提供商名称
- `provider_display_name`: 提供商显示名称
- `model_id`: Model 实现 ID
- `target_model`: 提供商侧的模型名称
- 价格信息input_price_per_1m、output_price_per_1m 等)
- 能力标志supports_vision、supports_function_calling、supports_streaming
- `is_active`: 是否活跃
- `total`: 关联提供商总数
"""
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -39,6 +39,34 @@ async def get_audit_logs(
offset: int = Query(0, description="偏移量"),
db: Session = Depends(get_db),
):
"""
获取审计日志
获取系统审计日志列表,支持按用户、事件类型、时间范围筛选。需要管理员权限。
**查询参数**:
- `user_id`: 可选,用户 ID 筛选UUID 格式)
- `event_type`: 可选,事件类型筛选
- `days`: 查询最近多少天的日志,默认 7 天
- `limit`: 返回数量限制,默认 100
- `offset`: 分页偏移量,默认 0
**返回字段**:
- `items`: 审计日志列表,每条日志包含:
- `id`: 日志 ID
- `event_type`: 事件类型
- `user_id`: 用户 ID
- `user_email`: 用户邮箱
- `user_username`: 用户名
- `description`: 事件描述
- `ip_address`: IP 地址
- `status_code`: HTTP 状态码
- `error_message`: 错误信息
- `metadata`: 事件元数据
- `created_at`: 创建时间
- `meta`: 分页元数据total, limit, offset, count
- `filters`: 筛选条件
"""
adapter = AdminGetAuditLogsAdapter(
user_id=user_id,
event_type=event_type,
@@ -51,6 +79,19 @@ async def get_audit_logs(
@router.get("/system-status")
async def get_system_status(request: Request, db: Session = Depends(get_db)):
"""
获取系统状态
获取系统当前的运行状态和关键指标。需要管理员权限。
**返回字段**:
- `timestamp`: 当前时间戳
- `users`: 用户统计total: 总用户数, active: 活跃用户数)
- `providers`: 提供商统计total: 总提供商数, active: 活跃提供商数)
- `api_keys`: API Key 统计total: 总数, active: 活跃数)
- `today_stats`: 今日统计requests: 请求数, tokens: token 数, cost_usd: 成本)
- `recent_errors`: 最近 1 小时内的错误数
"""
adapter = AdminSystemStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -61,6 +102,26 @@ async def get_suspicious_activities(
hours: int = Query(24, description="时间范围(小时)"),
db: Session = Depends(get_db),
):
"""
获取可疑活动记录
获取系统检测到的可疑活动记录。需要管理员权限。
**查询参数**:
- `hours`: 时间范围(小时),默认 24 小时
**返回字段**:
- `activities`: 可疑活动列表,每条记录包含:
- `id`: 记录 ID
- `event_type`: 事件类型
- `user_id`: 用户 ID
- `description`: 事件描述
- `ip_address`: IP 地址
- `metadata`: 事件元数据
- `created_at`: 创建时间
- `count`: 活动总数
- `time_range_hours`: 查询的时间范围(小时)
"""
adapter = AdminSuspiciousActivitiesAdapter(hours=hours)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -72,19 +133,56 @@ async def analyze_user_behavior(
days: int = Query(30, description="分析天数"),
db: Session = Depends(get_db),
):
"""
分析用户行为
分析指定用户的行为模式和使用情况。需要管理员权限。
**路径参数**:
- `user_id`: 用户 ID
**查询参数**:
- `days`: 分析最近多少天的数据,默认 30 天
**返回字段**:
- 用户行为分析结果,包括活动频率、使用模式、异常行为等
"""
adapter = AdminUserBehaviorAdapter(user_id=user_id, days=days)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/resilience-status")
async def get_resilience_status(request: Request, db: Session = Depends(get_db)):
"""
获取韧性系统状态
获取系统韧性管理的当前状态,包括错误统计、熔断器状态等。需要管理员权限。
**返回字段**:
- `timestamp`: 当前时间戳
- `health_score`: 健康评分0-100
- `status`: 系统状态healthy: 健康degraded: 降级critical: 严重)
- `error_statistics`: 错误统计信息
- `recent_errors`: 最近的错误列表(最多 10 条)
- `recommendations`: 系统建议
"""
adapter = AdminResilienceStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/resilience/error-stats")
async def reset_error_stats(request: Request, db: Session = Depends(get_db)):
"""Reset resilience error statistics"""
"""
重置错误统计
重置韧性系统的错误统计数据。需要管理员权限。
**返回字段**:
- `message`: 操作结果信息
- `previous_stats`: 重置前的统计数据
- `reset_by`: 执行重置的管理员邮箱
- `reset_at`: 重置时间
"""
adapter = AdminResetErrorStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -95,6 +193,18 @@ async def get_circuit_history(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
):
"""
获取熔断器历史记录
获取熔断器的状态变更历史记录。需要管理员权限。
**查询参数**:
- `limit`: 返回数量限制,默认 50最大 200
**返回字段**:
- `items`: 熔断器历史记录列表
- `count`: 记录总数
"""
adapter = AdminCircuitHistoryAdapter(limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -107,6 +217,9 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
limit: int
offset: int
# 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志
audit_log_enabled: bool = False
async def handle(self, context): # type: ignore[override]
db = context.db
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)

View File

@@ -117,12 +117,21 @@ async def get_cache_stats(
"""
获取缓存亲和性统计信息
返回:
- 缓存命中率
- 缓存用户数
- Provider切换次数
- Key切换次数
- 缓存预留配置
获取缓存调度器的运行统计数据,包括命中率、切换次数、调度器配置等。
用于监控缓存亲和性功能的运行状态和性能指标。
**返回字段**:
- `status`: 状态ok
- `data`: 统计数据对象
- `scheduler`: 调度器名称cache_aware 或 random
- `total_affinities`: 总缓存亲和性数量
- `cache_hit_rate`: 缓存命中率0.0-1.0
- `provider_switches`: Provider 切换次数
- `key_switches`: Key 切换次数
- `cache_hits`: 缓存命中次数
- `cache_misses`: 缓存未命中次数
- `scheduler_metrics`: 调度器详细指标
- `affinity_stats`: 亲和性统计数据
"""
adapter = AdminCacheStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -137,16 +146,33 @@ async def get_user_affinity(
"""
查询指定用户的所有缓存亲和性
参数:
- user_identifier: 用户标识符,支持以下格式:
* 用户名 (username),如: yuanhonghu
* 邮箱 (email),如: user@example.com
* 用户UUID (user_id),如: 550e8400-e29b-41d4-a716-446655440000
* API Key ID如: 660e8400-e29b-41d4-a716-446655440000
根据用户标识符查询该用户在各个端点上的缓存亲和性记录。
支持多种标识符格式的自动识别和解析。
返回:
- 用户信息
- 所有端点的缓存亲和性列表(每个端点一条记录)
**路径参数**:
- `user_identifier`: 用户标识符,支持以下格式:
- 用户名usernameyuanhonghu
- 邮箱emailuser@example.com
- 用户 UUIDuser_id550e8400-e29b-41d4-a716-446655440000
- API Key ID660e8400-e29b-41d4-a716-446655440000
**返回字段**:
- `status`: 状态ok 或 not_found
- `message`: 提示消息(当无缓存时)
- `user_info`: 用户信息
- `user_id`: 用户 ID
- `username`: 用户名
- `email`: 邮箱
- `affinities`: 缓存亲和性列表
- `provider_id`: Provider ID
- `endpoint_id`: Endpoint ID
- `key_id`: Key ID
- `api_format`: API 格式
- `model_name`: 模型名称global_model_id
- `created_at`: 创建时间
- `expire_at`: 过期时间
- `request_count`: 请求计数
- `total_endpoints`: 缓存的端点数量
"""
adapter = AdminGetUserAffinityAdapter(user_identifier=user_identifier)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -161,10 +187,50 @@ async def list_affinities(
db: Session = Depends(get_db),
) -> Any:
"""
获取所有缓存亲和性列表,可选按关键词过滤
获取所有缓存亲和性列表
参数:
- keyword: 可选,支持用户名/邮箱/User ID/API Key ID 或模糊匹配
查询系统中所有的缓存亲和性记录,支持按关键词过滤和分页。
返回详细的用户、Provider、Endpoint、Key 信息。
**查询参数**:
- `keyword`: 可选,支持以下过滤方式(可选)
- 用户名/邮箱/User ID/API Key ID精确匹配
- 任意字段的模糊匹配affinity_key、user_id、username、email、provider_id、key_id
- `limit`: 返回数量限制1-1000默认 100
- `offset`: 偏移量(用于分页,默认 0
**返回字段**:
- `status`: 状态ok
- `data`: 分页数据对象
- `items`: 缓存亲和性列表
- `affinity_key`: API Key ID用于缓存键
- `user_api_key_name`: 用户 API Key 名称
- `user_api_key_prefix`: 脱敏后的用户 API Key
- `is_standalone`: 是否为独立 API Key
- `user_id`: 用户 ID
- `username`: 用户名
- `email`: 邮箱
- `provider_id`: Provider ID
- `provider_name`: Provider 显示名称
- `endpoint_id`: Endpoint ID
- `endpoint_api_format`: Endpoint API 格式
- `endpoint_url`: Endpoint 基础 URL
- `key_id`: Key ID
- `key_name`: Key 名称
- `key_prefix`: 脱敏后的 Provider Key
- `rate_multiplier`: 速率倍数
- `global_model_id`: GlobalModel ID
- `model_name`: 模型名称
- `model_display_name`: 模型显示名称
- `api_format`: API 格式
- `created_at`: 创建时间
- `expire_at`: 过期时间
- `request_count`: 请求计数
- `meta`: 分页元数据
- `count`: 总数量
- `limit`: 每页数量
- `offset`: 当前偏移量
- `matched_user_id`: 匹配到的用户 ID当关键词为用户标识时
"""
adapter = AdminListAffinitiesAdapter(keyword=keyword, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -177,10 +243,27 @@ async def clear_user_cache(
db: Session = Depends(get_db),
) -> Any:
"""
Clear cache affinity for a specific user
清除指定用户的缓存亲和性
Parameters:
- user_identifier: User identifier (username, email, user_id, or API Key ID)
清除指定用户或 API Key 的所有缓存亲和性记录。
支持按用户维度或单个 API Key 维度清除。
**路径参数**:
- `user_identifier`: 用户标识符,支持以下格式:
- 用户名username
- 邮箱email
- 用户 UUIDuser_id
- API Key ID清除该 API Key 的缓存)
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `user_info`: 用户信息
- `user_id`: 用户 ID
- `username`: 用户名
- `email`: 邮箱
- `api_key_id`: API Key ID当清除单个 API Key 时)
- `api_key_name`: API Key 名称(当清除单个 API Key 时)
"""
adapter = AdminClearUserCacheAdapter(user_identifier=user_identifier)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -196,13 +279,23 @@ async def clear_single_affinity(
db: Session = Depends(get_db),
) -> Any:
"""
Clear a single cache affinity entry
清除单条缓存亲和性记录
Parameters:
- affinity_key: API Key ID
- endpoint_id: Endpoint ID
- model_id: Model ID (GlobalModel ID)
- api_format: API format (claude/openai)
根据精确的缓存键affinity_key + endpoint_id + model_id + api_format
清除单条缓存亲和性记录。用于精确控制缓存清除。
**路径参数**:
- `affinity_key`: API Key ID用于缓存的键
- `endpoint_id`: Endpoint ID
- `model_id`: GlobalModel ID
- `api_format`: API 格式claude、openai、gemini
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `affinity_key`: API Key ID
- `endpoint_id`: Endpoint ID
- `model_id`: GlobalModel ID
"""
adapter = AdminClearSingleAffinityAdapter(
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
@@ -216,9 +309,17 @@ async def clear_all_cache(
db: Session = Depends(get_db),
) -> Any:
"""
Clear all cache affinities
清除所有缓存亲和性
Warning: This affects all users, use with caution
清除系统中所有用户的缓存亲和性记录。此操作会影响所有用户,
下次请求时将重新建立缓存亲和性。请谨慎使用。
**警告**: 此操作影响所有用户,使用前请确认
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `count`: 清除的缓存数量
"""
adapter = AdminClearAllCacheAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -231,10 +332,19 @@ async def clear_provider_cache(
db: Session = Depends(get_db),
) -> Any:
"""
Clear cache affinities for a specific provider
清除指定 Provider 的缓存亲和性
Parameters:
- provider_id: Provider ID
清除与指定 Provider 相关的所有缓存亲和性记录。
当 Provider 配置变更或下线时使用。
**路径参数**:
- `provider_id`: Provider ID
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `provider_id`: Provider ID
- `count`: 清除的缓存数量
"""
adapter = AdminClearProviderCacheAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -248,9 +358,25 @@ async def get_cache_config(
"""
获取缓存相关配置
返回:
- 缓存TTL
- 缓存预留比例
获取缓存亲和性功能的配置参数,包括缓存 TTL、预留比例、
动态预留机制配置等。
**返回字段**:
- `status`: 状态ok
- `data`: 配置数据
- `cache_ttl_seconds`: 缓存亲和性有效期(秒)
- `cache_reservation_ratio`: 静态预留比例(已被动态预留替代)
- `dynamic_reservation`: 动态预留机制配置
- `enabled`: 是否启用
- `config`: 配置参数
- `probe_phase_requests`: 探测阶段请求数阈值
- `probe_reservation`: 探测阶段预留比例
- `stable_min_reservation`: 稳定阶段最小预留比例
- `stable_max_reservation`: 稳定阶段最大预留比例
- `low_load_threshold`: 低负载阈值
- `high_load_threshold`: 高负载阈值
- `description`: 各参数说明
- `description`: 配置说明
"""
adapter = AdminCacheConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -262,7 +388,31 @@ async def get_cache_metrics(
db: Session = Depends(get_db),
) -> Any:
"""
Prometheus 文本格式暴露缓存调度指标,方便接入 Grafana。
获取缓存调度指标(Prometheus 格式)
以 Prometheus 文本格式输出缓存调度器的监控指标,
方便接入 Prometheus/Grafana 等监控系统。
**返回格式**: Prometheus 文本格式Content-Type: text/plain
**指标列表**:
- `cache_scheduler_total_batches`: 总批次数
- `cache_scheduler_last_batch_size`: 最后一批候选数
- `cache_scheduler_total_candidates`: 总候选数
- `cache_scheduler_last_candidate_count`: 最后一批候选计数
- `cache_scheduler_cache_hits`: 缓存命中次数
- `cache_scheduler_cache_misses`: 缓存未命中次数
- `cache_scheduler_cache_hit_rate`: 缓存命中率
- `cache_scheduler_concurrency_denied`: 并发拒绝次数
- `cache_scheduler_avg_candidates_per_batch`: 平均每批候选数
- `cache_affinity_total`: 总缓存亲和性数量
- `cache_affinity_hits`: 亲和性命中次数
- `cache_affinity_misses`: 亲和性未命中次数
- `cache_affinity_hit_rate`: 亲和性命中率
- `cache_affinity_invalidations`: 亲和性失效次数
- `cache_affinity_provider_switches`: Provider 切换次数
- `cache_affinity_key_switches`: Key 切换次数
- `cache_scheduler_info`: 调度器信息label: scheduler
"""
adapter = AdminCacheMetricsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -998,10 +1148,39 @@ async def get_model_mapping_cache_stats(
"""
获取模型映射缓存统计信息
返回:
- 缓存键数量
- 缓存 TTL 配置
- 各类型缓存数量
获取模型解析缓存的详细统计信息,包括各类型缓存键数量、
映射关系列表、Provider 级别的模型映射缓存等。
**返回字段**:
- `status`: 状态ok
- `data`: 统计数据
- `available`: Redis 是否可用
- `message`: 提示消息(当 Redis 未启用时)
- `ttl_seconds`: 缓存 TTL
- `total_keys`: 总缓存键数量
- `breakdown`: 各类型缓存键数量分解
- `model_by_id`: Model ID 缓存数量
- `model_by_provider_global`: Provider-GlobalModel 缓存数量
- `global_model_by_id`: GlobalModel ID 缓存数量
- `global_model_by_name`: GlobalModel 名称缓存数量
- `global_model_resolve`: GlobalModel 解析缓存数量
- `mappings`: 模型映射列表(最多 100 条)
- `mapping_name`: 映射名称(别名)
- `global_model_name`: GlobalModel 名称
- `global_model_display_name`: GlobalModel 显示名称
- `providers`: 使用该映射的 Provider 列表
- `ttl`: 缓存剩余 TTL
- `provider_model_mappings`: Provider 级别的模型映射(最多 100 条)
- `provider_id`: Provider ID
- `provider_name`: Provider 名称
- `global_model_id`: GlobalModel ID
- `global_model_name`: GlobalModel 名称
- `global_model_display_name`: GlobalModel 显示名称
- `provider_model_name`: Provider 侧的模型名称
- `aliases`: 别名列表
- `ttl`: 缓存剩余 TTL
- `hit_count`: 缓存命中次数
- `unmapped`: 未映射或无效的缓存条目
"""
adapter = AdminModelMappingCacheStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -1015,7 +1194,15 @@ async def clear_all_model_mapping_cache(
"""
清除所有模型映射缓存
警告: 这会影响所有模型解析,请谨慎使用
清除系统中所有模型映射缓存,包括 Model、GlobalModel、
模型解析等所有相关缓存。下次请求时将重新从数据库查询。
**警告**: 此操作会影响所有模型解析,请谨慎使用
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `deleted_count`: 删除的缓存键数量
"""
adapter = AdminClearAllModelMappingCacheAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -1030,8 +1217,17 @@ async def clear_model_mapping_cache_by_name(
"""
清除指定模型名称的映射缓存
参数:
- model_name: 模型名称(可以是 GlobalModel.name 或映射名称)
根据模型名称清除相关的映射缓存,包括 resolve 缓存和 name 缓存。
用于更新单个模型的配置后刷新缓存。
**路径参数**:
- `model_name`: 模型名称(可以是 GlobalModel.name 或映射名称)
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `model_name`: 模型名称
- `deleted_keys`: 删除的缓存键列表
"""
adapter = AdminClearModelMappingCacheByNameAdapter(model_name=model_name)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -1047,9 +1243,19 @@ async def clear_provider_model_mapping_cache(
"""
清除指定 Provider 和 GlobalModel 的模型映射缓存
参数:
- provider_id: Provider ID
- global_model_id: GlobalModel ID
清除特定 Provider 和 GlobalModel 组合的映射缓存及其命中次数统计。
用于 Provider 模型配置更新后刷新缓存。
**路径参数**:
- `provider_id`: Provider ID
- `global_model_id`: GlobalModel ID
**返回字段**:
- `status`: 状态ok
- `message`: 操作结果消息
- `provider_id`: Provider ID
- `global_model_id`: GlobalModel ID
- `deleted_keys`: 删除的缓存键列表
"""
adapter = AdminClearProviderModelMappingCacheAdapter(
provider_id=provider_id, global_model_id=global_model_id

View File

@@ -71,7 +71,47 @@ async def get_request_trace(
request: Request,
db: Session = Depends(get_db),
):
"""获取特定请求的完整追踪信息"""
"""
获取请求的完整追踪信息
获取指定请求的完整链路追踪信息包括所有候选candidates的执行情况。
**路径参数**:
- `request_id`: 请求 ID
**返回字段**:
- `request_id`: 请求 ID
- `total_candidates`: 候选总数
- `final_status`: 最终状态success: 成功failed: 失败streaming: 流式传输中pending: 等待中)
- `total_latency_ms`: 总延迟(毫秒)
- `candidates`: 候选列表,每个候选包含:
- `id`: 候选 ID
- `request_id`: 请求 ID
- `candidate_index`: 候选索引
- `retry_index`: 重试序号
- `provider_id`: 提供商 ID
- `provider_name`: 提供商名称
- `provider_website`: 提供商官网
- `endpoint_id`: 端点 ID
- `endpoint_name`: 端点名称API 格式)
- `key_id`: 密钥 ID
- `key_name`: 密钥名称
- `key_preview`: 密钥脱敏预览
- `key_capabilities`: 密钥支持的能力
- `required_capabilities`: 请求需要的能力标签
- `status`: 状态pending, success, failed, skipped
- `skip_reason`: 跳过原因
- `is_cached`: 是否缓存命中
- `status_code`: HTTP 状态码
- `error_type`: 错误类型
- `error_message`: 错误信息
- `latency_ms`: 延迟(毫秒)
- `concurrent_requests`: 并发请求数
- `extra_data`: 额外数据
- `created_at`: 创建时间
- `started_at`: 开始时间
- `finished_at`: 完成时间
"""
adapter = AdminGetRequestTraceAdapter(request_id=request_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -85,9 +125,23 @@ async def get_provider_failure_rate(
db: Session = Depends(get_db),
):
"""
获取某个 Provider 的失败率统计
获取提供商的失败率统计
需要管理员权限
获取指定提供商最近的失败率统计信息。需要管理员权限
**路径参数**:
- `provider_id`: 提供商 ID
**查询参数**:
- `limit`: 统计最近的尝试数量,默认 100最大 1000
**返回字段**:
- `provider_id`: 提供商 ID
- `total_attempts`: 总尝试次数
- `success_count`: 成功次数
- `failed_count`: 失败次数
- `failure_rate`: 失败率(百分比)
- `avg_latency_ms`: 平均延迟(毫秒)
"""
adapter = AdminProviderFailureRateAdapter(provider_id=provider_id, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -39,6 +39,31 @@ async def update_provider_billing(
request: Request,
db: Session = Depends(get_db),
):
"""
更新提供商计费配置
更新指定提供商的计费策略、配额设置和优先级配置。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**:
- `billing_type`: 计费类型pay_as_you_go、subscription、prepaid、monthly_quota
- `monthly_quota_usd`: 月度配额(美元),可选
- `quota_reset_day`: 配额重置周期天数1-365默认 30
- `quota_last_reset_at`: 当前周期开始时间,可选(设置后会自动同步该周期内的历史使用量)
- `quota_expires_at`: 配额过期时间,可选
- `rpm_limit`: 每分钟请求数限制,可选
- `provider_priority`: 提供商优先级0-200默认 100
**返回字段**:
- `message`: 操作结果信息
- `provider`: 更新后的提供商信息
- `id`: 提供商 ID
- `name`: 提供商名称
- `billing_type`: 计费类型
- `provider_priority`: 提供商优先级
"""
adapter = AdminProviderBillingAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -50,6 +75,39 @@ async def get_provider_stats(
hours: int = 24,
db: Session = Depends(get_db),
):
"""
获取提供商统计数据
获取指定提供商的计费信息、RPM 使用情况和使用统计数据。
**路径参数**:
- `provider_id`: 提供商 ID
**查询参数**:
- `hours`: 统计时间范围(小时),默认 24
**返回字段**:
- `provider_id`: 提供商 ID
- `provider_name`: 提供商名称
- `period_hours`: 统计时间范围
- `billing_info`: 计费信息
- `billing_type`: 计费类型
- `monthly_quota_usd`: 月度配额
- `monthly_used_usd`: 月度已使用
- `quota_remaining_usd`: 剩余配额
- `quota_expires_at`: 配额过期时间
- `rpm_info`: RPM 信息
- `rpm_limit`: RPM 限制
- `rpm_used`: 已使用 RPM
- `rpm_reset_at`: RPM 重置时间
- `usage_stats`: 使用统计
- `total_requests`: 总请求数
- `successful_requests`: 成功请求数
- `failed_requests`: 失败请求数
- `success_rate`: 成功率
- `avg_response_time_ms`: 平均响应时间(毫秒)
- `total_cost_usd`: 总成本(美元)
"""
adapter = AdminProviderStatsAdapter(provider_id=provider_id, hours=hours)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -67,6 +125,20 @@ async def reset_provider_quota(
@router.get("/strategies")
async def list_available_strategies(request: Request, db: Session = Depends(get_db)):
"""
获取可用负载均衡策略列表
列出系统中所有已注册的负载均衡策略插件。
**返回字段**:
- `strategies`: 策略列表
- `name`: 策略名称
- `priority`: 策略优先级
- `version`: 策略版本
- `description`: 策略描述
- `author`: 策略作者
- `total`: 策略总数
"""
adapter = AdminListStrategiesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -49,7 +49,36 @@ async def list_provider_models(
limit: int = 100,
db: Session = Depends(get_db),
) -> List[ModelResponse]:
"""获取提供商的所有模型(管理员)"""
"""
获取提供商的所有模型
获取指定提供商的模型列表,支持分页和状态过滤。
**路径参数**:
- `provider_id`: 提供商 ID
**查询参数**:
- `is_active`: 可选的活跃状态过滤true 仅返回活跃模型false 返回禁用模型,不传则返回全部
- `skip`: 跳过的记录数,默认为 0
- `limit`: 返回的最大记录数,默认为 100
**返回字段**(数组,每项包含):
- `id`: 模型 ID
- `provider_id`: 提供商 ID
- `global_model_id`: 全局模型 ID
- `provider_model_name`: 提供商模型名称
- `is_active`: 是否启用
- `input_price_per_1m`: 输入价格(每百万 token
- `output_price_per_1m`: 输出价格(每百万 token
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token
- `price_per_request`: 每次请求价格
- `supports_vision`: 是否支持视觉
- `supports_function_calling`: 是否支持函数调用
- `supports_streaming`: 是否支持流式输出
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminListProviderModelsAdapter(
provider_id=provider_id,
is_active=is_active,
@@ -66,7 +95,29 @@ async def create_provider_model(
request: Request,
db: Session = Depends(get_db),
) -> ModelResponse:
"""创建模型(管理员)"""
"""
创建模型
为指定提供商创建一个新的模型配置。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**:
- `provider_model_name`: 提供商模型名称(必填)
- `global_model_id`: 全局模型 ID可选关联到全局模型
- `is_active`: 是否启用(默认 true
- `input_price_per_1m`: 输入价格(每百万 token可选
- `output_price_per_1m`: 输出价格(每百万 token可选
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token可选
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token可选
- `price_per_request`: 每次请求价格(可选)
- `supports_vision`: 是否支持视觉(可选)
- `supports_function_calling`: 是否支持函数调用(可选)
- `supports_streaming`: 是否支持流式输出(可选)
**返回字段**: 返回创建的模型详细信息(与 GET 单个模型接口返回格式相同)
"""
adapter = AdminCreateProviderModelAdapter(provider_id=provider_id, model_data=model_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -78,7 +129,32 @@ async def get_provider_model(
request: Request,
db: Session = Depends(get_db),
) -> ModelResponse:
"""获取模型详情(管理员)"""
"""
获取模型详情
获取指定模型的详细配置信息。
**路径参数**:
- `provider_id`: 提供商 ID
- `model_id`: 模型 ID
**返回字段**:
- `id`: 模型 ID
- `provider_id`: 提供商 ID
- `global_model_id`: 全局模型 ID
- `provider_model_name`: 提供商模型名称
- `is_active`: 是否启用
- `input_price_per_1m`: 输入价格(每百万 token
- `output_price_per_1m`: 输出价格(每百万 token
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token
- `price_per_request`: 每次请求价格
- `supports_vision`: 是否支持视觉
- `supports_function_calling`: 是否支持函数调用
- `supports_streaming`: 是否支持流式输出
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminGetProviderModelAdapter(provider_id=provider_id, model_id=model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -91,7 +167,30 @@ async def update_provider_model(
request: Request,
db: Session = Depends(get_db),
) -> ModelResponse:
"""更新模型(管理员)"""
"""
更新模型配置
更新指定模型的配置信息。只需传入需要更新的字段,未传入的字段保持不变。
**路径参数**:
- `provider_id`: 提供商 ID
- `model_id`: 模型 ID
**请求体字段**(所有字段可选):
- `provider_model_name`: 提供商模型名称
- `global_model_id`: 全局模型 ID
- `is_active`: 是否启用
- `input_price_per_1m`: 输入价格(每百万 token
- `output_price_per_1m`: 输出价格(每百万 token
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token
- `price_per_request`: 每次请求价格
- `supports_vision`: 是否支持视觉
- `supports_function_calling`: 是否支持函数调用
- `supports_streaming`: 是否支持流式输出
**返回字段**: 返回更新后的模型详细信息(与 GET 单个模型接口返回格式相同)
"""
adapter = AdminUpdateProviderModelAdapter(
provider_id=provider_id,
model_id=model_id,
@@ -107,7 +206,18 @@ async def delete_provider_model(
request: Request,
db: Session = Depends(get_db),
):
"""删除模型(管理员)"""
"""
删除模型
删除指定的模型配置。注意:此操作不可逆。
**路径参数**:
- `provider_id`: 提供商 ID
- `model_id`: 模型 ID
**返回字段**:
- `message`: 删除成功提示信息
"""
adapter = AdminDeleteProviderModelAdapter(provider_id=provider_id, model_id=model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -119,7 +229,29 @@ async def batch_create_provider_models(
request: Request,
db: Session = Depends(get_db),
) -> List[ModelResponse]:
"""批量创建模型(管理员)"""
"""
批量创建模型
为指定提供商批量创建多个模型配置。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体**: 模型数据数组,每项包含:
- `provider_model_name`: 提供商模型名称(必填)
- `global_model_id`: 全局模型 ID可选
- `is_active`: 是否启用(默认 true
- `input_price_per_1m`: 输入价格(每百万 token可选
- `output_price_per_1m`: 输出价格(每百万 token可选
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token可选
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token可选
- `price_per_request`: 每次请求价格(可选)
- `supports_vision`: 是否支持视觉(可选)
- `supports_function_calling`: 是否支持函数调用(可选)
- `supports_streaming`: 是否支持流式输出(可选)
**返回字段**: 返回创建的模型列表(与 GET 模型列表接口返回格式相同)
"""
adapter = AdminBatchCreateModelsAdapter(provider_id=provider_id, models_data=models_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -134,10 +266,23 @@ async def get_provider_available_source_models(
db: Session = Depends(get_db),
):
"""
获取该 Provider 支持的所有统一模型名source_model
获取提供商支持的可用源模型
包括:
1. 直连模型Model.provider_model_name 直接作为统一模型名)
获取该提供商支持的所有统一模型名source_model包含价格和能力信息。
**路径参数**:
- `provider_id`: 提供商 ID
**返回字段**:
- `models`: 可用源模型数组,每项包含:
- `global_model_name`: 全局模型名称
- `display_name`: 显示名称
- `provider_model_name`: 提供商模型名称
- `model_id`: 模型 ID
- `price`: 价格信息(包含 input_price_per_1m, output_price_per_1m, cache_creation_price_per_1m, cache_read_price_per_1m, price_per_request
- `capabilities`: 能力信息(包含 supports_vision, supports_function_calling, supports_streaming
- `is_active`: 是否启用
- `total`: 总数
"""
adapter = AdminGetProviderAvailableSourceModelsAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -153,7 +298,27 @@ async def batch_assign_global_models_to_provider(
request: Request,
db: Session = Depends(get_db),
) -> BatchAssignModelsToProviderResponse:
"""批量为 Provider 关联 GlobalModels自动继承价格和能力配置"""
"""
批量关联全局模型
批量为提供商关联全局模型,自动继承全局模型的价格和能力配置。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**:
- `global_model_ids`: 全局模型 ID 数组(必填)
**返回字段**:
- `success`: 成功关联的模型数组,每项包含:
- `global_model_id`: 全局模型 ID
- `global_model_name`: 全局模型名称
- `model_id`: 新创建的模型 ID
- `errors`: 失败的模型数组,每项包含:
- `global_model_id`: 全局模型 ID
- `global_model_name`: 全局模型名称(如果可用)
- `error`: 错误信息
"""
adapter = AdminBatchAssignModelsToProviderAdapter(
provider_id=provider_id, payload=payload
)
@@ -173,10 +338,30 @@ async def import_models_from_upstream(
"""
从上游提供商导入模型
流程:
从上游提供商导入模型列表。如果全局模型不存在,将自动创建。
**流程说明**:
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
2. 如不存在,自动创建新的 GlobalModel使用默认配置
2. 如不存在,自动创建新的 GlobalModel使用默认免费配置)
3. 创建 Model 关联到当前 Provider
4. 如模型已关联,则记录到成功列表中
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**:
- `model_ids`: 模型 ID 数组(必填,每个 ID 长度 1-100 字符)
**返回字段**:
- `success`: 成功导入的模型数组,每项包含:
- `model_id`: 模型 ID
- `global_model_id`: 全局模型 ID
- `global_model_name`: 全局模型名称
- `provider_model_id`: 提供商模型 ID
- `created_global_model`: 是否新创建了全局模型
- `errors`: 失败的模型数组,每项包含:
- `model_id`: 模型 ID
- `error`: 错误信息
"""
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -27,24 +27,114 @@ async def list_providers(
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""
获取提供商列表
获取所有提供商的基本信息列表,支持分页和状态过滤。
**查询参数**:
- `skip`: 跳过的记录数,用于分页,默认为 0
- `limit`: 返回的最大记录数,范围 1-500默认为 100
- `is_active`: 可选的活跃状态过滤true 仅返回活跃提供商false 返回禁用提供商,不传则返回全部
**返回字段**:
- `id`: 提供商 ID
- `name`: 提供商名称(唯一标识)
- `display_name`: 显示名称
- `api_format`: API 格式(如 claude、openai、gemini 等)
- `base_url`: API 基础 URL
- `api_key`: API 密钥(脱敏显示)
- `priority`: 优先级
- `is_active`: 是否活跃
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminListProvidersAdapter(skip=skip, limit=limit, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/")
async def create_provider(request: Request, db: Session = Depends(get_db)):
"""
创建新提供商
创建一个新的 AI 模型提供商配置。
**请求体字段**:
- `name`: 提供商名称(必填,唯一,用于系统标识)
- `display_name`: 显示名称(必填)
- `description`: 描述信息(可选)
- `website`: 官网地址(可选)
- `billing_type`: 计费类型可选pay_as_you_go/subscription/prepaid默认 pay_as_you_go
- `monthly_quota_usd`: 月度配额(美元)(可选)
- `quota_reset_day`: 配额重置日期1-31可选
- `quota_last_reset_at`: 上次配额重置时间(可选)
- `quota_expires_at`: 配额过期时间(可选)
- `rpm_limit`: 每分钟请求数限制(可选)
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100
- `is_active`: 是否启用(默认 true
- `concurrent_limit`: 并发限制(可选)
- `config`: 额外配置信息JSON可选
**返回字段**:
- `id`: 新创建的提供商 ID
- `name`: 提供商名称
- `display_name`: 显示名称
- `message`: 成功提示信息
"""
adapter = AdminCreateProviderAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/{provider_id}")
async def update_provider(provider_id: str, request: Request, db: Session = Depends(get_db)):
"""
更新提供商配置
更新指定提供商的配置信息。只需传入需要更新的字段,未传入的字段保持不变。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**(所有字段可选):
- `name`: 提供商名称
- `display_name`: 显示名称
- `description`: 描述信息
- `website`: 官网地址
- `billing_type`: 计费类型pay_as_you_go/subscription/prepaid
- `monthly_quota_usd`: 月度配额(美元)
- `quota_reset_day`: 配额重置日期1-31
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `rpm_limit`: 每分钟请求数限制
- `provider_priority`: 提供商优先级
- `is_active`: 是否启用
- `concurrent_limit`: 并发限制
- `config`: 额外配置信息JSON
**返回字段**:
- `id`: 提供商 ID
- `name`: 提供商名称
- `is_active`: 是否启用
- `message`: 成功提示信息
"""
adapter = AdminUpdateProviderAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{provider_id}")
async def delete_provider(provider_id: str, request: Request, db: Session = Depends(get_db)):
"""
删除提供商
删除指定的提供商。注意:此操作会级联删除关联的端点、密钥和模型配置。
**路径参数**:
- `provider_id`: 提供商 ID
**返回字段**:
- `message`: 删除成功提示信息
"""
adapter = AdminDeleteProviderAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -136,7 +226,6 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
rpm_limit=validated_data.rpm_limit,
provider_priority=validated_data.provider_priority,
is_active=validated_data.is_active,
rate_limit=validated_data.rate_limit,
concurrent_limit=validated_data.concurrent_limit,
config=validated_data.config,
)

View File

@@ -40,7 +40,41 @@ async def get_providers_summary(
request: Request,
db: Session = Depends(get_db),
) -> List[ProviderWithEndpointsSummary]:
"""获取所有 Providers 的摘要信息(包含 Endpoints 和 Keys 统计)"""
"""
获取所有提供商摘要信息
获取所有提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。
**返回字段**(数组,每项包含):
- `id`: 提供商 ID
- `name`: 提供商名称
- `display_name`: 显示名称
- `description`: 描述信息
- `website`: 官网地址
- `provider_priority`: 优先级
- `is_active`: 是否启用
- `billing_type`: 计费类型
- `monthly_quota_usd`: 月度配额(美元)
- `monthly_used_usd`: 本月已使用金额(美元)
- `quota_reset_day`: 配额重置日期
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `rpm_limit`: RPM 限制
- `rpm_used`: 已使用 RPM
- `rpm_reset_at`: RPM 重置时间
- `total_endpoints`: 端点总数
- `active_endpoints`: 活跃端点数
- `total_keys`: 密钥总数
- `active_keys`: 活跃密钥数
- `total_models`: 模型总数
- `active_models`: 活跃模型数
- `avg_health_score`: 平均健康分数0-1
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5
- `api_formats`: 支持的 API 格式列表
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminProviderSummaryAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -51,7 +85,44 @@ async def get_provider_summary(
request: Request,
db: Session = Depends(get_db),
) -> ProviderWithEndpointsSummary:
"""获取单个 Provider 的摘要信息(包含 Endpoints 和 Keys 统计)"""
"""
获取单个提供商摘要信息
获取指定提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。
**路径参数**:
- `provider_id`: 提供商 ID
**返回字段**:
- `id`: 提供商 ID
- `name`: 提供商名称
- `display_name`: 显示名称
- `description`: 描述信息
- `website`: 官网地址
- `provider_priority`: 优先级
- `is_active`: 是否启用
- `billing_type`: 计费类型
- `monthly_quota_usd`: 月度配额(美元)
- `monthly_used_usd`: 本月已使用金额(美元)
- `quota_reset_day`: 配额重置日期
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `rpm_limit`: RPM 限制
- `rpm_used`: 已使用 RPM
- `rpm_reset_at`: RPM 重置时间
- `total_endpoints`: 端点总数
- `active_endpoints`: 活跃端点数
- `total_keys`: 密钥总数
- `active_keys`: 活跃密钥数
- `total_models`: 模型总数
- `active_models`: 活跃模型数
- `avg_health_score`: 平均健康分数0-1
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5
- `api_formats`: 支持的 API 格式列表
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException(f"Provider {provider_id} not found")
@@ -67,7 +138,34 @@ async def get_provider_health_monitor(
per_endpoint_limit: int = Query(48, ge=10, le=200, description="每个端点的事件数量"),
db: Session = Depends(get_db),
) -> ProviderEndpointHealthMonitorResponse:
"""获取 Provider 下所有端点的健康监控时间线"""
"""
获取提供商健康监控数据
获取指定提供商下所有端点的健康监控时间线,包含请求成功率、延迟、错误信息等。
**路径参数**:
- `provider_id`: 提供商 ID
**查询参数**:
- `lookback_hours`: 回溯的小时数,范围 1-72默认为 6
- `per_endpoint_limit`: 每个端点返回的事件数量,范围 10-200默认为 48
**返回字段**:
- `provider_id`: 提供商 ID
- `provider_name`: 提供商名称
- `generated_at`: 生成时间
- `endpoints`: 端点健康监控数据数组,每项包含:
- `endpoint_id`: 端点 ID
- `api_format`: API 格式
- `is_active`: 是否活跃
- `total_attempts`: 总请求次数
- `success_count`: 成功次数
- `failed_count`: 失败次数
- `skipped_count`: 跳过次数
- `success_rate`: 成功率0-1
- `last_event_at`: 最后事件时间
- `events`: 事件详情数组(包含 timestamp, status, status_code, latency_ms, error_type, error_message
"""
adapter = AdminProviderHealthMonitorAdapter(
provider_id=provider_id,
@@ -84,7 +182,29 @@ async def update_provider_settings(
request: Request,
db: Session = Depends(get_db),
) -> ProviderWithEndpointsSummary:
"""更新 Provider 基础配置display_name, description, priority, weight 等)"""
"""
更新提供商基础配置
更新提供商的基础配置信息,如显示名称、描述、优先级等。只需传入需要更新的字段。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**(所有字段可选):
- `display_name`: 显示名称
- `description`: 描述信息
- `website`: 官网地址
- `provider_priority`: 优先级
- `is_active`: 是否启用
- `billing_type`: 计费类型
- `monthly_quota_usd`: 月度配额(美元)
- `quota_reset_day`: 配额重置日期
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `rpm_limit`: RPM 限制
**返回字段**: 返回更新后的提供商摘要信息(与 GET /summary 接口返回格式相同)
"""
adapter = AdminUpdateProviderSettingsAdapter(provider_id=provider_id, update_data=update_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -18,7 +18,7 @@ from src.core.logger import logger
from src.database import get_db
from src.services.rate_limit.ip_limiter import IPRateLimiter
router = APIRouter(prefix="/api/admin/security/ip", tags=["IP Security"])
router = APIRouter(prefix="/api/admin/security/ip", tags=["Admin - Security"])
pipeline = ApiRequestPipeline()
@@ -56,42 +56,110 @@ class RemoveIPFromWhitelistRequest(BaseModel):
@router.post("/blacklist")
async def add_to_blacklist(request: Request, db: Session = Depends(get_db)):
"""Add IP to blacklist"""
"""
添加 IP 到黑名单
将指定 IP 地址添加到黑名单,被加入黑名单的 IP 将无法访问系统。需要管理员权限。
**请求体字段**:
- `ip_address`: IP 地址
- `reason`: 加入黑名单的原因
- `ttl`: 可选,过期时间(秒),不指定表示永久
**返回字段**:
- `success`: 是否成功
- `message`: 操作结果信息
- `reason`: 加入黑名单的原因
- `ttl`: 过期时间(秒或"永久"
"""
adapter = AddToBlacklistAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
@router.delete("/blacklist/{ip_address}")
async def remove_from_blacklist(ip_address: str, request: Request, db: Session = Depends(get_db)):
"""Remove IP from blacklist"""
"""
从黑名单移除 IP
将指定 IP 地址从黑名单中移除。需要管理员权限。
**路径参数**:
- `ip_address`: IP 地址
**返回字段**:
- `success`: 是否成功
- `message`: 操作结果信息
"""
adapter = RemoveFromBlacklistAdapter(ip_address=ip_address)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
@router.get("/blacklist/stats")
async def get_blacklist_stats(request: Request, db: Session = Depends(get_db)):
"""Get blacklist statistics"""
"""
获取黑名单统计信息
获取黑名单的统计信息和列表。需要管理员权限。
**返回字段**:
- `total`: 黑名单总数
- `items`: 黑名单列表,每个项包含:
- `ip`: IP 地址
- `reason`: 加入原因
- `added_at`: 添加时间
- `ttl`: 剩余有效时间(秒)
"""
adapter = GetBlacklistStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
@router.post("/whitelist")
async def add_to_whitelist(request: Request, db: Session = Depends(get_db)):
"""Add IP to whitelist"""
"""
添加 IP 到白名单
将指定 IP 地址或 CIDR 网段添加到白名单,白名单中的 IP 将跳过速率限制检查。需要管理员权限。
**请求体字段**:
- `ip_address`: IP 地址或 CIDR 格式(如 192.168.1.0/24
**返回字段**:
- `success`: 是否成功
- `message`: 操作结果信息
"""
adapter = AddToWhitelistAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
@router.delete("/whitelist/{ip_address}")
async def remove_from_whitelist(ip_address: str, request: Request, db: Session = Depends(get_db)):
"""Remove IP from whitelist"""
"""
从白名单移除 IP
将指定 IP 地址从白名单中移除。需要管理员权限。
**路径参数**:
- `ip_address`: IP 地址
**返回字段**:
- `success`: 是否成功
- `message`: 操作结果信息
"""
adapter = RemoveFromWhitelistAdapter(ip_address=ip_address)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)
@router.get("/whitelist")
async def get_whitelist(request: Request, db: Session = Depends(get_db)):
"""Get whitelist"""
"""
获取白名单
获取当前的 IP 白名单列表。需要管理员权限。
**返回字段**:
- `whitelist`: 白名单 IP 地址列表
- `total`: 白名单总数
"""
adapter = GetWhitelistAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.ADMIN)

View File

@@ -44,7 +44,14 @@ def _get_version_from_git() -> str | None:
@router.get("/version")
async def get_system_version():
"""获取系统版本信息"""
"""
获取系统版本信息
获取当前系统的版本号。优先从 git describe 获取,回退到静态版本文件。
**返回字段**:
- `version`: 版本号字符串
"""
# 优先从 git 获取
version = _get_version_from_git()
if version:
@@ -64,7 +71,16 @@ pipeline = ApiRequestPipeline()
@router.get("/settings")
async def get_system_settings(request: Request, db: Session = Depends(get_db)):
"""获取系统设置(管理员)"""
"""
获取系统设置
获取系统的全局设置信息。需要管理员权限。
**返回字段**:
- `default_provider`: 默认提供商名称
- `default_model`: 默认模型名称
- `enable_usage_tracking`: 是否启用使用情况追踪
"""
adapter = AdminGetSystemSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -72,7 +88,19 @@ async def get_system_settings(request: Request, db: Session = Depends(get_db)):
@router.put("/settings")
async def update_system_settings(http_request: Request, db: Session = Depends(get_db)):
"""更新系统设置(管理员)"""
"""
更新系统设置
更新系统的全局设置。需要管理员权限。
**请求体字段**:
- `default_provider`: 可选,默认提供商名称(空字符串表示清除设置)
- `default_model`: 可选,默认模型名称(空字符串表示清除设置)
- `enable_usage_tracking`: 可选,是否启用使用情况追踪
**返回字段**:
- `message`: 操作结果信息
"""
adapter = AdminUpdateSystemSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
@@ -80,7 +108,14 @@ async def update_system_settings(http_request: Request, db: Session = Depends(ge
@router.get("/configs")
async def get_all_system_configs(request: Request, db: Session = Depends(get_db)):
"""获取所有系统配置(管理员)"""
"""
获取所有系统配置
获取系统中所有的配置项。需要管理员权限。
**返回字段**:
- 配置项的键值对字典
"""
adapter = AdminGetAllConfigsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -88,7 +123,19 @@ async def get_all_system_configs(request: Request, db: Session = Depends(get_db)
@router.get("/configs/{key}")
async def get_system_config(key: str, request: Request, db: Session = Depends(get_db)):
"""获取特定系统配置(管理员)"""
"""
获取特定系统配置
获取指定配置项的值。需要管理员权限。
**路径参数**:
- `key`: 配置项键名
**返回字段**:
- `key`: 配置项键名
- `value`: 配置项的值(敏感配置项不返回实际值)
- `is_set`: 可选,对于敏感配置项,指示是否已设置
"""
adapter = AdminGetSystemConfigAdapter(key=key)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -100,7 +147,24 @@ async def set_system_config(
request: Request,
db: Session = Depends(get_db),
):
"""设置系统配置(管理员)"""
"""
设置系统配置
设置或更新指定配置项的值。需要管理员权限。
**路径参数**:
- `key`: 配置项键名
**请求体字段**:
- `value`: 配置项的值
- `description`: 可选,配置项描述
**返回字段**:
- `key`: 配置项键名
- `value`: 配置项的值(敏感配置项显示为 ********
- `description`: 配置项描述
- `updated_at`: 更新时间
"""
adapter = AdminSetSystemConfigAdapter(key=key)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -108,7 +172,17 @@ async def set_system_config(
@router.delete("/configs/{key}")
async def delete_system_config(key: str, request: Request, db: Session = Depends(get_db)):
"""删除系统配置(管理员)"""
"""
删除系统配置
删除指定的配置项。需要管理员权限。
**路径参数**:
- `key`: 配置项键名
**返回字段**:
- `message`: 操作结果信息
"""
adapter = AdminDeleteSystemConfigAdapter(key=key)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -116,20 +190,54 @@ async def delete_system_config(key: str, request: Request, db: Session = Depends
@router.get("/stats")
async def get_system_stats(request: Request, db: Session = Depends(get_db)):
"""
获取系统统计信息
获取系统的整体统计数据。需要管理员权限。
**返回字段**:
- `users`: 用户统计total: 总用户数, active: 活跃用户数)
- `providers`: 提供商统计total: 总提供商数, active: 活跃提供商数)
- `api_keys`: API Key 总数
- `requests`: 请求总数
"""
adapter = AdminSystemStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/cleanup")
async def trigger_cleanup(request: Request, db: Session = Depends(get_db)):
"""Manually trigger usage record cleanup task"""
"""
手动触发清理任务
手动触发使用记录清理任务,清理过期的请求/响应数据。需要管理员权限。
**返回字段**:
- `message`: 操作结果信息
- `stats`: 清理统计信息
- `total_records`: 总记录数统计before, after, deleted
- `body_fields`: 请求/响应体字段清理统计before, after, cleaned
- `header_fields`: 请求/响应头字段清理统计before, after, cleaned
- `timestamp`: 清理完成时间
"""
adapter = AdminTriggerCleanupAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/api-formats")
async def get_api_formats(request: Request, db: Session = Depends(get_db)):
"""获取所有可用的API格式列表"""
"""
获取所有可用的 API 格式列表
获取系统支持的所有 API 格式及其元数据。需要管理员权限。
**返回字段**:
- `formats`: API 格式列表,每个格式包含:
- `value`: 格式值
- `label`: 显示名称
- `default_path`: 默认路径
- `aliases`: 别名列表
"""
adapter = AdminGetApiFormatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -576,7 +684,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"rpm_limit": provider.rpm_limit,
"provider_priority": provider.provider_priority,
"is_active": provider.is_active,
"rate_limit": provider.rate_limit,
"concurrent_limit": provider.concurrent_limit,
"config": provider.config,
"endpoints": endpoints_data,
@@ -723,7 +830,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
"provider_priority", 100
)
existing_provider.is_active = prov_data.get("is_active", True)
existing_provider.rate_limit = prov_data.get("rate_limit")
existing_provider.concurrent_limit = prov_data.get(
"concurrent_limit"
)
@@ -748,7 +854,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
rpm_limit=prov_data.get("rpm_limit"),
provider_priority=prov_data.get("provider_priority", 100),
is_active=prov_data.get("is_active", True),
rate_limit=prov_data.get("rate_limit"),
concurrent_limit=prov_data.get("concurrent_limit"),
config=prov_data.get("config"),
)
@@ -1001,7 +1106,6 @@ class AdminExportUsersAdapter(AdminApiAdapter):
"balance_used_usd": key.balance_used_usd,
"current_balance_usd": key.current_balance_usd,
"allowed_providers": key.allowed_providers,
"allowed_endpoints": key.allowed_endpoints,
"allowed_api_formats": key.allowed_api_formats,
"allowed_models": key.allowed_models,
"rate_limit": key.rate_limit,
@@ -1038,7 +1142,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
"password_hash": user.password_hash,
"role": user.role.value if user.role else "user",
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"model_capability_settings": user.model_capability_settings,
"quota_usd": user.quota_usd,
@@ -1130,7 +1234,6 @@ class AdminImportUsersAdapter(AdminApiAdapter):
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
allowed_endpoints=key_data.get("allowed_endpoints"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
rate_limit=key_data.get("rate_limit"),
@@ -1174,7 +1277,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
if user_data.get("role"):
existing_user.role = UserRole(user_data["role"])
existing_user.allowed_providers = user_data.get("allowed_providers")
existing_user.allowed_endpoints = user_data.get("allowed_endpoints")
existing_user.allowed_api_formats = user_data.get("allowed_api_formats")
existing_user.allowed_models = user_data.get("allowed_models")
existing_user.model_capability_settings = user_data.get(
"model_capability_settings"
@@ -1198,7 +1301,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
password_hash=user_data.get("password_hash", ""),
role=role,
allowed_providers=user_data.get("allowed_providers"),
allowed_endpoints=user_data.get("allowed_endpoints"),
allowed_api_formats=user_data.get("allowed_api_formats"),
allowed_models=user_data.get("allowed_models"),
model_capability_settings=user_data.get("model_capability_settings"),
quota_usd=user_data.get("quota_usd"),

View File

@@ -39,12 +39,21 @@ async def get_usage_aggregation(
db: Session = Depends(get_db),
):
"""
Get usage aggregation by specified dimension.
获取使用情况聚合统计
- group_by=model: Aggregate by model
- group_by=user: Aggregate by user
- group_by=provider: Aggregate by provider
- group_by=api_format: Aggregate by API format
按指定维度聚合使用情况统计数据。
**查询参数**:
- `group_by`: 必需聚合维度可选值model按模型、user按用户、provider按提供商、api_format按 API 格式)
- `start_date`: 可选开始日期ISO 格式)
- `end_date`: 可选结束日期ISO 格式)
- `limit`: 返回数量限制,默认 20最大 100
**返回字段**:
- 按模型聚合时model, request_count, total_tokens, total_cost, actual_cost
- 按用户聚合时user_id, email, username, request_count, total_tokens, total_cost
- 按提供商聚合时provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count
- 按 API 格式聚合时api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms
"""
if group_by == "model":
adapter = AdminUsageByModelAdapter(start_date=start_date, end_date=end_date, limit=limit)
@@ -69,6 +78,25 @@ async def get_usage_stats(
end_date: Optional[datetime] = None,
db: Session = Depends(get_db),
):
"""
获取使用情况总体统计
获取指定时间范围内的使用情况总体统计数据。
**查询参数**:
- `start_date`: 可选开始日期ISO 格式)
- `end_date`: 可选结束日期ISO 格式)
**返回字段**:
- `total_requests`: 总请求数
- `total_tokens`: 总 token 数
- `total_cost`: 总成本(美元)
- `total_actual_cost`: 实际总成本(美元)
- `avg_response_time`: 平均响应时间(秒)
- `error_count`: 错误请求数
- `error_rate`: 错误率(百分比)
- `cache_stats`: 缓存统计信息cache_creation_tokens, cache_read_tokens, cache_creation_cost, cache_read_cost
"""
adapter = AdminUsageStatsAdapter(start_date=start_date, end_date=end_date)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -79,9 +107,12 @@ async def get_activity_heatmap(
db: Session = Depends(get_db),
):
"""
Get activity heatmap data for the past 365 days.
获取活动热力图数据
This endpoint is cached for 5 minutes to reduce database load.
获取过去 365 天的活动热力图数据。此接口缓存 5 分钟以减少数据库负载。
**返回字段**:
- 按日期聚合的请求数、token 数、成本等统计数据
"""
adapter = AdminActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -102,6 +133,33 @@ async def get_usage_records(
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
"""
获取使用记录列表
获取详细的使用记录列表,支持多种筛选条件。
**查询参数**:
- `start_date`: 可选开始日期ISO 格式)
- `end_date`: 可选结束日期ISO 格式)
- `search`: 可选,通用搜索关键词(支持用户名、密钥名、模型名、提供商名模糊搜索,多个关键词用空格分隔)
- `user_id`: 可选,用户 ID 筛选
- `username`: 可选,用户名模糊搜索
- `model`: 可选,模型名模糊搜索
- `provider`: 可选,提供商名称搜索
- `status`: 可选状态筛选stream: 流式请求standard: 标准请求error: 错误请求pending: 等待中streaming: 流式中completed: 已完成failed: 失败active: 活跃请求)
- `limit`: 返回数量限制,默认 100最大 500
- `offset`: 分页偏移量,默认 0
**返回字段**:
- `records`: 使用记录列表,包含 id, user_id, user_email, username, api_key, provider, model, target_model,
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, total_tokens,
cost, actual_cost, rate_multiplier, response_time_ms, first_byte_time_ms, created_at, is_stream,
input_price_per_1m, output_price_per_1m, cache_creation_price_per_1m, cache_read_price_per_1m,
status_code, error_message, status, has_fallback, api_format, api_key_name, request_metadata
- `total`: 符合条件的总记录数
- `limit`: 当前分页限制
- `offset`: 当前分页偏移量
"""
adapter = AdminUsageRecordsAdapter(
start_date=start_date,
end_date=end_date,
@@ -124,10 +182,19 @@ async def get_active_requests(
db: Session = Depends(get_db),
):
"""
获取活跃请求的状态(轻量级接口,用于前端轮询)
获取活跃请求的状态
获取当前活跃pending/streaming 状态)请求的状态信息。这是一个轻量级接口,适合前端轮询。
**查询参数**:
- `ids`: 可选,逗号分隔的请求 ID 列表,用于查询特定请求的状态
**行为说明**:
- 如果提供 ids 参数,只返回这些 ID 对应请求的最新状态
- 如果不提供 ids返回所有 pending/streaming 状态的请求
**返回字段**:
- `requests`: 活跃请求列表,包含请求状态信息
"""
adapter = AdminActiveRequestsAdapter(ids=ids)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -142,9 +209,48 @@ async def get_usage_detail(
db: Session = Depends(get_db),
):
"""
Get detailed information of a specific usage record.
获取使用记录详情
Includes request/response headers and body.
获取指定使用记录的详细信息,包括请求/响应的头部和正文。
**路径参数**:
- `usage_id`: 使用记录 ID
**返回字段**:
- `id`: 记录 ID
- `request_id`: 请求 ID
- `user`: 用户信息id, username, email
- `api_key`: API Key 信息id, name, display
- `provider`: 提供商名称
- `api_format`: API 格式
- `model`: 请求的模型名称
- `target_model`: 映射后的目标模型名称
- `tokens`: Token 统计input, output, total
- `cost`: 成本统计input, output, total
- `cache_creation_input_tokens`: 缓存创建输入 token 数
- `cache_read_input_tokens`: 缓存读取输入 token 数
- `cache_creation_cost`: 缓存创建成本
- `cache_read_cost`: 缓存读取成本
- `request_cost`: 请求成本
- `input_price_per_1m`: 输入价格(每百万 token
- `output_price_per_1m`: 输出价格(每百万 token
- `cache_creation_price_per_1m`: 缓存创建价格(每百万 token
- `cache_read_price_per_1m`: 缓存读取价格(每百万 token
- `price_per_request`: 每请求价格
- `request_type`: 请求类型
- `is_stream`: 是否为流式请求
- `status_code`: HTTP 状态码
- `error_message`: 错误信息
- `response_time_ms`: 响应时间(毫秒)
- `first_byte_time_ms`: 首字节时间TTFB毫秒
- `created_at`: 创建时间
- `request_headers`: 请求头
- `request_body`: 请求体
- `provider_request_headers`: 提供商请求头
- `response_headers`: 响应头
- `response_body`: 响应体
- `metadata`: 提供商响应元数据
- `tiered_pricing`: 阶梯计费信息(如适用)
"""
adapter = AdminUsageDetailAdapter(usage_id=usage_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -247,8 +353,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
)
# 过滤掉 pending/streaming 状态的请求(尚未完成的请求不应计入统计)
query = query.filter(Usage.status.notin_(["pending", "streaming"]))
# 过滤掉 unknown/pending provider请求未到达任何提供商
query = query.filter(Usage.provider.notin_(["unknown", "pending"]))
# 过滤掉 unknown/pending provider_name(请求未到达任何提供商)
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
@@ -459,8 +565,8 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
)
# 过滤掉 pending/streaming 状态的请求
query = query.filter(Usage.status.notin_(["pending", "streaming"]))
# 过滤掉 unknown/pending provider
query = query.filter(Usage.provider.notin_(["unknown", "pending"]))
# 过滤掉 unknown/pending provider_name
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
# 只统计有 api_format 的记录
query = query.filter(Usage.api_format.isnot(None))
@@ -659,8 +765,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
float(usage.rate_multiplier) if usage.rate_multiplier is not None else 1.0
)
# 提供商名称优先级:关联的 Provider 表 > usage.provider 字段
provider_name = usage.provider
# 提供商名称优先级:关联的 Provider 表 > usage.provider_name 字段
provider_name = usage.provider_name
if usage.provider_id and str(usage.provider_id) in provider_map:
provider_name = provider_map[str(usage.provider_id)]
@@ -775,7 +881,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
"name": api_key.name if api_key else None,
"display": api_key.get_display_key() if api_key else None,
},
"provider": usage_record.provider,
"provider": usage_record.provider_name,
"api_format": usage_record.api_format,
"model": usage_record.model,
"target_model": usage_record.target_model,
@@ -828,7 +934,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
# 尝试获取模型的阶梯配置(带来源信息)
cost_service = ModelCostService(db)
pricing_result = await cost_service.get_tiered_pricing_with_source_async(
usage_record.provider, usage_record.model
usage_record.provider_name, usage_record.model
)
if not pricing_result:

View File

@@ -26,6 +26,18 @@ pipeline = ApiRequestPipeline()
# 管理员端点
@router.post("")
async def create_user_endpoint(request: Request, db: Session = Depends(get_db)):
"""
创建用户
创建新用户账号(管理员专用)。
**请求体**:
- `email`: 邮箱地址
- `username`: 用户名
- `password`: 密码
- `role`: 角色user/admin
- `quota_usd`: 配额USD
"""
adapter = AdminCreateUserAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -33,18 +45,33 @@ async def create_user_endpoint(request: Request, db: Session = Depends(get_db)):
@router.get("")
async def list_users(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
role: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=1000, description="返回记录数"),
role: Optional[str] = Query(None, description="按角色筛选user/admin"),
is_active: Optional[bool] = Query(None, description="按状态筛选"),
db: Session = Depends(get_db),
):
"""
获取用户列表
分页获取用户列表,支持按角色和状态筛选。
**返回字段**: id, email, username, role, quota_usd, used_usd, is_active, created_at 等
"""
adapter = AdminListUsersAdapter(skip=skip, limit=limit, role=role, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{user_id}")
async def get_user(user_id: str, request: Request, db: Session = Depends(get_db)): # UUID
async def get_user(user_id: str, request: Request, db: Session = Depends(get_db)):
"""
获取用户详情
获取指定用户的详细信息。
**路径参数**:
- `user_id`: 用户 ID (UUID)
"""
adapter = AdminGetUserAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -55,19 +82,51 @@ async def update_user(
request: Request,
db: Session = Depends(get_db),
):
"""
更新用户信息
更新指定用户的信息,包括角色、配额、权限等。
**路径参数**:
- `user_id`: 用户 ID (UUID)
**请求体** (均为可选):
- `email`: 邮箱地址
- `username`: 用户名
- `role`: 角色
- `quota_usd`: 配额
- `is_active`: 是否启用
- `allowed_providers`: 允许的提供商列表
- `allowed_models`: 允许的模型列表
"""
adapter = AdminUpdateUserAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{user_id}")
async def delete_user(user_id: str, request: Request, db: Session = Depends(get_db)): # UUID
async def delete_user(user_id: str, request: Request, db: Session = Depends(get_db)):
"""
删除用户
永久删除指定用户。不能删除最后一个管理员账户。
**路径参数**:
- `user_id`: 用户 ID (UUID)
"""
adapter = AdminDeleteUserAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{user_id}/quota")
async def reset_user_quota(user_id: str, request: Request, db: Session = Depends(get_db)):
"""Reset user quota (set used_usd to 0)"""
"""
重置用户配额
将用户的已用配额used_usd重置为 0。
**路径参数**:
- `user_id`: 用户 ID (UUID)
"""
adapter = AdminResetUserQuotaAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -76,10 +135,17 @@ async def reset_user_quota(user_id: str, request: Request, db: Session = Depends
async def get_user_api_keys(
user_id: str,
request: Request,
is_active: Optional[bool] = None,
is_active: Optional[bool] = Query(None, description="按状态筛选"),
db: Session = Depends(get_db),
):
"""获取用户的所有API Keys不包括独立Keys"""
"""
获取用户的 API 密钥列表
获取指定用户的所有 API 密钥(不包括独立密钥)。
**路径参数**:
- `user_id`: 用户 ID (UUID)
"""
adapter = AdminGetUserKeysAdapter(user_id=user_id, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -90,7 +156,23 @@ async def create_user_api_key(
request: Request,
db: Session = Depends(get_db),
):
"""为用户创建API Key"""
"""
为用户创建 API 密钥
为指定用户创建新的 API 密钥。
**路径参数**:
- `user_id`: 用户 ID (UUID)
**请求体**:
- `name`: 密钥名称
- `allowed_providers`: 允许的提供商(可选)
- `allowed_models`: 允许的模型(可选)
- `rate_limit`: 速率限制(可选)
- `expire_days`: 过期天数(可选)
**返回**: 包含完整密钥值的响应(仅此一次显示)
"""
adapter = AdminCreateUserKeyAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -102,7 +184,15 @@ async def delete_user_api_key(
request: Request,
db: Session = Depends(get_db),
):
"""删除用户的API Key"""
"""
删除用户的 API 密钥
删除指定用户的指定 API 密钥。
**路径参数**:
- `user_id`: 用户 ID (UUID)
- `key_id`: 密钥 ID
"""
adapter = AdminDeleteUserKeyAdapter(user_id=user_id, key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -156,7 +246,7 @@ class AdminCreateUserAdapter(AdminApiAdapter):
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
@@ -183,6 +273,9 @@ class AdminListUsersAdapter(AdminApiAdapter):
"email": u.email,
"username": u.username,
"role": u.role.value,
"allowed_providers": u.allowed_providers,
"allowed_api_formats": u.allowed_api_formats,
"allowed_models": u.allowed_models,
"quota_usd": u.quota_usd,
"used_usd": u.used_usd,
"total_usd": getattr(u, "total_usd", 0),
@@ -216,7 +309,7 @@ class AdminGetUserAdapter(AdminApiAdapter):
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
@@ -282,7 +375,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,

View File

@@ -35,7 +35,32 @@ async def list_announcements(
offset: int = Query(0, description="偏移量"),
db: Session = Depends(get_db),
):
"""获取公告列表(包含已读状态)"""
"""
获取公告列表
获取公告列表,支持分页和筛选。如果用户已登录,返回包含已读状态。
**查询参数**:
- `active_only`: 是否只返回有效公告,默认 true
- `limit`: 返回数量限制,默认 50
- `offset`: 分页偏移量,默认 0
**返回字段**:
- `items`: 公告列表,每条公告包含:
- `id`: 公告 ID
- `title`: 标题
- `content`: 内容
- `type`: 类型info/warning/error/success
- `priority`: 优先级
- `is_pinned`: 是否置顶
- `is_read`: 是否已读(仅登录用户)
- `author`: 作者信息
- `start_time`: 生效开始时间
- `end_time`: 生效结束时间
- `created_at`: 创建时间
- `total`: 总数
- `unread_count`: 未读数量(仅登录用户)
"""
adapter = ListAnnouncementsAdapter(active_only=active_only, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -45,7 +70,16 @@ async def get_active_announcements(
request: Request,
db: Session = Depends(get_db),
):
"""获取当前有效的公告(首页展示)"""
"""
获取当前有效的公告
获取当前时间范围内有效的公告列表,用于首页展示。
**返回字段**:
- `items`: 有效公告列表
- `total`: 有效公告总数
- `unread_count`: 未读数量(仅登录用户)
"""
adapter = GetActiveAnnouncementsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -56,7 +90,27 @@ async def get_announcement(
request: Request,
db: Session = Depends(get_db),
):
"""获取单个公告详情"""
"""
获取单个公告详情
获取指定公告的详细信息。
**路径参数**:
- `announcement_id`: 公告 IDUUID
**返回字段**:
- `id`: 公告 ID
- `title`: 标题
- `content`: 内容
- `type`: 类型info/warning/error/success
- `priority`: 优先级
- `is_pinned`: 是否置顶
- `author`: 作者信息id, username
- `start_time`: 生效开始时间
- `end_time`: 生效结束时间
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = GetAnnouncementAdapter(announcement_id=announcement_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -67,7 +121,17 @@ async def mark_announcement_as_read(
request: Request,
db: Session = Depends(get_db),
):
"""Mark announcement as read"""
"""
标记公告为已读
将指定公告标记为当前用户已读。需要登录。
**路径参数**:
- `announcement_id`: 公告 IDUUID
**返回字段**:
- `message`: 操作结果信息
"""
adapter = MarkAnnouncementReadAdapter(announcement_id=announcement_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -80,7 +144,25 @@ async def create_announcement(
request: Request,
db: Session = Depends(get_db),
):
"""创建公告(管理员权限)"""
"""
创建公告
创建新的系统公告。需要管理员权限。
**请求体字段**:
- `title`: 公告标题(必填)
- `content`: 公告内容(必填)
- `type`: 公告类型info/warning/error/success默认 info
- `priority`: 优先级0-100默认 0
- `is_pinned`: 是否置顶,默认 false
- `start_time`: 生效开始时间(可选)
- `end_time`: 生效结束时间(可选)
**返回字段**:
- `id`: 新创建的公告 ID
- `title`: 公告标题
- `message`: 操作结果信息
"""
adapter = CreateAnnouncementAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -91,7 +173,27 @@ async def update_announcement(
request: Request,
db: Session = Depends(get_db),
):
"""更新公告(管理员权限)"""
"""
更新公告
更新指定公告的信息。需要管理员权限。
**路径参数**:
- `announcement_id`: 公告 IDUUID
**请求体字段(均为可选)**:
- `title`: 公告标题
- `content`: 公告内容
- `type`: 公告类型info/warning/error/success
- `priority`: 优先级0-100
- `is_active`: 是否启用
- `is_pinned`: 是否置顶
- `start_time`: 生效开始时间
- `end_time`: 生效结束时间
**返回字段**:
- `message`: 操作结果信息
"""
adapter = UpdateAnnouncementAdapter(announcement_id=announcement_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -102,7 +204,17 @@ async def delete_announcement(
request: Request,
db: Session = Depends(get_db),
):
"""删除公告(管理员权限)"""
"""
删除公告
删除指定的公告。需要管理员权限。
**路径参数**:
- `announcement_id`: 公告 IDUUID
**返回字段**:
- `message`: 操作结果信息
"""
adapter = DeleteAnnouncementAdapter(announcement_id=announcement_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -115,7 +227,14 @@ async def get_my_unread_announcement_count(
request: Request,
db: Session = Depends(get_db),
):
"""获取我的未读公告数量"""
"""
获取我的未读公告数量
获取当前用户的未读公告数量。需要登录。
**返回字段**:
- `unread_count`: 未读公告数量
"""
adapter = UnreadAnnouncementCountAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -95,72 +95,142 @@ pipeline = ApiRequestPipeline()
# API端点
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
async def registration_settings(request: Request, db: Session = Depends(get_db)):
"""公开获取注册相关配置"""
"""
获取注册相关配置
返回系统注册配置,包括是否开放注册、是否需要邮箱验证等。
此接口为公开接口,无需认证。
"""
adapter = AuthRegistrationSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/settings")
async def auth_settings(request: Request, db: Session = Depends(get_db)):
"""公开获取认证设置(用于前端判断显示哪些登录选项)"""
"""
获取认证设置
返回系统支持的认证方式如本地认证、LDAP 认证等。
前端据此判断显示哪些登录选项。此接口为公开接口,无需认证。
"""
adapter = AuthSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/login", response_model=LoginResponse)
async def login(request: Request, db: Session = Depends(get_db)):
"""
用户登录
使用邮箱和密码登录,成功后返回 JWT access_token 和 refresh_token。
- **access_token**: 用于后续 API 调用,有效期 24 小时
- **refresh_token**: 用于刷新 access_token
速率限制: 5次/分钟/IP
"""
adapter = AuthLoginAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_token(request: Request, db: Session = Depends(get_db)):
"""
刷新访问令牌
使用 refresh_token 获取新的 access_token 和 refresh_token。
原 refresh_token 刷新后失效。
"""
adapter = AuthRefreshAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/register", response_model=RegisterResponse)
async def register(request: Request, db: Session = Depends(get_db)):
"""
用户注册
创建新用户账号。需要系统开放注册功能。
如果系统开启了邮箱验证,需先通过 /send-verification-code 和 /verify-email 完成邮箱验证。
速率限制: 3次/分钟/IP
"""
adapter = AuthRegisterAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/me")
async def get_current_user_info(request: Request, db: Session = Depends(get_db)):
"""
获取当前用户信息
返回当前登录用户的基本信息,包括邮箱、用户名、角色、配额等。
需要 Bearer Token 认证。
"""
adapter = AuthCurrentUserAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/password")
async def change_password(request: Request, db: Session = Depends(get_db)):
"""Change current user's password"""
"""
修改密码
修改当前用户的登录密码,需提供旧密码验证。
密码长度至少 6 位。
"""
adapter = AuthChangePasswordAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/logout", response_model=LogoutResponse)
async def logout(request: Request, db: Session = Depends(get_db)):
"""
用户登出
将当前 Token 加入黑名单,使其失效。
"""
adapter = AuthLogoutAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
"""发送邮箱验证码"""
"""
发送邮箱验证码
向指定邮箱发送验证码,用于注册前的邮箱验证。
验证码有效期 5 分钟,同一邮箱 60 秒内只能发送一次。
速率限制: 3次/分钟/IP
"""
adapter = AuthSendVerificationCodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verify-email", response_model=VerifyEmailResponse)
async def verify_email(request: Request, db: Session = Depends(get_db)):
"""验证邮箱验证码"""
"""
验证邮箱验证码
验证邮箱收到的验证码是否正确。
验证成功后,邮箱会被标记为已验证状态,可用于注册。
速率限制: 10次/分钟/IP
"""
adapter = AuthVerifyEmailAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verification-status", response_model=VerificationStatusResponse)
async def verification_status(request: Request, db: Session = Depends(get_db)):
"""查询邮箱验证状态"""
"""
查询邮箱验证状态
查询指定邮箱的验证状态,包括是否有待验证的验证码、是否已验证等。
速率限制: 20次/分钟/IP
"""
adapter = AuthVerificationStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -458,7 +528,7 @@ class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
"used_usd": user.used_usd,
"total_usd": user.total_usd,
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"created_at": user.created_at.isoformat(),
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,

View File

@@ -15,6 +15,7 @@ class ApiMode(str, Enum):
ADMIN = "admin"
USER = "user" # JWT 认证的普通用户(不要求管理员权限)
PUBLIC = "public"
MANAGEMENT = "management" # Management Token 认证
class ApiAdapter(ABC):

View File

@@ -10,7 +10,7 @@ from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, User
from src.models.database import ApiKey, ManagementToken, User
from src.utils.request_utils import get_client_ip
@@ -38,6 +38,9 @@ class ApiRequestContext:
# URL 路径参数(如 Gemini API 的 /v1beta/models/{model}:generateContent
path_params: Dict[str, Any] = field(default_factory=dict)
# Management Token用于管理 API 认证)
management_token: Optional[ManagementToken] = None
# 供适配器扩展的状态存储
extra: Dict[str, Any] = field(default_factory=dict)
audit_metadata: Dict[str, Any] = field(default_factory=dict)

View File

@@ -143,12 +143,13 @@ class AccessRestrictions:
allowed_api_formats = api_key.allowed_api_formats
# 如果 API Key 没有限制,检查 User 的限制
# 注意: User 没有 allowed_api_formats 字段
if user:
if allowed_providers is None and user.allowed_providers is not None:
allowed_providers = user.allowed_providers
if allowed_models is None and user.allowed_models is not None:
allowed_models = user.allowed_models
if allowed_api_formats is None and user.allowed_api_formats is not None:
allowed_api_formats = user.allowed_api_formats
return cls(
allowed_providers=allowed_providers,

View File

@@ -2,19 +2,23 @@ from __future__ import annotations
import time
from enum import Enum
from typing import Any, Optional, Tuple
from typing import TYPE_CHECKING, Any, Optional, Tuple
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.enums import UserRole
from src.core.exceptions import QuotaExceededException
from src.core.logger import logger
from src.models.database import ApiKey, AuditEventType, User, UserRole
from src.models.database import ApiKey, AuditEventType, User
from src.services.auth.service import AuthService
from src.services.system.audit import AuditService
from src.services.usage.service import UsageService
if TYPE_CHECKING:
from src.models.database import ManagementToken
from .adapter import ApiAdapter, ApiMode
from .context import ApiRequestContext
@@ -47,17 +51,22 @@ class ApiRequestPipeline:
logger.debug(f"[Pipeline] Running with mode={mode}, adapter={adapter.__class__.__name__}, "
f"adapter.mode={adapter.mode}, path={http_request.url.path}")
if mode == ApiMode.ADMIN:
user = await self._authenticate_admin(http_request, db)
user, management_token = await self._authenticate_admin(http_request, db)
api_key = None
elif mode == ApiMode.USER:
user = await self._authenticate_user(http_request, db)
user, management_token = await self._authenticate_user(http_request, db)
api_key = None
elif mode == ApiMode.PUBLIC:
user = None
api_key = None
management_token = None
elif mode == ApiMode.MANAGEMENT:
user, management_token = await self._authenticate_management(http_request, db)
api_key = None
else:
logger.debug("[Pipeline] 调用 _authenticate_client")
user, api_key = self._authenticate_client(http_request, db, adapter)
management_token = None
logger.debug(f"[Pipeline] 认证完成 | user={user.username if user else None}")
raw_body = None
@@ -90,6 +99,9 @@ class ApiRequestPipeline:
api_format_hint=api_format_hint,
path_params=path_params,
)
# 存储 management_token 到 context用于权限检查
if management_token:
context.management_token = management_token
logger.debug(f"[Pipeline] Context构建完成 | adapter={adapter.name} | request_id={context.request_id}")
if mode != ApiMode.ADMIN and user:
@@ -177,12 +189,41 @@ class ApiRequestPipeline:
return user, api_key
async def _authenticate_admin(self, request: Request, db: Session) -> User:
async def _authenticate_admin(
self, request: Request, db: Session
) -> Tuple[User, Optional["ManagementToken"]]:
"""管理员认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少管理员凭证")
token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀)
if token.startswith(ManagementToken.TOKEN_PREFIX):
client_ip = get_client_ip(request)
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 检查管理员权限
if user.role != UserRole.ADMIN:
logger.warning(f"非管理员尝试通过 Management Token 访问管理端点: {user.email}")
raise HTTPException(status_code=403, detail="需要管理员权限")
# 存储到 request.state
request.state.user_id = user.id
request.state.management_token_id = management_token.id
return user, management_token
# JWT 认证
try:
payload = await self.auth_service.verify_token(token, token_type="access")
except HTTPException:
@@ -200,16 +241,43 @@ class ApiRequestPipeline:
if not user or not user.is_active:
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
request.state.user_id = user.id
return user
# 检查管理员权限
if user.role != UserRole.ADMIN:
logger.warning(f"非管理员尝试通过 JWT 访问管理端点: {user.email}")
raise HTTPException(status_code=403, detail="需要管理员权限")
request.state.user_id = user.id
return user, None
async def _authenticate_user(
self, request: Request, db: Session
) -> Tuple[User, Optional["ManagementToken"]]:
"""用户认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
async def _authenticate_user(self, request: Request, db: Session) -> User:
"""JWT 认证普通用户(不要求管理员权限)"""
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少用户凭证")
token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀)
if token.startswith(ManagementToken.TOKEN_PREFIX):
client_ip = get_client_ip(request)
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
request.state.user_id = user.id
request.state.management_token_id = management_token.id
return user, management_token
# JWT 认证
try:
payload = await self.auth_service.verify_token(token, token_type="access")
except HTTPException:
@@ -222,13 +290,47 @@ class ApiRequestPipeline:
if not user_id:
raise HTTPException(status_code=401, detail="无效的用户令牌")
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
request.state.user_id = user.id
return user
return user, None
async def _authenticate_management(
self, request: Request, db: Session
) -> Tuple[User, "ManagementToken"]:
"""Management Token 认证"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少 Management Token")
token = authorization[7:].strip()
# 检查是否为 Management Token 格式
if not token.startswith(ManagementToken.TOKEN_PREFIX):
raise HTTPException(
status_code=401,
detail=f"无效的 Token 格式,需要 Management Token ({ManagementToken.TOKEN_PREFIX}xxx)",
)
client_ip = get_client_ip(request)
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 存储到 request.state
request.state.user_id = user.id
request.state.management_token_id = management_token.id
return user, management_token
def _calculate_quota_remaining(self, user: Optional[User]) -> Optional[float]:
if not user:

View File

@@ -45,6 +45,29 @@ def format_tokens(num: int) -> str:
@router.get("/stats")
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)):
"""
获取仪表盘统计数据
根据用户角色返回不同的统计数据。管理员可以看到全局数据,普通用户只能看到自己的数据。
**返回字段(管理员)**:
- `stats`: 统计卡片数组包含总请求、总费用、总Token、总缓存等信息
- `today`: 今日统计requests, cost, actual_cost, tokens, cache_creation_tokens, cache_read_tokens
- `api_keys`: API Key 统计total, active
- `tokens`: 本月 Token 统计
- `token_breakdown`: Token 详细分类input, output, cache_creation, cache_read
- `system_health`: 系统健康指标avg_response_time, error_rate, error_requests, fallback_count, total_requests
- `cost_stats`: 成本统计total_cost, total_actual_cost, cost_savings
- `cache_stats`: 缓存统计信息
- `users`: 用户统计total, active
**返回字段(普通用户)**:
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、配额使用、总Token 等信息
- `today`: 今日统计
- `token_breakdown`: Token 详细分类
- `cache_stats`: 缓存统计信息
- `monthly_cost`: 本月费用
"""
adapter = DashboardStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -55,6 +78,23 @@ async def get_recent_requests(
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db),
):
"""
获取最近请求列表
获取最近的 API 请求记录。管理员可以看到所有用户的请求,普通用户只能看到自己的请求。
**查询参数**:
- `limit`: 返回记录数,默认 10最大 100
**返回字段**:
- `requests`: 请求列表,每条记录包含:
- `id`: 请求 ID
- `user`: 用户名
- `model`: 使用的模型
- `tokens`: Token 数量
- `time`: 请求时间HH:MM 格式)
- `is_stream`: 是否为流式请求
"""
adapter = DashboardRecentRequestsAdapter(limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -65,6 +105,17 @@ async def get_recent_requests(
@router.get("/provider-status")
async def get_provider_status(request: Request, db: Session = Depends(get_db)):
"""
获取提供商状态
获取所有活跃提供商的状态和最近 24 小时的请求统计。
**返回字段**:
- `providers`: 提供商列表,每个提供商包含:
- `name`: 提供商名称
- `status`: 状态active/inactive
- `requests`: 最近 24 小时的请求数
"""
adapter = DashboardProviderStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -75,6 +126,28 @@ async def get_daily_stats(
days: int = Query(7, ge=1, le=30),
db: Session = Depends(get_db),
):
"""
获取每日统计数据
获取指定天数的每日使用统计数据,用于生成图表。
**查询参数**:
- `days`: 统计天数,默认 7 天,最大 30 天
**返回字段**:
- `daily_stats`: 每日统计数组,每天包含:
- `date`: 日期ISO 格式)
- `requests`: 请求数
- `tokens`: Token 数量
- `cost`: 费用USD
- `avg_response_time`: 平均响应时间(秒)
- `unique_models`: 使用的模型数量(仅管理员)
- `unique_providers`: 使用的提供商数量(仅管理员)
- `fallback_count`: 故障转移次数(仅管理员)
- `model_breakdown`: 按模型分解的统计(仅管理员)
- `model_summary`: 模型使用汇总,按费用排序
- `period`: 统计周期信息start_date, end_date, days
"""
adapter = DashboardDailyStatsAdapter(days=days)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -693,7 +766,7 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
for provider in providers:
count = (
db.query(func.count(Usage.id))
.filter(and_(Usage.provider == provider.name, Usage.created_at >= since))
.filter(and_(Usage.provider_name == provider.name, Usage.created_at >= since))
.scalar()
)
entries.append(
@@ -781,7 +854,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
.scalar() or 0
)
today_unique_providers = (
db.query(func.count(func.distinct(Usage.provider)))
db.query(func.count(func.distinct(Usage.provider_name)))
.filter(Usage.created_at >= today)
.scalar() or 0
)

View File

@@ -19,6 +19,7 @@ Chat Handler Base - Chat API 格式的通用基类
- StreamTelemetryRecorder: 统计记录Usage、Audit、Candidate
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator, Callable, Dict, Optional
@@ -55,7 +56,6 @@ from src.models.database import (
from src.services.provider.transport import build_provider_url
class ChatHandlerBase(BaseMessageHandler, ABC):
"""
Chat Handler 基类
@@ -89,7 +89,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
user_agent: str,
start_time: float,
allowed_api_formats: Optional[list] = None,
adapter_detector: Optional[Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]] = None,
adapter_detector: Optional[
Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]
] = None,
):
allowed = allowed_api_formats or [self.FORMAT_ID]
super().__init__(
@@ -459,14 +461,19 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
f"模型={ctx.model} -> {mapped_model or '无映射'}"
)
# 发送请求(使用配置中的超时设置)
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用 endpoint.timeout
timeout_config = httpx.Timeout(
connect=config.http_connect_timeout,
read=float(endpoint.timeout),
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
# endpoint.timeout 作为整体请求超时(建立连接 + 获取首字节)
request_timeout = float(endpoint.timeout or 300)
# 创建 HTTP 客户端(支持代理配置)
from src.clients.http_client import HTTPClientPool
@@ -474,7 +481,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
proxy_config=endpoint.proxy,
timeout=timeout_config,
)
try:
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
byte_iterator: Any = None
prefetched_chunks: Any = None
response_ctx: Any = None
async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream(
"POST", url, json=provider_payload, headers=provider_headers
)
@@ -497,6 +512,28 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
max_prefetch_lines=config.stream_prefetch_lines,
)
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# endpoint.timeout 控制整体超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
@@ -507,7 +544,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except EmbeddedErrorException:
try:
await response_ctx.__aexit__(None, None, None)
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
@@ -517,6 +555,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
await http_client.aclose()
raise
# 类型断言:成功执行后这些变量不会为 None
assert byte_iterator is not None
assert prefetched_chunks is not None
assert response_ctx is not None
# 创建流生成器(传入字节流迭代器)
return stream_processor.create_response_stream(
ctx,
@@ -639,17 +682,23 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
is_stream=False,
)
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
f"模型={model} -> {mapped_model or '无映射'}")
logger.info(
f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
f"模型={model} -> {mapped_model or '无映射'}"
)
logger.debug(f" [{self.request_id}] 请求URL: {url}")
logger.debug(f" [{self.request_id}] 请求体stream字段: {provider_payload.get('stream', 'N/A')}")
logger.debug(
f" [{self.request_id}] 请求体stream字段: {provider_payload.get('stream', 'N/A')}"
)
# 创建 HTTP 客户端(支持代理配置)
# endpoint.timeout 作为整体请求超时
from src.clients.http_client import HTTPClientPool
request_timeout = float(endpoint.timeout or 300)
http_client = HTTPClientPool.create_client_with_proxy(
proxy_config=endpoint.proxy,
timeout=httpx.Timeout(float(endpoint.timeout)),
timeout=httpx.Timeout(request_timeout),
)
async with http_client:
resp = await http_client.post(url, json=provider_payload, headers=provider_hdrs)
@@ -670,7 +719,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_body = ""
try:
error_body = resp.text[:1000]
logger.error(f" [{self.request_id}] 上游返回5xx错误: status={resp.status_code}, body={error_body[:500]}")
logger.error(
f" [{self.request_id}] 上游返回5xx错误: status={resp.status_code}, body={error_body[:500]}"
)
except Exception:
pass
raise ProviderNotAvailableException(
@@ -684,7 +735,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_body = ""
try:
error_body = resp.text[:1000]
logger.warning(f" [{self.request_id}] 上游返回非200: status={resp.status_code}, body={error_body[:500]}")
logger.warning(
f" [{self.request_id}] 上游返回非200: status={resp.status_code}, body={error_body[:500]}"
)
except Exception:
pass
raise ProviderNotAvailableException(
@@ -765,8 +818,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
logger.debug(f"{self.FORMAT_ID} 非流式响应完成")
# 简洁的请求完成摘要
logger.info(f"[OK] {self.request_id[:8]} | {model} | {provider_name or 'unknown'} | {response_time_ms}ms | "
f"in:{input_tokens or 0} out:{output_tokens or 0}")
logger.info(
f"[OK] {self.request_id[:8]} | {model} | {provider_name or 'unknown'} | {response_time_ms}ms | "
f"in:{input_tokens or 0} out:{output_tokens or 0}"
)
return JSONResponse(status_code=status_code, content=response_json)
@@ -807,8 +862,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_bytes = await e.response.aread()
return error_bytes.decode("utf-8", errors="replace")
else:
return (
e.response.text if hasattr(e.response, "_content") else "Unable to read"
)
return e.response.text if hasattr(e.response, "_content") else "Unable to read"
except Exception as decode_error:
return f"Unable to read error: {decode_error}"

View File

@@ -33,19 +33,21 @@ from src.api.handlers.base.base_handler import (
)
from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.utils import (
build_sse_headers,
check_html_response,
check_prefetched_response_error,
)
from src.core.error_utils import extract_error_message
# 直接从具体模块导入,避免循环依赖
from src.api.handlers.base.response_parser import (
ResponseParser,
StreamStats,
)
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.utils import (
build_sse_headers,
check_html_response,
check_prefetched_response_error,
)
from src.config.constants import StreamDefaults
from src.config.settings import config
from src.core.error_utils import extract_error_message
from src.core.exceptions import (
EmbeddedErrorException,
ProviderAuthException,
@@ -62,8 +64,6 @@ from src.models.database import (
ProviderEndpoint,
User,
)
from src.config.constants import StreamDefaults
from src.config.settings import config
from src.services.provider.transport import build_provider_url
from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
@@ -100,7 +100,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
user_agent: str,
start_time: float,
allowed_api_formats: Optional[list] = None,
adapter_detector: Optional[Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]] = None,
adapter_detector: Optional[
Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]
] = None,
):
allowed = allowed_api_formats or [self.FORMAT_ID]
super().__init__(
@@ -158,7 +160,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapper = ModelMapperMiddleware(self.db)
mapping = await mapper.get_mapping(source_model, provider_id)
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:
# 使用 select_provider_model_name 支持模型映射功能
@@ -168,7 +172,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID
)
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
logger.debug(f"[CLI] 无模型映射,使用原始名称: {source_model}")
@@ -459,18 +465,26 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=True, # CLI handler 处理流式请求
)
# 配置超时
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 _connect_and_prefetch 内部的 asyncio.wait_for 控制
timeout_config = httpx.Timeout(
connect=10.0,
read=float(endpoint.timeout),
write=60.0, # 写入超时增加到60秒支持大请求体如包含图片的长对话
pool=10.0,
connect=config.http_connect_timeout,
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
logger.debug(f" └─ [{self.request_id}] 发送流式请求: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
f"Key=***{key.api_key[-4:]}, "
f"原始模型={ctx.model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
# endpoint.timeout 作为整体请求超时(建立连接 + 获取首字节)
request_timeout = float(endpoint.timeout or 300)
logger.debug(
f" └─ [{self.request_id}] 发送流式请求: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8] if endpoint.id else 'N/A'}..., "
f"Key=***{key.api_key[-4:] if key.api_key else 'N/A'}, "
f"原始模型={ctx.model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}, "
f"timeout={request_timeout}s"
)
# 创建 HTTP 客户端(支持代理配置)
from src.clients.http_client import HTTPClientPool
@@ -479,7 +493,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
proxy_config=endpoint.proxy,
timeout=timeout_config,
)
try:
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
byte_iterator: Any = None
prefetched_chunks: Any = None
response_ctx: Any = None
async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream(
"POST", url, json=provider_payload, headers=provider_headers
)
@@ -500,9 +522,33 @@ class CliMessageHandlerBase(BaseMessageHandler):
byte_iterator, provider, endpoint, ctx
)
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# endpoint.timeout 控制整体超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}")
logger.error(
f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}"
)
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
@@ -511,7 +557,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
except EmbeddedErrorException:
# 嵌套错误需要触发重试,关闭连接后重新抛出
try:
await response_ctx.__aexit__(None, None, None)
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
@@ -521,6 +568,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
await http_client.aclose()
raise
# 类型断言:成功执行后这些变量不会为 None
assert byte_iterator is not None
assert prefetched_chunks is not None
assert response_ctx is not None
# 创建流生成器(带预读数据,使用同一个迭代器)
return self._create_response_stream_with_prefetch(
ctx,
@@ -593,7 +645,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
},
}
self._mark_first_output(ctx, output_state)
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode(
"utf-8"
)
return # 结束生成器
# 格式转换或直接透传
@@ -801,10 +855,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
if isinstance(data, dict) and provider_parser.is_error_response(data):
# 提取错误信息
parsed = provider_parser.parse_response(data, 200)
logger.warning(f" [{self.request_id}] 检测到嵌套错误: "
logger.warning(
f" [{self.request_id}] 检测到嵌套错误: "
f"Provider={provider.name}, "
f"error_type={parsed.error_type}, "
f"message={parsed.error_message}")
f"message={parsed.error_message}"
)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=(
@@ -849,14 +905,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
raise
except (OSError, IOError) as e:
# 网络 I/O 异常:记录警告,可能需要重试
logger.warning(
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
)
logger.warning(f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}")
except Exception as e:
# 未预期的严重异常:记录错误并重新抛出,避免掩盖问题
logger.error(
f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}",
exc_info=True
exc_info=True,
)
raise
@@ -979,7 +1033,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
},
}
self._mark_first_output(ctx, output_state)
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode("utf-8")
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode(
"utf-8"
)
return
# 格式转换或直接透传
@@ -1255,8 +1311,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
logger.debug(f"{self.FORMAT_ID} 流式响应中断")
# 简洁的请求失败摘要(包含预估 token 信息)
logger.info(f"[FAIL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
f"{ctx.status_code} | in:{actual_input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}")
logger.info(
f"[FAIL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
f"{ctx.status_code} | in:{actual_input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
)
else:
# 在记录统计前,允许子类从 parsed_chunks 中提取额外的元数据
self._finalize_stream_metadata(ctx)
@@ -1289,9 +1347,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
logger.debug(f"{self.FORMAT_ID} 流式响应完成")
# 简洁的请求完成摘要(两行格式)
line1 = (
f"[OK] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name}"
)
line1 = f"[OK] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name}"
if ctx.first_byte_time_ms:
line1 += f" | TTFB: {ctx.first_byte_time_ms}ms"
@@ -1314,7 +1370,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
RequestCandidateService.mark_candidate_failed(
db=bg_db,
candidate_id=ctx.attempt_id,
error_type="client_disconnected" if ctx.status_code == 499 else "stream_error",
error_type=(
"client_disconnected" if ctx.status_code == 499 else "stream_error"
),
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
status_code=ctx.status_code,
latency_ms=response_time_ms,
@@ -1469,17 +1527,21 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=False, # 非流式请求
)
logger.info(f" └─ [{self.request_id}] 发送非流式请求: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
f"Key=***{key.api_key[-4:]}, "
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
logger.info(
f" └─ [{self.request_id}] 发送非流式请求: "
f"Provider={provider.name}, Endpoint={endpoint.id[:8] if endpoint.id else 'N/A'}..., "
f"Key=***{key.api_key[-4:] if key.api_key else 'N/A'}, "
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}"
)
# 创建 HTTP 客户端(支持代理配置)
# endpoint.timeout 作为整体请求超时
from src.clients.http_client import HTTPClientPool
request_timeout = float(endpoint.timeout or 300)
http_client = HTTPClientPool.create_client_with_proxy(
proxy_config=endpoint.proxy,
timeout=httpx.Timeout(float(endpoint.timeout)),
timeout=httpx.Timeout(request_timeout),
)
async with http_client:
resp = await http_client.post(url, json=provider_payload, headers=provider_headers)
@@ -1525,9 +1587,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 记录原始响应信息用于调试
content_type = resp.headers.get("content-type", "unknown")
content_encoding = resp.headers.get("content-encoding", "none")
logger.error(f"[{self.request_id}] 无法解析响应 JSON: {e}, "
logger.error(
f"[{self.request_id}] 无法解析响应 JSON: {e}, "
f"Content-Type: {content_type}, Content-Encoding: {content_encoding}, "
f"响应长度: {len(resp.content)} bytes")
f"响应长度: {len(resp.content)} bytes"
)
raise ProviderNotAvailableException(
f"提供商返回无效响应: {provider.name}, 无法解析 JSON: {str(e)[:100]}"
)

View File

@@ -28,12 +28,48 @@ async def get_my_audit_logs(
offset: int = Query(0, ge=0, description="偏移量"),
db: Session = Depends(get_db),
):
"""
获取我的审计日志
获取当前用户的审计日志记录。需要登录。
**查询参数**:
- `event_type`: 可选,事件类型筛选
- `days`: 查询最近多少天的日志,默认 30 天
- `limit`: 返回数量限制,默认 50
- `offset`: 分页偏移量,默认 0
**返回字段**:
- `items`: 审计日志列表,每条日志包含:
- `id`: 日志 ID
- `event_type`: 事件类型
- `description`: 事件描述
- `ip_address`: IP 地址
- `status_code`: HTTP 状态码
- `created_at`: 创建时间
- `meta`: 分页元数据total, limit, offset, count
- `filters`: 筛选条件
"""
adapter = UserAuditLogsAdapter(event_type=event_type, days=days, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/rate-limit-status")
async def get_rate_limit_status(request: Request, db: Session = Depends(get_db)):
"""
获取速率限制状态
获取当前用户所有活跃 API Key 的速率限制状态。需要登录。
**返回字段**:
- `user_id`: 用户 ID
- `api_keys`: API Key 限流状态列表,每个包含:
- `api_key_name`: API Key 名称
- `limit`: 速率限制上限
- `remaining`: 剩余可用次数
- `reset_time`: 限制重置时间
- `window`: 时间窗口
"""
adapter = UserRateLimitStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -13,12 +13,26 @@ from src.core.key_capabilities import (
)
from src.database import get_db
router = APIRouter(prefix="/api/capabilities", tags=["Capabilities"])
router = APIRouter(prefix="/api/capabilities", tags=["System Catalog"])
@router.get("")
async def list_capabilities():
"""获取所有能力定义"""
"""
获取所有能力定义
返回系统中定义的所有能力capabilities包括用户可配置和系统内部使用的能力。
能力用于描述模型支持的功能特性,如视觉输入、函数调用、流式输出等。
**返回字段**
- capabilities: 能力列表,每个能力包含:
- name: 能力的唯一标识符(如 vision、function_calling
- display_name: 能力的显示名称(如"视觉输入""函数调用"
- short_name: 能力的简短名称(如"视觉""函数"
- description: 能力的详细描述
- match_mode: 匹配模式exact 精确匹配fuzzy 模糊匹配prefix 前缀匹配等)
- config_mode: 配置模式user_configurable 用户可配置system_only 仅系统使用)
"""
return {
"capabilities": [
{
@@ -36,7 +50,21 @@ async def list_capabilities():
@router.get("/user-configurable")
async def list_user_configurable_capabilities():
"""获取用户可配置的能力列表(用于前端展示配置选项)"""
"""
获取用户可配置的能力列表
返回允许用户在 API Key 中配置的能力列表,用于前端展示配置选项。
用户可以通过配置这些能力来限制或指定 API Key 可以访问的模型功能。
**返回字段**
- capabilities: 用户可配置的能力列表,每个能力包含:
- name: 能力的唯一标识符
- display_name: 能力的显示名称
- short_name: 能力的简短名称
- description: 能力的详细描述
- match_mode: 匹配模式exact、fuzzy、prefix 等)
- config_mode: 配置模式(此接口返回的都是 user_configurable
"""
return {
"capabilities": [
{
@@ -60,11 +88,24 @@ async def get_model_supported_capabilities(
"""
获取指定模型支持的能力列表
Args:
model_name: 模型名称(如 claude-sonnet-4-20250514必须是 GlobalModel.name
根据全局模型名称GlobalModel.name查询该模型支持的能力
并返回每个能力的详细定义。只查询活跃的全局模型。
Returns:
模型支持的能力列表,以及每个能力的详细定义
**路径参数**
- model_name: 全局模型名称(如 claude-sonnet-4-20250514必须是 GlobalModel.name
**返回字段**
- model: 查询的模型名称
- global_model_id: 全局模型的 UUID
- global_model_name: 全局模型的标准名称
- supported_capabilities: 该模型支持的能力名称列表
- capability_details: 支持的能力详细信息列表,每个能力包含:
- name: 能力标识符
- display_name: 能力显示名称
- description: 能力描述
- match_mode: 匹配模式
- config_mode: 配置模式
- error: 错误信息(仅在模型不存在时返回)
"""
from src.models.database import GlobalModel

View File

@@ -37,7 +37,7 @@ from src.models.endpoint_models import (
)
from src.services.health.endpoint import EndpointHealthService
router = APIRouter(prefix="/api/public", tags=["Public Catalog"])
router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = ApiRequestPipeline()
@@ -49,7 +49,29 @@ async def get_public_providers(
limit: int = Query(100, description="返回记录数限制"),
db: Session = Depends(get_db),
):
"""获取提供商列表(用户视图)。"""
"""
获取提供商列表(用户视图)
返回系统中可用的提供商列表,包含提供商的基本信息和统计数据。
默认只返回活跃的提供商。
**查询参数**
- is_active: 可选过滤活跃状态。None 表示只返回活跃提供商True 返回活跃False 返回非活跃
- skip: 跳过的记录数,用于分页,默认 0
- limit: 返回记录数限制,默认 100最大 100
**返回字段**
- id: 提供商唯一标识符
- name: 提供商名称(英文标识)
- display_name: 提供商显示名称
- description: 提供商描述信息
- is_active: 是否活跃
- provider_priority: 提供商优先级
- models_count: 该提供商下的模型总数
- active_models_count: 该提供商下活跃的模型数
- endpoints_count: 该提供商下的端点总数
- active_endpoints_count: 该提供商下活跃的端点数
"""
adapter = PublicProvidersAdapter(is_active=is_active, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@@ -64,6 +86,37 @@ async def get_public_models(
limit: int = Query(100, description="返回记录数限制"),
db: Session = Depends(get_db),
):
"""
获取模型列表(用户视图)
返回系统中可用的模型列表,包含模型的详细信息和定价。
默认只返回活跃提供商下的活跃模型。
**查询参数**
- provider_id: 可选,按提供商 ID 过滤,只返回该提供商下的模型
- is_active: 可选,过滤活跃状态(当前未使用,始终返回活跃模型)
- skip: 跳过的记录数,用于分页,默认 0
- limit: 返回记录数限制,默认 100最大 100
**返回字段**
- id: 模型唯一标识符
- provider_id: 所属提供商 ID
- provider_name: 提供商名称
- provider_display_name: 提供商显示名称
- name: 模型统一名称(优先使用 GlobalModel 名称)
- display_name: 模型显示名称
- description: 模型描述信息
- tags: 模型标签(当前为 null
- icon_url: 模型图标 URL
- input_price_per_1m: 输入价格(每 100 万 token
- output_price_per_1m: 输出价格(每 100 万 token
- cache_creation_price_per_1m: 缓存创建价格(每 100 万 token
- cache_read_price_per_1m: 缓存读取价格(每 100 万 token
- supports_vision: 是否支持视觉输入
- supports_function_calling: 是否支持函数调用
- supports_streaming: 是否支持流式输出
- is_active: 是否活跃
"""
adapter = PublicModelsAdapter(
provider_id=provider_id, is_active=is_active, skip=skip, limit=limit
)
@@ -72,6 +125,19 @@ async def get_public_models(
@router.get("/stats", response_model=ProviderStatsResponse)
async def get_public_stats(request: Request, db: Session = Depends(get_db)):
"""
获取系统统计信息
返回系统的整体统计数据,包括提供商数量、模型数量和支持的 API 格式。
只统计活跃的提供商和模型。
**返回字段**
- total_providers: 活跃提供商总数
- active_providers: 活跃提供商数量(与 total_providers 相同)
- total_models: 活跃模型总数
- active_models: 活跃模型数量(与 total_models 相同)
- supported_formats: 支持的 API 格式列表(如 claude、openai、gemini 等)
"""
adapter = PublicStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@@ -84,6 +150,37 @@ async def search_models(
limit: int = Query(20, description="返回记录数限制"),
db: Session = Depends(get_db),
):
"""
搜索模型
根据关键词搜索模型,支持按模型名称、显示名称等字段进行模糊匹配。
只返回活跃提供商下的活跃模型。
**查询参数**
- q: 必填,搜索关键词,支持模糊匹配模型的 provider_model_name、GlobalModel.name 或 GlobalModel.display_name
- provider_id: 可选,按提供商 ID 过滤,只在该提供商下搜索
- limit: 返回记录数限制,默认 20最大值取决于系统配置
**返回字段**
返回符合条件的模型列表,字段与 /api/public/models 接口相同:
- id: 模型唯一标识符
- provider_id: 所属提供商 ID
- provider_name: 提供商名称
- provider_display_name: 提供商显示名称
- name: 模型统一名称
- display_name: 模型显示名称
- description: 模型描述
- tags: 模型标签
- icon_url: 模型图标 URL
- input_price_per_1m: 输入价格(每 100 万 token
- output_price_per_1m: 输出价格(每 100 万 token
- cache_creation_price_per_1m: 缓存创建价格(每 100 万 token
- cache_read_price_per_1m: 缓存读取价格(每 100 万 token
- supports_vision: 是否支持视觉
- supports_function_calling: 是否支持函数调用
- supports_streaming: 是否支持流式输出
- is_active: 是否活跃
"""
adapter = PublicSearchModelsAdapter(query=q, provider_id=provider_id, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@@ -95,7 +192,37 @@ async def get_public_api_format_health(
per_format_limit: int = Query(100, ge=10, le=500, description="每个格式的事件数限制"),
db: Session = Depends(get_db),
):
"""获取各 API 格式的健康监控数据(公开版,不含敏感信息)"""
"""
获取各 API 格式的健康监控数据
返回系统中各 API 格式(如 Claude、OpenAI、Gemini的健康状态和历史事件。
公开版本,不包含敏感信息(如 provider_id、key_id 等)。
**查询参数**
- lookback_hours: 回溯的时间范围(小时),默认 6 小时,范围 1-1687 天)
- per_format_limit: 每个 API 格式返回的历史事件数量上限,默认 100范围 10-500
**返回字段**
- generated_at: 响应生成时间
- formats: API 格式健康监控数据列表,每个格式包含:
- api_format: API 格式名称(如 claude、openai、gemini
- api_path: 本站入口路径
- total_attempts: 总请求尝试次数
- success_count: 成功次数
- failed_count: 失败次数
- skipped_count: 跳过次数
- success_rate: 成功率success / (success + failed)
- last_event_at: 最后事件时间
- events: 历史事件列表,按时间倒序,每个事件包含:
- timestamp: 事件时间
- status: 状态success、failed、skipped
- status_code: HTTP 状态码
- latency_ms: 延迟(毫秒)
- error_type: 错误类型(如果失败)
- timeline: 时间线数据,用于展示请求量趋势
- time_range_start: 时间范围起始
- time_range_end: 时间范围结束
"""
adapter = PublicApiFormatHealthMonitorAdapter(
lookback_hours=lookback_hours,
per_format_limit=per_format_limit,
@@ -112,7 +239,30 @@ async def get_public_global_models(
search: Optional[str] = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
):
"""获取 GlobalModel 列表(用户视图,只读)"""
"""
获取全局模型GlobalModel列表
返回系统定义的全局模型列表,用于统一不同提供商的模型标识。
默认只返回活跃的全局模型。
**查询参数**
- skip: 跳过的记录数,用于分页,默认 0最小 0
- limit: 返回记录数限制,默认 100范围 1-1000
- is_active: 可选过滤活跃状态。None 表示只返回活跃模型True 返回活跃False 返回非活跃
- search: 可选搜索关键词支持模糊匹配模型名称name和显示名称display_name
**返回字段**
- models: 全局模型列表,每个模型包含:
- id: 全局模型唯一标识符UUID
- name: 模型名称(统一标识符)
- display_name: 模型显示名称
- is_active: 是否活跃
- default_price_per_request: 默认的按请求计价配置
- default_tiered_pricing: 默认的阶梯定价配置
- supported_capabilities: 支持的能力列表(如 vision、function_calling 等)
- config: 模型配置信息(如 description、icon_url 等)
- total: 符合条件的模型总数
"""
adapter = PublicGlobalModelsAdapter(
skip=skip,
limit=limit,

View File

@@ -29,7 +29,27 @@ async def create_message(
http_request: Request,
db: Session = Depends(get_db),
):
"""统一入口:根据 x-app 自动在标准/Claude Code 之间切换。"""
"""
Claude Messages API
兼容 Anthropic Claude Messages API 格式的代理接口。
根据请求头 `x-app` 自动在标准 API 和 Claude Code CLI 模式之间切换。
**认证方式**: x-api-key 请求头
**请求格式**:
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}
```
**必需请求头**:
- `x-api-key`: API 密钥
- `anthropic-version`: API 版本(如 2023-06-01
"""
adapter = build_claude_adapter(http_request.headers.get("x-app", ""))
return await pipeline.run(
adapter=adapter,
@@ -45,6 +65,13 @@ async def count_tokens(
http_request: Request,
db: Session = Depends(get_db),
):
"""
Claude Token Count API
计算消息的 Token 数量,用于预估请求成本。
**认证方式**: x-api-key 请求头
"""
adapter = ClaudeTokenCountAdapter()
return await pipeline.run(
adapter=adapter,

View File

@@ -56,9 +56,23 @@ async def generate_content(
db: Session = Depends(get_db),
):
"""
Gemini generateContent 端点
Gemini generateContent API
非流式生成内容请求
兼容 Google Gemini API 格式的代理接口(非流式)。
**认证方式**:
- `x-goog-api-key` 请求头,或
- `?key=` URL 参数
**请求格式**:
```json
{
"contents": [{"parts": [{"text": "Hello"}]}]
}
```
**路径参数**:
- `model`: 模型名称,如 gemini-2.0-flash
"""
# 根据 user-agent 或 x-app header 选择适配器
if _is_cli_request(http_request):
@@ -84,9 +98,16 @@ async def stream_generate_content(
db: Session = Depends(get_db),
):
"""
Gemini streamGenerateContent 端点
Gemini streamGenerateContent API
流式生成内容请求
兼容 Google Gemini API 格式的代理接口(流式)。
**认证方式**:
- `x-goog-api-key` 请求头,或
- `?key=` URL 参数
**路径参数**:
- `model`: 模型名称,如 gemini-2.0-flash
注意: Gemini API 通过 URL 端点区分流式/非流式,不需要在请求体中添加 stream 字段
"""
@@ -114,7 +135,11 @@ async def generate_content_v1(
http_request: Request,
db: Session = Depends(get_db),
):
"""v1 兼容端点"""
"""
Gemini generateContent API (v1 兼容)
v1 版本 API 端点,兼容部分使用旧版路径的 SDK。
"""
return await generate_content(model, http_request, db)
@@ -124,5 +149,9 @@ async def stream_generate_content_v1(
http_request: Request,
db: Session = Depends(get_db),
):
"""v1 兼容端点"""
"""
Gemini streamGenerateContent API (v1 兼容)
v1 版本流式 API 端点,兼容部分使用旧版路径的 SDK。
"""
return await stream_generate_content(model, http_request, db)

View File

@@ -27,7 +27,7 @@ from src.database import get_db
from src.models.database import ApiKey, User
from src.services.auth.service import AuthService
router = APIRouter(tags=["Models API"])
router = APIRouter(tags=["System Catalog"])
# 各格式对应的 API 格式列表
# 注意: CLI 格式是透传格式Models API 只返回非 CLI 格式的端点支持的模型
@@ -126,7 +126,9 @@ def _filter_formats_by_restrictions(
"""
if restrictions.allowed_api_formats is None:
return formats, None
filtered = [f for f in formats if f in restrictions.allowed_api_formats]
# 统一转为大写比较,兼容数据库中存储的大小写
allowed_upper = {f.upper() for f in restrictions.allowed_api_formats}
filtered = [f for f in formats if f.upper() in allowed_upper]
if not filtered:
logger.info(f"[Models] API Key 不允许访问格式 {api_format}")
return [], _build_empty_list_response(api_format)
@@ -395,11 +397,65 @@ async def list_models(
db: Session = Depends(get_db),
) -> Union[dict, JSONResponse]:
"""
List models - 根据请求头认证方式返回对应格式
列出可用模型(统一端点)
- x-api-key -> Claude 格式
- x-goog-api-key 或 ?key= -> Gemini 格式
- Authorization: Bearer -> OpenAI 格式
根据请求头中的认证方式自动检测 API 格式,并返回相应格式的模型列表。
此接口兼容 Claude、OpenAI 和 Gemini 三种 API 格式
**格式检测规则**
- x-api-key + anthropic-version → Claude 格式
- x-goog-api-key 或 ?key= → Gemini 格式
- Authorization: Bearer → OpenAI 格式(默认)
**查询参数**
Claude 格式:
- before_id: 返回此 ID 之前的结果,用于向前分页
- after_id: 返回此 ID 之后的结果,用于向后分页
- limit: 返回数量限制,默认 20范围 1-1000
Gemini 格式:
- pageSize: 每页数量,默认 50范围 1-1000
- pageToken: 分页 token用于获取下一页
**返回字段**
Claude 格式:
- data: 模型列表,每个模型包含:
- id: 模型标识符
- type: "model"
- display_name: 显示名称
- created_at: 创建时间ISO 8601 格式)
- has_more: 是否有更多结果
- first_id: 当前页第一个模型 ID
- last_id: 当前页最后一个模型 ID
OpenAI 格式:
- object: "list"
- data: 模型列表,每个模型包含:
- id: 模型标识符
- object: "model"
- created: Unix 时间戳
- owned_by: 提供商名称
Gemini 格式:
- models: 模型列表,每个模型包含:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
- nextPageToken: 下一页的 token如果有更多结果
**错误响应**
401: API Key 无效或未提供(格式根据检测到的 API 格式返回)
"""
api_format, api_key = _detect_api_format_and_key(request)
logger.info(f"[Models] GET /v1/models | format={api_format}")
@@ -440,7 +496,50 @@ async def retrieve_model(
db: Session = Depends(get_db),
) -> Union[dict, JSONResponse]:
"""
Retrieve model - 根据请求头认证方式返回对应格式
获取单个模型详情(统一端点)
根据请求头中的认证方式自动检测 API 格式,并返回相应格式的模型详情。
此接口兼容 Claude、OpenAI 和 Gemini 三种 API 格式。
**格式检测规则**
- x-api-key + anthropic-version → Claude 格式
- x-goog-api-key 或 ?key= → Gemini 格式
- Authorization: Bearer → OpenAI 格式(默认)
**路径参数**
- model_id: 模型标识符Gemini 格式支持 models/ 前缀,会自动移除)
**返回字段**
Claude 格式:
- id: 模型标识符
- type: "model"
- display_name: 显示名称
- created_at: 创建时间ISO 8601 格式)
OpenAI 格式:
- id: 模型标识符
- object: "model"
- created: Unix 时间戳
- owned_by: 提供商名称
Gemini 格式:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
**错误响应**
401: API Key 无效或未提供
404: 模型不存在或不可访问
"""
api_format, api_key = _detect_api_format_and_key(request)
@@ -486,7 +585,35 @@ async def list_models_gemini(
page_token: Optional[str] = Query(None, alias="pageToken"),
db: Session = Depends(get_db),
) -> Union[dict, JSONResponse]:
"""List models (Gemini v1beta 端点)"""
"""
列出可用模型Gemini v1beta 专用端点)
Gemini API 的专用模型列表端点,使用 x-goog-api-key 或 ?key= 参数进行认证。
返回 Gemini 格式的模型列表。
**查询参数**
- pageSize: 每页数量,默认 50范围 1-1000
- pageToken: 分页 token用于获取下一页
**返回字段**
- models: 模型列表,每个模型包含:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法列表
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
- nextPageToken: 下一页的 token如果有更多结果
**错误响应**
401: API Key 无效或未提供
"""
logger.info("[Models] GET /v1beta/models | format=gemini")
# 从 x-goog-api-key 或 ?key= 提取 API Key
@@ -525,7 +652,33 @@ async def get_model_gemini(
model_name: str,
db: Session = Depends(get_db),
) -> Union[dict, JSONResponse]:
"""Get model (Gemini v1beta 端点)"""
"""
获取单个模型详情Gemini v1beta 专用端点)
Gemini API 的专用模型详情端点,使用 x-goog-api-key 或 ?key= 参数进行认证。
返回 Gemini 格式的模型详情。
**路径参数**
- model_name: 模型名称或资源路径(支持 models/ 前缀,会自动移除)
**返回字段**
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法列表
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
**错误响应**
401: API Key 无效或未提供
404: 模型不存在或不可访问
"""
# 移除 "models/" 前缀(如果有)
model_id = model_name[7:] if model_name.startswith("models/") else model_name
logger.info(f"[Models] GET /v1beta/models/{model_id} | format=gemini")

View File

@@ -27,6 +27,24 @@ async def create_chat_completion(
http_request: Request,
db: Session = Depends(get_db),
):
"""
OpenAI Chat Completions API
兼容 OpenAI Chat Completions API 格式的代理接口。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
**请求格式**:
```json
{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
}
```
**支持的参数**: model, messages, stream, temperature, max_tokens 等标准 OpenAI 参数
"""
adapter = OpenAIChatAdapter()
return await pipeline.run(
adapter=adapter,
@@ -42,6 +60,13 @@ async def create_responses(
http_request: Request,
db: Session = Depends(get_db),
):
"""
OpenAI Responses API (CLI)
兼容 OpenAI Codex CLI 使用的 Responses API 格式,请求透传到上游。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
"""
adapter = OpenAICliAdapter()
return await pipeline.run(
adapter=adapter,

View File

@@ -2,9 +2,11 @@
from fastapi import APIRouter
from .management_tokens import router as management_tokens_router
from .routes import router as me_router
router = APIRouter()
router.include_router(me_router)
router.include_router(management_tokens_router)
__all__ = ["router"]

View File

@@ -0,0 +1,577 @@
"""用户 Management Token 管理端点"""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
from src.models.database import AuditEventType
from src.services.management_token import (
ManagementTokenService,
parse_expires_at,
token_to_dict,
validate_ip_list,
)
router = APIRouter(prefix="/api/me/management-tokens", tags=["Management Tokens"])
pipeline = ApiRequestPipeline()
# ============== 安全基类 ==============
class ManagementTokenApiAdapter(AuthenticatedApiAdapter):
"""Management Token 管理 API 的基类
安全限制:禁止使用 Management Token 调用这些接口,
防止用户通过已有的 Token 再创建/修改/删除其他 Token。
"""
def authorize(self, context: ApiRequestContext):
# 先调用父类的认证检查
super().authorize(context)
# 禁止使用 Management Token 调用 management-tokens 相关接口
if context.management_token is not None:
raise HTTPException(
status_code=403,
detail="不允许使用 Management Token 管理其他 Token请使用 Web 界面或 JWT 认证",
)
# ============== 请求/响应模型 ==============
class CreateManagementTokenRequest(BaseModel):
"""创建 Management Token 请求"""
name: str = Field(..., min_length=1, max_length=100, description="Token 名称")
description: Optional[str] = Field(None, max_length=500, description="描述")
allowed_ips: Optional[list[str]] = Field(None, description="IP 白名单")
expires_at: Optional[datetime] = Field(None, description="过期时间")
@field_validator("allowed_ips")
@classmethod
def validate_allowed_ips(cls, v: Optional[list[str]]) -> Optional[list[str]]:
return validate_ip_list(v)
@field_validator("expires_at", mode="before")
@classmethod
def parse_expires(cls, v):
return parse_expires_at(v)
class UpdateManagementTokenRequest(BaseModel):
"""更新 Management Token 请求
对于 allowed_ips 和 expires_at 字段:
- 未提供(字段不在请求中): 不修改
- 显式设为 null: 清空该字段
- 提供有效值: 更新为新值
"""
model_config = {"extra": "allow"} # 允许额外字段以便检测哪些字段被显式提供
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
allowed_ips: Optional[list[str]] = None
expires_at: Optional[datetime] = None
# 用于追踪哪些字段被显式提供(包括显式设为 null 的情况)
_provided_fields: set[str] = set()
def __init__(self, **data):
# 记录实际传入的字段(包括值为 None 的)
provided = set(data.keys())
super().__init__(**data)
object.__setattr__(self, "_provided_fields", provided)
def is_field_provided(self, field_name: str) -> bool:
"""检查字段是否被显式提供(区分未提供和显式设为 null"""
return field_name in self._provided_fields
@field_validator("allowed_ips")
@classmethod
def validate_allowed_ips(cls, v: Optional[list[str]]) -> Optional[list[str]]:
# 如果是 None表示要清空直接返回
if v is None:
return None
return validate_ip_list(v)
@field_validator("expires_at", mode="before")
@classmethod
def parse_expires(cls, v):
# 如果是 None 或空字符串,表示要清空
if v is None or (isinstance(v, str) and not v.strip()):
return None
return parse_expires_at(v)
# ============== 路由 ==============
@router.get("")
async def list_my_management_tokens(
request: Request,
is_active: Optional[bool] = Query(None, description="筛选激活状态"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
):
"""列出当前用户的 Management Tokens
获取当前登录用户创建的所有 Management Tokens支持按激活状态筛选和分页。
**查询参数**
- is_active (Optional[bool]): 筛选激活状态true/false不传则返回全部
- skip (int): 分页偏移量,默认 0
- limit (int): 每页数量,范围 1-100默认 50
**返回字段**
- items (List[dict]): Token 列表
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值(不返回明文)
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间ISO 8601 格式)
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
- total (int): 总数量
- skip (int): 当前偏移量
- limit (int): 当前每页数量
- quota (dict): 配额信息
- used (int): 已使用数量
- max (int): 最大允许数量
"""
adapter = ListMyManagementTokensAdapter(is_active=is_active, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("")
async def create_my_management_token(request: Request, db: Session = Depends(get_db)):
"""创建 Management Token
为当前用户创建一个新的 Management Token。
**请求体字段**
- name (str): Token 名称,必填,长度 1-100
- description (Optional[str]): 描述,可选,最大长度 500
- allowed_ips (Optional[List[str]]): IP 白名单,可选,支持 IPv4/IPv6 和 CIDR 格式
- expires_at (Optional[datetime]): 过期时间,可选,支持 ISO 8601 格式字符串或 datetime 对象
**返回字段**
- message (str): 操作结果消息
- token (str): 生成的 Token 明文(仅在创建时返回一次,请妥善保存)
- data (dict): Token 信息
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值
- is_active (bool): 是否激活(新创建默认为 true
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = CreateMyManagementTokenAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{token_id}")
async def get_my_management_token(
token_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""获取 Management Token 详情
获取当前用户指定 Token 的详细信息。
**路径参数**
- token_id (str): Token ID
**返回字段**
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值(不返回明文)
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间ISO 8601 格式)
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = GetMyManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/{token_id}")
async def update_my_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""更新 Management Token
更新当前用户指定 Token 的信息。支持部分字段更新。
**路径参数**
- token_id (str): Token ID
**请求体字段**(所有字段均可选)
- name (Optional[str]): Token 名称,长度 1-100
- description (Optional[str]): 描述,最大长度 500传空字符串或 null 可清空
- allowed_ips (Optional[List[str]]): IP 白名单,传 null 可清空
- expires_at (Optional[datetime]): 过期时间,传 null 可清空
注意:未提供的字段不会被修改,显式传 null 表示清空该字段。
**返回字段**
- message (str): 操作结果消息
- data (dict): 更新后的 Token 信息
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = UpdateMyManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{token_id}")
async def delete_my_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""删除 Management Token
删除当前用户指定的 Token。
**路径参数**
- token_id (str): 要删除的 Token ID
**返回字段**
- message (str): 操作结果消息
"""
adapter = DeleteMyManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{token_id}/status")
async def toggle_my_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""切换 Management Token 状态
启用或禁用当前用户指定的 Token。
**路径参数**
- token_id (str): Token ID
**返回字段**
- message (str): 操作结果消息("Token 已启用""Token 已禁用"
- data (dict): 更新后的 Token 信息
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): Token 哈希值
- is_active (bool): 是否激活(已切换后的状态)
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间
- last_used_at (Optional[str]): 最后使用时间
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = ToggleMyManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{token_id}/regenerate")
async def regenerate_my_management_token(
token_id: str, request: Request, db: Session = Depends(get_db)
):
"""重新生成 Management Token
重新生成当前用户指定 Token 的值,旧 Token 将立即失效。
**路径参数**
- token_id (str): Token ID
**返回字段**
- message (str): 操作结果消息
- token (str): 新生成的 Token 明文(仅在重新生成时返回一次,请妥善保存)
- data (dict): Token 信息
- id (str): Token ID
- user_id (str): 所属用户 ID
- name (str): Token 名称
- description (Optional[str]): 描述
- token_hash (str): 新的 Token 哈希值
- is_active (bool): 是否激活
- allowed_ips (Optional[List[str]]): IP 白名单
- expires_at (Optional[str]): 过期时间
- last_used_at (Optional[str]): 最后使用时间(重置为 null
- created_at (str): 创建时间
- updated_at (str): 更新时间
"""
adapter = RegenerateMyManagementTokenAdapter(token_id=token_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 适配器 ==============
@dataclass
class ListMyManagementTokensAdapter(ManagementTokenApiAdapter):
"""列出用户的 Management Tokens"""
name: str = "list_my_management_tokens"
is_active: Optional[bool] = None
skip: int = 0
limit: int = 50
async def handle(self, context: ApiRequestContext):
from src.config.settings import config
tokens, total = ManagementTokenService.list_tokens(
db=context.db,
user_id=context.user.id,
is_active=self.is_active,
skip=self.skip,
limit=self.limit,
)
# 获取用户 Token 总数(用于配额显示)
max_tokens = config.management_token_max_per_user
return JSONResponse(
content={
"items": [token_to_dict(t) for t in tokens],
"total": total,
"skip": self.skip,
"limit": self.limit,
"quota": {
"used": total,
"max": max_tokens,
},
}
)
@dataclass
class CreateMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""创建 Management Token"""
name: str = "create_my_management_token"
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_CREATED
async def handle(self, context: ApiRequestContext):
body = context.ensure_json_body()
try:
req = CreateManagementTokenRequest(**body)
except Exception as e:
raise InvalidRequestException(str(e))
try:
token, raw_token = ManagementTokenService.create_token(
db=context.db,
user_id=context.user.id,
name=req.name,
description=req.description,
allowed_ips=req.allowed_ips,
expires_at=req.expires_at,
)
except ValueError as e:
raise InvalidRequestException(str(e))
context.add_audit_metadata(token_id=token.id, token_name=token.name)
return JSONResponse(
status_code=201,
content={
"message": "Management Token 创建成功",
"token": raw_token, # 仅在创建时返回一次
"data": token_to_dict(token),
},
)
@dataclass
class GetMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""获取 Management Token 详情"""
name: str = "get_my_management_token"
token_id: str = ""
async def handle(self, context: ApiRequestContext):
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id, user_id=context.user.id
)
if not token:
raise NotFoundException("Management Token 不存在")
return JSONResponse(content=token_to_dict(token))
@dataclass
class UpdateMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""更新 Management Token"""
name: str = "update_my_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
async def handle(self, context: ApiRequestContext):
body = context.ensure_json_body()
try:
req = UpdateManagementTokenRequest(**body)
except Exception as e:
raise InvalidRequestException(str(e))
# 构建更新参数,只包含显式提供的字段
update_kwargs: dict = {
"db": context.db,
"token_id": self.token_id,
"user_id": context.user.id,
}
# 对于普通字段,只有提供了才更新
if req.is_field_provided("name"):
update_kwargs["name"] = req.name
if req.is_field_provided("description"):
update_kwargs["description"] = req.description
update_kwargs["clear_description"] = req.description is None or req.description == ""
# 对于可清空字段,需要传递特殊标记
if req.is_field_provided("allowed_ips"):
update_kwargs["allowed_ips"] = req.allowed_ips
update_kwargs["clear_allowed_ips"] = req.allowed_ips is None
if req.is_field_provided("expires_at"):
update_kwargs["expires_at"] = req.expires_at
update_kwargs["clear_expires_at"] = req.expires_at is None
try:
token = ManagementTokenService.update_token(**update_kwargs)
except ValueError as e:
raise InvalidRequestException(str(e))
if not token:
raise NotFoundException("Management Token 不存在")
context.add_audit_metadata(token_id=token.id, token_name=token.name)
return JSONResponse(
content={"message": "更新成功", "data": token_to_dict(token)}
)
@dataclass
class DeleteMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""删除 Management Token"""
name: str = "delete_my_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
async def handle(self, context: ApiRequestContext):
# 先获取 token 信息用于审计
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id, user_id=context.user.id
)
if not token:
raise NotFoundException("Management Token 不存在")
context.add_audit_metadata(token_id=token.id, token_name=token.name)
success = ManagementTokenService.delete_token(
db=context.db, token_id=self.token_id, user_id=context.user.id
)
if not success:
raise NotFoundException("Management Token 不存在")
return JSONResponse(content={"message": "删除成功"})
@dataclass
class ToggleMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""切换 Management Token 状态"""
name: str = "toggle_my_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
async def handle(self, context: ApiRequestContext):
token = ManagementTokenService.toggle_status(
db=context.db, token_id=self.token_id, user_id=context.user.id
)
if not token:
raise NotFoundException("Management Token 不存在")
context.add_audit_metadata(
token_id=token.id, token_name=token.name, is_active=token.is_active
)
return JSONResponse(
content={
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
"data": token_to_dict(token),
}
)
@dataclass
class RegenerateMyManagementTokenAdapter(ManagementTokenApiAdapter):
"""重新生成 Management Token"""
name: str = "regenerate_my_management_token"
token_id: str = ""
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
async def handle(self, context: ApiRequestContext):
token, raw_token, old_token_hash = ManagementTokenService.regenerate_token(
db=context.db, token_id=self.token_id, user_id=context.user.id
)
if not token:
raise NotFoundException("Management Token 不存在")
context.add_audit_metadata(
token_id=token.id,
token_name=token.name,
regenerated=True,
)
return JSONResponse(
content={
"message": "Token 已重新生成",
"token": raw_token, # 仅在重新生成时返回一次
"data": token_to_dict(token),
}
)

View File

@@ -35,20 +35,43 @@ pipeline = ApiRequestPipeline()
@router.get("")
async def get_my_profile(request: Request, db: Session = Depends(get_db)):
"""获取当前用户完整信息(包含偏好设置)"""
"""
获取当前用户信息
返回当前登录用户的完整信息,包括基本信息和偏好设置。
**返回字段**: id, email, username, role, is_active, quota_usd, used_usd, preferences 等
"""
adapter = MeProfileAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("")
async def update_my_profile(request: Request, db: Session = Depends(get_db)):
"""
更新个人信息
更新当前用户的邮箱或用户名。
**请求体**:
- `email`: 新邮箱地址(可选)
- `username`: 新用户名(可选)
"""
adapter = UpdateProfileAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/password")
async def change_my_password(request: Request, db: Session = Depends(get_db)):
"""Change current user's password"""
"""
修改密码
修改当前用户的登录密码。
**请求体**:
- `old_password`: 当前密码
- `new_password`: 新密码(至少 6 位)
"""
adapter = ChangePasswordAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -58,12 +81,30 @@ async def change_my_password(request: Request, db: Session = Depends(get_db)):
@router.get("/api-keys")
async def list_my_api_keys(request: Request, db: Session = Depends(get_db)):
"""
获取 API 密钥列表
返回当前用户的所有 API 密钥,包含使用统计信息。
密钥值仅显示前后几位,完整密钥需通过详情接口获取。
**返回字段**: id, name, key_display, is_active, total_requests, total_cost_usd, last_used_at 等
"""
adapter = ListMyApiKeysAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/api-keys")
async def create_my_api_key(request: Request, db: Session = Depends(get_db)):
"""
创建 API 密钥
为当前用户创建新的 API 密钥。创建成功后会返回完整的密钥值,请妥善保存。
**请求体**:
- `name`: 密钥名称
**返回**: 包含完整密钥值的响应(仅此一次显示完整密钥)
"""
adapter = CreateMyApiKeyAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -72,10 +113,20 @@ async def create_my_api_key(request: Request, db: Session = Depends(get_db)):
async def get_my_api_key(
key_id: str,
request: Request,
include_key: bool = Query(False, description="Include full decrypted key in response"),
include_key: bool = Query(False, description="是否返回完整密钥"),
db: Session = Depends(get_db),
):
"""Get API key detail, optionally include full key"""
"""
获取 API 密钥详情
获取指定 API 密钥的详细信息。
**路径参数**:
- `key_id`: 密钥 ID
**查询参数**:
- `include_key`: 设为 true 时返回完整解密后的密钥值
"""
if include_key:
adapter = GetMyFullKeyAdapter(key_id=key_id)
else:
@@ -85,13 +136,28 @@ async def get_my_api_key(
@router.delete("/api-keys/{key_id}")
async def delete_my_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
删除 API 密钥
永久删除指定的 API 密钥,删除后无法恢复。
**路径参数**:
- `key_id`: 密钥 ID
"""
adapter = DeleteMyApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/api-keys/{key_id}")
async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""Toggle API key active status"""
"""
切换 API 密钥状态
启用或禁用指定的 API 密钥。禁用后该密钥将无法用于 API 调用。
**路径参数**:
- `key_id`: 密钥 ID
"""
adapter = ToggleMyApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -102,13 +168,27 @@ async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends
@router.get("/usage")
async def get_my_usage(
request: Request,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
search: Optional[str] = None, # 通用搜索:密钥名、模型名
start_date: Optional[datetime] = Query(None, description="开始时间ISO 格式)"),
end_date: Optional[datetime] = Query(None, description="结束时间ISO 格式)"),
search: Optional[str] = Query(None, description="搜索关键词(密钥名、模型名)"),
limit: int = Query(100, ge=1, le=200, description="每页记录数默认100最大200"),
offset: int = Query(0, ge=0, le=2000, description="偏移量用于分页最大2000"),
db: Session = Depends(get_db),
):
"""
获取使用统计
获取当前用户的 API 使用统计数据,包括总量汇总、按模型/提供商分组统计及详细记录。
**返回字段**:
- `total_requests`: 总请求数
- `total_tokens`: 总 Token 数
- `total_cost`: 总成本USD
- `summary_by_model`: 按模型分组统计
- `summary_by_provider`: 按提供商分组统计
- `records`: 详细使用记录列表
- `pagination`: 分页信息
"""
adapter = GetUsageAdapter(
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
)
@@ -118,10 +198,17 @@ async def get_my_usage(
@router.get("/usage/active")
async def get_my_active_requests(
request: Request,
ids: Optional[str] = Query(None, description="Comma-separated request IDs to query"),
ids: Optional[str] = Query(None, description="请求 ID 列表,逗号分隔"),
db: Session = Depends(get_db),
):
"""获取用户活跃请求状态(用于轮询更新)"""
"""
获取活跃请求状态
查询正在进行中的请求状态,用于前端轮询更新流式请求的进度。
**查询参数**:
- `ids`: 要查询的请求 ID 列表,逗号分隔
"""
adapter = GetActiveRequestsAdapter(ids=ids)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -133,7 +220,13 @@ async def get_my_interval_timeline(
limit: int = Query(5000, ge=100, le=20000, description="最大返回数据点数量"),
db: Session = Depends(get_db),
):
"""获取当前用户的请求间隔时间线数据,用于散点图展示"""
"""
获取请求间隔时间线
获取请求间隔时间线数据,用于散点图展示请求分布情况。
**返回**: 包含时间戳和间隔时间的数据点列表
"""
adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -144,9 +237,12 @@ async def get_my_activity_heatmap(
db: Session = Depends(get_db),
):
"""
Get user's activity heatmap data for the past 365 days.
获取活动热力图数据
This endpoint is cached for 5 minutes to reduce database load.
获取过去 365 天的活动热力图数据,用于展示每日使用频率。
此接口有 5 分钟缓存。
**返回**: 包含日期和请求数量的数据列表
"""
adapter = GetMyActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -154,13 +250,26 @@ async def get_my_activity_heatmap(
@router.get("/providers")
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
"""
获取可用提供商列表
获取当前用户可用的所有提供商及其模型信息。
**返回字段**: id, name, display_name, endpoints, models 等
"""
adapter = ListAvailableProvidersAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/endpoint-status")
async def get_endpoint_status(request: Request, db: Session = Depends(get_db)):
"""获取端点状态(简化版,不包含敏感信息)"""
"""
获取端点健康状态
获取各 API 格式端点的健康状态(简化版,不包含敏感信息)。
**返回**: 按 API 格式分组的端点健康状态
"""
adapter = GetEndpointStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -177,6 +286,17 @@ async def update_api_key_providers(
request: Request,
db: Session = Depends(get_db),
):
"""
更新 API 密钥可用提供商
设置指定 API 密钥可以使用哪些提供商。未设置时使用用户默认权限。
**路径参数**:
- `api_key_id`: API 密钥 ID
**请求体**:
- `allowed_providers`: 允许的提供商 ID 列表
"""
adapter = UpdateApiKeyProvidersAdapter(api_key_id=api_key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -187,7 +307,17 @@ async def update_api_key_capabilities(
request: Request,
db: Session = Depends(get_db),
):
"""更新 API Key 的强制能力配置"""
"""
更新 API 密钥能力配置
设置指定 API 密钥的强制能力配置(如是否启用代码执行等)。
**路径参数**:
- `api_key_id`: API 密钥 ID
**请求体**:
- `force_capabilities`: 能力配置字典,如 `{"code_execution": true}`
"""
adapter = UpdateApiKeyCapabilitiesAdapter(api_key_id=api_key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -197,26 +327,59 @@ async def update_api_key_capabilities(
@router.get("/preferences")
async def get_my_preferences(request: Request, db: Session = Depends(get_db)):
"""
获取偏好设置
获取当前用户的偏好设置,包括主题、语言、通知配置等。
**返回字段**: avatar_url, bio, theme, language, timezone, notifications 等
"""
adapter = GetPreferencesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/preferences")
async def update_my_preferences(request: Request, db: Session = Depends(get_db)):
"""
更新偏好设置
更新当前用户的偏好设置。
**请求体**:
- `theme`: 主题light/dark
- `language`: 语言
- `timezone`: 时区
- `email_notifications`: 邮件通知开关
- `usage_alerts`: 用量告警开关
- 等
"""
adapter = UpdatePreferencesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/model-capabilities")
async def get_model_capability_settings(request: Request, db: Session = Depends(get_db)):
"""获取用户的模型能力配置"""
"""
获取模型能力配置
获取用户针对各模型的能力配置(如是否启用特定功能)。
**返回**: model_capability_settings 字典
"""
adapter = GetModelCapabilitySettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/model-capabilities")
async def update_model_capability_settings(request: Request, db: Session = Depends(get_db)):
"""更新用户的模型能力配置"""
"""
更新模型能力配置
更新用户针对各模型的能力配置。
**请求体**:
- `model_capability_settings`: 模型能力配置字典,格式为 `{"model_name": {"capability": true}}`
"""
adapter = UpdateModelCapabilitySettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -225,11 +388,15 @@ async def update_model_capability_settings(request: Request, db: Session = Depen
class MeProfileAdapter(AuthenticatedApiAdapter):
"""获取当前用户信息的适配器"""
async def handle(self, context): # type: ignore[override]
return PreferenceService.get_user_with_preferences(context.db, context.user.id)
class UpdateProfileAdapter(AuthenticatedApiAdapter):
"""更新用户个人信息的适配器"""
async def handle(self, context): # type: ignore[override]
db = context.db
user = context.user
@@ -265,6 +432,8 @@ class UpdateProfileAdapter(AuthenticatedApiAdapter):
class ChangePasswordAdapter(AuthenticatedApiAdapter):
"""修改用户密码的适配器"""
async def handle(self, context): # type: ignore[override]
db = context.db
user = context.user
@@ -290,6 +459,8 @@ class ChangePasswordAdapter(AuthenticatedApiAdapter):
class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
"""获取用户 API 密钥列表的适配器"""
async def handle(self, context): # type: ignore[override]
db = context.db
user = context.user
@@ -359,6 +530,8 @@ class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
class CreateMyApiKeyAdapter(AuthenticatedApiAdapter):
"""创建 API 密钥的适配器"""
async def handle(self, context): # type: ignore[override]
payload = context.ensure_json_body()
try:
@@ -388,6 +561,8 @@ class CreateMyApiKeyAdapter(AuthenticatedApiAdapter):
@dataclass
class GetMyFullKeyAdapter(AuthenticatedApiAdapter):
"""获取 API 密钥完整密钥值的适配器"""
key_id: str
async def handle(self, context): # type: ignore[override]
@@ -420,7 +595,8 @@ class GetMyFullKeyAdapter(AuthenticatedApiAdapter):
@dataclass
class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
"""Get API key detail without full key"""
"""获取 API 密钥详情的适配器(不包含完整密钥值)"""
key_id: str
async def handle(self, context): # type: ignore[override]
@@ -449,6 +625,8 @@ class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
@dataclass
class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
"""删除 API 密钥的适配器"""
key_id: str
async def handle(self, context): # type: ignore[override]
@@ -466,6 +644,8 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
@dataclass
class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
"""切换 API 密钥启用/禁用状态的适配器"""
key_id: str
async def handle(self, context): # type: ignore[override]
@@ -488,6 +668,8 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
@dataclass
class GetUsageAdapter(AuthenticatedApiAdapter):
"""获取用户使用统计的适配器"""
start_date: Optional[datetime]
end_date: Optional[datetime]
search: Optional[str] = None
@@ -665,7 +847,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"records": [
{
"id": r.id,
"provider": r.provider,
"provider": r.provider_name,
"model": r.model,
"target_model": r.target_model, # 映射后的目标模型名
"api_format": r.api_format,
@@ -766,7 +948,7 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
"""Activity heatmap adapter with Redis caching for user."""
"""获取用户活动热力图数据的适配器(带 Redis 缓存)"""
async def handle(self, context): # type: ignore[override]
user = context.user
@@ -780,6 +962,8 @@ class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
"""获取可用提供商列表的适配器"""
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import selectinload
@@ -851,6 +1035,8 @@ class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
@dataclass
class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
"""更新 API 密钥可用提供商的适配器"""
api_key_id: str
async def handle(self, context): # type: ignore[override]
@@ -962,6 +1148,8 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
class GetPreferencesAdapter(AuthenticatedApiAdapter):
"""获取用户偏好设置的适配器"""
async def handle(self, context): # type: ignore[override]
preferences = PreferenceService.get_or_create_preferences(context.db, context.user.id)
return {
@@ -983,6 +1171,8 @@ class GetPreferencesAdapter(AuthenticatedApiAdapter):
class UpdatePreferencesAdapter(AuthenticatedApiAdapter):
"""更新用户偏好设置的适配器"""
async def handle(self, context): # type: ignore[override]
payload = context.ensure_json_body()
try:

View File

@@ -181,6 +181,28 @@ class Config:
os.getenv("VERIFICATION_SEND_COOLDOWN", "60")
)
# Management Token 速率限制(每分钟每 IP
self.management_token_rate_limit = int(
os.getenv("MANAGEMENT_TOKEN_RATE_LIMIT", "30")
)
# 每个用户最多可创建的 Management Token 数量
self.management_token_max_per_user = int(
os.getenv("MANAGEMENT_TOKEN_MAX_PER_USER", "20")
)
# API 文档配置
# DOCS_ENABLED: 是否启用 API 文档(/docs, /redoc, /openapi.json
# - 未设置: 开发环境启用,生产环境禁用
# - true: 强制启用
# - false: 强制禁用
docs_enabled_env = os.getenv("DOCS_ENABLED")
if docs_enabled_env is not None:
self.docs_enabled = docs_enabled_env.lower() == "true"
else:
# 默认:开发环境启用,生产环境禁用
self.docs_enabled = self.environment == "development"
# 验证连接池配置
self._validate_pool_config()

View File

@@ -248,11 +248,94 @@ async def lifespan(app: FastAPI):
from src import __version__ as app_version
# OpenAPI Tags 元数据定义
openapi_tags = [
{
"name": "Authentication",
"description": "用户认证相关接口,包括登录、注册、令牌刷新等",
},
{
"name": "User Profile",
"description": "用户个人信息管理,包括 API 密钥、使用统计、偏好设置等",
},
{
"name": "Management Tokens",
"description": "管理令牌,用于 CLI 工具等外部应用的认证",
},
{
"name": "Dashboard",
"description": "仪表盘统计数据包括请求量、Token 用量、成本等概览信息",
},
{
"name": "Announcements",
"description": "系统公告管理",
},
{
"name": "Monitoring",
"description": "用户监控与审计日志查询",
},
{
"name": "Admin - Users",
"description": "用户管理(管理员)",
},
{
"name": "Admin - Providers",
"description": "提供商管理(管理员)",
},
{
"name": "Admin - Endpoints",
"description": "端点管理(管理员)",
},
{
"name": "Admin - Models",
"description": "模型管理(管理员)",
},
{
"name": "Admin - API Keys",
"description": "API 密钥管理(管理员)",
},
{
"name": "Admin - Usage",
"description": "使用统计管理(管理员)",
},
{
"name": "Admin - Monitoring",
"description": "系统监控(管理员)",
},
{
"name": "Admin - Security",
"description": "安全配置管理(管理员)",
},
{
"name": "Admin - System",
"description": "系统配置管理(管理员)",
},
{
"name": "Claude API",
"description": "Claude API 代理接口,兼容 Anthropic Claude API 格式",
},
{
"name": "OpenAI API",
"description": "OpenAI API 代理接口,兼容 OpenAI Chat Completions API 格式",
},
{
"name": "Gemini API",
"description": "Gemini API 代理接口,兼容 Google Gemini API 格式",
},
{
"name": "System Catalog",
"description": "系统目录接口,用于获取可用模型列表等",
},
]
app = FastAPI(
title="AI Proxy with Modular Architecture",
title="Aether API Gateway",
version=app_version,
description="AI代理服务采用模块化架构支持插件化扩展",
lifespan=lifespan,
docs_url="/docs" if config.docs_enabled else None,
redoc_url="/redoc" if config.docs_enabled else None,
openapi_url="/openapi.json" if config.docs_enabled else None,
openapi_tags=openapi_tags
)
# 注册全局异常处理器
@@ -272,15 +355,17 @@ app.add_middleware(PluginMiddleware)
# 生产环境必须通过 CORS_ORIGINS 环境变量显式指定允许的域名
# 开发环境默认允许本地前端访问
if config.cors_origins:
# CORS_ORIGINS=* 时自动禁用 credentials浏览器规范要求
allow_credentials = config.cors_allow_credentials and "*" not in config.cors_origins
app.add_middleware(
CORSMiddleware,
allow_origins=config.cors_origins, # 使用配置的白名单
allow_credentials=config.cors_allow_credentials,
allow_credentials=allow_credentials,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
)
logger.info(f"CORS已启用,允许的源: {config.cors_origins}")
logger.info(f"CORS已启用,允许的源: {config.cors_origins}, credentials: {allow_credentials}")
else:
# 没有配置CORS源,不允许跨域
logger.warning(

View File

@@ -71,7 +71,6 @@ class CreateProviderRequest(BaseModel):
rpm_limit: Optional[int] = Field(None, ge=0, description="RPM 限制")
provider_priority: Optional[int] = Field(100, ge=0, le=1000, description="提供商优先级(数字越小越优先)")
is_active: Optional[bool] = Field(True, description="是否启用")
rate_limit: Optional[int] = Field(None, ge=0, description="速率限制")
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
@@ -174,7 +173,6 @@ class UpdateProviderRequest(BaseModel):
rpm_limit: Optional[int] = Field(None, ge=0)
provider_priority: Optional[int] = Field(None, ge=0, le=1000)
is_active: Optional[bool] = None
rate_limit: Optional[int] = Field(None, ge=0)
concurrent_limit: Optional[int] = Field(None, ge=0)
config: Optional[Dict[str, Any]] = None
@@ -322,7 +320,7 @@ class UpdateUserRequest(BaseModel):
is_active: Optional[bool] = None
role: Optional[str] = None
allowed_providers: Optional[List[str]] = Field(None, description="允许使用的提供商 ID 列表")
allowed_endpoints: Optional[List[str]] = Field(None, description="允许使用的端点 ID 列表")
allowed_api_formats: Optional[List[str]] = Field(None, description="允许使用的 API 格式列表")
allowed_models: Optional[List[str]] = Field(None, description="允许使用的模型名称列表")
@field_validator("username")

View File

@@ -293,7 +293,7 @@ class UpdateUserRequest(BaseModel):
password: Optional[str] = None
role: Optional[UserRole] = None
allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表
allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表
allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表
allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表
quota_usd: Optional[float] = None
is_active: Optional[bool] = None
@@ -316,7 +316,6 @@ class CreateApiKeyRequest(BaseModel):
name: Optional[str] = None
allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表
allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表
allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表
allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表
rate_limit: Optional[int] = None # None = 无限制
@@ -339,7 +338,7 @@ class UserResponse(BaseModel):
username: str
role: UserRole
allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表
allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表
allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表
allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表
quota_usd: float
used_usd: float

View File

@@ -14,6 +14,7 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Column,
DateTime,
Enum,
@@ -71,7 +72,7 @@ class User(Base):
# 访问限制NULL 表示不限制,允许访问所有资源)
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
allowed_endpoints = Column(JSON, nullable=True) # 允许使用的端点 ID 列表
allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表
allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表
# Key 能力配置
@@ -101,6 +102,9 @@ class User(Base):
# 关系 - CASCADE delete: 让数据库处理级联删除
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
management_tokens = relationship(
"ManagementToken", back_populates="user", cascade="all, delete-orphan"
)
preferences = relationship(
"UserPreference", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
@@ -161,7 +165,6 @@ class ApiKey(Base):
# 访问限制NULL 表示不限制,允许访问所有资源)
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
allowed_endpoints = Column(JSON, nullable=True) # 允许使用的端点 ID 列表
allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表
allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表
rate_limit = Column(Integer, default=None, nullable=True) # 每分钟请求限制None = 无限制
@@ -268,7 +271,7 @@ class Usage(Base):
# 请求信息
request_id = Column(String(100), unique=True, index=True, nullable=False)
provider = Column(String(100), nullable=False)
provider_name = Column(String(100), nullable=False) # Provider 名称(非外键)
model = Column(String(100), nullable=False)
target_model = Column(String(100), nullable=True, comment="映射后的目标模型名(若无映射则为空)")
@@ -550,7 +553,6 @@ class Provider(Base):
is_active = Column(Boolean, default=True, nullable=False)
# 限制
rate_limit = Column(Integer, nullable=True) # 每分钟请求限制
concurrent_limit = Column(Integer, nullable=True) # 并发请求限制
# 配置
@@ -595,7 +597,7 @@ class ProviderEndpoint(Base):
# 请求配置
headers = Column(JSON, nullable=True) # 额外请求头
timeout = Column(Integer, default=300) # 超时(秒)
max_retries = Column(Integer, default=3) # 最大重试次数
max_retries = Column(Integer, default=2) # 最大重试次数
# 限制
max_concurrent = Column(
@@ -1229,6 +1231,192 @@ class AuditEventType(PyEnum):
DATA_EXPORT = "data_export"
CONFIG_CHANGED = "config_changed"
# Management Token 相关
MANAGEMENT_TOKEN_CREATED = "management_token_created"
MANAGEMENT_TOKEN_UPDATED = "management_token_updated"
MANAGEMENT_TOKEN_DELETED = "management_token_deleted"
MANAGEMENT_TOKEN_USED = "management_token_used"
MANAGEMENT_TOKEN_EXPIRED = "management_token_expired"
MANAGEMENT_TOKEN_IP_BLOCKED = "management_token_ip_blocked"
class ManagementToken(Base):
"""Management Token 模型 - 用于程序化管理 API 调用"""
__tablename__ = "management_tokens"
# Token 格式常量
TOKEN_PREFIX = "ae_"
TOKEN_RANDOM_LENGTH = 40
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# Token 信息
token_hash = Column(String(64), unique=True, index=True, nullable=False) # SHA256 哈希
token_prefix = Column(String(12), nullable=True) # Token 前缀用于显示(如 ae_xxxxxxxx
name = Column(String(100), nullable=False) # Token 名称
description = Column(Text, nullable=True) # 描述
# IP 白名单(可选)
allowed_ips = Column(JSON, nullable=True) # 允许的 IP 列表NULL = 不限制
# 格式: ["192.168.1.1", "10.0.0.0/24"]
# 有效期
expires_at = Column(DateTime(timezone=True), nullable=True) # NULL = 永不过期
# 使用统计
last_used_at = Column(DateTime(timezone=True), nullable=True)
last_used_ip = Column(String(45), nullable=True)
usage_count = Column(Integer, default=0) # 使用次数
# 状态
is_active = Column(Boolean, default=True, nullable=False)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
# 关系
user = relationship("User", back_populates="management_tokens")
# 索引和约束
__table_args__ = (
Index("idx_management_tokens_user_id", "user_id"),
Index("idx_management_tokens_is_active", "is_active"),
UniqueConstraint("user_id", "name", name="uq_management_tokens_user_name"),
# IP 白名单必须为 NULL不限制或非空数组禁止空数组
# 注意JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
CheckConstraint(
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
name="check_allowed_ips_not_empty",
),
)
@staticmethod
def generate_token() -> str:
"""生成 Management Token使用加密安全的随机数"""
import string
alphabet = string.ascii_letters + string.digits
random_part = "".join(
secrets.choice(alphabet) for _ in range(ManagementToken.TOKEN_RANDOM_LENGTH)
)
return f"{ManagementToken.TOKEN_PREFIX}{random_part}"
@staticmethod
def hash_token(token: str) -> str:
"""对 Token 进行 SHA256 哈希
安全性说明(当前方案是安全的):
- Token 熵为 62^40约 2^238暴力破解在计算上不可行
- 结合速率限制(默认 30 次/分钟/IP在线攻击不可行
- 不需要盐值:盐值用于防止彩虹表攻击,但 Token 是高熵随机值,
不存在可预计算的"常见值",因此彩虹表攻击不适用
"""
return hashlib.sha256(token.encode()).hexdigest()
def set_token(self, token: str) -> None:
"""设置 Token只存储哈希和前缀用于显示"""
self.token_hash = self.hash_token(token)
# 存储前缀用于显示ae_ + 4 个字符,共 7 个字符)
self.token_prefix = token[:7] if len(token) > 7 else token
def get_display_token(self) -> str:
"""获取用于显示的脱敏 Token显示前缀 + 掩码)"""
if self.token_prefix:
return f"{self.token_prefix}...****"
return "ae_****"
def is_ip_allowed(self, client_ip: str) -> bool:
"""检查 IP 是否在白名单中
安全策略:
- None 或不设置表示不限制(允许所有 IP
- 非空列表表示只允许列表中的 IP
- 无效的白名单条目会被记录并跳过
- 无效的客户端 IP 直接拒绝
- 支持 IPv4 映射的 IPv6 地址规范化
"""
if self.allowed_ips is None:
return True # 未设置白名单,不限制
import ipaddress
from src.core.logger import logger
# 防御性检查:空列表应该在数据库层被拒绝,但这里再检查一次
if not self.allowed_ips:
logger.critical(f"Management Token {self.id} - allowed_ips 为空列表(违反数据库约束)")
return False # fail-safe
def normalize_ip(ip_str: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""规范化 IP 地址,将 IPv4 映射的 IPv6 转换为 IPv4"""
try:
ip = ipaddress.ip_address(ip_str)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
return ip.ipv4_mapped
return ip
except ValueError:
return None
# 规范化客户端 IP
client = normalize_ip(client_ip)
if client is None:
logger.error(f"Management Token {self.id} - 拒绝无效的客户端 IP: {client_ip}")
return False
valid_entries = 0
for allowed in self.allowed_ips:
try:
if "/" in allowed:
# CIDR 格式
network = ipaddress.ip_network(allowed, strict=False)
valid_entries += 1
if client in network:
return True
else:
# 精确 IP
allowed_ip = normalize_ip(allowed)
if allowed_ip is None:
logger.error(f"Management Token {self.id} - 白名单包含无效条目: {allowed}")
continue
valid_entries += 1
if client == allowed_ip:
return True
except ValueError:
logger.error(f"Management Token {self.id} - 白名单包含无效条目: {allowed}")
continue
# 如果白名单全部无效,记录严重错误并拒绝
if valid_entries == 0:
logger.critical(f"Management Token {self.id} - 白名单全部无效,拒绝所有访问")
return False
@property
def is_expired(self) -> bool:
"""检查 Token 是否已过期(时区安全)"""
if not self.expires_at:
return False
expires = self.expires_at
if expires.tzinfo is None:
# 数据库中的时间应该有时区信息,如果没有则表示数据完整性问题
from src.core.logger import logger
logger.error(f"Management Token {self.id} expires_at 缺少时区信息(数据完整性问题)")
expires = expires.replace(tzinfo=timezone.utc)
return expires < datetime.now(timezone.utc)
class AuditLog(Base):
"""审计日志模型"""

View File

@@ -24,7 +24,7 @@ class ProviderEndpointCreate(BaseModel):
# 请求配置
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
timeout: int = Field(default=300, ge=10, le=600, description="超时时间(秒)")
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
# 限制
max_concurrent: Optional[int] = Field(default=None, ge=1, description="最大并发数")

View File

@@ -1,11 +1,11 @@
"""
Google Gemini API 请求/响应模型
支持 Gemini 3 Pro 及之前版本的 API 格式
参考文档: https://ai.google.dev/gemini-api/docs/gemini-3
支持 Gemini API 的请求/响应格式
作为 API 网关,采用宽松类型定义以支持 API 新特性透传
"""
from typing import Any, Dict, List, Literal, Optional, Union
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -17,282 +17,23 @@ class BaseModelWithExtras(BaseModel):
# ---------------------------------------------------------------------------
# 内容定义
# 内容定义 - 使用宽松类型以支持透传
# ---------------------------------------------------------------------------
class GeminiTextPart(BaseModelWithExtras):
"""文本内容块"""
text: str
thought_signature: Optional[str] = Field(
default=None,
alias="thoughtSignature",
description="Gemini 3 思维签名,用于维护多轮对话中的推理上下文",
)
class GeminiInlineData(BaseModelWithExtras):
"""内联数据(图片等)"""
mime_type: str = Field(alias="mimeType")
data: str # base64 encoded
class GeminiMediaResolution(BaseModelWithExtras):
"""
媒体分辨率配置 (Gemini 3 新增)
控制图片/视频的处理分辨率:
- media_resolution_low: 图片 280 tokens, 视频 70 tokens/帧
- media_resolution_medium: 图片 560 tokens, 视频 70 tokens/帧
- media_resolution_high: 图片 1120 tokens, 视频 280 tokens/帧
"""
level: Literal["media_resolution_low", "media_resolution_medium", "media_resolution_high"]
class GeminiFileData(BaseModelWithExtras):
"""文件引用"""
mime_type: Optional[str] = Field(default=None, alias="mimeType")
file_uri: str = Field(alias="fileUri")
class GeminiFunctionCall(BaseModelWithExtras):
"""函数调用"""
name: str
args: Dict[str, Any]
class GeminiFunctionResponse(BaseModelWithExtras):
"""函数响应"""
name: str
response: Dict[str, Any]
class GeminiPart(BaseModelWithExtras):
"""
Gemini 内容部分 - 支持多种类型
可以是以下类型之一:
- text: 文本内容
- inline_data: 内联数据(图片等)
- file_data: 文件引用
- function_call: 函数调用
- function_response: 函数响应
Gemini 3 新增:
- thought_signature: 思维签名,用于维护推理上下文
- media_resolution: 媒体分辨率配置
"""
text: Optional[str] = None
inline_data: Optional[GeminiInlineData] = Field(default=None, alias="inlineData")
file_data: Optional[GeminiFileData] = Field(default=None, alias="fileData")
function_call: Optional[GeminiFunctionCall] = Field(default=None, alias="functionCall")
function_response: Optional[GeminiFunctionResponse] = Field(
default=None, alias="functionResponse"
)
# Gemini 3 新增
thought_signature: Optional[str] = Field(
default=None,
alias="thoughtSignature",
description="思维签名,用于函数调用和图片生成的上下文保持",
)
media_resolution: Optional[GeminiMediaResolution] = Field(
default=None, alias="mediaResolution", description="媒体分辨率配置"
)
class GeminiContent(BaseModelWithExtras):
"""
Gemini 消息内容
对应 Gemini API 的 Content 对象
使用宽松类型定义parts 接受任意字典列表以支持 API 新特性
"""
role: Optional[Literal["user", "model"]] = None
parts: List[Union[GeminiPart, Dict[str, Any]]]
role: Optional[str] = None
parts: List[Dict[str, Any]]
# ---------------------------------------------------------------------------
# 配置定义
# ---------------------------------------------------------------------------
class GeminiImageConfig(BaseModelWithExtras):
"""
图片生成配置 (Gemini 3 Pro Image)
用于 gemini-3-pro-image-preview 模型
"""
aspect_ratio: Optional[str] = Field(
default=None, alias="aspectRatio", description="图片宽高比,如 '16:9', '1:1', '4:3'"
)
image_size: Optional[Literal["2K", "4K"]] = Field(
default=None, alias="imageSize", description="图片尺寸: 2K 或 4K"
)
class GeminiGenerationConfig(BaseModelWithExtras):
"""
生成配置
Gemini 3 新增:
- thinking_level: 思考深度 (low/medium/high)
- response_json_schema: 结构化输出的 JSON Schema
- image_config: 图片生成配置
"""
temperature: Optional[float] = Field(
default=None, description="采样温度Gemini 3 建议保持默认值 1.0"
)
top_p: Optional[float] = Field(default=None, alias="topP")
top_k: Optional[int] = Field(default=None, alias="topK")
max_output_tokens: Optional[int] = Field(default=None, alias="maxOutputTokens")
stop_sequences: Optional[List[str]] = Field(default=None, alias="stopSequences")
candidate_count: Optional[int] = Field(default=None, alias="candidateCount")
response_mime_type: Optional[str] = Field(default=None, alias="responseMimeType")
response_schema: Optional[Dict[str, Any]] = Field(default=None, alias="responseSchema")
# Gemini 3 新增
response_json_schema: Optional[Dict[str, Any]] = Field(
default=None, alias="responseJsonSchema", description="结构化输出的 JSON Schema"
)
thinking_level: Optional[Literal["low", "medium", "high"]] = Field(
default=None,
alias="thinkingLevel",
description="Gemini 3 思考深度: low(快速), medium(平衡), high(深度推理,默认)",
)
image_config: Optional[GeminiImageConfig] = Field(
default=None, alias="imageConfig", description="图片生成配置"
)
class GeminiSafetySettings(BaseModelWithExtras):
"""安全设置"""
category: str
threshold: str
class GeminiFunctionDeclaration(BaseModelWithExtras):
"""函数声明"""
name: str
description: Optional[str] = None
parameters: Optional[Dict[str, Any]] = None
class GeminiGoogleSearchTool(BaseModelWithExtras):
"""Google Search 工具 (Gemini 3)"""
pass # 空对象表示启用
class GeminiUrlContextTool(BaseModelWithExtras):
"""URL Context 工具 (Gemini 3)"""
pass # 空对象表示启用
class GeminiCodeExecutionTool(BaseModelWithExtras):
"""代码执行工具"""
pass # 空对象表示启用
class GeminiTool(BaseModelWithExtras):
"""
工具定义
支持的工具类型:
- function_declarations: 自定义函数
- code_execution: 代码执行
- google_search: Google 搜索 (Gemini 3)
- url_context: URL 上下文 (Gemini 3)
"""
function_declarations: Optional[List[GeminiFunctionDeclaration]] = Field(
default=None, alias="functionDeclarations"
)
code_execution: Optional[Dict[str, Any]] = Field(default=None, alias="codeExecution")
# Gemini 3 内置工具
google_search: Optional[Dict[str, Any]] = Field(
default=None, alias="googleSearch", description="启用 Google 搜索工具"
)
url_context: Optional[Dict[str, Any]] = Field(
default=None, alias="urlContext", description="启用 URL 上下文工具"
)
class GeminiToolConfig(BaseModelWithExtras):
"""工具配置"""
function_calling_config: Optional[Dict[str, Any]] = Field(
default=None, alias="functionCallingConfig"
)
class GeminiSystemInstruction(BaseModelWithExtras):
"""系统指令"""
parts: List[Union[GeminiPart, Dict[str, Any]]]
# ---------------------------------------------------------------------------
# 请求模型
# ---------------------------------------------------------------------------
class GeminiGenerateContentRequest(BaseModelWithExtras):
"""
Gemini generateContent 请求模型
对应 POST /v1beta/models/{model}:generateContent 端点
"""
contents: List[GeminiContent]
system_instruction: Optional[GeminiSystemInstruction] = Field(
default=None, alias="systemInstruction"
)
tools: Optional[List[GeminiTool]] = None
tool_config: Optional[GeminiToolConfig] = Field(default=None, alias="toolConfig")
safety_settings: Optional[List[GeminiSafetySettings]] = Field(
default=None, alias="safetySettings"
)
generation_config: Optional[GeminiGenerationConfig] = Field(
default=None, alias="generationConfig"
)
class GeminiStreamGenerateContentRequest(BaseModelWithExtras):
"""
Gemini streamGenerateContent 请求模型
对应 POST /v1beta/models/{model}:streamGenerateContent 端点
与 generateContent 相同,但返回流式响应
"""
contents: List[GeminiContent]
system_instruction: Optional[GeminiSystemInstruction] = Field(
default=None, alias="systemInstruction"
)
tools: Optional[List[GeminiTool]] = None
tool_config: Optional[GeminiToolConfig] = Field(default=None, alias="toolConfig")
safety_settings: Optional[List[GeminiSafetySettings]] = Field(
default=None, alias="safetySettings"
)
generation_config: Optional[GeminiGenerationConfig] = Field(
default=None, alias="generationConfig"
)
# ---------------------------------------------------------------------------
# 统一请求模型(用于内部处理)
# 请求模型 - 只定义网关需要的字段,其余透传
# ---------------------------------------------------------------------------
@@ -306,158 +47,31 @@ class GeminiRequest(BaseModelWithExtras):
- generateContent - 非流式
- streamGenerateContent - 流式
请求体中不应包含 stream 字段
采用宽松类型定义,除必要字段外全部透传
"""
model: Optional[str] = Field(default=None, description="模型名称,从 URL 路径提取(内部使用)")
contents: List[GeminiContent]
system_instruction: Optional[GeminiSystemInstruction] = Field(
default=None, alias="systemInstruction"
)
tools: Optional[List[GeminiTool]] = None
tool_config: Optional[GeminiToolConfig] = Field(default=None, alias="toolConfig")
safety_settings: Optional[List[GeminiSafetySettings]] = Field(
default=None, alias="safetySettings"
)
generation_config: Optional[GeminiGenerationConfig] = Field(
default=None, alias="generationConfig"
)
# 以下字段全部使用 Dict[str, Any] 透传,不做结构验证
system_instruction: Optional[Dict[str, Any]] = Field(default=None, alias="systemInstruction")
tools: Optional[List[Dict[str, Any]]] = None
tool_config: Optional[Dict[str, Any]] = Field(default=None, alias="toolConfig")
safety_settings: Optional[List[Dict[str, Any]]] = Field(default=None, alias="safetySettings")
generation_config: Optional[Dict[str, Any]] = Field(default=None, alias="generationConfig")
# ---------------------------------------------------------------------------
# 响应模型
# 响应模型 - 用于解析上游响应提取必要信息(如 usage
# ---------------------------------------------------------------------------
class GeminiUsageMetadata(BaseModelWithExtras):
"""Token 使用量"""
"""Token 使用量 - 用于计费统计"""
prompt_token_count: int = Field(default=0, alias="promptTokenCount")
candidates_token_count: int = Field(default=0, alias="candidatesTokenCount")
total_token_count: int = Field(default=0, alias="totalTokenCount")
cached_content_token_count: Optional[int] = Field(default=None, alias="cachedContentTokenCount")
class GeminiSafetyRating(BaseModelWithExtras):
"""安全评级"""
category: str
probability: str
blocked: Optional[bool] = None
class GeminiCitationSource(BaseModelWithExtras):
"""引用来源"""
start_index: Optional[int] = Field(default=None, alias="startIndex")
end_index: Optional[int] = Field(default=None, alias="endIndex")
uri: Optional[str] = None
license: Optional[str] = None
class GeminiCitationMetadata(BaseModelWithExtras):
"""引用元数据"""
citation_sources: Optional[List[GeminiCitationSource]] = Field(
default=None, alias="citationSources"
)
class GeminiGroundingMetadata(BaseModelWithExtras):
"""
Grounding 元数据 (Gemini 3)
当使用 Google Search 工具时返回
"""
search_entry_point: Optional[Dict[str, Any]] = Field(default=None, alias="searchEntryPoint")
grounding_chunks: Optional[List[Dict[str, Any]]] = Field(default=None, alias="groundingChunks")
grounding_supports: Optional[List[Dict[str, Any]]] = Field(
default=None, alias="groundingSupports"
)
web_search_queries: Optional[List[str]] = Field(default=None, alias="webSearchQueries")
class GeminiCandidate(BaseModelWithExtras):
"""候选响应"""
content: Optional[GeminiContent] = None
finish_reason: Optional[str] = Field(default=None, alias="finishReason")
safety_ratings: Optional[List[GeminiSafetyRating]] = Field(default=None, alias="safetyRatings")
citation_metadata: Optional[GeminiCitationMetadata] = Field(
default=None, alias="citationMetadata"
)
grounding_metadata: Optional[GeminiGroundingMetadata] = Field(
default=None, alias="groundingMetadata"
)
token_count: Optional[int] = Field(default=None, alias="tokenCount")
index: Optional[int] = None
class GeminiPromptFeedback(BaseModelWithExtras):
"""提示反馈"""
block_reason: Optional[str] = Field(default=None, alias="blockReason")
safety_ratings: Optional[List[GeminiSafetyRating]] = Field(default=None, alias="safetyRatings")
class GeminiGenerateContentResponse(BaseModelWithExtras):
"""
Gemini generateContent 响应模型
对应 generateContent 端点的响应体
"""
candidates: Optional[List[GeminiCandidate]] = None
prompt_feedback: Optional[GeminiPromptFeedback] = Field(default=None, alias="promptFeedback")
usage_metadata: Optional[GeminiUsageMetadata] = Field(default=None, alias="usageMetadata")
model_version: Optional[str] = Field(default=None, alias="modelVersion")
# ---------------------------------------------------------------------------
# 流式响应模型
# ---------------------------------------------------------------------------
class GeminiStreamChunk(BaseModelWithExtras):
"""
Gemini 流式响应块
流式响应中的单个数据块,结构与完整响应相同
"""
candidates: Optional[List[GeminiCandidate]] = None
prompt_feedback: Optional[GeminiPromptFeedback] = Field(default=None, alias="promptFeedback")
usage_metadata: Optional[GeminiUsageMetadata] = Field(default=None, alias="usageMetadata")
model_version: Optional[str] = Field(default=None, alias="modelVersion")
# ---------------------------------------------------------------------------
# 错误响应
# ---------------------------------------------------------------------------
class GeminiErrorDetail(BaseModelWithExtras):
"""错误详情"""
type: Optional[str] = Field(default=None, alias="@type")
reason: Optional[str] = None
domain: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class GeminiError(BaseModelWithExtras):
"""错误信息"""
code: int
message: str
status: str
details: Optional[List[GeminiErrorDetail]] = None
class GeminiErrorResponse(BaseModelWithExtras):
"""错误响应"""
error: GeminiError
# ---------------------------------------------------------------------------

View File

@@ -2,12 +2,14 @@
认证服务
"""
from __future__ import annotations
import hashlib
import secrets
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
import jwt
from fastapi import HTTPException, status
@@ -18,6 +20,9 @@ from sqlalchemy.orm import Session, joinedload
from src.config import config
from src.core.logger import logger
from src.core.enums import AuthSource
if TYPE_CHECKING:
from src.models.database import ManagementToken
from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.auth.ldap import LDAPService
@@ -478,3 +483,137 @@ class AuthService:
except Exception as e:
logger.error(f"撤销 Token 失败: {e}")
return False
@staticmethod
async def authenticate_management_token(
db: Session, raw_token: str, client_ip: str
) -> Optional[tuple[User, "ManagementToken"]]:
"""Management Token 认证
Args:
db: 数据库会话
raw_token: Management Token 字符串
client_ip: 客户端 IP
Returns:
(User, ManagementToken) 元组,认证失败返回 None
Raises:
RateLimitException: 超过速率限制时抛出(用于返回 429
"""
from src.core.exceptions import RateLimitException
from src.models.database import AuditEventType, ManagementToken
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
# 速率限制检查(防止暴力破解)
allowed, remaining, ttl = await IPRateLimiter.check_limit(
client_ip,
endpoint_type="management_token",
limit=config.management_token_rate_limit,
)
if not allowed:
logger.warning(f"Management Token 认证 - IP {client_ip} 超过速率限制")
raise RateLimitException(limit=config.management_token_rate_limit, window="分钟")
# 检查 Token 格式
if not raw_token.startswith(ManagementToken.TOKEN_PREFIX):
logger.warning("Management Token 认证失败 - 格式错误")
return None
# 哈希查找
token_hash = ManagementToken.hash_token(raw_token)
token_record = (
db.query(ManagementToken)
.options(joinedload(ManagementToken.user))
.filter(ManagementToken.token_hash == token_hash)
.first()
)
if not token_record:
logger.warning("Management Token 认证失败 - Token 不存在")
return None
# 注意:数据库查询已通过 token_hash 索引匹配,此处不再需要额外的常量时间比较
# Token 的 62^40 熵(约 238 位)加上速率限制已足够防止暴力破解
# 检查状态
if not token_record.is_active:
logger.warning(f"Management Token 认证失败 - Token 已禁用: {token_record.id}")
return None
# 检查过期(使用属性方法,确保时区安全)
if token_record.is_expired:
logger.warning(f"Management Token 认证失败 - Token 已过期: {token_record.id}")
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_EXPIRED,
description=f"Management Token 已过期: {token_record.name}",
user_id=token_record.user_id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
"expired_at": (
token_record.expires_at.isoformat() if token_record.expires_at else None
),
},
)
return None
# 检查 IP 白名单
if not token_record.is_ip_allowed(client_ip):
logger.warning(
f"Management Token IP 限制 - Token: {token_record.id}, IP: {client_ip}"
)
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_IP_BLOCKED,
description=f"Management Token IP 被拒绝: {token_record.name}",
user_id=token_record.user_id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
"blocked_ip": client_ip,
# 不记录 allowed_ips 以防信息泄露
},
)
return None
# 获取用户
user = token_record.user
if not user or not user.is_active:
logger.warning("Management Token 认证失败 - 用户不存在或已禁用")
return None
# 使用 SQL 原子操作更新使用统计
from sqlalchemy import func
db.query(ManagementToken).filter(ManagementToken.id == token_record.id).update(
{
ManagementToken.last_used_at: func.now(), # 使用数据库时间确保一致性
ManagementToken.last_used_ip: client_ip,
ManagementToken.usage_count: ManagementToken.usage_count + 1,
ManagementToken.updated_at: func.now(), # 显式更新,因为原子 SQL 绕过 ORM
},
synchronize_session=False,
)
# 记录 Token 使用审计日志
AuditService.log_event(
db=db,
event_type=AuditEventType.MANAGEMENT_TOKEN_USED,
description=f"Management Token 认证成功: {token_record.name}",
user_id=user.id,
ip_address=client_ip,
metadata={
"token_id": token_record.id,
"token_name": token_record.name,
},
)
db.commit()
logger.debug(f"Management Token 认证成功: user={user.email}, token={token_record.id}")
return user, token_record

View File

@@ -23,6 +23,7 @@ class BillingTemplates:
# - 输出 token
# - 缓存创建(创建时收费,约 1.25x 输入价格)
# - 缓存读取(约 0.1x 输入价格)
# - 按次计费(可选,配置 price_per_request 时生效)
# =========================================================================
CLAUDE_STANDARD: List[BillingDimension] = [
BillingDimension(
@@ -45,6 +46,12 @@ class BillingTemplates:
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
@@ -52,6 +59,7 @@ class BillingTemplates:
# - 输入 token
# - 输出 token
# - 缓存读取(部分模型支持,无缓存创建费用)
# - 按次计费(可选,配置 price_per_request 时生效)
# =========================================================================
OPENAI_STANDARD: List[BillingDimension] = [
BillingDimension(
@@ -69,6 +77,12 @@ class BillingTemplates:
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
@@ -77,6 +91,7 @@ class BillingTemplates:
# - 推理输出 (output_tokens)
# - 缓存命中 (cache_read_tokens) - 类似 Claude 的缓存读取
# - 缓存存储 (cache_storage_token_hours) - 按 token 数 * 存储时长计费
# - 按次计费(可选,配置 price_per_request 时生效)
#
# 注意:豆包的缓存创建是免费的,但存储需要按时付费
# =========================================================================
@@ -102,6 +117,12 @@ class BillingTemplates:
price_field="cache_storage_price_per_1m_hour",
unit=BillingUnit.PER_1M_TOKENS_HOUR,
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================
@@ -109,6 +130,7 @@ class BillingTemplates:
# - 输入 token
# - 输出 token
# - 缓存读取
# - 按次计费(用于图片生成等模型,需配置 price_per_request
# =========================================================================
GEMINI_STANDARD: List[BillingDimension] = [
BillingDimension(
@@ -126,6 +148,12 @@ class BillingTemplates:
usage_field="cache_read_tokens",
price_field="cache_read_price_per_1m",
),
BillingDimension(
name="request",
usage_field="request_count",
price_field="price_per_request",
unit=BillingUnit.PER_REQUEST,
),
]
# =========================================================================

View File

@@ -486,11 +486,10 @@ class CacheAwareScheduler:
user_api_key: 用户 API Key 对象(可能包含 user relationship
Returns:
包含 allowed_providers, allowed_endpoints, allowed_models 的字典
包含 allowed_providers, allowed_models, allowed_api_formats 的字典
"""
result = {
"allowed_providers": None,
"allowed_endpoints": None,
"allowed_models": None,
"allowed_api_formats": None,
}
@@ -534,20 +533,16 @@ class CacheAwareScheduler:
user_api_key.allowed_providers, user.allowed_providers if user else None
)
# 合并 allowed_endpoints
result["allowed_endpoints"] = merge_restrictions(
user_api_key.allowed_endpoints if hasattr(user_api_key, "allowed_endpoints") else None,
user.allowed_endpoints if user else None,
)
# 合并 allowed_models
result["allowed_models"] = merge_restrictions(
user_api_key.allowed_models, user.allowed_models if user else None
)
# API 格式仅从 ApiKey 获取User 不设置此限制)
if user_api_key.allowed_api_formats:
result["allowed_api_formats"] = set(user_api_key.allowed_api_formats)
# 合并 allowed_api_formats
result["allowed_api_formats"] = merge_restrictions(
user_api_key.allowed_api_formats,
user.allowed_api_formats if user else None
)
return result
@@ -607,12 +602,13 @@ class CacheAwareScheduler:
restrictions = self._get_effective_restrictions(user_api_key)
allowed_api_formats = restrictions["allowed_api_formats"]
allowed_providers = restrictions["allowed_providers"]
allowed_endpoints = restrictions["allowed_endpoints"]
allowed_models = restrictions["allowed_models"]
# 0.1 检查 API 格式是否被允许
if allowed_api_formats is not None:
if target_format.value not in allowed_api_formats:
# 统一转为大写比较,兼容数据库中存储的大小写
allowed_upper = {f.upper() for f in allowed_api_formats}
if target_format.value.upper() not in allowed_upper:
logger.debug(
f"API Key {user_api_key.id[:8] if user_api_key else 'N/A'}... 不允许使用 API 格式 {target_format.value}, "
f"允许的格式: {allowed_api_formats}"
@@ -659,7 +655,7 @@ class CacheAwareScheduler:
if not providers:
return [], global_model_id
# 2. 构建候选列表(传入 allowed_endpoints、is_stream 和 capability_requirements 用于过滤)
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤)
candidates = await self._build_candidates(
db=db,
providers=providers,
@@ -668,7 +664,6 @@ class CacheAwareScheduler:
resolved_model_name=resolved_model_name,
affinity_key=affinity_key,
max_candidates=max_candidates,
allowed_endpoints=allowed_endpoints,
is_stream=is_stream,
capability_requirements=capability_requirements,
)
@@ -905,7 +900,6 @@ class CacheAwareScheduler:
affinity_key: Optional[str],
resolved_model_name: Optional[str] = None,
max_candidates: Optional[int] = None,
allowed_endpoints: Optional[set] = None,
is_stream: bool = False,
capability_requirements: Optional[Dict[str, bool]] = None,
) -> List[ProviderCandidate]:
@@ -920,7 +914,6 @@ class CacheAwareScheduler:
affinity_key: 亲和性标识符通常为API Key ID
resolved_model_name: 解析后的 GlobalModel.name用于 Key.allowed_models 校验)
max_candidates: 最大候选数
allowed_endpoints: 允许的 Endpoint ID 集合None 表示不限制)
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(可选)
@@ -949,13 +942,6 @@ class CacheAwareScheduler:
if not endpoint.is_active or endpoint_format_str != target_format.value:
continue
# 检查 Endpoint 是否在允许列表中
if allowed_endpoints is not None and endpoint.id not in allowed_endpoints:
logger.debug(
f"Endpoint {endpoint.id[:8]}... 不在用户/API Key 的允许列表中,跳过"
)
continue
# 获取活跃的 Key 并按 internal_priority + 负载均衡排序
active_keys = [key for key in endpoint.api_keys if key.is_active]
# 检查是否所有 Key 都是 TTL=0轮换模式

View File

@@ -0,0 +1,15 @@
"""Management Token 服务模块"""
from .service import (
ManagementTokenService,
parse_expires_at,
token_to_dict,
validate_ip_list,
)
__all__ = [
"ManagementTokenService",
"parse_expires_at",
"token_to_dict",
"validate_ip_list",
]

View File

@@ -0,0 +1,416 @@
"""Management Token 服务"""
import ipaddress
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.logger import logger
from src.models.database import ManagementToken
def validate_ip_list(ips: Optional[list[str]]) -> Optional[list[str]]:
"""验证 IP 白名单格式
- None: 不限制 IP
- 非空列表: 只允许列表中的 IP
- 空列表: 不允许(会抛出错误)
"""
if ips is None:
return None
if len(ips) == 0:
raise ValueError("IP 白名单不能为空列表,如需取消限制请不提供此字段")
validated = []
for i, ip_str in enumerate(ips):
original = ip_str
ip_str = ip_str.strip()
if not ip_str:
raise ValueError(f"IP 白名单第 {i + 1} 项为空")
try:
if "/" in ip_str:
ipaddress.ip_network(ip_str, strict=False)
else:
ipaddress.ip_address(ip_str)
validated.append(ip_str)
except ValueError:
raise ValueError(f"无效的 IP 地址或 CIDR: {original}")
if not validated:
raise ValueError("IP 白名单不能为空,如需取消限制请不提供此字段")
return validated
def parse_expires_at(v, allow_past: bool = False) -> Optional[datetime]:
"""解析过期时间,确保时区安全
前端 datetime-local 输入返回本地时间字符串(无时区信息)。
后端要求:
- 如果是字符串且不含时区,视为 UTC
- 如果是 datetime 且无时区,视为 UTC
- 带时区的输入直接使用
- 默认要求过期时间必须在未来allow_past=False
Args:
v: 时间值(字符串或 datetime
allow_past: 是否允许过去的时间(用于清除过期时间等场景)
Returns:
解析后的 datetime 或 None
"""
if v is None:
return None
if isinstance(v, str):
if not v.strip():
return None
try:
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
except ValueError as e:
raise ValueError(f"无效的时间格式: {v}") from e
elif isinstance(v, datetime):
dt = v
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
raise ValueError(f"不支持的时间类型: {type(v)}")
if not allow_past and dt <= datetime.now(timezone.utc):
raise ValueError("过期时间必须在未来")
return dt
def token_to_dict(
token: ManagementToken,
raw_token: Optional[str] = None,
include_user: bool = False,
) -> dict:
"""将 ManagementToken 转换为字典
Args:
token: ManagementToken 实例
raw_token: 明文 Token仅在创建/重新生成时提供)
include_user: 是否包含用户信息(管理员视图使用)
Returns:
Token 字典表示
"""
result = {
"id": token.id,
"user_id": token.user_id,
"name": token.name,
"description": token.description,
"token_display": token.get_display_token(),
"allowed_ips": token.allowed_ips,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
"last_used_ip": token.last_used_ip,
"usage_count": token.usage_count,
"is_active": token.is_active,
"created_at": token.created_at.isoformat() if token.created_at else None,
"updated_at": token.updated_at.isoformat() if token.updated_at else None,
}
if raw_token:
result["token"] = raw_token
if include_user and token.user:
result["user"] = {
"id": token.user.id,
"email": token.user.email,
"username": token.user.username,
"role": token.user.role.value if token.user.role else None,
}
return result
class ManagementTokenService:
"""Management Token 服务类"""
@staticmethod
def create_token(
db: Session,
user_id: str,
name: str,
description: Optional[str] = None,
allowed_ips: Optional[list[str]] = None,
expires_at: Optional[datetime] = None,
) -> tuple[ManagementToken, str]:
"""创建 Management Token
Args:
db: 数据库会话
user_id: 用户 ID
name: Token 名称
description: 描述
allowed_ips: IP 白名单
expires_at: 过期时间
Returns:
(ManagementToken, 明文 Token) 元组
Raises:
ValueError: 如果名称已存在或超过数量限制
"""
# 检查用户 Token 数量限制
token_count = (
db.query(ManagementToken).filter(ManagementToken.user_id == user_id).count()
)
max_tokens = config.management_token_max_per_user
if token_count >= max_tokens:
raise ValueError(f"已达到 Token 数量上限({max_tokens}")
# 检查名称是否已存在
existing = (
db.query(ManagementToken)
.filter(ManagementToken.user_id == user_id, ManagementToken.name == name)
.first()
)
if existing:
raise ValueError(f"已存在名为 '{name}' 的 Token")
# 生成 Token
raw_token = ManagementToken.generate_token()
# 创建记录
token = ManagementToken(
user_id=user_id,
name=name,
description=description,
allowed_ips=allowed_ips,
expires_at=expires_at,
)
token.set_token(raw_token)
db.add(token)
try:
db.commit()
except IntegrityError:
db.rollback()
# 并发创建导致唯一约束冲突
raise ValueError(f"已存在名为 '{name}' 的 Token")
db.refresh(token)
logger.info(f"创建 Management Token: {token.id} for user {user_id}")
return token, raw_token
@staticmethod
def get_token_by_id(
db: Session, token_id: str, user_id: Optional[str] = None
) -> Optional[ManagementToken]:
"""根据 ID 获取 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只查询该用户的 Token
Returns:
ManagementToken 或 None
"""
query = db.query(ManagementToken).filter(ManagementToken.id == token_id)
if user_id:
query = query.filter(ManagementToken.user_id == user_id)
return query.first()
@staticmethod
def list_tokens(
db: Session,
user_id: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[ManagementToken], int]:
"""列出 Tokens
Args:
db: 数据库会话
user_id: 用户 ID如果提供则只查询该用户的 Token
is_active: 筛选激活状态
skip: 跳过记录数
limit: 返回记录数
Returns:
(Token 列表, 总数) 元组
"""
query = db.query(ManagementToken)
if user_id:
query = query.filter(ManagementToken.user_id == user_id)
if is_active is not None:
query = query.filter(ManagementToken.is_active == is_active)
total = query.count()
tokens = query.order_by(ManagementToken.created_at.desc()).offset(skip).limit(limit).all()
return tokens, total
@staticmethod
def update_token(
db: Session,
token_id: str,
user_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
allowed_ips: Optional[list[str]] = None,
expires_at: Optional[datetime] = None,
is_active: Optional[bool] = None,
clear_description: bool = False,
clear_allowed_ips: bool = False,
clear_expires_at: bool = False,
) -> Optional[ManagementToken]:
"""更新 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只更新该用户的 Token
name: 新名称
description: 新描述
allowed_ips: 新 IP 白名单
expires_at: 新过期时间
is_active: 新激活状态
clear_description: 是否清空描述True 时 description 被忽略)
clear_allowed_ips: 是否清空 IP 白名单True 时 allowed_ips 被忽略)
clear_expires_at: 是否清空过期时间True 时 expires_at 被忽略)
Returns:
更新后的 ManagementToken 或 None
Raises:
ValueError: 如果新名称已被其他 Token 使用
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None
# 如果更新名称,检查是否与其他 Token 冲突
if name is not None and name != token.name:
existing = (
db.query(ManagementToken)
.filter(
ManagementToken.user_id == token.user_id,
ManagementToken.name == name,
ManagementToken.id != token_id,
)
.first()
)
if existing:
raise ValueError(f"已存在名为 '{name}' 的 Token")
token.name = name
# 处理 description支持清空
if clear_description:
token.description = None
elif description is not None:
token.description = description
# 处理 allowed_ips支持清空
if clear_allowed_ips:
token.allowed_ips = None
elif allowed_ips is not None:
token.allowed_ips = allowed_ips if allowed_ips else None
# 处理 expires_at支持清空
if clear_expires_at:
token.expires_at = None
elif expires_at is not None:
token.expires_at = expires_at
if is_active is not None:
token.is_active = is_active
db.commit()
db.refresh(token)
logger.info(f"更新 Management Token: {token.id}")
return token
@staticmethod
def delete_token(
db: Session, token_id: str, user_id: Optional[str] = None
) -> bool:
"""删除 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只删除该用户的 Token
Returns:
是否删除成功
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return False
db.delete(token)
db.commit()
logger.info(f"删除 Management Token: {token_id}")
return True
@staticmethod
def toggle_status(
db: Session, token_id: str, user_id: Optional[str] = None
) -> Optional[ManagementToken]:
"""切换 Token 状态
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只操作该用户的 Token
Returns:
更新后的 ManagementToken 或 None
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None
token.is_active = not token.is_active
db.commit()
db.refresh(token)
logger.info(f"切换 Management Token 状态: {token.id} -> {token.is_active}")
return token
@staticmethod
def regenerate_token(
db: Session, token_id: str, user_id: Optional[str] = None
) -> tuple[Optional[ManagementToken], Optional[str], Optional[str]]:
"""重新生成 Token
Args:
db: 数据库会话
token_id: Token ID
user_id: 用户 ID如果提供则只操作该用户的 Token
Returns:
(ManagementToken, 新的明文 Token, 旧的 token_hash) 元组,失败返回 (None, None, None)
"""
token = ManagementTokenService.get_token_by_id(db, token_id, user_id)
if not token:
return None, None, None
# 保存旧的 token_hash 用于审计
old_token_hash = token.token_hash
# 生成新 Token
raw_token = ManagementToken.generate_token()
token.set_token(raw_token)
db.commit()
db.refresh(token)
logger.info(f"重新生成 Management Token: {token.id}")
return token, raw_token, old_token_hash

View File

@@ -194,6 +194,13 @@ class ModelCostService:
"pricing": global_model.default_tiered_pricing,
"source": "global"
}
else:
# Provider 没有实现该模型,直接使用 GlobalModel 的默认阶梯配置
if global_model.default_tiered_pricing is not None:
result = {
"pricing": global_model.default_tiered_pricing,
"source": "global"
}
self._tiered_pricing_cache[cache_key] = result
return result
@@ -276,6 +283,18 @@ class ModelCostService:
output_price = model_obj.get_effective_output_price()
logger.debug(f"找到模型价格配置: {provider_name}/{model} "
f"(输入: ${input_price}/M, 输出: ${output_price}/M)")
else:
# Provider 没有实现该模型,直接使用 GlobalModel 的默认价格
tiered = global_model.default_tiered_pricing
if tiered and tiered.get("tiers"):
first_tier = tiered["tiers"][0]
input_price = first_tier.get("input_price_per_1m", 0)
output_price = first_tier.get("output_price_per_1m", 0)
else:
input_price = 0.0
output_price = 0.0
logger.debug(f"使用 GlobalModel 默认价格: {provider_name}/{model} "
f"(输入: ${input_price}/M, 输出: ${output_price}/M)")
# 如果没有找到价格配置,使用 0.0 并记录警告
if input_price is None:
@@ -380,6 +399,16 @@ class ModelCostService:
# 使用 get_effective_* 方法,会自动回退到 GlobalModel 的默认值
cache_creation_price = model_obj.get_effective_cache_creation_price()
cache_read_price = model_obj.get_effective_cache_read_price()
else:
# Provider 没有实现该模型,直接使用 GlobalModel 的默认价格
tiered = global_model.default_tiered_pricing
if tiered and tiered.get("tiers"):
first_tier = tiered["tiers"][0]
cache_creation_price = first_tier.get("cache_creation_price_per_1m")
cache_read_price = first_tier.get("cache_read_price_per_1m")
else:
cache_creation_price = None
cache_read_price = None
# 默认缓存价格估算(如果没有配置)- 基于输入价格计算
if cache_creation_price is None or cache_read_price is None:
@@ -434,6 +463,9 @@ class ModelCostService:
if model_obj:
# 使用 get_effective_* 方法,会自动回退到 GlobalModel 的默认值
price_per_request = model_obj.get_effective_price_per_request()
else:
# Provider 没有实现该模型,直接使用 GlobalModel 的默认价格
price_per_request = global_model.default_price_per_request
return price_per_request

View File

@@ -78,6 +78,51 @@ class SystemConfigService:
"value": False,
"description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置",
},
"email_suffix_mode": {
"value": "none",
"description": "邮箱后缀限制模式none(不限制), whitelist(白名单), blacklist(黑名单)",
},
"email_suffix_list": {
"value": [],
"description": "邮箱后缀列表,配合 email_suffix_mode 使用",
},
"audit_log_retention_days": {
"value": 30,
"description": "审计日志保留天数,超过此天数的审计日志将被自动清理",
},
# SMTP 邮件配置
"smtp_host": {
"value": None,
"description": "SMTP 服务器地址",
},
"smtp_port": {
"value": 587,
"description": "SMTP 服务器端口",
},
"smtp_user": {
"value": None,
"description": "SMTP 用户名",
},
"smtp_password": {
"value": None,
"description": "SMTP 密码(加密存储)",
},
"smtp_use_tls": {
"value": True,
"description": "是否使用 STARTTLS",
},
"smtp_use_ssl": {
"value": False,
"description": "是否使用 SSL/TLS",
},
"smtp_from_email": {
"value": None,
"description": "发件人邮箱地址",
},
"smtp_from_name": {
"value": "Aether",
"description": "发件人名称",
},
}
@classmethod

View File

@@ -144,7 +144,7 @@ class StatsAggregatorService:
or 0
)
unique_providers = (
db.query(func.count(func.distinct(Usage.provider)))
db.query(func.count(func.distinct(Usage.provider_name)))
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
.scalar()
or 0

View File

@@ -309,7 +309,7 @@ class UsageService:
"user_id": user.id if user else None,
"api_key_id": api_key.id if api_key else None,
"request_id": request_id,
"provider": provider,
"provider_name": provider,
"model": model,
"target_model": target_model,
"provider_id": provider_id,
@@ -479,7 +479,7 @@ class UsageService:
) -> None:
"""更新已存在的 Usage 记录(内部方法)"""
# 更新关键字段
existing_usage.provider = usage_params["provider"]
existing_usage.provider_name = usage_params["provider_name"]
existing_usage.status = usage_params["status"]
existing_usage.status_code = usage_params["status_code"]
existing_usage.error_message = usage_params["error_message"]
@@ -1092,7 +1092,7 @@ class UsageService:
# 汇总查询
summary = db.query(
date_func.label("period"),
Usage.provider,
Usage.provider_name,
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.input_tokens).label("input_tokens"),
@@ -1111,12 +1111,12 @@ class UsageService:
if end_date:
summary = summary.filter(Usage.created_at <= end_date)
summary = summary.group_by(date_func, Usage.provider, Usage.model).all()
summary = summary.group_by(date_func, Usage.provider_name, Usage.model).all()
return [
{
"period": row.period,
"provider": row.provider,
"provider": row.provider_name,
"model": row.model,
"requests": row.requests,
"input_tokens": row.input_tokens,
@@ -1445,7 +1445,7 @@ class UsageService:
user_id=user.id if user else None,
api_key_id=api_key.id if api_key else None,
request_id=request_id,
provider="pending", # 尚未确定 provider
provider_name="pending", # 尚未确定 provider
model=model,
input_tokens=0,
output_tokens=0,
@@ -1508,12 +1508,12 @@ class UsageService:
if error_message:
usage.error_message = error_message
if provider:
usage.provider = provider
elif status == "streaming" and usage.provider == "pending":
# 状态变为 streaming 但 provider 仍为 pending记录警告
usage.provider_name = provider
elif status == "streaming" and usage.provider_name == "pending":
# 状态变为 streaming 但 provider_name 仍为 pending记录警告
logger.warning(
f"状态更新为 streaming 但 provider 为空: request_id={request_id}, "
f"当前 provider={usage.provider}"
f"状态更新为 streaming 但 provider_name 为空: request_id={request_id}, "
f"当前 provider_name={usage.provider_name}"
)
if target_model:
usage.target_model = target_model
@@ -1679,7 +1679,7 @@ class UsageService:
from src.models.database import ProviderAPIKey
query = query.add_columns(
Usage.provider,
Usage.provider_name,
ProviderAPIKey.name.label("api_key_name"),
).outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
@@ -1731,7 +1731,7 @@ class UsageService:
"first_byte_time_ms": r.first_byte_time_ms, # 首字时间 (TTFB)
}
if include_admin_fields:
item["provider"] = r.provider
item["provider"] = r.provider_name
item["api_key_name"] = r.api_key_name
result.append(item)

View File

@@ -182,12 +182,12 @@ class UserService:
"role",
# 访问限制字段
"allowed_providers",
"allowed_endpoints",
"allowed_api_formats",
"allowed_models",
]
# 允许设置为 None 的字段(表示无限制)
nullable_fields = ["quota_usd", "allowed_providers", "allowed_endpoints", "allowed_models"]
nullable_fields = ["quota_usd", "allowed_providers", "allowed_api_formats", "allowed_models"]
for field, value in kwargs.items():
if field not in updatable_fields: