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
+38 -2
View File
@@ -59,6 +59,9 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动执行数据库迁移)
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(本地构建镜像)
@@ -148,9 +151,42 @@ cd frontend && npm install && npm run dev
| **提供商优先** | 按 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,如果迁移涉及不可逆的数据变更(如删除列),可能无法完全恢复数据。因此强烈建议升级前备份。
---
+1
View File
@@ -58,6 +58,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
export interface RevealKeyResult {
auth_type: 'api_key' | 'vertex_ai' | 'oauth'
api_key?: string
refresh_token?: string
auth_config?: string | Record<string, any>
}
@@ -46,3 +46,11 @@ export async function completeProviderLevelOAuth(
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, 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
}
+1
View File
@@ -447,6 +447,7 @@ export interface ProviderWithEndpointsSummary {
active_keys: number
total_models: number
active_models: number
global_model_ids: string[]
avg_health_score: number
unhealthy_endpoints: number
api_formats: string[]
@@ -6,70 +6,188 @@
size="md"
@update:model-value="handleDialogUpdate"
>
<div class="space-y-5">
<!-- 加载中 -->
<div
v-if="oauth.starting && !oauth.authorization_url"
class="py-12 text-center"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p class="text-sm text-muted-foreground">
正在准备授权...
</p>
<div class="space-y-4">
<!-- Tab 切换 -->
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
:class="mode === 'oauth'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
@click="switchMode('oauth')"
>
获取授权
</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>
<!-- 授权流程 -->
<template v-else-if="oauth.authorization_url">
<!-- 步骤 1: 打开授权链接 -->
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
第一步 · 前往授权
</p>
<p class="text-xs text-muted-foreground">
点击下方按钮在浏览器中完成登录授权
</p>
<div class="flex gap-2 pt-1">
<Button
size="sm"
:disabled="oauthBusy"
@click="openAuthorizationUrl"
>
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
前往授权
</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>
<!-- Tab 内容grid 叠放高度取较高者 -->
<div class="grid [&>*]:col-start-1 [&>*]:row-start-1">
<!-- ===== 获取授权 ===== -->
<div
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<div
v-if="oauth.starting && !oauth.authorization_url"
class="flex items-center justify-center py-12"
>
<div class="text-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
<p class="text-xs text-muted-foreground">
正在准备授权...
</p>
</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>
<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">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
第二步 · 粘贴回调
</p>
<p class="text-xs text-muted-foreground">
授权完成后复制浏览器地址栏的完整 URL 并粘贴到下方
</p>
<div class="pt-1">
<!-- 主区域拖拽 粘贴输入框同一位置切换 -->
<div v-if="!importText" class="mt-3">
<!-- 拖拽模式 -->
<div
v-if="!showManualInput"
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
:class="isDragging
? 'border-primary bg-primary/5'
: '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
v-model="oauth.callback_url"
:disabled="oauthBusy"
placeholder="http://localhost:xxx/callback?code=..."
class="min-h-[80px] text-xs font-mono break-all !rounded-xl"
v-else
v-model="manualPasteText"
:disabled="importing"
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"
/>
</div>
</div>
</template>
</div>
</div>
<template #footer>
@@ -80,25 +198,34 @@
取消
</Button>
<Button
v-if="mode === 'oauth'"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
{{ oauth.completing ? '验证中...' : '验证' }}
</Button>
<Button
v-else
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? '导入中...' : '导入' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Dialog, Button, Textarea, Separator } from '@/components/ui'
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
import { Dialog, Button, Textarea } from '@/components/ui'
import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser'
import {
startProviderLevelOAuth,
completeProviderLevelOAuth,
importProviderRefreshToken,
} from '@/api/endpoints'
const props = defineProps<{
@@ -114,6 +241,10 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 模式
type DialogMode = 'oauth' | 'import'
const mode = ref<DialogMode>('oauth')
// OAuth 状态
interface OAuthState {
authorization_url: string
@@ -139,6 +270,15 @@ function createInitialOAuthState(): OAuthState {
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 oauthBusy = computed(() =>
@@ -151,8 +291,41 @@ const canCompleteOAuth = computed(() => {
return !oauthBusy.value
})
const canImport = computed(() => {
const text = importText.value || manualPasteText.value
return text.trim().length > 0 && !importing.value
})
function resetForm() {
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) {
@@ -172,7 +345,6 @@ function openAuthorizationUrl() {
window.open(url, '_blank', 'noopener,noreferrer')
}
// 对话框打开时获取授权 URL(不创建 key)
async function initOAuth() {
if (!props.providerId) return
@@ -192,7 +364,6 @@ async function initOAuth() {
}
}
// 完成授权(此时才创建 key
async function handleCompleteOAuth() {
if (!canCompleteOAuth.value || !props.providerId) return
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) => {
if (newOpen) {
initOAuth()
@@ -265,10 +265,21 @@
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
</span>
<Button
v-if="key.auth_type === 'oauth'"
variant="ghost"
size="icon"
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)"
>
<Copy class="w-2.5 h-2.5" />
@@ -524,15 +535,18 @@
/>
</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"
>
<template v-if="group.resetSeconds > 0">
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
{{ formatResetTime(group.resetSeconds) }}后重置
</template>
<template v-else>
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
已重置
</template>
<template v-else>
重置时间未知
</template>
</div>
</div>
</div>
@@ -764,6 +778,7 @@ import {
Power,
GripVertical,
Copy,
Download,
Shield,
Shuffle,
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) {
keyToDelete.value = key
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-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: 'other', label: 'Other', match: () => true },
]
interface AntigravityQuotaSummaryItem {
+143 -9
View File
@@ -26,6 +26,66 @@
/>
</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" />
<!-- 调度策略 -->
@@ -73,20 +133,20 @@
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="text-muted-foreground mb-2">
<template v-if="searchQuery">
未找到匹配 "{{ searchQuery }}" 的提供商
<template v-if="hasActiveFilters">
未找到匹配当前筛选条件的提供商
</template>
<template v-else>
暂无提供商点击右上角添加
</template>
</div>
<Button
v-if="searchQuery"
v-if="hasActiveFilters"
variant="outline"
size="sm"
@click="searchQuery = ''"
@click="searchQuery = ''; filterStatus = 'all'; filterApiFormat = 'all'; filterModel = 'all'"
>
清除搜索
清除筛选
</Button>
</div>
@@ -604,7 +664,8 @@ import {
ChevronDown,
Power,
KeyRound,
Loader2
Loader2,
FilterX
} from 'lucide-vue-next'
import Button from '@/components/ui/button.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 Pagination from '@/components/ui/pagination.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 ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast'
@@ -627,6 +693,7 @@ import {
getProvidersSummary,
deleteProvider,
updateProvider,
getGlobalModels,
type ProviderWithEndpointsSummary,
API_FORMAT_SHORT
} from '@/api/endpoints'
@@ -659,8 +726,44 @@ const balanceCache = ref<Record<string, ActionResultResponse>>({})
// 使用普通变量而非 ref,因为不需要响应式,仅用于比较请求版本
let balanceLoadVersion = 0
// 搜索
// 搜索与筛选
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)
@@ -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) => {
// 1. 优先显示活跃的提供商
@@ -715,8 +838,8 @@ const paginatedProviders = computed(() => {
return filteredProviders.value.slice(start, end)
})
// 搜索时重置分页
watch(searchQuery, () => {
// 搜索/筛选时重置分页
watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
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() {
loading.value = true
@@ -1179,6 +1312,7 @@ let tickInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
loadProviders()
loadPriorityMode()
loadGlobalModelList()
// 每秒更新一次倒计时
tickInterval = setInterval(() => {
tickCounter.value++
+27 -7
View File
@@ -30,6 +30,7 @@ from src.models.endpoint_models import (
EndpointAPIKeyUpdate,
)
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
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
try:
decrypted_key = crypto_service.decrypt(key.api_key)
@@ -1178,13 +1202,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if key.id in metadata_updates:
updates = metadata_updates[key.id]
if isinstance(updates, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# 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
key.upstream_metadata = merge_upstream_metadata(
key.upstream_metadata, updates
)
db.add(key)
# 提交数据库更改
+160 -2
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.types import ProviderType
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"])
@@ -189,7 +190,7 @@ def _parse_callback_params(callback_url: str) -> dict[str, str]:
@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
result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items():
@@ -216,6 +217,7 @@ async def start_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -295,6 +297,7 @@ async def complete_oauth(
payload: CompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
redis = await get_redis_client(require_redis=True)
assert redis is not None
@@ -428,6 +431,7 @@ async def refresh_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CompleteOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
@@ -586,6 +590,7 @@ async def start_provider_oauth(
provider_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> StartOAuthResponse:
"""基于 Provider 启动 OAuth(不需要预先创建 key)。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
@@ -659,6 +664,7 @@ async def complete_provider_oauth(
payload: ProviderCompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProviderCompleteOAuthResponse:
"""完成 Provider OAuth 并创建 key。"""
redis = await get_redis_client(require_redis=True)
@@ -799,3 +805,155 @@ async def complete_provider_oauth(
has_refresh_token=bool(refresh_token),
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"),
)
+14
View File
@@ -240,6 +240,19 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
total_models = model_stats.total 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]
# 优化: 一次性加载 Provider 的 keys,避免 N+1 查询
@@ -328,6 +341,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
active_keys=active_keys,
total_models=total_models,
active_models=active_models,
global_model_ids=global_model_ids,
avg_health_score=avg_health_score,
unhealthy_endpoints=unhealthy_endpoints,
api_formats=api_formats,
@@ -409,6 +409,32 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
"""
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(
self,
request_body: dict[str, Any],
@@ -827,6 +853,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
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).
if provider_api_format:
enforce_stream_mode_for_upstream(
@@ -1440,6 +1473,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
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).
if provider_api_format:
enforce_stream_mode_for_upstream(
+40
View File
@@ -368,6 +368,32 @@ class CliMessageHandlerBase(BaseMessageHandler):
"""
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
def _get_format_metadata(format_id: str) -> "EndpointDefinition | None":
"""获取 endpoint 元数据(解析失败返回 None)"""
@@ -801,6 +827,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
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).
if provider_api_format:
enforce_stream_mode_for_upstream(
@@ -2812,6 +2845,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
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).
if provider_api_format:
enforce_stream_mode_for_upstream(
+20 -1
View File
@@ -708,6 +708,7 @@ class StreamProcessor:
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
line = ""
buffer = b"" # 标记已消费,避免 finally 中重复处理
if line:
# 需要格式转换时,跳过记录原始数据
_process_line_with_perf(line, skip_record=True)
@@ -782,8 +783,26 @@ class StreamProcessor:
logger.warning(
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():
self.handle_sse_event(ctx, event.get("event"), event.get("data") or "")
+67
View File
@@ -8,6 +8,7 @@
"""
import asyncio
import json
import time
from typing import Any
@@ -104,6 +105,19 @@ class StreamTelemetryRecorder:
if writer is None:
return
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)
include_bodies = (
writer.include_bodies
@@ -536,6 +550,59 @@ class StreamTelemetryRecorder:
return "cancelled"
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:
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()
+16
View File
@@ -157,6 +157,22 @@ class GeminiChatHandler(ChatHandlerBase):
"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:
"""
规范化 Gemini 响应
+45
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
+16
View File
@@ -78,6 +78,22 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
result.pop("model", None)
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(
self,
request_body: dict[str, Any],
+1
View File
@@ -759,6 +759,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Model 统计
total_models: int = Field(default=0, description="总模型数量")
active_models: int = Field(default=0, description="活跃模型数量")
global_model_ids: list[str] = Field(default=[], description="活跃模型关联的全局模型 ID 列表")
# API 格式列表
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
+4 -7
View File
@@ -30,6 +30,7 @@ from src.models.database import Provider, ProviderAPIKey
from src.services.model.upstream_fetcher import (
UpstreamModelsFetchContext,
fetch_models_for_key,
merge_upstream_metadata,
)
from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.system.scheduler import get_scheduler
@@ -491,13 +492,9 @@ class ModelFetchScheduler:
# 最佳努力:保存上游元数据(如 Antigravity 配额信息)
if upstream_metadata and isinstance(upstream_metadata, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# 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
key.upstream_metadata = merge_upstream_metadata(
key.upstream_metadata, upstream_metadata
)
# 去重获取模型 ID 列表
fetched_model_ids: set[str] = set()
+44
View File
@@ -82,6 +82,50 @@ async def fetch_models_for_key(
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()
# (called from envelope.py bootstrap)
@@ -345,31 +345,45 @@ def wrap_v1internal_request(
1. 移除 model移到顶层
2. 移除 safetySettingsv1internal 不支持
3. 深度清理 [undefined] 字符串
4. Claude model tool ID 注入
5. Thinking budget 处理自动注入 + Auto Cap
6. 工具声明清洗schema 清理 + 字段重命名
7. System Instruction 注入
4. Claude model tool ID 注入图像生成模型跳过
5. Thinking budget 处理
6. 工具声明清洗图像生成模型跳过
7. System Instruction 注入图像生成模型跳过
8. 注入 sessionId对齐 CLIProxyAPI
9. 构建 v1internal 信封
"""
from src.api.handlers.gemini.image_gen import is_image_gen_model
inner_request = dict(gemini_request)
inner_request.pop("model", None)
inner_request.pop("safetySettings", None)
is_image_gen = is_image_gen_model(model)
# 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request)
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
if not is_image_gen:
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
# 3. Thinking budget 处理
_process_thinking_budget(inner_request, model)
# 4. 工具声明清洗
_clean_tool_declarations(inner_request)
if not is_image_gen:
# 4. 工具声明清洗
_clean_tool_declarations(inner_request)
# 5. System Instruction 注入
_inject_system_instruction(inner_request)
# 5. System Instruction 注入
_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
if "sessionId" not in inner_request:
@@ -255,6 +255,11 @@ async def fetch_models_antigravity(
quota_info = model_data.get("quotaInfo")
if not isinstance(quota_info, dict):
# 没有 quotaInfo 视为配额耗尽
quota_by_model[model_id] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
continue
remaining = quota_info.get("remainingFraction")
@@ -268,6 +273,14 @@ async def fetch_models_antigravity(
remaining_fraction = 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
used_percent = (1.0 - remaining_fraction) * 100.0
@@ -1,3 +1,4 @@
import json
from typing import Any
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")
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