mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -19,6 +19,49 @@
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu :modal="false">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="loadingGlobalModels || loadingProviderKeys || fetchingAutoMatchedModels || providerKeys.length === 0"
|
||||
:title="autoMatchButtonTitle"
|
||||
aria-label="按密钥匹配"
|
||||
>
|
||||
<Loader2
|
||||
v-if="loadingProviderKeys || fetchingAutoMatchedModels"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<ListChecks
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="w-72 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="key in providerKeys"
|
||||
:key="key.id"
|
||||
class="flex-col items-start gap-0.5"
|
||||
:disabled="fetchingAutoMatchedModels"
|
||||
@select="applyAutoMatchFromKey(key)"
|
||||
>
|
||||
<span class="w-full truncate font-medium">
|
||||
{{ getAutoMatchKeyLabel(key) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAutoMatchKeyDetail(key)"
|
||||
class="w-full truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ getAutoMatchKeyDetail(key) }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
@@ -126,45 +169,68 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
|
||||
import { Layers, Loader2, Search, Check, ListChecks } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
import {
|
||||
getGlobalModels,
|
||||
type GlobalModelResponse
|
||||
} from '@/api/endpoints/global-models'
|
||||
import {
|
||||
getProviderModels,
|
||||
getProviderKeys,
|
||||
batchAssignModelsToProvider,
|
||||
deleteModel,
|
||||
type Model
|
||||
type Model,
|
||||
type EndpointAPIKey
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
type AutoMatchKey = Pick<EndpointAPIKey, 'id' | 'name' | 'api_key_masked'>
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName?: string
|
||||
}>()
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
interface AutoMatchKeyLike {
|
||||
id: string
|
||||
name?: string | null
|
||||
api_key_masked?: string | null
|
||||
}
|
||||
|
||||
const { error: showError, success, warning: showWarning } = useToast()
|
||||
const { confirmWarning } = useConfirm()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
// 状态
|
||||
const loadingGlobalModels = ref(false)
|
||||
const loadingProviderKeys = ref(false)
|
||||
const saving = ref(false)
|
||||
const fetchingAutoMatchedModels = ref(false)
|
||||
|
||||
// 数据
|
||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const existingModels = ref<Model[]>([])
|
||||
const providerKeys = ref<AutoMatchKey[]>([])
|
||||
|
||||
// 选择状态(本地状态,保存时才提交)
|
||||
const selectedGlobalModelIds = ref<Set<string>>(new Set())
|
||||
@@ -175,6 +241,12 @@ const initialGlobalModelIds = ref<Set<string>>(new Set())
|
||||
// 搜索状态
|
||||
const searchQuery = ref('')
|
||||
|
||||
const autoMatchButtonTitle = computed(() => {
|
||||
if (loadingProviderKeys.value) return '正在加载密钥'
|
||||
if (providerKeys.value.length === 0) return '暂无可用于匹配的密钥'
|
||||
return '选择密钥,并按该密钥的上游模型自动勾选同名模型'
|
||||
})
|
||||
|
||||
// 已关联的全局模型 ID 集合(从已有数据计算)
|
||||
const existingGlobalModelIds = computed(() => {
|
||||
return new Set(
|
||||
@@ -264,6 +336,77 @@ function toggleAllGlobalModels() {
|
||||
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
|
||||
}
|
||||
|
||||
function normalizeModelName(name: string | null | undefined): string {
|
||||
return (name || '').trim()
|
||||
}
|
||||
|
||||
function getAutoMatchKeyLabel(key: AutoMatchKeyLike): string {
|
||||
return key.name || key.api_key_masked || key.id.slice(0, 8)
|
||||
}
|
||||
|
||||
function getAutoMatchKeyDetail(key: AutoMatchKeyLike): string {
|
||||
if (key.name && key.api_key_masked) return key.api_key_masked
|
||||
return key.name ? key.id.slice(0, 8) : ''
|
||||
}
|
||||
|
||||
async function applyAutoMatchFromKey(key: AutoMatchKey) {
|
||||
if (!props.providerId || !key || fetchingAutoMatchedModels.value) return
|
||||
|
||||
fetchingAutoMatchedModels.value = true
|
||||
try {
|
||||
const result = await fetchCachedModels(props.providerId, key.id, true)
|
||||
if (!props.open) return
|
||||
|
||||
if (result.warning) {
|
||||
showWarning(`部分格式获取失败: ${result.warning}`)
|
||||
}
|
||||
|
||||
if (result.models.length === 0) {
|
||||
if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
} else {
|
||||
showWarning('此 Key 未返回可用模型')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const upstreamModelIds = new Set(
|
||||
result.models
|
||||
.map(model => normalizeModelName(model.id))
|
||||
.filter(Boolean)
|
||||
)
|
||||
const matchedGlobalModelIds = allGlobalModels.value
|
||||
.filter(model => upstreamModelIds.has(normalizeModelName(model.name)))
|
||||
.map(model => model.id)
|
||||
|
||||
if (matchedGlobalModelIds.length === 0) {
|
||||
showWarning('未找到与此 Key 上游模型 ID 同名的全局模型')
|
||||
return
|
||||
}
|
||||
|
||||
const nextSelected = new Set(selectedGlobalModelIds.value)
|
||||
let newlySelectedCount = 0
|
||||
for (const id of matchedGlobalModelIds) {
|
||||
if (!nextSelected.has(id)) {
|
||||
newlySelectedCount++
|
||||
}
|
||||
nextSelected.add(id)
|
||||
}
|
||||
selectedGlobalModelIds.value = nextSelected
|
||||
searchQuery.value = ''
|
||||
|
||||
if (newlySelectedCount > 0) {
|
||||
success(`已按 ${getAutoMatchKeyLabel(key)} 勾选 ${matchedGlobalModelIds.length} 个同名模型`)
|
||||
} else {
|
||||
success(`${matchedGlobalModelIds.length} 个同名模型已在选中列表中`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '自动匹配模型失败'), '错误')
|
||||
} finally {
|
||||
fetchingAutoMatchedModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
async function handleClose() {
|
||||
if (hasChanges.value) {
|
||||
@@ -355,12 +498,14 @@ watch(() => props.open, async (isOpen) => {
|
||||
searchQuery.value = ''
|
||||
selectedGlobalModelIds.value = new Set()
|
||||
initialGlobalModelIds.value = new Set()
|
||||
providerKeys.value = []
|
||||
fetchingAutoMatchedModels.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||
await Promise.all([loadGlobalModels(), loadExistingModels(), loadProviderKeys()])
|
||||
syncGlobalModelSelection()
|
||||
}
|
||||
|
||||
@@ -385,4 +530,17 @@ async function loadExistingModels() {
|
||||
showError(parseApiError(err, '加载已关联模型失败'), '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载密钥列表
|
||||
async function loadProviderKeys() {
|
||||
try {
|
||||
loadingProviderKeys.value = true
|
||||
providerKeys.value = await getProviderKeys(props.providerId)
|
||||
} catch (err: unknown) {
|
||||
providerKeys.value = []
|
||||
showError(parseApiError(err, '加载密钥失败'), '错误')
|
||||
} finally {
|
||||
loadingProviderKeys.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -156,10 +156,16 @@
|
||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||
<Input
|
||||
:model-value="getDisplayedPath(endpoint)"
|
||||
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
|
||||
:disabled="isFixedProvider"
|
||||
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
|
||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||
/>
|
||||
<p
|
||||
v-if="getEndpointDefaultPath(endpoint)"
|
||||
class="text-[10px] text-muted-foreground truncate"
|
||||
:title="getEndpointDefaultPath(endpoint)"
|
||||
>
|
||||
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
||||
@@ -969,6 +975,13 @@
|
||||
size="sm"
|
||||
:placeholder="newEndpointDefaultPath || '留空使用默认'"
|
||||
/>
|
||||
<p
|
||||
v-if="newEndpointDefaultPath"
|
||||
class="text-[10px] text-muted-foreground truncate"
|
||||
:title="newEndpointDefaultPath"
|
||||
>
|
||||
当前默认路径:{{ newEndpointDefaultPath }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1869,10 +1882,11 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
|
||||
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
if (isFixedProvider.value) {
|
||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
||||
}
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
|
||||
@@ -3211,13 +3225,13 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
||||
|
||||
savingEndpointId.value = endpoint.id
|
||||
try {
|
||||
// 仅提交变更字段,避免 fixed provider 因 base_url/custom_path 被锁定而更新失败
|
||||
// 仅提交变更字段;fixed provider 锁定 base_url,但允许覆盖 custom_path。
|
||||
const payload: Record<string, unknown> = {}
|
||||
|
||||
if (!isFixedProvider.value) {
|
||||
if (state.url !== endpoint.base_url) payload.base_url = state.url
|
||||
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
|
||||
}
|
||||
if (state.path !== (endpoint.custom_path || '')) payload.custom_path = state.path || null
|
||||
|
||||
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
|
||||
if (hasResponseHeaderRulesChanges(endpoint)) {
|
||||
|
||||
@@ -212,7 +212,7 @@ const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
@@ -295,9 +295,8 @@ async function fetchUpstreamModels() {
|
||||
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
|
||||
.map((m: UpstreamModel) => m.id)
|
||||
hasQueried.value = true
|
||||
// 如果有部分失败,显示警告提示
|
||||
if (result.error) {
|
||||
showError(`部分格式获取失败: ${result.error}`, '警告')
|
||||
if (result.warning) {
|
||||
showWarning(result.warning, '部分格式获取失败')
|
||||
}
|
||||
} else if (result.error) {
|
||||
errorMessage.value = result.error
|
||||
|
||||
@@ -395,7 +395,7 @@ const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { confirmWarning } = useConfirm()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
@@ -691,6 +691,9 @@ async function fetchUpstreamModels(forceRefresh = false) {
|
||||
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
||||
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
|
||||
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
||||
if (result.warning) {
|
||||
showWarning(result.warning, '部分格式获取失败')
|
||||
}
|
||||
} else if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ const emit = defineEmits<{
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
type EndpointOption = {
|
||||
@@ -564,6 +564,9 @@ async function fetchUpstreamModels() {
|
||||
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
||||
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
||||
}
|
||||
if (result.warning) {
|
||||
showWarning(result.warning, '部分格式获取失败')
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
|
||||
@@ -1408,6 +1408,8 @@ function parseImportText(text: string): {
|
||||
const refreshTokenCamel = obj.refreshToken
|
||||
const accessToken = obj.access_token
|
||||
const accessTokenCamel = obj.accessToken
|
||||
const sessionToken = obj.session_token
|
||||
const sessionTokenCamel = obj.sessionToken
|
||||
const grokSsoToken = isGrokProvider.value
|
||||
? normalizeStringField(obj.sso_token) ?? normalizeStringField(obj.ssoToken) ?? normalizeStringField(obj.token) ?? grokCookieImport?.access_token
|
||||
: undefined
|
||||
@@ -1417,12 +1419,15 @@ function parseImportText(text: string): {
|
||||
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
|
||||
? accessToken.trim()
|
||||
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
|
||||
const importedAccessToken = normalizedAccessToken ?? grokSsoToken
|
||||
const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim()
|
||||
? sessionToken.trim()
|
||||
: (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined)
|
||||
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken
|
||||
if (normalizedRefreshToken || importedAccessToken) {
|
||||
return {
|
||||
refresh_token: normalizedRefreshToken,
|
||||
access_token: importedAccessToken,
|
||||
expires_at: normalizeNumberField(obj.expires_at) ?? normalizeNumberField(obj.expiresAt),
|
||||
expires_at: normalizeExpiryField(obj.expires_at) ?? normalizeExpiryField(obj.expiresAt) ?? normalizeExpiryField(obj.expired),
|
||||
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
|
||||
email: normalizeStringField(obj.email) ?? normalizeStringField(obj.oauth_email),
|
||||
account_id: normalizeStringField(obj.account_id) ?? normalizeStringField(obj.accountId) ?? normalizeStringField(obj.chatgpt_account_id) ?? normalizeStringField(obj.chatgptAccountId),
|
||||
@@ -1534,6 +1539,18 @@ function normalizeNumberField(value: unknown): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeExpiryField(value: unknown): number | undefined {
|
||||
const numeric = normalizeNumberField(value)
|
||||
if (numeric) return numeric
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Date.parse(value.trim())
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed / 1000)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isLikelyJwtToken(token: string): boolean {
|
||||
const parts = token.trim().split('.')
|
||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||
|
||||
@@ -205,6 +205,7 @@ import { RefreshCw, RotateCcw } from 'lucide-vue-next'
|
||||
import { getPoolStatus, clearPoolCooldown, resetPoolCost } from '@/api/endpoints/pool'
|
||||
import type { PoolStatusResponse } from '@/api/endpoints/pool'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatTokens } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
import Card from '@/components/ui/card.vue'
|
||||
@@ -287,12 +288,6 @@ function formatTTL(seconds: number): string {
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
function formatEmaHeat(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0.0'
|
||||
return value.toFixed(1)
|
||||
|
||||
@@ -1122,7 +1122,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">使用额度</span>
|
||||
<span class="text-muted-foreground">剩余额度</span>
|
||||
<span :class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))">
|
||||
{{ getChatGPTWebQuotaRemainingPercent(key).toFixed(1) }}%
|
||||
</span>
|
||||
@@ -1136,7 +1136,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||||
<span>
|
||||
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_used) }} /
|
||||
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_remaining) }} /
|
||||
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
|
||||
</span>
|
||||
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
|
||||
@@ -1389,7 +1389,7 @@
|
||||
:open="batchAssignDialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:provider-name="provider.name"
|
||||
@update:open="batchAssignDialogOpen = $event"
|
||||
@update:open="handleBatchAssignDialogOpenUpdate"
|
||||
@changed="handleBatchAssignChanged"
|
||||
/>
|
||||
|
||||
@@ -1506,6 +1506,7 @@ import {
|
||||
} from '../utils/quotaAutoRefreshCooldown'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import {
|
||||
canEditOAuthCredential,
|
||||
canExportOAuthCredential,
|
||||
@@ -2700,13 +2701,10 @@ const formatKiroUpdatedAt = formatUpdatedAt
|
||||
// 格式化 Kiro 使用量(带单位)
|
||||
function formatKiroUsage(value: number | undefined): string {
|
||||
if (value === undefined || value === null) return '-'
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`
|
||||
}
|
||||
return value.toFixed(1)
|
||||
const normalized = Number(value)
|
||||
if (!Number.isFinite(normalized)) return '-'
|
||||
if (normalized >= 1000) return formatCompactNumber(normalized, { fractionDigits: 1 })
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
// 格式化 Kiro 重置时间
|
||||
@@ -3186,6 +3184,10 @@ function handleBatchAssign() {
|
||||
batchAssignDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleBatchAssignDialogOpenUpdate(value: boolean) {
|
||||
batchAssignDialogOpen.value = value
|
||||
}
|
||||
|
||||
// 处理批量关联完成
|
||||
async function handleBatchAssignChanged() {
|
||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||
|
||||
@@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||
|
||||
const apiFormats = [
|
||||
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
|
||||
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'claude:messages', default_path: '/v1/messages' },
|
||||
]
|
||||
|
||||
describe('endpoint default paths', () => {
|
||||
@@ -52,4 +55,73 @@ describe('endpoint default paths', () => {
|
||||
apiFormats,
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/chat/completions')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:embedding',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
||||
apiFormats,
|
||||
})).toBe('/embeddings')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/openai',
|
||||
apiFormats,
|
||||
})).toBe('/chat/completions')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com',
|
||||
apiFormats,
|
||||
})).toBe('/v1/chat/completions')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
apiFormats,
|
||||
})).toBe('/chat/completions')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:responses',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.openai.example/v1',
|
||||
apiFormats,
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('keeps /v1 for Claude Messages defaults unless base URL already ends with v1', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.anthropic.example/v1',
|
||||
apiFormats,
|
||||
})).toBe('/messages')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/anthropic',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,75 @@ function isCodexUrl(baseUrl: string): boolean {
|
||||
return url.includes('/backend-api/codex') || url.endsWith('/codex')
|
||||
}
|
||||
|
||||
function parseBaseUrlParts(baseUrl?: string | null): { host: string; path: string } | null {
|
||||
const raw = (baseUrl || '').trim()
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
return {
|
||||
host: parsed.hostname.toLowerCase(),
|
||||
path: parsed.pathname.replace(/\/+$/, '').toLowerCase(),
|
||||
}
|
||||
} catch {
|
||||
const pathStart = raw.indexOf('/')
|
||||
return {
|
||||
host: '',
|
||||
path: pathStart >= 0 ? raw.slice(pathStart).split('?')[0].replace(/\/+$/, '').toLowerCase() : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrlHasPathApiRoot(baseUrl?: string | null): boolean {
|
||||
const path = parseBaseUrlParts(baseUrl)?.path
|
||||
return !!path && path !== '/'
|
||||
}
|
||||
|
||||
function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
|
||||
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
|
||||
}
|
||||
|
||||
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
|
||||
}
|
||||
|
||||
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'generativelanguage.googleapis.com'
|
||||
&& (parts.path === '/v1beta/openai' || parts.path === '/v1/openai')
|
||||
}
|
||||
|
||||
function isVertexOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return !!parts
|
||||
&& (parts.host === 'aiplatform.googleapis.com' || parts.host.endsWith('.aiplatform.googleapis.com') || parts.host.endsWith('-aiplatform.googleapis.com'))
|
||||
&& parts.path.endsWith('/endpoints/openapi')
|
||||
}
|
||||
|
||||
function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
return baseUrlEndsWithV1Root(baseUrl)
|
||||
|| baseUrlHasPathApiRoot(baseUrl)
|
||||
|| isBigModelCodingApiRoot(baseUrl)
|
||||
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
}
|
||||
|
||||
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
return baseUrlEndsWithV1Root(baseUrl)
|
||||
}
|
||||
|
||||
function stripV1PrefixForApiRoot(path: string): string {
|
||||
return path.replace(/^\/v1(?=\/)/i, '')
|
||||
}
|
||||
|
||||
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
||||
}
|
||||
|
||||
function isClaudeCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat === 'claude:messages'
|
||||
}
|
||||
|
||||
export function getDefaultEndpointPath(params: {
|
||||
apiFormat: string
|
||||
providerType?: string | null
|
||||
@@ -48,5 +117,11 @@ export function getDefaultEndpointPath(params: {
|
||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||
return '/responses'
|
||||
}
|
||||
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
}
|
||||
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
}
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
@@ -99,6 +99,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showKeySelector"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
测试 Key
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ keySelectionStatus }}
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
:model-value="selectedKeyIds"
|
||||
:options="keyOptions"
|
||||
:placeholder="keySelectorPlaceholder"
|
||||
search-placeholder="搜索 Key"
|
||||
empty-text="暂无可选 Key"
|
||||
no-results-text="未找到匹配 Key"
|
||||
trigger-class="h-9 min-h-9 rounded-md border-border/60 text-xs"
|
||||
dropdown-min-width="24rem"
|
||||
:search-threshold="0"
|
||||
:disabled="keyOptionsLoading && keyOptions.length === 0"
|
||||
@update:model-value="emit('update:selectedKeyIds', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -772,9 +799,11 @@ import {
|
||||
} from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import MultiSelect from '@/components/common/MultiSelect.vue'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { TestAttemptDetail, TestCandidateSummary, TestModelFailoverResponse } from '@/api/endpoints/providers'
|
||||
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
@@ -797,6 +826,8 @@ type TestModelMappingOption = {
|
||||
priority?: number
|
||||
}
|
||||
|
||||
type TestKeyOption = MultiSelectOption
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
result: TestModelFailoverResponse | null
|
||||
@@ -818,6 +849,9 @@ const props = defineProps<{
|
||||
modelMappingAvailable?: boolean
|
||||
modelMappingOptions?: TestModelMappingOption[]
|
||||
selectedModelMapping?: string | null
|
||||
keyOptions?: TestKeyOption[]
|
||||
selectedKeyIds?: string[]
|
||||
keyOptionsLoading?: boolean
|
||||
startDisabled?: boolean
|
||||
}>()
|
||||
|
||||
@@ -827,15 +861,30 @@ const emit = defineEmits<{
|
||||
start: []
|
||||
selectEndpoint: [endpointId: string]
|
||||
selectModelMapping: [modelName: string]
|
||||
'update:selectedKeyIds': [value: string[]]
|
||||
'update:requestHeadersDraft': [value: string]
|
||||
'update:requestBodyDraft': [value: string]
|
||||
}>()
|
||||
|
||||
const endpoints = computed(() => props.endpoints ?? [])
|
||||
const modelMappingOptions = computed(() => props.modelMappingOptions ?? [])
|
||||
const keyOptions = computed(() => props.keyOptions ?? [])
|
||||
const selectedKeyIds = computed(() => props.selectedKeyIds ?? [])
|
||||
const keyOptionsLoading = computed(() => props.keyOptionsLoading === true)
|
||||
const modelMappingAvailable = computed(
|
||||
() => props.modelMappingAvailable === true && modelMappingOptions.value.length > 0,
|
||||
)
|
||||
const showKeySelector = computed(() => (
|
||||
keyOptionsLoading.value || keyOptions.value.length > 0 || selectedKeyIds.value.length > 0
|
||||
))
|
||||
const keySelectorPlaceholder = computed(() => (
|
||||
keyOptionsLoading.value && keyOptions.value.length === 0 ? '正在加载 Key' : '默认调度(不指定 Key)'
|
||||
))
|
||||
const keySelectionStatus = computed(() => {
|
||||
if (selectedKeyIds.value.length > 0) return `已选 ${selectedKeyIds.value.length}`
|
||||
if (keyOptionsLoading.value) return '加载中'
|
||||
return '默认'
|
||||
})
|
||||
const requestedModelName = computed(() => props.requestedModelName?.trim() || '')
|
||||
const selectedModelMapping = computed(() => props.selectedModelMapping?.trim() || '')
|
||||
const selectedModelMappingValue = computed(() => (
|
||||
|
||||
@@ -232,12 +232,16 @@
|
||||
:model-mapping-available="testModelMappingAvailable"
|
||||
:model-mapping-options="testModelMappingOptions"
|
||||
:selected-model-mapping="selectedTestMappedModelName"
|
||||
:key-options="testKeyOptions"
|
||||
:selected-key-ids="selectedTestKeyIds"
|
||||
:key-options-loading="loadingModelTestKeys"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@start="handleStartPendingTest"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@select-model-mapping="handleSelectModelMapping"
|
||||
@update:selected-key-ids="handleSelectTestKeyIds"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
@@ -257,7 +261,7 @@ import {
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
} from '@/api/endpoints'
|
||||
import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
@@ -269,6 +273,7 @@ import {
|
||||
isModelTestableApiFormat,
|
||||
isModelTestableEndpoint,
|
||||
listModelTestMappedModelOptions,
|
||||
modelTestKeySupportsEndpoint,
|
||||
normalizeModelTestMappedModelSelection,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
@@ -307,6 +312,10 @@ const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const testRequestBodyResetValue = ref('')
|
||||
const selectedTestMappedModelName = ref<string | null>(null)
|
||||
const selectedTestKeyIds = ref<string[]>([])
|
||||
const modelTestProviderKeys = ref<EndpointAPIKey[]>([])
|
||||
const modelTestKeysLoadedProviderId = ref<string | null>(null)
|
||||
const loadingModelTestKeys = ref(false)
|
||||
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? [])
|
||||
.filter(endpoint => {
|
||||
@@ -336,6 +345,32 @@ const mappedTestModelName = computed(() => {
|
||||
: null
|
||||
})
|
||||
const testModelMappingAvailable = computed(() => testModelMappingOptions.value.length > 0)
|
||||
const providerKeysForModelTest = computed(() => (
|
||||
modelTestKeysLoadedProviderId.value === props.provider.id
|
||||
? modelTestProviderKeys.value
|
||||
: props.providerKeys ?? []
|
||||
))
|
||||
const testKeyOptions = computed(() => {
|
||||
const endpoint = selectedTestEndpoint.value
|
||||
if (!endpoint) return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
return [...providerKeysForModelTest.value]
|
||||
.filter((key) => {
|
||||
if (seen.has(key.id)) return false
|
||||
seen.add(key.id)
|
||||
return modelTestKeySupportsEndpoint(key, endpoint, props.provider.provider_type)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const priority = left.internal_priority - right.internal_priority
|
||||
if (priority !== 0) return priority
|
||||
return formatTestKeyOptionLabel(left).localeCompare(formatTestKeyOptionLabel(right))
|
||||
})
|
||||
.map(key => ({
|
||||
value: key.id,
|
||||
label: formatTestKeyOptionLabel(key),
|
||||
}))
|
||||
})
|
||||
const effectiveTestRequestModelName = computed(() => (
|
||||
mappedTestModelName.value || pendingRequestedModelName.value
|
||||
))
|
||||
@@ -506,6 +541,7 @@ function handleTestDialogClose() {
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
selectedTestMappedModelName.value = null
|
||||
selectedTestKeyIds.value = []
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
testRequestBodyDraft.value = ''
|
||||
@@ -524,6 +560,7 @@ function handleSelectTestEndpoint(endpointId: string) {
|
||||
selectedTestEndpoint.value = endpoint
|
||||
syncSelectedTestModelMapping()
|
||||
resetTestRequestBodyForSelectedEndpoint()
|
||||
pruneSelectedTestKeyIds()
|
||||
}
|
||||
|
||||
function handleSelectModelMapping(modelName: string) {
|
||||
@@ -534,6 +571,10 @@ function handleSelectModelMapping(modelName: string) {
|
||||
syncTestRequestBodyModel()
|
||||
}
|
||||
|
||||
function handleSelectTestKeyIds(ids: string[]) {
|
||||
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(ids)
|
||||
}
|
||||
|
||||
async function handleStartPendingTest() {
|
||||
if (modelTest.testing.value) return
|
||||
if (!pendingTestModel.value) return
|
||||
@@ -557,6 +598,7 @@ async function handleStartPendingTest() {
|
||||
}
|
||||
|
||||
selectedTestEndpoint.value = endpoint
|
||||
pruneSelectedTestKeyIds()
|
||||
const model = pendingTestModel.value
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
|
||||
@@ -567,6 +609,7 @@ async function handleStartPendingTest() {
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
endpointBaseUrl: endpoint.base_url,
|
||||
apiKeyIds: selectedTestKeyIds.value,
|
||||
applyModelMapping: Boolean(mappedTestModelName.value),
|
||||
mappedModelName: mappedTestModelName.value ?? undefined,
|
||||
requestHeaders,
|
||||
@@ -591,6 +634,7 @@ async function testModelConnection(model: Model) {
|
||||
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
|
||||
const requestedModelName = getModelTestRequestedModelName(model)
|
||||
selectedTestMappedModelName.value = null
|
||||
selectedTestKeyIds.value = []
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
@@ -601,6 +645,49 @@ async function testModelConnection(model: Model) {
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
void ensureModelTestKeysLoaded()
|
||||
}
|
||||
|
||||
function normalizeSelectedTestKeyIds(ids: string[]): string[] {
|
||||
const allowed = new Set(testKeyOptions.value.map(option => option.value))
|
||||
const selected = ids
|
||||
.map(id => id.trim())
|
||||
.filter(id => id && allowed.has(id))
|
||||
return [...new Set(selected)]
|
||||
}
|
||||
|
||||
function pruneSelectedTestKeyIds() {
|
||||
if (selectedTestKeyIds.value.length === 0) return
|
||||
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(selectedTestKeyIds.value)
|
||||
}
|
||||
|
||||
async function ensureModelTestKeysLoaded() {
|
||||
if (modelTestKeysLoadedProviderId.value === props.provider.id || loadingModelTestKeys.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingModelTestKeys.value = true
|
||||
try {
|
||||
modelTestProviderKeys.value = await getProviderKeys(props.provider.id)
|
||||
modelTestKeysLoadedProviderId.value = props.provider.id
|
||||
pruneSelectedTestKeyIds()
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '加载测试 Key 失败'), '错误')
|
||||
} finally {
|
||||
loadingModelTestKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTestKeyOptionLabel(key: EndpointAPIKey): string {
|
||||
const name = key.name?.trim()
|
||||
const masked = key.api_key_masked?.trim()
|
||||
const authType = key.auth_type?.trim()
|
||||
const primary = name || masked || key.id
|
||||
const suffix = [
|
||||
masked && masked !== primary ? masked : '',
|
||||
authType || '',
|
||||
].filter(Boolean)
|
||||
return suffix.length > 0 ? `${primary} · ${suffix.join(' · ')}` : primary
|
||||
}
|
||||
|
||||
function getModelTestRequestedModelName(model: Model | null): string {
|
||||
@@ -661,6 +748,17 @@ watch(
|
||||
() => syncTestRequestBodyModel(),
|
||||
)
|
||||
|
||||
watch(testKeyOptions, () => pruneSelectedTestKeyIds())
|
||||
|
||||
watch(
|
||||
() => props.provider.id,
|
||||
() => {
|
||||
modelTestProviderKeys.value = []
|
||||
modelTestKeysLoadedProviderId.value = null
|
||||
selectedTestKeyIds.value = []
|
||||
},
|
||||
)
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose({
|
||||
reload: refresh
|
||||
|
||||
+5
@@ -372,6 +372,11 @@ describe('isModelTestableEndpoint', () => {
|
||||
})
|
||||
|
||||
describe('formatModelTestDiagnostic', () => {
|
||||
it('maps model permission skips to an actionable label', () => {
|
||||
expect(formatModelTestDiagnostic('key_model_not_allowed'))
|
||||
.toBe('Key 未允许当前模型,已跳过')
|
||||
})
|
||||
|
||||
it('maps pool account blocked scheduler code to an actionable label', () => {
|
||||
expect(formatModelTestDiagnostic('pool_account_blocked')).toBe('账号已失效,需重新授权')
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
|
||||
])
|
||||
|
||||
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
|
||||
key_model_not_allowed: 'Key 未允许当前模型,已跳过',
|
||||
pool_account_blocked: '账号已失效,需重新授权',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user