feat(openai): port /responses/compact account support flow (PR #1555)
将 vansour/sub2api#1555 的 OpenAI compact 能力建模手工移植到当前 main:账号 级 compact 状态/auto-force_on-force_off 模式、compact-only 模型映射、调度器 tier 分层(已支持 > 未知 > 已知不支持)、管理后台 compact 主动探测,以及对应 i18n/状态徽章。普通 /responses 流量行为不变,无数据库迁移。
This commit is contained in:
@@ -55,6 +55,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isOpenAIAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.openai.testMode') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="testMode"
|
||||
:options="openAITestModeOptions"
|
||||
:disabled="status === 'connecting'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
v-model="testPrompt"
|
||||
@@ -274,6 +285,12 @@ const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const testMode = ref<'default' | 'compact'>('default')
|
||||
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
|
||||
const openAITestModeOptions = computed(() => [
|
||||
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
|
||||
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
|
||||
])
|
||||
const previewImageUrl = ref('')
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
@@ -308,6 +325,7 @@ watch(
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
testPrompt.value = ''
|
||||
testMode.value = 'default'
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
@@ -410,9 +428,10 @@ const startTest = async () => {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
}),
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : '',
|
||||
mode: isOpenAIAccount.value ? testMode.value : 'default'
|
||||
}),
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
|
||||
@@ -2449,6 +2449,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Compact 能力配置 -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.compactMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.compactModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-44">
|
||||
<Select v-model="openAICompactMode" :options="openAICompactModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.openai.compactModelMapping') }}</label>
|
||||
<p class="input-hint">{{ t('admin.accounts.openai.compactModelMappingDesc') }}</p>
|
||||
<div v-if="openAICompactModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in openAICompactModelMappings"
|
||||
:key="getOpenAICompactModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" />
|
||||
<span class="text-gray-400">→</span>
|
||||
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" />
|
||||
<button type="button" @click="removeOpenAICompactModelMapping(index)" class="text-red-500 hover:text-red-700">
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="addOpenAICompactModelMapping" class="btn btn-secondary text-sm">
|
||||
+ {{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -2918,7 +2957,8 @@ import type {
|
||||
AccountPlatform,
|
||||
AccountType,
|
||||
CheckMixedChannelResponse,
|
||||
CreateAccountRequest
|
||||
CreateAccountRequest,
|
||||
OpenAICompactMode
|
||||
} from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
@@ -3059,6 +3099,7 @@ const editWeeklyResetDay = ref<number | null>(null)
|
||||
const editWeeklyResetHour = ref<number | null>(null)
|
||||
const editResetTimezone = ref<string | null>(null)
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||
@@ -3071,6 +3112,7 @@ const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
@@ -3112,10 +3154,16 @@ const bedrockApiKeyValue = ref('')
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
||||
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
const openAICompactModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
|
||||
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
|
||||
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
|
||||
])
|
||||
|
||||
function buildAntigravityExtra(): Record<string, unknown> | undefined {
|
||||
const extra: Record<string, unknown> = {}
|
||||
@@ -3124,6 +3172,9 @@ function buildAntigravityExtra(): Record<string, unknown> | undefined {
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
const buildOpenAICompactModelMapping = () =>
|
||||
buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||
|
||||
const showMixedChannelWarning = ref(false)
|
||||
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
||||
null
|
||||
@@ -3489,6 +3540,14 @@ const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const addOpenAICompactModelMapping = () => {
|
||||
openAICompactModelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeOpenAICompactModelMapping = (index: number) => {
|
||||
openAICompactModelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const removeModelMapping = (index: number) => {
|
||||
modelMappings.value.splice(index, 1)
|
||||
}
|
||||
@@ -3781,6 +3840,7 @@ const resetForm = () => {
|
||||
editWeeklyResetHour.value = null
|
||||
editResetTimezone.value = null
|
||||
modelMappings.value = []
|
||||
openAICompactModelMappings.value = []
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = [...claudeModels] // Default fill related models
|
||||
|
||||
@@ -3797,6 +3857,7 @@ const resetForm = () => {
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
openaiPassthroughEnabled.value = false
|
||||
openAICompactMode.value = 'auto'
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
@@ -3874,6 +3935,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
||||
} else {
|
||||
delete extra.codex_cli_only
|
||||
}
|
||||
if (openAICompactMode.value !== 'auto') {
|
||||
extra.openai_compact_mode = openAICompactMode.value
|
||||
} else {
|
||||
delete extra.openai_compact_mode
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
@@ -4086,6 +4152,12 @@ const handleSubmit = async () => {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
}
|
||||
if (form.platform === 'openai') {
|
||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||
if (compactModelMapping) {
|
||||
credentials.compact_model_mapping = compactModelMapping
|
||||
}
|
||||
}
|
||||
|
||||
// Add pool mode if enabled
|
||||
if (poolModeEnabled.value) {
|
||||
@@ -4198,6 +4270,14 @@ const createAccountAndFinish = async (
|
||||
finalExtra = quotaExtra
|
||||
}
|
||||
}
|
||||
if (platform === 'openai') {
|
||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||
if (compactModelMapping) {
|
||||
credentials.compact_model_mapping = compactModelMapping
|
||||
} else {
|
||||
delete credentials.compact_model_mapping
|
||||
}
|
||||
}
|
||||
await doCreateAccount({
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
@@ -4252,6 +4332,12 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
}
|
||||
if (shouldCreateOpenAI) {
|
||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||
if (compactModelMapping) {
|
||||
credentials.compact_model_mapping = compactModelMapping
|
||||
}
|
||||
}
|
||||
|
||||
// 应用临时不可调度配置
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
@@ -4344,6 +4430,12 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
}
|
||||
if (shouldCreateOpenAI) {
|
||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||
if (compactModelMapping) {
|
||||
credentials.compact_model_mapping = compactModelMapping
|
||||
}
|
||||
}
|
||||
|
||||
// Generate account name; fallback to email if name is empty (ent schema requires NotEmpty)
|
||||
const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account'
|
||||
|
||||
@@ -1306,6 +1306,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.compactMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.compactModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-44">
|
||||
<Select v-model="openAICompactMode" :options="openAICompactModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||
<span class="font-medium">{{ t(openAICompactStatusKey) }}</span>
|
||||
<span
|
||||
v-if="account?.extra?.openai_compact_checked_at"
|
||||
class="ml-2 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.accounts.openai.compactLastChecked') }}:
|
||||
{{ formatDateTime(new Date(String(account.extra.openai_compact_checked_at))) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.openai.compactModelMapping') }}</label>
|
||||
<p class="input-hint">{{ t('admin.accounts.openai.compactModelMappingDesc') }}</p>
|
||||
<div v-if="openAICompactModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in openAICompactModelMappings"
|
||||
:key="getOpenAICompactModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.fromModel')"
|
||||
/>
|
||||
<span class="text-gray-400">→</span>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.toModel')"
|
||||
/>
|
||||
<button type="button" @click="removeOpenAICompactModelMapping(index)" class="text-red-500 hover:text-red-700">
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="addOpenAICompactModelMapping" class="btn btn-secondary text-sm">
|
||||
+ {{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1849,7 +1907,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
|
||||
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
|
||||
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse, OpenAICompactMode } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -1859,7 +1917,7 @@ import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
@@ -1934,6 +1992,7 @@ const isBedrockAPIKeyMode = computed(() =>
|
||||
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
|
||||
)
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||
@@ -1953,6 +2012,7 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||
|
||||
@@ -1992,6 +2052,7 @@ const customBaseUrl = ref('')
|
||||
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
@@ -2045,9 +2106,27 @@ const openaiResponsesWebSocketV2Mode = computed({
|
||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
const openAICompactModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
|
||||
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
|
||||
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
|
||||
])
|
||||
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||||
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
|
||||
)
|
||||
const openAICompactStatusKey = computed(() => {
|
||||
const extra = props.account?.extra as Record<string, unknown> | undefined
|
||||
if (!props.account || props.account.platform !== 'openai') return ''
|
||||
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
|
||||
if (mode === 'force_on') return 'admin.accounts.openai.compactSupported'
|
||||
if (mode === 'force_off') return 'admin.accounts.openai.compactUnsupported'
|
||||
if (typeof extra?.openai_compact_supported === 'boolean') {
|
||||
return extra.openai_compact_supported
|
||||
? 'admin.accounts.openai.compactSupported'
|
||||
: 'admin.accounts.openai.compactUnsupported'
|
||||
}
|
||||
return 'admin.accounts.openai.compactUnknown'
|
||||
})
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
@@ -2177,6 +2256,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
openAICompactMode.value = 'auto'
|
||||
openAICompactModelMappings.value = []
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
@@ -2184,6 +2265,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
webSearchEmulationMode.value = 'default'
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||||
modeKey: 'openai_oauth_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
|
||||
@@ -2199,6 +2281,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
if (newAccount.type === 'oauth') {
|
||||
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
|
||||
}
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | undefined
|
||||
if (compactMappings && typeof compactMappings === 'object') {
|
||||
openAICompactModelMappings.value = Object.entries(compactMappings).map(([from, to]) => ({ from, to }))
|
||||
}
|
||||
}
|
||||
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
|
||||
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
|
||||
@@ -2423,6 +2510,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editApiKey.value = ''
|
||||
}
|
||||
|
||||
async function loadTLSProfiles() {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.show, () => props.account],
|
||||
([show, newAccount], [wasShow, previousAccount]) => {
|
||||
@@ -2437,15 +2533,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadTLSProfiles = async () => {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@@ -2468,6 +2555,14 @@ const addAntigravityModelMapping = () => {
|
||||
antigravityModelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const addOpenAICompactModelMapping = () => {
|
||||
openAICompactModelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeOpenAICompactModelMapping = (index: number) => {
|
||||
openAICompactModelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const removeAntigravityModelMapping = (index: number) => {
|
||||
antigravityModelMappings.value.splice(index, 1)
|
||||
}
|
||||
@@ -2911,6 +3006,14 @@ const handleSubmit = async () => {
|
||||
} else if (currentCredentials.model_mapping) {
|
||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||
}
|
||||
if (props.account.platform === 'openai') {
|
||||
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||
if (compactModelMapping) {
|
||||
newCredentials.compact_model_mapping = compactModelMapping
|
||||
} else {
|
||||
delete newCredentials.compact_model_mapping
|
||||
}
|
||||
}
|
||||
|
||||
// Add pool mode if enabled
|
||||
if (poolModeEnabled.value) {
|
||||
@@ -3036,6 +3139,12 @@ const handleSubmit = async () => {
|
||||
// 透传模式保留现有映射
|
||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||
}
|
||||
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||
if (compactModelMapping) {
|
||||
newCredentials.compact_model_mapping = compactModelMapping
|
||||
} else {
|
||||
delete newCredentials.compact_model_mapping
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
@@ -3208,6 +3317,11 @@ const handleSubmit = async () => {
|
||||
delete newExtra.openai_passthrough
|
||||
delete newExtra.openai_oauth_passthrough
|
||||
}
|
||||
if (openAICompactMode.value === 'auto') {
|
||||
delete newExtra.openai_compact_mode
|
||||
} else {
|
||||
newExtra.openai_compact_mode = openAICompactMode.value
|
||||
}
|
||||
|
||||
if (props.account.type === 'oauth') {
|
||||
if (codexCLIOnlyEnabled.value) {
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('account.creditsExhausted')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
|
||||
})
|
||||
|
||||
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
|
||||
@@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => {
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
expect(wrapper.text()).not.toContain('⚡')
|
||||
// AICredits 积分耗尽状态应显示
|
||||
expect(wrapper.text()).toContain('account.creditsExhausted')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import AccountTestModal from '../AccountTestModal.vue'
|
||||
|
||||
const { getAvailableModelsMock } = vi.hoisted(() => ({
|
||||
getAvailableModelsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
getAvailableModels: getAvailableModelsMock
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const BaseDialogStub = defineComponent({
|
||||
name: 'BaseDialog',
|
||||
props: { show: { type: Boolean, default: false } },
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'SelectStub',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, Boolean, null], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
valueKey: { type: String, default: 'value' },
|
||||
labelKey: { type: String, default: 'label' }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option[valueKey]"
|
||||
:value="option[valueKey]"
|
||||
>
|
||||
{{ option[labelKey] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
})
|
||||
|
||||
const TextAreaStub = defineComponent({
|
||||
name: 'TextArea',
|
||||
props: {
|
||||
modelValue: { type: String, default: '' }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<textarea
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
function buildAccount() {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'OpenAI OAuth',
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
status: 'active',
|
||||
credentials: {},
|
||||
extra: {},
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
proxy_id: null,
|
||||
auto_pause_on_expired: false
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('AccountTestModal', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
getAvailableModelsMock.mockReset()
|
||||
getAvailableModelsMock.mockResolvedValue([
|
||||
{ id: 'gpt-5.4', display_name: 'GPT-5.4' }
|
||||
])
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined })
|
||||
})
|
||||
}
|
||||
} as any)
|
||||
localStorage.setItem('auth_token', 'test-token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('posts compact mode for OpenAI compact probe', async () => {
|
||||
const wrapper = mount(AccountTestModal, {
|
||||
props: {
|
||||
show: true,
|
||||
account: buildAccount()
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
Select: SelectStub,
|
||||
TextArea: TextAreaStub,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
;(wrapper.vm as any).selectedModelId = 'gpt-5.4'
|
||||
;(wrapper.vm as any).testMode = 'compact'
|
||||
await (wrapper.vm as any).startTest()
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
const [, options] = (global.fetch as any).mock.calls[0]
|
||||
expect(JSON.parse(options.body)).toMatchObject({
|
||||
model_id: 'gpt-5.4',
|
||||
mode: 'compact'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,13 @@ vi.mock('@/api/admin', () => ({
|
||||
accounts: {
|
||||
update: updateAccountMock,
|
||||
checkMixedChannelRisk: checkMixedChannelRiskMock
|
||||
},
|
||||
settings: {
|
||||
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
|
||||
getSettings: vi.fn().mockResolvedValue({})
|
||||
},
|
||||
tlsFingerprintProfiles: {
|
||||
list: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -82,6 +89,32 @@ const ModelWhitelistSelectorStub = defineComponent({
|
||||
`
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'SelectStub',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, null],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
})
|
||||
|
||||
function buildAccount() {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -119,7 +152,7 @@ function mountModal(account = buildAccount()) {
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
Select: true,
|
||||
Select: SelectStub,
|
||||
Icon: true,
|
||||
ProxySelector: true,
|
||||
GroupSelector: true,
|
||||
@@ -156,4 +189,31 @@ describe('EditAccountModal', () => {
|
||||
'gpt-5.2': 'gpt-5.2'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits OpenAI compact mode and compact-only model mapping', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
openai_compact_mode: 'force_on'
|
||||
}
|
||||
account.credentials = {
|
||||
...account.credentials,
|
||||
compact_model_mapping: {
|
||||
'gpt-5.4': 'gpt-5.4-openai-compact'
|
||||
}
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_compact_mode).toBe('force_on')
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.compact_model_mapping).toEqual({
|
||||
'gpt-5.4': 'gpt-5.4-openai-compact'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2848,6 +2848,22 @@ export default {
|
||||
codexCLIOnly: 'Codex official clients only',
|
||||
codexCLIOnlyDesc:
|
||||
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
|
||||
compactMode: 'Compact mode',
|
||||
compactModeDesc:
|
||||
'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.',
|
||||
compactModeAuto: 'Auto',
|
||||
compactModeForceOn: 'Force On',
|
||||
compactModeForceOff: 'Force Off',
|
||||
compactModelMapping: 'Compact-only model mapping',
|
||||
compactModelMappingDesc:
|
||||
'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.',
|
||||
compactSupported: 'Compact supported',
|
||||
compactUnsupported: 'Compact unsupported',
|
||||
compactUnknown: 'Compact unknown',
|
||||
compactLastChecked: 'Last compact probe',
|
||||
testMode: 'Test mode',
|
||||
testModeDefault: 'Default request',
|
||||
testModeCompact: 'Compact probe',
|
||||
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
|
||||
},
|
||||
anthropic: {
|
||||
|
||||
@@ -2993,6 +2993,22 @@ export default {
|
||||
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
|
||||
codexCLIOnly: '仅允许 Codex 官方客户端',
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
compactMode: 'Compact 模式',
|
||||
compactModeDesc:
|
||||
'控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',
|
||||
compactModeAuto: '自动',
|
||||
compactModeForceOn: '强制开启',
|
||||
compactModeForceOff: '强制关闭',
|
||||
compactModelMapping: 'Compact 专属模型映射',
|
||||
compactModelMappingDesc:
|
||||
'仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。',
|
||||
compactSupported: '支持 Compact',
|
||||
compactUnsupported: '不支持 Compact',
|
||||
compactUnknown: 'Compact 未知',
|
||||
compactLastChecked: '最近探测',
|
||||
testMode: '测试模式',
|
||||
testModeDefault: '常规请求',
|
||||
testModeCompact: 'Compact 探测',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
},
|
||||
anthropic: {
|
||||
|
||||
@@ -767,8 +767,8 @@ export interface Account {
|
||||
platform: AccountPlatform
|
||||
type: AccountType
|
||||
credentials?: Record<string, unknown>
|
||||
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
|
||||
extra?: (CodexUsageSnapshot & {
|
||||
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
|
||||
extra?: (CodexUsageSnapshot & OpenAICompactState & {
|
||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
|
||||
} & Record<string, unknown>)
|
||||
@@ -940,6 +940,16 @@ export interface CodexUsageSnapshot {
|
||||
codex_usage_updated_at?: string // Last update timestamp
|
||||
}
|
||||
|
||||
export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off'
|
||||
|
||||
export interface OpenAICompactState {
|
||||
openai_compact_mode?: OpenAICompactMode
|
||||
openai_compact_supported?: boolean
|
||||
openai_compact_checked_at?: string
|
||||
openai_compact_last_status?: number
|
||||
openai_compact_last_error?: string
|
||||
}
|
||||
|
||||
export interface CreateAccountRequest {
|
||||
name: string
|
||||
notes?: string | null
|
||||
|
||||
@@ -188,6 +188,13 @@
|
||||
<template #cell-platform_type="{ row }">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
|
||||
<span
|
||||
v-if="getOpenAICompactLabel(row)"
|
||||
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getOpenAICompactClass(row)]"
|
||||
:title="getOpenAICompactTitle(row)"
|
||||
>
|
||||
{{ getOpenAICompactLabel(row) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAntigravityTierLabel(row)"
|
||||
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
|
||||
@@ -932,6 +939,43 @@ function getAntigravityTierLabel(row: any): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactState(row: any): 'supported' | 'unsupported' | 'unknown' | null {
|
||||
if (row.platform !== 'openai' || (row.type !== 'oauth' && row.type !== 'apikey')) return null
|
||||
const extra = row.extra as Record<string, unknown> | undefined
|
||||
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
|
||||
if (mode === 'force_on') return 'supported'
|
||||
if (mode === 'force_off') return 'unsupported'
|
||||
if (typeof extra?.openai_compact_supported === 'boolean') {
|
||||
return extra.openai_compact_supported ? 'supported' : 'unsupported'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function getOpenAICompactLabel(row: any): string | null {
|
||||
switch (getOpenAICompactState(row)) {
|
||||
case 'supported': return t('admin.accounts.openai.compactSupported')
|
||||
case 'unsupported': return t('admin.accounts.openai.compactUnsupported')
|
||||
case 'unknown': return t('admin.accounts.openai.compactUnknown')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactClass(row: any): string {
|
||||
switch (getOpenAICompactState(row)) {
|
||||
case 'supported': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'unsupported': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
|
||||
case 'unknown': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactTitle(row: any): string {
|
||||
const extra = row.extra as Record<string, unknown> | undefined
|
||||
const checkedAt = typeof extra?.openai_compact_checked_at === 'string' ? extra.openai_compact_checked_at : ''
|
||||
if (!checkedAt) return getOpenAICompactLabel(row) || ''
|
||||
return `${getOpenAICompactLabel(row)} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
|
||||
}
|
||||
|
||||
function getAntigravityTierClass(row: any): string {
|
||||
const tier = getAntigravityTierFromRow(row)
|
||||
switch (tier) {
|
||||
|
||||
Reference in New Issue
Block a user