feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强

- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON
- OAuth: 所有 OAuth 端点添加 require_admin 鉴权
- 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids
- Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段)
- 流式处理: buffer 残留数据 flush 与 token 兜底估算
- 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并
- Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组
- README: 新增升级备份与回滚指南
This commit is contained in:
fawney19
2026-02-06 21:52:22 +08:00
parent 62dae22a2c
commit 8b6a5d3824
23 changed files with 1129 additions and 101 deletions

View File

@@ -59,6 +59,9 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动执行数据库迁移) # 3. 部署 / 更新(自动执行数据库迁移)
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
# 4. 升级前备份
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
``` ```
### Docker Compose本地构建镜像 ### Docker Compose本地构建镜像
@@ -148,9 +151,42 @@ cd frontend && npm install && npm run dev
| **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 | | **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 |
| **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key | | **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key |
### Q: 提供商免费套餐的计费模式会计入成本吗? ### Q: 更新出问题如何回滚?
> **不会**。免费套餐的计费模式倍率为 0, 产生的记录不计入成本费用。 **有备份的情况(推荐):**
```bash
# 1. 停止应用
docker compose stop app
# 2. 恢复数据库(先清空再导入)
docker compose exec -T postgres psql -U postgres -c "DROP DATABASE aether; CREATE DATABASE aether;"
gunzip < backup_xxx.sql.gz | docker compose exec -T postgres psql -U postgres -d aether
# 3. 拉取旧版本镜像并重启
# 方式一:使用具体版本 tag如果有发布版本号
# 将 docker-compose.yml 中 image 从 ghcr.io/fawney19/aether:latest 改为指定版本
# 方式二:使用之前记录的镜像 digest
# 将 image 改为 ghcr.io/fawney19/aether@sha256:xxxxx
docker compose up -d app
```
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest方便回滚时使用。
**没有备份的情况:**
```bash
# 1. 用当前容器回退数据库迁移(回退 1 步,按需调整数字)
docker compose exec app alembic downgrade -1
# 2. 查看回退后的版本确认正确
docker compose exec app alembic current
# 3. 切回旧镜像并重启(同上方式修改 docker-compose.yml 中的 image
docker compose up -d app
```
> 注意:没有备份的回滚依赖 alembic downgrade如果迁移涉及不可逆的数据变更如删除列可能无法完全恢复数据。因此强烈建议升级前备份。
--- ---

View File

@@ -58,6 +58,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
export interface RevealKeyResult { export interface RevealKeyResult {
auth_type: 'api_key' | 'vertex_ai' | 'oauth' auth_type: 'api_key' | 'vertex_ai' | 'oauth'
api_key?: string api_key?: string
refresh_token?: string
auth_config?: string | Record<string, any> auth_config?: string | Record<string, any>
} }

View File

@@ -46,3 +46,11 @@ export async function completeProviderLevelOAuth(
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data) const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
return resp.data return resp.data
} }
export async function importProviderRefreshToken(
providerId: string,
data: { refresh_token: string; name?: string }
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data
}

View File

@@ -447,6 +447,7 @@ export interface ProviderWithEndpointsSummary {
active_keys: number active_keys: number
total_models: number total_models: number
active_models: number active_models: number
global_model_ids: string[]
avg_health_score: number avg_health_score: number
unhealthy_endpoints: number unhealthy_endpoints: number
api_formats: string[] api_formats: string[]

View File

@@ -6,70 +6,188 @@
size="md" size="md"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<div class="space-y-5"> <div class="space-y-4">
<!-- 加载中 --> <!-- Tab 切换 -->
<div <div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
v-if="oauth.starting && !oauth.authorization_url" <button
class="py-12 text-center" class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
> :class="mode === 'oauth'
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" /> ? 'bg-background text-foreground shadow-sm'
<p class="text-sm text-muted-foreground"> : 'text-muted-foreground hover:text-foreground'"
正在准备授权... @click="switchMode('oauth')"
</p> >
获取授权
</button>
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
:class="mode === 'import'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
@click="switchMode('import')"
>
导入授权
</button>
</div> </div>
<!-- 授权流程 --> <!-- Tab 内容grid 叠放高度取较高者 -->
<template v-else-if="oauth.authorization_url"> <div class="grid [&>*]:col-start-1 [&>*]:row-start-1">
<!-- 步骤 1: 打开授权链接 --> <!-- ===== 获取授权 ===== -->
<div class="space-y-2"> <div
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider"> class="space-y-4 transition-opacity duration-150"
第一步 · 前往授权 :class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
</p> >
<p class="text-xs text-muted-foreground"> <div
点击下方按钮在浏览器中完成登录授权 v-if="oauth.starting && !oauth.authorization_url"
</p> class="flex items-center justify-center py-12"
<div class="flex gap-2 pt-1"> >
<Button <div class="text-center">
size="sm" <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
:disabled="oauthBusy" <p class="text-xs text-muted-foreground">
@click="openAuthorizationUrl" 正在准备授权...
> </p>
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> </div>
前往授权
</Button>
<Button
size="sm"
variant="outline"
:disabled="oauthBusy"
@click="copyToClipboard(oauth.authorization_url)"
>
<Copy class="w-3.5 h-3.5 mr-1.5" />
复制链接
</Button>
</div> </div>
<template v-else-if="oauth.authorization_url">
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
<span class="text-xs font-medium">前往授权</span>
</div>
<div class="flex gap-2 pl-6">
<Button
size="sm"
:disabled="oauthBusy"
@click="openAuthorizationUrl"
>
<ExternalLink class="w-3 h-3 mr-1" />
打开
</Button>
<Button
size="sm"
variant="outline"
:disabled="oauthBusy"
@click="copyToClipboard(oauth.authorization_url)"
>
<Copy class="w-3 h-3 mr-1" />
复制
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
<span class="text-xs font-medium">粘贴回调 URL</span>
</div>
<div class="pl-6">
<Textarea
v-model="oauth.callback_url"
:disabled="oauthBusy"
placeholder="http://localhost:xxx/callback?code=..."
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
</div>
</div>
</template>
</div> </div>
<Separator /> <!-- ===== 导入授权 ===== -->
<div
class="flex flex-col gap-3 transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<input
ref="fileInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleFileSelect"
>
<!-- 步骤 2: 粘贴回调地址 --> <!-- 主区域拖拽 粘贴输入框同一位置切换 -->
<div class="space-y-2"> <div v-if="!importText" class="mt-3">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <!-- 拖拽模式 -->
第二步 · 粘贴回调 <div
</p> v-if="!showManualInput"
<p class="text-xs text-muted-foreground"> class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
授权完成后复制浏览器地址栏的完整 URL 并粘贴到下方 :class="isDragging
</p> ? 'border-primary bg-primary/5'
<div class="pt-1"> : 'border-border hover:border-muted-foreground/40'"
@click="fileInputRef?.click()"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
>
<div class="flex flex-col items-center justify-center py-10 gap-2">
<div class="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center">
<Upload class="w-4 h-4 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-xs font-medium">
拖入授权文件或点击选择
</p>
<p class="text-[10px] text-muted-foreground mt-0.5">
支持 .json 格式
</p>
</div>
</div>
</div>
<!-- 粘贴模式 -->
<Textarea <Textarea
v-model="oauth.callback_url" v-else
:disabled="oauthBusy" v-model="manualPasteText"
placeholder="http://localhost:xxx/callback?code=..." :disabled="importing"
class="min-h-[80px] text-xs font-mono break-all !rounded-xl" placeholder="粘贴 Refresh Token 或 JSON 内容"
class="min-h-[168px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
</div>
<!-- 底部切换链接占满剩余空间居中 -->
<div
v-if="!importText"
class="flex-1 flex items-center justify-center"
>
<button
v-if="!showManualInput"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = true"
>
或手动粘贴 Refresh Token
</button>
<button
v-else
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = false"
>
或选择 JSON 文件导入
</button>
</div>
<!-- 已有内容文件导入后显示文本框 -->
<div v-if="importText" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ importFileName || '已粘贴内容' }}</span>
<button
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
:disabled="importing"
@click="clearImport"
>
清除
</button>
</div>
<Textarea
v-model="importText"
:disabled="importing"
class="min-h-[160px] text-xs font-mono break-all !rounded-xl"
spellcheck="false" spellcheck="false"
/> />
</div> </div>
</div> </div>
</template> </div>
</div> </div>
<template #footer> <template #footer>
@@ -80,25 +198,34 @@
取消 取消
</Button> </Button>
<Button <Button
v-if="mode === 'oauth'"
:disabled="!canCompleteOAuth" :disabled="!canCompleteOAuth"
@click="handleCompleteOAuth" @click="handleCompleteOAuth"
> >
{{ oauth.completing ? '验证中...' : '验证' }} {{ oauth.completing ? '验证中...' : '验证' }}
</Button> </Button>
<Button
v-else
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? '导入中...' : '导入' }}
</Button>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Dialog, Button, Textarea, Separator } from '@/components/ui' import { Dialog, Button, Textarea } from '@/components/ui'
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next' import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { import {
startProviderLevelOAuth, startProviderLevelOAuth,
completeProviderLevelOAuth, completeProviderLevelOAuth,
importProviderRefreshToken,
} from '@/api/endpoints' } from '@/api/endpoints'
const props = defineProps<{ const props = defineProps<{
@@ -114,6 +241,10 @@ const emit = defineEmits<{
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
// 模式
type DialogMode = 'oauth' | 'import'
const mode = ref<DialogMode>('oauth')
// OAuth 状态 // OAuth 状态
interface OAuthState { interface OAuthState {
authorization_url: string authorization_url: string
@@ -139,6 +270,15 @@ function createInitialOAuthState(): OAuthState {
const oauth = ref<OAuthState>(createInitialOAuthState()) const oauth = ref<OAuthState>(createInitialOAuthState())
// 导入状态
const importText = ref('')
const importFileName = ref('')
const manualPasteText = ref('')
const importing = ref(false)
const isDragging = ref(false)
const showManualInput = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const isOpen = computed(() => props.open) const isOpen = computed(() => props.open)
const oauthBusy = computed(() => const oauthBusy = computed(() =>
@@ -151,8 +291,41 @@ const canCompleteOAuth = computed(() => {
return !oauthBusy.value return !oauthBusy.value
}) })
const canImport = computed(() => {
const text = importText.value || manualPasteText.value
return text.trim().length > 0 && !importing.value
})
function resetForm() { function resetForm() {
oauth.value = createInitialOAuthState() oauth.value = createInitialOAuthState()
importText.value = ''
importFileName.value = ''
manualPasteText.value = ''
importing.value = false
isDragging.value = false
showManualInput.value = false
mode.value = 'oauth'
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function clearImport() {
importText.value = ''
importFileName.value = ''
manualPasteText.value = ''
showManualInput.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return
mode.value = newMode
if (newMode === 'oauth' && !oauth.value.authorization_url && !oauth.value.starting) {
initOAuth()
}
} }
function handleDialogUpdate(value: boolean) { function handleDialogUpdate(value: boolean) {
@@ -172,7 +345,6 @@ function openAuthorizationUrl() {
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} }
// 对话框打开时获取授权 URL不创建 key
async function initOAuth() { async function initOAuth() {
if (!props.providerId) return if (!props.providerId) return
@@ -192,7 +364,6 @@ async function initOAuth() {
} }
} }
// 完成授权(此时才创建 key
async function handleCompleteOAuth() { async function handleCompleteOAuth() {
if (!canCompleteOAuth.value || !props.providerId) return if (!canCompleteOAuth.value || !props.providerId) return
oauth.value.completing = true oauth.value.completing = true
@@ -211,7 +382,80 @@ async function handleCompleteOAuth() {
} }
} }
// 监听对话框打开 function parseImportText(text: string): { refresh_token: string; name?: string } | null {
const trimmed = text.trim()
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const refreshToken = parsed.refresh_token
if (typeof refreshToken === 'string' && refreshToken.trim()) {
return {
refresh_token: refreshToken.trim(),
name: parsed.name || parsed.oauth_email || undefined,
}
}
}
} catch {
// 不是 JSON
}
if (trimmed) {
return { refresh_token: trimmed }
}
return null
}
function readFile(file: File) {
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
showError('仅支持 .json 文件', '格式错误')
return
}
importFileName.value = file.name
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result
if (typeof content === 'string') {
importText.value = content
}
}
reader.readAsText(file)
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) readFile(file)
}
function handleFileDrop(event: DragEvent) {
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) readFile(file)
}
async function handleImport() {
if (!canImport.value || !props.providerId) return
const inputText = importText.value || manualPasteText.value
const parsed = parseImportText(inputText)
if (!parsed) {
showError('无法解析输入内容,请检查格式', '格式错误')
return
}
importing.value = true
try {
await importProviderRefreshToken(props.providerId, parsed)
success('导入成功,账号已添加')
emit('saved')
handleClose()
} catch (err: any) {
const errorMessage = parseApiError(err, '导入失败')
showError(errorMessage, '错误')
} finally {
importing.value = false
}
}
watch(() => props.open, (newOpen) => { watch(() => props.open, (newOpen) => {
if (newOpen) { if (newOpen) {
initOAuth() initOAuth()

View File

@@ -265,10 +265,21 @@
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }} {{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
</span> </span>
<Button <Button
v-if="key.auth_type === 'oauth'"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-4 w-4 shrink-0" class="h-4 w-4 shrink-0"
:title="key.auth_type === 'oauth' ? '复制 Refresh Token' : '复制密钥'" title="下载 Refresh Token 授权文件"
@click.stop="downloadRefreshToken(key)"
>
<Download class="w-2.5 h-2.5" />
</Button>
<Button
v-else
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)" @click.stop="copyFullKey(key)"
> >
<Copy class="w-2.5 h-2.5" /> <Copy class="w-2.5 h-2.5" />
@@ -524,15 +535,18 @@
/> />
</div> </div>
<div <div
v-if="group.resetSeconds !== null" v-if="group.resetSeconds !== null || group.usedPercent > 0"
class="text-[9px] text-muted-foreground/70 mt-0.5" class="text-[9px] text-muted-foreground/70 mt-0.5"
> >
<template v-if="group.resetSeconds > 0"> <template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
{{ formatResetTime(group.resetSeconds) }}后重置 {{ formatResetTime(group.resetSeconds) }}后重置
</template> </template>
<template v-else> <template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
已重置 已重置
</template> </template>
<template v-else>
重置时间未知
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -764,6 +778,7 @@ import {
Power, Power,
GripVertical, GripVertical,
Copy, Copy,
Download,
Shield, Shield,
Shuffle, Shuffle,
ExternalLink, ExternalLink,
@@ -1134,6 +1149,48 @@ async function copyFullKey(key: EndpointAPIKey) {
} }
} }
// 下载 Refresh Token 授权文件
async function downloadRefreshToken(key: EndpointAPIKey) {
try {
const result = await revealEndpointKey(key.id)
const refreshToken = result.refresh_token || ''
const accessToken = result.api_key || ''
if (!refreshToken) {
showError('该账号没有 Refresh Token无法导出', '错误')
return
}
// 缓存 access_token 用于显示
if (accessToken) {
revealedKeys.value.set(key.id, accessToken)
}
const data = {
auth_type: 'oauth',
access_token: accessToken,
refresh_token: refreshToken,
name: key.name || '',
oauth_email: key.oauth_email || '',
exported_at: new Date().toISOString(),
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const providerType = provider.value?.provider_type || 'unknown'
const safeName = (key.name || key.oauth_email || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
a.download = `aether_${providerType}_${safeName}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err: any) {
showError(err.response?.data?.detail || '获取 Refresh Token 失败', '错误')
}
}
function handleDeleteKey(key: EndpointAPIKey) { function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key keyToDelete.value = key
deleteKeyConfirmOpen.value = true deleteKeyConfirmOpen.value = true
@@ -1843,7 +1900,6 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [
{ key: 'gemini-3-pro', label: 'Gemini 3 Pro', match: m => m.includes('gemini-3-pro') && !m.includes('image') }, { key: 'gemini-3-pro', label: 'Gemini 3 Pro', match: m => m.includes('gemini-3-pro') && !m.includes('image') },
{ key: 'gemini-3-flash', label: 'Gemini 3 Flash', match: m => m.includes('gemini-3-flash') }, { key: 'gemini-3-flash', label: 'Gemini 3 Flash', match: m => m.includes('gemini-3-flash') },
{ key: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image', match: m => m.includes('gemini-3-pro-image') }, { key: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image', match: m => m.includes('gemini-3-pro-image') },
{ key: 'other', label: 'Other', match: () => true },
] ]
interface AntigravityQuotaSummaryItem { interface AntigravityQuotaSummaryItem {

View File

@@ -26,6 +26,66 @@
/> />
</div> </div>
<!-- 状态筛选 -->
<Select v-model="filterStatus">
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="status in statusFilters"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- API 格式筛选 -->
<Select v-model="filterApiFormat">
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in apiFormatFilters"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Select v-model="filterModel">
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in modelFilters"
:key="model.value"
:value="model.value"
>
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 重置筛选 -->
<Button
v-if="hasActiveFilters"
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置筛选"
@click="searchQuery = ''; filterStatus = 'all'; filterApiFormat = 'all'; filterModel = 'all'"
>
<FilterX class="w-3.5 h-3.5" />
</Button>
<div class="hidden sm:block h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 --> <!-- 调度策略 -->
@@ -73,20 +133,20 @@
class="flex flex-col items-center justify-center py-16 text-center" class="flex flex-col items-center justify-center py-16 text-center"
> >
<div class="text-muted-foreground mb-2"> <div class="text-muted-foreground mb-2">
<template v-if="searchQuery"> <template v-if="hasActiveFilters">
未找到匹配 "{{ searchQuery }}" 的提供商 未找到匹配当前筛选条件的提供商
</template> </template>
<template v-else> <template v-else>
暂无提供商点击右上角添加 暂无提供商点击右上角添加
</template> </template>
</div> </div>
<Button <Button
v-if="searchQuery" v-if="hasActiveFilters"
variant="outline" variant="outline"
size="sm" size="sm"
@click="searchQuery = ''" @click="searchQuery = ''; filterStatus = 'all'; filterApiFormat = 'all'; filterModel = 'all'"
> >
清除搜索 清除筛选
</Button> </Button>
</div> </div>
@@ -604,7 +664,8 @@ import {
ChevronDown, ChevronDown,
Power, Power,
KeyRound, KeyRound,
Loader2 Loader2,
FilterX
} from 'lucide-vue-next' } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
@@ -618,6 +679,11 @@ import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue' import TableCell from '@/components/ui/table-cell.vue'
import Pagination from '@/components/ui/pagination.vue' import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue' import RefreshButton from '@/components/ui/refresh-button.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components' import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue' import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -627,6 +693,7 @@ import {
getProvidersSummary, getProvidersSummary,
deleteProvider, deleteProvider,
updateProvider, updateProvider,
getGlobalModels,
type ProviderWithEndpointsSummary, type ProviderWithEndpointsSummary,
API_FORMAT_SHORT API_FORMAT_SHORT
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -659,8 +726,44 @@ const balanceCache = ref<Record<string, ActionResultResponse>>({})
// 使用普通变量而非 ref因为不需要响应式仅用于比较请求版本 // 使用普通变量而非 ref因为不需要响应式仅用于比较请求版本
let balanceLoadVersion = 0 let balanceLoadVersion = 0
// 搜索 // 搜索与筛选
const searchQuery = ref('') const searchQuery = ref('')
const filterStatus = ref('all')
const filterApiFormat = ref('all')
const filterModel = ref('all')
// 全局模型数据(用于模型筛选下拉)
const globalModels = ref<{ id: string; name: string }[]>([])
const statusFilters = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '已停用' },
]
const apiFormatFilters = [
{ value: 'all', label: '全部格式' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:cli', label: 'OpenAI CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
]
// 动态计算模型筛选选项:只展示当前提供商列表中实际关联的全局模型
const modelFilters = computed(() => {
const usedIds = new Set(providers.value.flatMap(p => p.global_model_ids || []))
const items = globalModels.value
.filter(m => usedIds.has(m.id))
.map(m => ({ value: m.id, label: m.name }))
.sort((a, b) => a.label.localeCompare(b.label))
return [{ value: 'all', label: '全部模型' }, ...items]
})
const hasActiveFilters = computed(() => {
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterApiFormat.value !== 'all' || filterModel.value !== 'all'
})
// 分页 // 分页
const currentPage = ref(1) const currentPage = ref(1)
@@ -693,6 +796,26 @@ const filteredProviders = computed(() => {
}) })
} }
// 状态筛选
if (filterStatus.value !== 'all') {
const isActive = filterStatus.value === 'active'
result = result.filter(p => p.is_active === isActive)
}
// API 格式筛选
if (filterApiFormat.value !== 'all') {
result = result.filter(p =>
p.api_formats && p.api_formats.includes(filterApiFormat.value)
)
}
// 模型筛选
if (filterModel.value !== 'all') {
result = result.filter(p =>
p.global_model_ids && p.global_model_ids.includes(filterModel.value)
)
}
// 排序 // 排序
return result.sort((a, b) => { return result.sort((a, b) => {
// 1. 优先显示活跃的提供商 // 1. 优先显示活跃的提供商
@@ -715,8 +838,8 @@ const paginatedProviders = computed(() => {
return filteredProviders.value.slice(start, end) return filteredProviders.value.slice(start, end)
}) })
// 搜索时重置分页 // 搜索/筛选时重置分页
watch(searchQuery, () => { watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
currentPage.value = 1 currentPage.value = 1
}) })
@@ -732,6 +855,16 @@ async function loadPriorityMode() {
} }
} }
// 加载全局模型列表(用于模型筛选下拉)
async function loadGlobalModelList() {
try {
const response = await getGlobalModels({ is_active: true, limit: 1000 })
globalModels.value = response.models.map(m => ({ id: m.id, name: m.name }))
} catch {
globalModels.value = []
}
}
// 加载提供商列表 // 加载提供商列表
async function loadProviders() { async function loadProviders() {
loading.value = true loading.value = true
@@ -1179,6 +1312,7 @@ let tickInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
loadProviders() loadProviders()
loadPriorityMode() loadPriorityMode()
loadGlobalModelList()
// 每秒更新一次倒计时 // 每秒更新一次倒计时
tickInterval = setInterval(() => { tickInterval = setInterval(() => {
tickCounter.value++ tickCounter.value++

View File

@@ -30,6 +30,7 @@ from src.models.endpoint_models import (
EndpointAPIKeyUpdate, EndpointAPIKeyUpdate,
) )
from src.services.cache.provider_cache import ProviderCacheService from src.services.cache.provider_cache import ProviderCacheService
from src.services.model.upstream_fetcher import merge_upstream_metadata
from src.utils.auth_utils import require_admin from src.utils.auth_utils import require_admin
router = APIRouter(tags=["Provider Keys"]) router = APIRouter(tags=["Provider Keys"])
@@ -467,6 +468,29 @@ class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"无法解密认证配置,可能是加密密钥已更改。请重新添加该密钥。" "无法解密认证配置,可能是加密密钥已更改。请重新添加该密钥。"
) )
# OAuth 类型:返回 access_token + refresh_token
if auth_type == "oauth":
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
result: dict[str, Any] = {"auth_type": "oauth", "api_key": decrypted_key}
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
refresh_token = auth_config.get("refresh_token")
if refresh_token:
result["refresh_token"] = refresh_token
except Exception as e:
logger.error(f"解密 auth_config 失败: ID={self.key_id}, Error={e}")
logger.info(f"[REVEAL] 查看 OAuth Key: ID={self.key_id}, Name={key.name}")
return result
# API Key 类型返回 api_key # API Key 类型返回 api_key
try: try:
decrypted_key = crypto_service.decrypt(key.api_key) decrypted_key = crypto_service.decrypt(key.api_key)
@@ -1178,13 +1202,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if key.id in metadata_updates: if key.id in metadata_updates:
updates = metadata_updates[key.id] updates = metadata_updates[key.id]
if isinstance(updates, dict): if isinstance(updates, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict), key.upstream_metadata = merge_upstream_metadata(
# so in-place mutation won't be persisted reliably. Always assign key.upstream_metadata, updates
# a new dict object to mark the column as dirty. )
current = key.upstream_metadata
merged: dict = dict(current) if isinstance(current, dict) else {}
merged.update(updates)
key.upstream_metadata = merged
db.add(key) db.add(key)
# 提交数据库更改 # 提交数据库更改

View File

@@ -35,7 +35,8 @@ from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType from src.core.provider_templates.types import ProviderType
from src.database.database import get_db from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey from src.models.database import Provider, ProviderAPIKey, User
from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"]) router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
@@ -189,7 +190,7 @@ def _parse_callback_params(callback_url: str) -> dict[str, str]:
@router.get("/supported-types") @router.get("/supported-types")
async def supported_types() -> list[dict[str, Any]]: async def supported_types(_: User = Depends(require_admin)) -> list[dict[str, Any]]:
# 不返回 client_secret # 不返回 client_secret
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items(): for provider_type, template in FIXED_PROVIDERS.items():
@@ -216,6 +217,7 @@ async def start_oauth(
key_id: str, key_id: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse: ) -> StartOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first() key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key: if not key:
@@ -295,6 +297,7 @@ async def complete_oauth(
payload: CompleteOAuthRequest, payload: CompleteOAuthRequest,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse: ) -> CompleteOAuthResponse:
redis = await get_redis_client(require_redis=True) redis = await get_redis_client(require_redis=True)
assert redis is not None assert redis is not None
@@ -428,6 +431,7 @@ async def refresh_oauth(
key_id: str, key_id: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse: ) -> CompleteOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first() key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key: if not key:
@@ -586,6 +590,7 @@ async def start_provider_oauth(
provider_id: str, provider_id: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse: ) -> StartOAuthResponse:
"""基于 Provider 启动 OAuth不需要预先创建 key""" """基于 Provider 启动 OAuth不需要预先创建 key"""
provider = db.query(Provider).filter(Provider.id == provider_id).first() provider = db.query(Provider).filter(Provider.id == provider_id).first()
@@ -659,6 +664,7 @@ async def complete_provider_oauth(
payload: ProviderCompleteOAuthRequest, payload: ProviderCompleteOAuthRequest,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse: ) -> ProviderCompleteOAuthResponse:
"""完成 Provider OAuth 并创建 key。""" """完成 Provider OAuth 并创建 key。"""
redis = await get_redis_client(require_redis=True) redis = await get_redis_client(require_redis=True)
@@ -799,3 +805,155 @@ async def complete_provider_oauth(
has_refresh_token=bool(refresh_token), has_refresh_token=bool(refresh_token),
email=auth_config.get("email"), email=auth_config.get("email"),
) )
# ==============================================================================
# Import Refresh Token (从导出文件导入)
# ==============================================================================
class ImportRefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1, description="Refresh Token")
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
@router.post(
"/providers/{provider_id}/import-refresh-token",
response_model=ProviderCompleteOAuthResponse,
)
async def import_refresh_token(
provider_id: str,
payload: ImportRefreshTokenRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""通过 Refresh Token 导入 OAuth 账号。
使用导出的 Refresh Token 换取 Access Token 并创建新的 OAuth Key。
"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
# 用 refresh_token 换取 access_token
refresh_token = payload.refresh_token.strip()
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
proxy_config = getattr(provider, "proxy", None)
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=30.0,
)
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(error_body.get("error_description") or error_body.get("error"))
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
raise InvalidRequestException(f"Refresh Token 验证失败: {error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
new_refresh_token = str(token.get("refresh_token") or "") or refresh_token
expires_in = token.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
# 构建 auth_config
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
# 从 Provider 的 endpoints 中提取所有 api_format 作为 Key 的支持格式
api_formats = [ep.api_format for ep in provider.endpoints if ep.api_format and ep.is_active]
# 创建 key
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
new_key = ProviderAPIKeyModel(
provider_id=provider_id,
name=name,
api_key=crypto_service.encrypt(access_token),
auth_type="oauth",
auth_config=crypto_service.encrypt(json.dumps(auth_config)),
api_formats=api_formats,
is_active=True,
)
db.add(new_key)
db.commit()
db.refresh(new_key)
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(new_refresh_token),
email=auth_config.get("email"),
)

View File

@@ -240,6 +240,19 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
total_models = model_stats.total or 0 total_models = model_stats.total or 0
active_models = int(model_stats.active or 0) active_models = int(model_stats.active or 0)
# 活跃模型关联的全局模型 ID 列表
global_model_ids = [
row[0]
for row in db.query(Model.global_model_id)
.filter(
Model.provider_id == provider.id,
Model.is_active == True,
Model.global_model_id.isnot(None),
)
.distinct()
.all()
]
api_formats = [e.api_format for e in endpoints] api_formats = [e.api_format for e in endpoints]
# 优化: 一次性加载 Provider 的 keys避免 N+1 查询 # 优化: 一次性加载 Provider 的 keys避免 N+1 查询
@@ -328,6 +341,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
active_keys=active_keys, active_keys=active_keys,
total_models=total_models, total_models=total_models,
active_models=active_models, active_models=active_models,
global_model_ids=global_model_ids,
avg_health_score=avg_health_score, avg_health_score=avg_health_score,
unhealthy_endpoints=unhealthy_endpoints, unhealthy_endpoints=unhealthy_endpoints,
api_formats=api_formats, api_formats=api_formats,

View File

@@ -409,6 +409,32 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
""" """
return request_body return request_body
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
"""
格式转换完成后、envelope 之前的模型感知后处理钩子 - 子类可覆盖
用于根据目标模型的特性对请求体做最终调整,例如:
- 图像生成模型需要移除不兼容的 tools/system_instruction 并注入 imageConfig
- 特定模型需要注入/移除某些字段
此方法在流式和非流式路径中均会被调用,且 mapped_model 已确定。
Args:
request_body: 已完成格式转换的请求体
mapped_model: 映射后的目标模型名
provider_api_format: Provider 侧 API 格式标识
Returns:
调整后的请求体
"""
return request_body
def _set_model_after_conversion( def _set_model_after_conversion(
self, self,
request_body: dict[str, Any], request_body: dict[str, Any],
@@ -827,6 +853,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
target_variant=same_format_variant, target_variant=same_format_variant,
) )
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=str(provider_api_format) if provider_api_format else None,
)
# Force upstream stream/sync mode in request body (best-effort). # Force upstream stream/sync mode in request body (best-effort).
if provider_api_format: if provider_api_format:
enforce_stream_mode_for_upstream( enforce_stream_mode_for_upstream(
@@ -1440,6 +1473,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
target_variant=same_format_variant, target_variant=same_format_variant,
) )
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=str(provider_api_format) if provider_api_format else None,
)
# Force upstream stream/sync mode in request body (best-effort). # Force upstream stream/sync mode in request body (best-effort).
if provider_api_format: if provider_api_format:
enforce_stream_mode_for_upstream( enforce_stream_mode_for_upstream(

View File

@@ -368,6 +368,32 @@ class CliMessageHandlerBase(BaseMessageHandler):
""" """
return request_body return request_body
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
"""
格式转换完成后、envelope 之前的模型感知后处理钩子 - 子类可覆盖
用于根据目标模型的特性对请求体做最终调整,例如:
- 图像生成模型需要移除不兼容的 tools/system_instruction 并注入 imageConfig
- 特定模型需要注入/移除某些字段
此方法在流式和非流式路径中均会被调用,且 mapped_model 已确定。
Args:
request_body: 已完成格式转换的请求体
mapped_model: 映射后的目标模型名
provider_api_format: Provider 侧 API 格式标识
Returns:
调整后的请求体
"""
return request_body
@staticmethod @staticmethod
def _get_format_metadata(format_id: str) -> "EndpointDefinition | None": def _get_format_metadata(format_id: str) -> "EndpointDefinition | None":
"""获取 endpoint 元数据(解析失败返回 None""" """获取 endpoint 元数据(解析失败返回 None"""
@@ -801,6 +827,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant, target_variant=target_variant,
) )
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=provider_api_format,
)
# Force upstream stream/sync mode in request body (best-effort). # Force upstream stream/sync mode in request body (best-effort).
if provider_api_format: if provider_api_format:
enforce_stream_mode_for_upstream( enforce_stream_mode_for_upstream(
@@ -2812,6 +2845,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
target_variant=target_variant, target_variant=target_variant,
) )
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=provider_api_format,
)
# Force upstream stream/sync mode in request body (best-effort). # Force upstream stream/sync mode in request body (best-effort).
if provider_api_format: if provider_api_format:
enforce_stream_mode_for_upstream( enforce_stream_mode_for_upstream(

View File

@@ -708,6 +708,7 @@ class StreamProcessor:
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}" f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
) )
line = "" line = ""
buffer = b"" # 标记已消费,避免 finally 中重复处理
if line: if line:
# 需要格式转换时,跳过记录原始数据 # 需要格式转换时,跳过记录原始数据
_process_line_with_perf(line, skip_record=True) _process_line_with_perf(line, skip_record=True)
@@ -782,8 +783,26 @@ class StreamProcessor:
logger.warning( logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}" f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
) )
buffer = b"" # 标记已消费,避免下方重复处理
# 处理剩余事件 # flush 残留的字节 buffer异常中断时 buffer 可能仍有未解析的数据,
# 如包含 usage 的 message_delta/response.completed 事件)
# 正常结束时 buffer 已在上方被消费为空,此处为 no-op
if buffer:
try:
remaining = decoder.decode(buffer, True)
for line in remaining.split("\n"):
stripped = line.rstrip("\r\n")
if stripped:
events = sse_parser.feed_line(stripped)
for event in events:
self.handle_sse_event(
ctx, event.get("event"), event.get("data") or ""
)
except Exception:
pass # best-effort: 不应因 flush 失败影响后续流程
# flush SSE parser 内部累积的未完成事件
for event in sse_parser.flush(): for event in sse_parser.flush():
self.handle_sse_event(ctx, event.get("event"), event.get("data") or "") self.handle_sse_event(ctx, event.get("event"), event.get("data") or "")

View File

@@ -8,6 +8,7 @@
""" """
import asyncio import asyncio
import json
import time import time
from typing import Any from typing import Any
@@ -104,6 +105,19 @@ class StreamTelemetryRecorder:
if writer is None: if writer is None:
return return
actual_request_body = ctx.provider_request_body or original_request_body actual_request_body = ctx.provider_request_body or original_request_body
# 兜底估算:流未正常完成且 token 均为 0 时,从请求体粗略估算
# 覆盖 Chat Handler 路径CLI Handler 在更早的位置已做估算,
# 若已估算过则 token > 0此处条件不会触发
if (
ctx.is_success()
and not ctx.has_completion
and ctx.data_count > 0
and ctx.input_tokens == 0
and ctx.output_tokens == 0
):
self._estimate_tokens_for_incomplete_stream(ctx, actual_request_body)
should_log_body = SystemConfigService.should_log_body(bg_db) should_log_body = SystemConfigService.should_log_body(bg_db)
include_bodies = ( include_bodies = (
writer.include_bodies writer.include_bodies
@@ -536,6 +550,59 @@ class StreamTelemetryRecorder:
return "cancelled" return "cancelled"
return "failed" return "failed"
@staticmethod
def _estimate_tokens_for_incomplete_stream(
ctx: StreamContext,
request_body: dict[str, Any],
) -> None:
"""
流未正常完成(无 response.completed且 token 均为 0 时的兜底估算。
从已收集的输出文本和请求体粗略估算 token 数,确保 usage 记录不为 0。
估算采用 ~4 字符/token 的保守比例。
"""
# 输出 tokens从已收集的文本估算
collected = ctx.collected_text
if collected:
ctx.output_tokens = max(1, len(collected) // 4)
# 输入 tokens从请求体文本内容估算
try:
total_input_len = 0
instructions = request_body.get("instructions")
if isinstance(instructions, str):
total_input_len += len(instructions)
# OpenAI Responses API 使用 input 字段Claude 使用 messages
input_items = request_body.get("input") or request_body.get("messages") or []
if isinstance(input_items, list):
for item in input_items:
if isinstance(item, str):
total_input_len += len(item)
elif isinstance(item, dict):
content = item.get("content", "")
if isinstance(content, str):
total_input_len += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
text = block.get("text", "")
if isinstance(text, str):
total_input_len += len(text)
if total_input_len > 0:
ctx.input_tokens = max(1, total_input_len // 4)
else:
# fallback: 整个请求体 JSON 大小
body_str = json.dumps(request_body, ensure_ascii=False)
ctx.input_tokens = max(1, len(body_str) // 4)
except Exception:
pass
if ctx.input_tokens > 0 or ctx.output_tokens > 0:
logger.warning(
f"[{ctx.request_id}] 流未正常完成 (has_completion=False, data_count={ctx.data_count}), "
f"使用估算 tokens: in={ctx.input_tokens}, out={ctx.output_tokens}"
)
def _build_db_writer(self, bg_db: Session) -> DbTelemetryWriter | None: def _build_db_writer(self, bg_db: Session) -> DbTelemetryWriter | None:
user = bg_db.query(User).filter(User.id == self.user_id).first() user = bg_db.query(User).filter(User.id == self.user_id).first()
api_key_obj = bg_db.query(ApiKey).filter(ApiKey.id == self.api_key_id).first() api_key_obj = bg_db.query(ApiKey).filter(ApiKey.id == self.api_key_id).first()

View File

@@ -157,6 +157,22 @@ class GeminiChatHandler(ChatHandlerBase):
"cache_read_input_tokens": usage.get("cached_tokens", 0), "cache_read_input_tokens": usage.get("cached_tokens", 0),
} }
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None, # noqa: ARG002
) -> dict[str, Any]:
from src.api.handlers.gemini.image_gen import (
adapt_request_for_image_gen,
is_image_gen_model,
)
if not is_image_gen_model(mapped_model):
return request_body
return adapt_request_for_image_gen(request_body)
def _normalize_response(self, response: dict) -> dict: def _normalize_response(self, response: dict) -> dict:
""" """
规范化 Gemini 响应 规范化 Gemini 响应

View File

@@ -0,0 +1,45 @@
"""
Gemini 图像生成模型请求适配
- 图像生成模型不支持 tools / system_instruction需要移除
- responseModalities / responseMimeType 与 imageConfig 冲突,需要移除
"""
from typing import Any
def is_image_gen_model(model: str | None) -> bool:
"""判断是否为图像生成模型(模式匹配,覆盖 gemini-*-image / imagen-* 系列)"""
if not model:
return False
m = model.lower()
return "image" in m and ("gemini" in m or "imagen" in m)
def adapt_request_for_image_gen(body: dict[str, Any]) -> dict[str, Any]:
"""为图像生成模型清理不兼容字段"""
# 移除图像生成不支持的顶层字段
for key in ("tools", "tool_config", "toolConfig", "system_instruction", "systemInstruction"):
if key in body:
body.pop(key)
# 处理 generationConfig
gc_key = "generationConfig" if "generationConfig" in body else "generation_config"
gc = body.get(gc_key)
if not isinstance(gc, dict):
gc = {}
body[gc_key] = gc
# 移除与图像生成冲突的字段
for key in (
"responseMimeType",
"response_mime_type",
"responseModalities",
"response_modalities",
):
gc.pop(key, None)
# 设置输出模态
gc["responseModalities"] = ["TEXT", "IMAGE"]
return body

View File

@@ -78,6 +78,22 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
result.pop("model", None) result.pop("model", None)
return result return result
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None, # noqa: ARG002
) -> dict[str, Any]:
from src.api.handlers.gemini.image_gen import (
adapt_request_for_image_gen,
is_image_gen_model,
)
if not is_image_gen_model(mapped_model):
return request_body
return adapt_request_for_image_gen(request_body)
def get_model_for_url( def get_model_for_url(
self, self,
request_body: dict[str, Any], request_body: dict[str, Any],

View File

@@ -759,6 +759,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Model 统计 # Model 统计
total_models: int = Field(default=0, description="总模型数量") total_models: int = Field(default=0, description="总模型数量")
active_models: int = Field(default=0, description="活跃模型数量") active_models: int = Field(default=0, description="活跃模型数量")
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
# API 格式列表 # API 格式列表
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表") api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")

View File

@@ -30,6 +30,7 @@ from src.models.database import Provider, ProviderAPIKey
from src.services.model.upstream_fetcher import ( from src.services.model.upstream_fetcher import (
UpstreamModelsFetchContext, UpstreamModelsFetchContext,
fetch_models_for_key, fetch_models_for_key,
merge_upstream_metadata,
) )
from src.services.provider.oauth_token import resolve_oauth_access_token from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.system.scheduler import get_scheduler from src.services.system.scheduler import get_scheduler
@@ -491,13 +492,9 @@ class ModelFetchScheduler:
# 最佳努力:保存上游元数据(如 Antigravity 配额信息) # 最佳努力:保存上游元数据(如 Antigravity 配额信息)
if upstream_metadata and isinstance(upstream_metadata, dict): if upstream_metadata and isinstance(upstream_metadata, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict), key.upstream_metadata = merge_upstream_metadata(
# so in-place mutation won't be persisted reliably. Always assign key.upstream_metadata, upstream_metadata
# a new dict object to mark the column as dirty. )
current = key.upstream_metadata
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
merged.update(upstream_metadata)
key.upstream_metadata = merged
# 去重获取模型 ID 列表 # 去重获取模型 ID 列表
fetched_model_ids: set[str] = set() fetched_model_ids: set[str] = set()

View File

@@ -82,6 +82,50 @@ async def fetch_models_for_key(
return await fetcher(ctx, timeout_seconds) return await fetcher(ctx, timeout_seconds)
def merge_upstream_metadata(
current: dict[str, Any] | None,
incoming: dict[str, Any],
) -> dict[str, Any]:
"""合并上游元数据,对 quota_by_model 做模型级深度合并。
上游 API 在配额耗尽后可能不再返回该模型的 quotaInfo因此需要
1. 保留旧数据中已有的 reset_time当新数据缺少时
2. 保留旧数据中存在但新数据中缺失的模型条目(标记为 100% 已用)
"""
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
for ns_key, ns_val in incoming.items():
old_ns = merged.get(ns_key)
if (
isinstance(ns_val, dict)
and isinstance(old_ns, dict)
and "quota_by_model" in ns_val
and "quota_by_model" in old_ns
):
old_qbm = old_ns["quota_by_model"]
new_qbm = ns_val["quota_by_model"]
if isinstance(old_qbm, dict) and isinstance(new_qbm, dict):
# 保留新数据中已有模型的旧 reset_time
for model_id, new_info in new_qbm.items():
if not isinstance(new_info, dict):
continue
old_info = old_qbm.get(model_id)
if (
isinstance(old_info, dict)
and "reset_time" in old_info
and "reset_time" not in new_info
):
new_info["reset_time"] = old_info["reset_time"]
# 保留新数据中缺失但旧数据中存在的模型(配额耗尽后上游可能不返回)
for model_id, old_info in old_qbm.items():
if model_id not in new_qbm and isinstance(old_info, dict):
exhausted = dict(old_info)
exhausted["remaining_fraction"] = 0.0
exhausted["used_percent"] = 100.0
new_qbm[model_id] = exhausted
merged[ns_key] = ns_val
return merged
# Provider-specific fetchers are registered by plugin.register_all() # Provider-specific fetchers are registered by plugin.register_all()
# (called from envelope.py bootstrap) # (called from envelope.py bootstrap)

View File

@@ -345,31 +345,45 @@ def wrap_v1internal_request(
1. 移除 model移到顶层 1. 移除 model移到顶层
2. 移除 safetySettingsv1internal 不支持) 2. 移除 safetySettingsv1internal 不支持)
3. 深度清理 [undefined] 字符串 3. 深度清理 [undefined] 字符串
4. Claude model tool ID 注入 4. Claude model tool ID 注入(图像生成模型跳过)
5. Thinking budget 处理(自动注入 + Auto Cap 5. Thinking budget 处理
6. 工具声明清洗(schema 清理 + 字段重命名 6. 工具声明清洗(图像生成模型跳过
7. System Instruction 注入 7. System Instruction 注入(图像生成模型跳过)
8. 注入 sessionId对齐 CLIProxyAPI 8. 注入 sessionId对齐 CLIProxyAPI
9. 构建 v1internal 信封 9. 构建 v1internal 信封
""" """
from src.api.handlers.gemini.image_gen import is_image_gen_model
inner_request = dict(gemini_request) inner_request = dict(gemini_request)
inner_request.pop("model", None) inner_request.pop("model", None)
inner_request.pop("safetySettings", None) inner_request.pop("safetySettings", None)
is_image_gen = is_image_gen_model(model)
# 1. 深度清理 [undefined] # 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request) _deep_clean_undefined(inner_request)
# 2. Claude tool ID 注入 if not is_image_gen:
_inject_claude_tool_ids_request(inner_request, model) # 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
# 3. Thinking budget 处理 # 3. Thinking budget 处理
_process_thinking_budget(inner_request, model) _process_thinking_budget(inner_request, model)
# 4. 工具声明清洗 if not is_image_gen:
_clean_tool_declarations(inner_request) # 4. 工具声明清洗
_clean_tool_declarations(inner_request)
# 5. System Instruction 注入 # 5. System Instruction 注入
_inject_system_instruction(inner_request) _inject_system_instruction(inner_request)
else:
# 图像生成模型:对齐 AM wrapper.rs移除不兼容字段
inner_request.pop("tools", None)
inner_request.pop("toolConfig", None)
inner_request.pop("tool_config", None)
inner_request.pop("systemInstruction", None)
inner_request.pop("system_instruction", None)
request_type = "image_gen"
# 6. 注入 sessionId对齐 CLIProxyAPI/sub2api # 6. 注入 sessionId对齐 CLIProxyAPI/sub2api
if "sessionId" not in inner_request: if "sessionId" not in inner_request:

View File

@@ -255,6 +255,11 @@ async def fetch_models_antigravity(
quota_info = model_data.get("quotaInfo") quota_info = model_data.get("quotaInfo")
if not isinstance(quota_info, dict): if not isinstance(quota_info, dict):
# 没有 quotaInfo 视为配额耗尽
quota_by_model[model_id] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
continue continue
remaining = quota_info.get("remainingFraction") remaining = quota_info.get("remainingFraction")
@@ -268,6 +273,14 @@ async def fetch_models_antigravity(
remaining_fraction = None remaining_fraction = None
if remaining_fraction is None: if remaining_fraction is None:
# remainingFraction 缺失视为配额耗尽
payload: dict[str, Any] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
if isinstance(reset_time, str) and reset_time.strip():
payload["reset_time"] = reset_time.strip()
quota_by_model[model_id] = payload
continue continue
used_percent = (1.0 - remaining_fraction) * 100.0 used_percent = (1.0 - remaining_fraction) * 100.0

View File

@@ -1,3 +1,4 @@
import json
from typing import Any from typing import Any
from src.api.handlers.base.response_parser import ( from src.api.handlers.base.response_parser import (
@@ -34,3 +35,46 @@ def test_process_line_strips_newlines_and_finalizes_event() -> None:
processor._process_line(ctx, sse_parser, "\n") processor._process_line(ctx, sse_parser, "\n")
assert ctx.has_completion is True assert ctx.has_completion is True
def test_process_line_updates_openai_usage_from_usage_only_chunk() -> None:
ctx = StreamContext(model="test-model", api_format="openai:chat")
ctx.provider_api_format = "openai:chat"
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
sse_parser = SSEEventParser()
usage_chunk = {
"id": "chatcmpl_test",
"object": "chat.completion.chunk",
"choices": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
processor._process_line(ctx, sse_parser, f"data: {json.dumps(usage_chunk)}\n")
processor._process_line(ctx, sse_parser, "\n")
assert ctx.input_tokens == 10
assert ctx.output_tokens == 5
def test_process_line_handles_openai_usage_chunk_followed_by_done_without_blank_line() -> None:
ctx = StreamContext(model="test-model", api_format="openai:chat")
ctx.provider_api_format = "openai:chat"
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
sse_parser = SSEEventParser()
usage_chunk = {
"id": "chatcmpl_test",
"object": "chat.completion.chunk",
"choices": [],
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
}
# Some SSE implementations may emit consecutive data lines without an empty separator.
processor._process_line(ctx, sse_parser, f"data: {json.dumps(usage_chunk)}\n")
processor._process_line(ctx, sse_parser, "data: [DONE]\n")
processor._process_line(ctx, sse_parser, "\n")
assert ctx.input_tokens == 7
assert ctx.output_tokens == 3
assert ctx.has_completion is True