mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
40
README.md
40
README.md
@@ -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,如果迁移涉及不可逆的数据变更(如删除列),可能无法完全恢复数据。因此强烈建议升级前备份。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# 提交数据库更改
|
# 提交数据库更改
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 响应
|
||||||
|
|||||||
45
src/api/handlers/gemini/image_gen.py
Normal file
45
src/api/handlers/gemini/image_gen.py
Normal 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
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 格式列表")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -345,31 +345,45 @@ def wrap_v1internal_request(
|
|||||||
1. 移除 model(移到顶层)
|
1. 移除 model(移到顶层)
|
||||||
2. 移除 safetySettings(v1internal 不支持)
|
2. 移除 safetySettings(v1internal 不支持)
|
||||||
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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user