- TopK initial filter now drops quota-paused accounts: fold the quota check into isAccountRequestCompatible so session-hash, TopK pool, and per-candidate rechecks all skip paused accounts. Previously the candidate pool was built without the quota check, so paused accounts could fill TopK and leave the scheduler returning "no available accounts" even with healthy ones available. - Add per-account explicit disable flags auto_pause_5h_disabled / auto_pause_7d_disabled with toggles in EditAccountModal. Without these, leaving the account threshold blank silently falls back to the global default, so admins could not exempt a single account once a global default existed. Disable is per-window: an account can opt out of 5h auto-pause while still honoring 7d. Schedule snapshot whitelist includes the new fields, i18n EN/ZH updated, threshold-hint text revised to explain "blank = global default". - Move quota auto-pause settings off the request hot path: replace the per-repo TTL+singleflight sync DB read with a per-SettingService stale-while-revalidate in-memory snapshot. Get is non-blocking (atomic.Pointer load + async refresh on staleness); writes via UpdateOpsAdvancedSettings push directly into the cache through an injected sink; wire warms the cache at startup. Adds Warm (sync) for tests/init and SetOpenAIQuotaAutoPauseSettings (sink target). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4242 lines
176 KiB
Vue
4242 lines
176 KiB
Vue
<template>
|
||
<BaseDialog
|
||
:show="show"
|
||
:title="t('admin.accounts.editAccount')"
|
||
width="wide"
|
||
@close="handleClose"
|
||
>
|
||
<form
|
||
v-if="account"
|
||
id="edit-account-form"
|
||
@submit.prevent="handleSubmit"
|
||
class="space-y-5"
|
||
>
|
||
<div>
|
||
<label class="input-label">{{ t('common.name') }}</label>
|
||
<input v-model="form.name" type="text" required class="input" data-tour="edit-account-form-name" />
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.notes') }}</label>
|
||
<textarea
|
||
v-model="form.notes"
|
||
rows="3"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.notesPlaceholder')"
|
||
></textarea>
|
||
<p class="input-hint">{{ t('admin.accounts.notesHint') }}</p>
|
||
</div>
|
||
|
||
<!-- API Key fields (only for apikey type) -->
|
||
<div v-if="account.type === 'apikey'" class="space-y-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
|
||
<input
|
||
v-model="editBaseUrl"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="
|
||
account.platform === 'openai'
|
||
? 'https://api.openai.com'
|
||
: account.platform === 'gemini'
|
||
? 'https://generativelanguage.googleapis.com'
|
||
: account.platform === 'antigravity'
|
||
? 'https://cloudcode-pa.googleapis.com'
|
||
: 'https://api.anthropic.com'
|
||
"
|
||
/>
|
||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||
<input
|
||
v-model="editApiKey"
|
||
type="password"
|
||
class="input font-mono"
|
||
autocomplete="new-password"
|
||
data-1p-ignore
|
||
data-lpignore="true"
|
||
data-bwignore="true"
|
||
:placeholder="
|
||
account.platform === 'openai'
|
||
? 'sk-proj-...'
|
||
: account.platform === 'gemini'
|
||
? 'AIza...'
|
||
: account.platform === 'antigravity'
|
||
? 'sk-...'
|
||
: 'sk-ant-...'
|
||
"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||
</div>
|
||
|
||
<!-- Model Restriction Section (不适用于 Antigravity) -->
|
||
<div v-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||
|
||
<div
|
||
v-if="isOpenAIModelRestrictionDisabled"
|
||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||
>
|
||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||
</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- Mode Toggle -->
|
||
<div class="mb-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'whitelist'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'whitelist'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelWhitelist') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'mapping'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'mapping'
|
||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelMapping') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Whitelist Mode -->
|
||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||
t('admin.accounts.supportsAllModels')
|
||
}}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Mapping Mode -->
|
||
<div v-else>
|
||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.mapRequestModels') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Model Mapping List -->
|
||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||
<div
|
||
v-for="(mapping, index) in modelMappings"
|
||
:key="getModelMappingKey(mapping)"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<input
|
||
v-model="mapping.from"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.requestModel')"
|
||
/>
|
||
<svg
|
||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||
/>
|
||
</svg>
|
||
<input
|
||
v-model="mapping.to"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.actualModel')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="removeModelMapping(index)"
|
||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="addModelMapping"
|
||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
>
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
|
||
<!-- Quick Add Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in presetMappings"
|
||
:key="preset.label"
|
||
type="button"
|
||
@click="addPresetMapping(preset.from, preset.to)"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Pool Mode Section -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.poolModeHint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="poolModeEnabled = !poolModeEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||
{{ t('admin.accounts.poolModeInfo') }}
|
||
</p>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="mt-3">
|
||
<label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
|
||
<input
|
||
v-model.number="poolModeRetryCount"
|
||
type="number"
|
||
min="0"
|
||
:max="MAX_POOL_MODE_RETRY_COUNT"
|
||
step="1"
|
||
class="input"
|
||
/>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{
|
||
t('admin.accounts.poolModeRetryCountHint', {
|
||
default: DEFAULT_POOL_MODE_RETRY_COUNT,
|
||
max: MAX_POOL_MODE_RETRY_COUNT
|
||
})
|
||
}}
|
||
</p>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="mt-3">
|
||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||
<input
|
||
v-model="poolModeRetryStatusCodesInput"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||
/>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom Error Codes Section -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.customErrorCodesHint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="customErrorCodesEnabled = !customErrorCodesEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
customErrorCodesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
customErrorCodesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="customErrorCodesEnabled" class="space-y-3">
|
||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Error Code Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="code in commonErrorCodes"
|
||
:key="code.value"
|
||
type="button"
|
||
@click="toggleErrorCode(code.value)"
|
||
:class="[
|
||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||
selectedErrorCodes.includes(code.value)
|
||
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
{{ code.value }} {{ code.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Manual input -->
|
||
<div class="flex items-center gap-2">
|
||
<input
|
||
v-model.number="customErrorCodeInput"
|
||
type="number"
|
||
min="100"
|
||
max="599"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.enterErrorCode')"
|
||
@keyup.enter="addCustomErrorCode"
|
||
/>
|
||
<button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Selected codes summary -->
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<span
|
||
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||
:key="code"
|
||
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
{{ code }}
|
||
<button
|
||
type="button"
|
||
@click="removeErrorCode(code)"
|
||
class="hover:text-red-900 dark:hover:text-red-300"
|
||
>
|
||
<Icon name="x" size="sm" :stroke-width="2" />
|
||
</button>
|
||
</span>
|
||
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||
{{ t('admin.accounts.noneSelectedUsesDefault') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||
<div
|
||
v-if="account.platform === 'openai' && account.type === 'oauth'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||
|
||
<div
|
||
v-if="isOpenAIModelRestrictionDisabled"
|
||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||
>
|
||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||
</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- Mode Toggle -->
|
||
<div class="mb-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'whitelist'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'whitelist'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
{{ t('admin.accounts.modelWhitelist') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'mapping'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'mapping'
|
||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
{{ t('admin.accounts.modelMapping') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Whitelist Mode -->
|
||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||
t('admin.accounts.supportsAllModels')
|
||
}}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Mapping Mode -->
|
||
<div v-else>
|
||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||
{{ t('admin.accounts.mapRequestModels') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||
<div
|
||
v-for="(mapping, index) in modelMappings"
|
||
:key="'oauth-' + getModelMappingKey(mapping)"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<input
|
||
v-model="mapping.from"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.requestModel')"
|
||
/>
|
||
<svg
|
||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||
/>
|
||
</svg>
|
||
<input
|
||
v-model="mapping.to"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.actualModel')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="removeModelMapping(index)"
|
||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="addModelMapping"
|
||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
>
|
||
+ {{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
|
||
<!-- Quick Add Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in presetMappings"
|
||
:key="'oauth-' + preset.label"
|
||
type="button"
|
||
@click="addPresetMapping(preset.from, preset.to)"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Upstream fields (only for upstream type) -->
|
||
<div v-if="account.type === 'upstream'" class="space-y-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
|
||
<input
|
||
v-model="editBaseUrl"
|
||
type="text"
|
||
class="input"
|
||
placeholder="https://cloudcode-pa.googleapis.com"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.upstream.apiKey') }}</label>
|
||
<input
|
||
v-model="editApiKey"
|
||
type="password"
|
||
class="input font-mono"
|
||
placeholder="sk-..."
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Vertex Service Account -->
|
||
<div v-if="(account.platform === 'gemini' || account.platform === 'anthropic') && account.type === 'service_account'" class="space-y-4">
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<label class="input-label">Project ID</label>
|
||
<input
|
||
v-model="editVertexProjectId"
|
||
type="text"
|
||
class="input font-mono"
|
||
readonly
|
||
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonEditHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">Location</label>
|
||
<select
|
||
v-model="editVertexLocation"
|
||
required
|
||
class="input font-mono"
|
||
>
|
||
<optgroup
|
||
v-for="group in VERTEX_LOCATION_OPTIONS"
|
||
:key="group.label"
|
||
:label="group.label"
|
||
>
|
||
<option
|
||
v-for="option in group.options"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</optgroup>
|
||
</select>
|
||
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Model Restriction Section for Service Account -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||
|
||
<!-- Mode Toggle -->
|
||
<div class="mb-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'whitelist'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'whitelist'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelWhitelist') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'mapping'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'mapping'
|
||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelMapping') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Whitelist Mode -->
|
||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||
t('admin.accounts.supportsAllModels')
|
||
}}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Mapping Mode -->
|
||
<div v-else>
|
||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.mapRequestModels') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Model Mapping List -->
|
||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||
<div
|
||
v-for="(mapping, index) in modelMappings"
|
||
:key="getModelMappingKey(mapping)"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<input
|
||
v-model="mapping.from"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.requestModel')"
|
||
/>
|
||
<svg
|
||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||
/>
|
||
</svg>
|
||
<input
|
||
v-model="mapping.to"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.actualModel')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="removeModelMapping(index)"
|
||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="addModelMapping"
|
||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
>
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
|
||
<!-- Quick Add Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in presetMappings"
|
||
:key="preset.label"
|
||
type="button"
|
||
@click="addPresetMapping(preset.from, preset.to)"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bedrock fields (for bedrock type, both SigV4 and API Key modes) -->
|
||
<div v-if="account.type === 'bedrock'" class="space-y-4">
|
||
<!-- SigV4 fields -->
|
||
<template v-if="!isBedrockAPIKeyMode">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label>
|
||
<input
|
||
v-model="editBedrockAccessKeyId"
|
||
type="text"
|
||
class="input font-mono"
|
||
placeholder="AKIA..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.bedrockSecretAccessKey') }}</label>
|
||
<input
|
||
v-model="editBedrockSecretAccessKey"
|
||
type="password"
|
||
class="input font-mono"
|
||
:placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.bedrockSecretKeyLeaveEmpty') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.bedrockSessionToken') }}</label>
|
||
<input
|
||
v-model="editBedrockSessionToken"
|
||
type="password"
|
||
class="input font-mono"
|
||
:placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- API Key field -->
|
||
<div v-if="isBedrockAPIKeyMode">
|
||
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
|
||
<input
|
||
v-model="editBedrockApiKeyValue"
|
||
type="password"
|
||
class="input font-mono"
|
||
:placeholder="t('admin.accounts.bedrockApiKeyLeaveEmpty')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.bedrockApiKeyLeaveEmpty') }}</p>
|
||
</div>
|
||
|
||
<!-- Shared: Region -->
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
|
||
<input
|
||
v-model="editBedrockRegion"
|
||
type="text"
|
||
class="input"
|
||
placeholder="us-east-1"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
|
||
</div>
|
||
|
||
<!-- Shared: Force Global -->
|
||
<div>
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
v-model="editBedrockForceGlobal"
|
||
type="checkbox"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockForceGlobal') }}</span>
|
||
</label>
|
||
<p class="input-hint mt-1">{{ t('admin.accounts.bedrockForceGlobalHint') }}</p>
|
||
</div>
|
||
|
||
<!-- Model Restriction for Bedrock -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||
|
||
<!-- Mode Toggle -->
|
||
<div class="mb-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'whitelist'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'whitelist'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
{{ t('admin.accounts.modelWhitelist') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="modelRestrictionMode = 'mapping'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'mapping'
|
||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
{{ t('admin.accounts.modelMapping') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Whitelist Mode -->
|
||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" />
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Mapping Mode -->
|
||
<div v-else class="space-y-3">
|
||
<div v-for="(mapping, index) in modelMappings" :key="getModelMappingKey(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="modelMappings.splice(index, 1)" class="text-red-500 hover:text-red-700">
|
||
<Icon name="trash" size="sm" />
|
||
</button>
|
||
</div>
|
||
<button type="button" @click="modelMappings.push({ from: '', to: '' })" class="btn btn-secondary text-sm">
|
||
+ {{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
<!-- Bedrock Preset Mappings -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in bedrockPresets"
|
||
:key="preset.from"
|
||
type="button"
|
||
@click="modelMappings.push({ from: preset.from, to: preset.to })"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pool Mode Section for Bedrock -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.poolModeHint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="poolModeEnabled = !poolModeEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||
{{ t('admin.accounts.poolModeInfo') }}
|
||
</p>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="mt-3">
|
||
<label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
|
||
<input
|
||
v-model.number="poolModeRetryCount"
|
||
type="number"
|
||
min="0"
|
||
:max="MAX_POOL_MODE_RETRY_COUNT"
|
||
step="1"
|
||
class="input"
|
||
/>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{
|
||
t('admin.accounts.poolModeRetryCountHint', {
|
||
default: DEFAULT_POOL_MODE_RETRY_COUNT,
|
||
max: MAX_POOL_MODE_RETRY_COUNT
|
||
})
|
||
}}
|
||
</p>
|
||
</div>
|
||
<div v-if="poolModeEnabled" class="mt-3">
|
||
<label class="input-label">{{ t('admin.accounts.poolModeRetryStatusCodes') }}</label>
|
||
<input
|
||
v-model="poolModeRetryStatusCodesInput"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ')"
|
||
/>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.poolModeRetryStatusCodesHint', { default: DEFAULT_POOL_MODE_RETRY_STATUS_CODES.join(', ') }) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Antigravity model restriction (applies to all antigravity types) -->
|
||
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
||
<div v-if="account.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||
|
||
<!-- Mapping Mode Only (no toggle for Antigravity) -->
|
||
<div>
|
||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||
<p class="text-xs text-purple-700 dark:text-purple-400">{{ t('admin.accounts.mapRequestModels') }}</p>
|
||
</div>
|
||
|
||
<div class="mb-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
@click="syncAntigravityUpstreamModels"
|
||
:disabled="isSyncingAntigravityUpstream || !account?.id"
|
||
class="rounded-lg border border-emerald-200 px-3 py-1.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-900/30"
|
||
>
|
||
{{ isSyncingAntigravityUpstream ? t('admin.accounts.syncUpstreamModelsLoading') : t('admin.accounts.syncUpstreamModels') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||
<div
|
||
v-for="(mapping, index) in antigravityModelMappings"
|
||
:key="getAntigravityModelMappingKey(mapping)"
|
||
class="space-y-1"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<input
|
||
v-model="mapping.from"
|
||
type="text"
|
||
:class="[
|
||
'input flex-1',
|
||
!isValidWildcardPattern(mapping.from) ? 'border-red-500 dark:border-red-500' : '',
|
||
mapping.to.includes('*') ? '' : ''
|
||
]"
|
||
:placeholder="t('admin.accounts.requestModel')"
|
||
/>
|
||
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||
</svg>
|
||
<input
|
||
v-model="mapping.to"
|
||
type="text"
|
||
:class="[
|
||
'input flex-1',
|
||
mapping.to.includes('*') ? 'border-red-500 dark:border-red-500' : ''
|
||
]"
|
||
:placeholder="t('admin.accounts.actualModel')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="removeAntigravityModelMapping(index)"
|
||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<!-- 校验错误提示 -->
|
||
<p v-if="!isValidWildcardPattern(mapping.from)" class="text-xs text-red-500">
|
||
{{ t('admin.accounts.wildcardOnlyAtEnd') }}
|
||
</p>
|
||
<p v-if="mapping.to.includes('*')" class="text-xs text-red-500">
|
||
{{ t('admin.accounts.targetNoWildcard') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="addAntigravityModelMapping"
|
||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
>
|
||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
{{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in antigravityPresetMappings"
|
||
:key="preset.label"
|
||
type="button"
|
||
@click="addAntigravityPresetMapping(preset.from, preset.to)"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Temp Unschedulable Rules -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.tempUnschedulable.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="tempUnschedEnabled = !tempUnschedEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
tempUnschedEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="tempUnschedEnabled" class="space-y-3">
|
||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in tempUnschedPresets"
|
||
:key="preset.label"
|
||
type="button"
|
||
@click="addTempUnschedRule(preset.rule)"
|
||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||
<div
|
||
v-for="(rule, index) in tempUnschedRules"
|
||
:key="getTempUnschedRuleKey(rule)"
|
||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||
>
|
||
<div class="mb-2 flex items-center justify-between">
|
||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
|
||
</span>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
:disabled="index === 0"
|
||
@click="moveTempUnschedRule(index, -1)"
|
||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||
>
|
||
<Icon name="chevronUp" size="sm" :stroke-width="2" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:disabled="index === tempUnschedRules.length - 1"
|
||
@click="moveTempUnschedRule(index, 1)"
|
||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="removeTempUnschedRule(index)"
|
||
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
|
||
>
|
||
<Icon name="x" size="sm" :stroke-width="2" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
|
||
<input
|
||
v-model.number="rule.error_code"
|
||
type="number"
|
||
min="100"
|
||
max="599"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
|
||
<input
|
||
v-model.number="rule.duration_minutes"
|
||
type="number"
|
||
min="1"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
|
||
/>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
|
||
<input
|
||
v-model="rule.keywords"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
|
||
<input
|
||
v-model="rule.description"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="addTempUnschedRule()"
|
||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
>
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
{{ t('admin.accounts.tempUnschedulable.addRule') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||
<div
|
||
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{
|
||
t('admin.accounts.interceptWarmupRequests')
|
||
}}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="mb-1 flex items-center gap-2">
|
||
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||
<ProxyAdBanner />
|
||
</div>
|
||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
|
||
<input v-model.number="form.concurrency" type="number" min="1" class="input"
|
||
@input="form.concurrency = Math.max(1, form.concurrency || 1)" />
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.loadFactor') }}</label>
|
||
<input v-model.number="form.load_factor" type="number" min="1"
|
||
class="input" :placeholder="String(form.concurrency || 1)"
|
||
@input="form.load_factor = (form.load_factor && form.load_factor >= 1) ? form.load_factor : null" />
|
||
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||
<input
|
||
v-model.number="form.priority"
|
||
type="number"
|
||
min="1"
|
||
class="input"
|
||
data-tour="account-form-priority"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||
<input v-model="expiresAtInput" type="datetime-local" class="input" />
|
||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||
</div>
|
||
|
||
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
|
||
<div
|
||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.oauthPassthrough') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.openai.oauthPassthroughDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
openaiPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
openaiPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OpenAI Codex 图片生成桥接账号级覆盖 -->
|
||
<div
|
||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="overflow-hidden rounded-lg border border-sky-100 bg-sky-50/60 shadow-sm dark:border-sky-900/50 dark:bg-sky-950/20">
|
||
<div class="flex items-start gap-3 px-4 py-3">
|
||
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-white text-sky-600 shadow-sm ring-1 ring-sky-100 dark:bg-dark-800 dark:text-sky-300 dark:ring-sky-900/60">
|
||
<Icon name="sparkles" size="sm" />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexImageGenerationBridge') }}</label>
|
||
<span
|
||
class="rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||
:class="codexImageGenerationBridgeBadgeClass"
|
||
>
|
||
{{ codexImageGenerationBridgeBadgeLabel }}
|
||
</span>
|
||
</div>
|
||
<p class="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||
{{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="border-t border-sky-100 bg-white/70 p-2 dark:border-sky-900/50 dark:bg-dark-800/70">
|
||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||
<button
|
||
v-for="option in codexImageGenerationBridgeOptions"
|
||
:key="option.value"
|
||
type="button"
|
||
:data-testid="`codex-image-bridge-${option.value}`"
|
||
@click="codexImageGenerationBridgeMode = option.value"
|
||
:class="[
|
||
'group flex min-h-[68px] items-start gap-2 rounded-md border px-3 py-2 text-left transition-all',
|
||
codexImageGenerationBridgeMode === option.value
|
||
? 'border-sky-300 bg-sky-50 text-sky-900 shadow-sm ring-1 ring-sky-200 dark:border-sky-700 dark:bg-sky-900/25 dark:text-sky-100 dark:ring-sky-800'
|
||
: 'border-transparent bg-transparent text-slate-600 hover:border-gray-200 hover:bg-gray-50 dark:text-slate-300 dark:hover:border-dark-500 dark:hover:bg-dark-700'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||
codexImageGenerationBridgeMode === option.value
|
||
? 'border-sky-500 bg-sky-500 text-white'
|
||
: 'border-gray-300 text-transparent group-hover:border-gray-400 dark:border-dark-500'
|
||
]"
|
||
>
|
||
<Icon name="check" size="xs" :stroke-width="2" />
|
||
</span>
|
||
<span class="min-w-0">
|
||
<span class="block text-sm font-medium">{{ option.label }}</span>
|
||
<span class="mt-0.5 block text-xs leading-4 text-slate-500 dark:text-slate-400">{{ option.description }}</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||
<div
|
||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.wsMode') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.openai.wsModeDesc') }}
|
||
</p>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t(openAIWSModeConcurrencyHintKey) }}
|
||
</p>
|
||
</div>
|
||
<div class="w-52">
|
||
<Select v-model="openaiResponsesWebSocketV2Mode" :options="openAIWSModeOptions" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OpenAI APIKey Responses API support mode -->
|
||
<div
|
||
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
|
||
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.responsesMode') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.openai.responsesModeDesc') }}
|
||
</p>
|
||
</div>
|
||
<div class="w-56">
|
||
<Select
|
||
v-model="openAIResponsesMode"
|
||
:options="openAIResponsesModeOptions"
|
||
:disabled="!openAITextGenerationCapabilityEnabled"
|
||
data-testid="openai-responses-mode-select"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="openAITextGenerationCapabilityEnabled"
|
||
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(openAIResponsesStatusKey) }}</span>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||
data-testid="openai-responses-mode-not-applicable"
|
||
>
|
||
{{ t('admin.accounts.openai.responsesModeTextDisabledHint') }}
|
||
</div>
|
||
<div>
|
||
<label class="input-label mb-2 block">{{ t('admin.accounts.openai.endpointCapabilities') }}</label>
|
||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<label
|
||
v-for="option in openAIEndpointCapabilityOptions"
|
||
:key="option.value"
|
||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
|
||
:data-testid="`openai-endpoint-capability-${option.value}`"
|
||
:checked="openAIEndpointCapabilities.includes(option.value)"
|
||
@change="toggleOpenAIEndpointCapability(option.value, $event)"
|
||
/>
|
||
<span class="text-gray-700 dark:text-gray-200">{{ option.label }}</span>
|
||
</label>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.openai.endpointCapabilitiesDesc') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Anthropic API Key 自动透传开关 -->
|
||
<div
|
||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyPassthrough') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.anthropic.apiKeyPassthroughDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="anthropicPassthroughEnabled = !anthropicPassthroughEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
anthropicPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
anthropicPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
|
||
<div
|
||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey' && webSearchGlobalEnabled"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.webSearchEmulation') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
|
||
</p>
|
||
</div>
|
||
<select v-model="webSearchEmulationMode" class="input w-24 text-sm">
|
||
<option value="default">{{ t('admin.accounts.anthropic.webSearchDefault') }}</option>
|
||
<option value="enabled">{{ t('admin.accounts.anthropic.webSearchEnabled') }}</option>
|
||
<option value="disabled">{{ t('admin.accounts.anthropic.webSearchDisabled') }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
|
||
<div
|
||
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||
>
|
||
<div class="mb-3">
|
||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.hint') }}
|
||
</p>
|
||
</div>
|
||
<QuotaLimitCard
|
||
:totalLimit="editQuotaLimit"
|
||
:dailyLimit="editQuotaDailyLimit"
|
||
:weeklyLimit="editQuotaWeeklyLimit"
|
||
:dailyResetMode="editDailyResetMode"
|
||
:dailyResetHour="editDailyResetHour"
|
||
:weeklyResetMode="editWeeklyResetMode"
|
||
:weeklyResetDay="editWeeklyResetDay"
|
||
:weeklyResetHour="editWeeklyResetHour"
|
||
:resetTimezone="editResetTimezone"
|
||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||
@update:totalLimit="editQuotaLimit = $event"
|
||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||
@update:dailyResetMode="editDailyResetMode = $event"
|
||
@update:dailyResetHour="editDailyResetHour = $event"
|
||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||
@update:resetTimezone="editResetTimezone = $event"
|
||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||
/>
|
||
</div>
|
||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||
<div
|
||
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||
>
|
||
<div class="mb-3">
|
||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaLimitHint') }}
|
||
</p>
|
||
</div>
|
||
<QuotaLimitCard
|
||
:totalLimit="editQuotaLimit"
|
||
:dailyLimit="editQuotaDailyLimit"
|
||
:weeklyLimit="editQuotaWeeklyLimit"
|
||
:dailyResetMode="editDailyResetMode"
|
||
:dailyResetHour="editDailyResetHour"
|
||
:weeklyResetMode="editWeeklyResetMode"
|
||
:weeklyResetDay="editWeeklyResetDay"
|
||
:weeklyResetHour="editWeeklyResetHour"
|
||
:resetTimezone="editResetTimezone"
|
||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||
@update:totalLimit="editQuotaLimit = $event"
|
||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||
@update:dailyResetMode="editDailyResetMode = $event"
|
||
@update:dailyResetHour="editDailyResetHour = $event"
|
||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||
@update:resetTimezone="editResetTimezone = $event"
|
||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||
/>
|
||
</div>
|
||
|
||
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
|
||
<div
|
||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnly') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.openai.codexCLIOnlyDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="codexCLIOnlyEnabled = !codexCLIOnlyEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
codexCLIOnlyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
codexCLIOnlyEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div
|
||
v-if="codexCLIOnlyEnabled"
|
||
class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600"
|
||
>
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</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>
|
||
<label class="input-label mb-0">{{
|
||
t('admin.accounts.autoPauseOnExpired')
|
||
}}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="autoPauseOnExpired = !autoPauseOnExpired"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="account?.platform === 'openai'"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||
>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="input-label mb-0">{{ t('admin.accounts.autoPause5hDisabled') }}</label>
|
||
<button
|
||
type="button"
|
||
@click="autoPause5hDisabled = !autoPause5hDisabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
autoPause5hDisabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
data-testid="auto-pause-5h-disabled"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
autoPause5hDisabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.autoPauseDisabledHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.autoPause5hThreshold') }}</label>
|
||
<input
|
||
v-model.number="autoPause5hThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
step="0.1"
|
||
class="input"
|
||
:disabled="autoPause5hDisabled"
|
||
data-testid="auto-pause-5h-threshold"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.autoPauseThresholdHint') }}</p>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="input-label mb-0">{{ t('admin.accounts.autoPause7dDisabled') }}</label>
|
||
<button
|
||
type="button"
|
||
@click="autoPause7dDisabled = !autoPause7dDisabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
autoPause7dDisabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
data-testid="auto-pause-7d-disabled"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
autoPause7dDisabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.autoPauseDisabledHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.autoPause7dThreshold') }}</label>
|
||
<input
|
||
v-model.number="autoPause7dThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
step="0.1"
|
||
class="input"
|
||
:disabled="autoPause7dDisabled"
|
||
data-testid="auto-pause-7d-threshold"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.autoPauseThresholdHint') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM 等) -->
|
||
<div
|
||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||
>
|
||
<div class="mb-3">
|
||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.hint') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Window Cost Limit -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="windowCostEnabled = !windowCostEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||
<div class="relative">
|
||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||
<input
|
||
v-model.number="windowCostLimit"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="input pl-7"
|
||
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||
/>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||
<div class="relative">
|
||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||
<input
|
||
v-model.number="windowCostStickyReserve"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="input pl-7"
|
||
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||
/>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session Limit -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||
<input
|
||
v-model.number="maxSessions"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||
</div>
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||
<div class="relative">
|
||
<input
|
||
v-model.number="sessionIdleTimeout"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
class="input pr-12"
|
||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||
/>
|
||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||
</div>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RPM Limit -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.rpmLimit.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="rpmLimitEnabled = !rpmLimitEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
rpmLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="rpmLimitEnabled" class="space-y-4">
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||
<input
|
||
v-model.number="baseRpm"
|
||
type="number"
|
||
min="1"
|
||
max="1000"
|
||
step="1"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
@click="rpmStrategy = 'tiered'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||
rpmStrategy === 'tiered'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<div class="text-center">
|
||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
|
||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
|
||
</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="rpmStrategy = 'sticky_exempt'"
|
||
:class="[
|
||
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||
rpmStrategy === 'sticky_exempt'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
>
|
||
<div class="text-center">
|
||
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
|
||
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="rpmStrategy === 'tiered'">
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||
<input
|
||
v-model.number="rpmStickyBuffer"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- 用户消息限速模式(独立于 RPM 开关,始终可见) -->
|
||
<div class="mt-4">
|
||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueue') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||
{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueueHint') }}
|
||
</p>
|
||
<div class="flex space-x-2">
|
||
<button type="button" v-for="opt in umqModeOptions" :key="opt.value"
|
||
@click="userMsgQueueMode = opt.value"
|
||
:class="[
|
||
'px-3 py-1.5 text-sm rounded-md border transition-colors',
|
||
userMsgQueueMode === opt.value
|
||
? 'bg-primary-600 text-white border-primary-600'
|
||
: 'bg-white dark:bg-dark-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-dark-500 hover:bg-gray-50 dark:hover:bg-dark-600'
|
||
]">
|
||
{{ opt.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TLS Fingerprint -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<!-- Profile selector -->
|
||
<div v-if="tlsFingerprintEnabled" class="mt-3">
|
||
<select v-model="tlsFingerprintProfileId" class="input">
|
||
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
|
||
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
|
||
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session ID Masking -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionIdMasking.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cache TTL Override -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.cacheTTLOverride.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.cacheTTLOverride.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="cacheTTLOverrideEnabled = !cacheTTLOverrideEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
cacheTTLOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
cacheTTLOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div v-if="cacheTTLOverrideEnabled" class="mt-3">
|
||
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.cacheTTLOverride.target') }}</label>
|
||
<select
|
||
v-model="cacheTTLOverrideTarget"
|
||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-white"
|
||
>
|
||
<option value="5m">5m</option>
|
||
<option value="1h">1h</option>
|
||
</select>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.cacheTTLOverride.targetHint') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom Base URL Relay -->
|
||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.customBaseUrl.label') }}</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.quotaControl.customBaseUrl.hint') }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
@click="customBaseUrlEnabled = !customBaseUrlEnabled"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
customBaseUrlEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||
customBaseUrlEnabled ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<div v-if="customBaseUrlEnabled" class="mt-3">
|
||
<input
|
||
v-model="customBaseUrl"
|
||
type="text"
|
||
class="input"
|
||
:placeholder="t('admin.accounts.quotaControl.customBaseUrl.urlHint')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div>
|
||
<label class="input-label">{{ t('common.status') }}</label>
|
||
<Select v-model="form.status" :options="statusOptions" />
|
||
</div>
|
||
|
||
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
|
||
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
|
||
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
|
||
<input
|
||
type="checkbox"
|
||
v-model="mixedScheduling"
|
||
disabled
|
||
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||
/>
|
||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
{{ t('admin.accounts.mixedScheduling') }}
|
||
</span>
|
||
</label>
|
||
<div class="group relative">
|
||
<span
|
||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||
>
|
||
?
|
||
</span>
|
||
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||
<div
|
||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||
>
|
||
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||
<div
|
||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="account?.platform === 'antigravity'" class="mt-3 flex items-center gap-2">
|
||
<label class="flex cursor-pointer items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
v-model="allowOverages"
|
||
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||
/>
|
||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
{{ t('admin.accounts.allowOverages') }}
|
||
</span>
|
||
</label>
|
||
<div class="group relative">
|
||
<span
|
||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||
>
|
||
?
|
||
</span>
|
||
<div
|
||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||
>
|
||
{{ t('admin.accounts.allowOveragesTooltip') }}
|
||
<div
|
||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Group Selection - 仅标准模式显示 -->
|
||
<GroupSelector
|
||
v-if="!authStore.isSimpleMode"
|
||
v-model="form.group_ids"
|
||
:groups="groups"
|
||
:platform="account?.platform"
|
||
:mixed-scheduling="mixedScheduling"
|
||
data-tour="account-form-groups"
|
||
/>
|
||
|
||
</form>
|
||
|
||
<template #footer>
|
||
<div v-if="account" class="flex justify-end gap-3">
|
||
<button @click="handleClose" type="button" class="btn btn-secondary">
|
||
{{ t('common.cancel') }}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
form="edit-account-form"
|
||
:disabled="submitting"
|
||
class="btn btn-primary"
|
||
data-tour="account-form-submit"
|
||
>
|
||
<svg
|
||
v-if="submitting"
|
||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
class="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="4"
|
||
></circle>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
{{ submitting ? t('admin.accounts.updating') : t('common.update') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</BaseDialog>
|
||
|
||
<!-- Mixed Channel Warning Dialog -->
|
||
<ConfirmDialog
|
||
:show="showMixedChannelWarning"
|
||
:title="t('admin.accounts.mixedChannelWarningTitle')"
|
||
:message="mixedChannelWarningMessageText"
|
||
:confirm-text="t('common.confirm')"
|
||
:cancel-text="t('common.cancel')"
|
||
:danger="true"
|
||
@confirm="handleMixedChannelConfirm"
|
||
@cancel="handleMixedChannelCancel"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
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,
|
||
OpenAICompactMode,
|
||
OpenAIResponsesMode,
|
||
OpenAIEndpointCapability
|
||
} from '@/types'
|
||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||
import Select from '@/components/common/Select.vue'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||
import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||
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 { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||
import {
|
||
OPENAI_WS_MODE_CTX_POOL,
|
||
OPENAI_WS_MODE_OFF,
|
||
OPENAI_WS_MODE_PASSTHROUGH,
|
||
isOpenAIWSModeEnabled,
|
||
resolveOpenAIWSModeConcurrencyHintKey,
|
||
type OpenAIWSMode,
|
||
resolveOpenAIWSModeFromExtra
|
||
} from '@/utils/openaiWsMode'
|
||
import {
|
||
getPresetMappingsByPlatform,
|
||
commonErrorCodes,
|
||
buildModelMappingObject,
|
||
splitModelMappingObject,
|
||
isValidWildcardPattern
|
||
} from '@/composables/useModelWhitelist'
|
||
|
||
interface Props {
|
||
show: boolean
|
||
account: Account | null
|
||
proxies: Proxy[]
|
||
groups: AdminGroup[]
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<{
|
||
close: []
|
||
updated: [account: Account]
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
const appStore = useAppStore()
|
||
const authStore = useAuthStore()
|
||
|
||
// Platform-specific hint for Base URL
|
||
const baseUrlHint = computed(() => {
|
||
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||
return t('admin.accounts.baseUrlHint')
|
||
})
|
||
|
||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
|
||
|
||
// Model mapping type
|
||
interface ModelMapping {
|
||
from: string
|
||
to: string
|
||
}
|
||
|
||
interface TempUnschedRuleForm {
|
||
error_code: number | null
|
||
keywords: string
|
||
duration_minutes: number | null
|
||
description: string
|
||
}
|
||
|
||
// State
|
||
const submitting = ref(false)
|
||
const editBaseUrl = ref('https://api.anthropic.com')
|
||
const editApiKey = ref('')
|
||
// Bedrock credentials
|
||
const editBedrockAccessKeyId = ref('')
|
||
const editBedrockSecretAccessKey = ref('')
|
||
const editBedrockSessionToken = ref('')
|
||
const editBedrockRegion = ref('')
|
||
const editBedrockForceGlobal = ref(false)
|
||
const editBedrockApiKeyValue = ref('')
|
||
const editVertexProjectId = ref('')
|
||
const editVertexClientEmail = ref('')
|
||
const editVertexLocation = ref('us-central1')
|
||
const isBedrockAPIKeyMode = computed(() =>
|
||
props.account?.type === 'bedrock' &&
|
||
(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
|
||
const MAX_POOL_MODE_RETRY_COUNT = 10
|
||
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
|
||
const poolModeEnabled = ref(false)
|
||
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
|
||
const poolModeRetryStatusCodesInput = ref('')
|
||
|
||
function parsePoolModeRetryStatusCodes(input: string): number[] {
|
||
if (!input || !input.trim()) return []
|
||
const seen = new Set<number>()
|
||
const out: number[] = []
|
||
for (const token of input.split(/[,\s]+/)) {
|
||
const trimmed = token.trim()
|
||
if (!trimmed) continue
|
||
const n = Number(trimmed)
|
||
if (!Number.isFinite(n) || !Number.isInteger(n)) continue
|
||
if (n < 100 || n > 599) continue
|
||
if (seen.has(n)) continue
|
||
seen.add(n)
|
||
out.push(n)
|
||
}
|
||
return out.sort((a, b) => a - b)
|
||
}
|
||
|
||
function formatPoolModeRetryStatusCodes(value: unknown): string {
|
||
if (!Array.isArray(value)) return ''
|
||
const out: number[] = []
|
||
const seen = new Set<number>()
|
||
for (const v of value) {
|
||
const n = typeof v === 'string' ? Number(v.trim()) : Number(v)
|
||
if (!Number.isFinite(n) || !Number.isInteger(n)) continue
|
||
if (n < 100 || n > 599) continue
|
||
if (seen.has(n)) continue
|
||
seen.add(n)
|
||
out.push(n)
|
||
}
|
||
return out.sort((a, b) => a - b).join(', ')
|
||
}
|
||
const customErrorCodesEnabled = ref(false)
|
||
const selectedErrorCodes = ref<number[]>([])
|
||
const customErrorCodeInput = ref<number | null>(null)
|
||
const interceptWarmupRequests = ref(false)
|
||
const autoPauseOnExpired = ref(false)
|
||
const autoPause5hThreshold = ref<number | null>(null)
|
||
const autoPause7dThreshold = ref<number | null>(null)
|
||
const autoPause5hDisabled = ref(false)
|
||
const autoPause7dDisabled = ref(false)
|
||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||
const antigravityWhitelistModels = ref<string[]>([])
|
||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||
const isSyncingAntigravityUpstream = ref(false)
|
||
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')
|
||
|
||
const showMixedChannelWarning = ref(false)
|
||
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
|
||
null
|
||
)
|
||
const mixedChannelWarningRawMessage = ref('')
|
||
const mixedChannelWarningAction = ref<(() => Promise<void>) | null>(null)
|
||
const antigravityMixedChannelConfirmed = ref(false)
|
||
|
||
// Quota control state (Anthropic OAuth/SetupToken only)
|
||
const windowCostEnabled = ref(false)
|
||
const windowCostLimit = ref<number | null>(null)
|
||
const windowCostStickyReserve = ref<number | null>(null)
|
||
const sessionLimitEnabled = ref(false)
|
||
const maxSessions = ref<number | null>(null)
|
||
const sessionIdleTimeout = ref<number | null>(null)
|
||
const rpmLimitEnabled = ref(false)
|
||
const baseRpm = ref<number | null>(null)
|
||
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||
const rpmStickyBuffer = ref<number | null>(null)
|
||
const userMsgQueueMode = ref('')
|
||
const umqModeOptions = computed(() => [
|
||
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
|
||
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
|
||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||
])
|
||
const tlsFingerprintEnabled = ref(false)
|
||
const tlsFingerprintProfileId = ref<number | null>(null)
|
||
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
|
||
const sessionIdMaskingEnabled = ref(false)
|
||
const cacheTTLOverrideEnabled = ref(false)
|
||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||
const customBaseUrlEnabled = ref(false)
|
||
const customBaseUrl = ref('')
|
||
|
||
// OpenAI 自动透传开关(OAuth/API Key)
|
||
const openaiPassthroughEnabled = ref(false)
|
||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
||
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||
const codexCLIOnlyEnabled = ref(false)
|
||
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
|
||
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
|
||
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
|
||
const anthropicPassthroughEnabled = ref(false)
|
||
const webSearchEmulationMode = ref('default')
|
||
const webSearchGlobalEnabled = ref(false)
|
||
const {
|
||
globalEnabled: quotaNotifyGlobalEnabled,
|
||
state: quotaNotifyState,
|
||
loadGlobalState: loadQuotaNotifyGlobal,
|
||
loadFromExtra: loadQuotaNotifyFromExtra,
|
||
writeToExtra: writeQuotaNotifyToExtra,
|
||
reset: resetQuotaNotify,
|
||
} = useQuotaNotifyState()
|
||
|
||
// Load global feature states once
|
||
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
|
||
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
|
||
}).catch(() => { webSearchGlobalEnabled.value = false })
|
||
|
||
loadQuotaNotifyGlobal()
|
||
const editQuotaLimit = ref<number | null>(null)
|
||
const editQuotaDailyLimit = ref<number | null>(null)
|
||
const editQuotaWeeklyLimit = ref<number | null>(null)
|
||
const editDailyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||
const editDailyResetHour = ref<number | null>(null)
|
||
const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||
const editWeeklyResetDay = ref<number | null>(null)
|
||
const editWeeklyResetHour = ref<number | null>(null)
|
||
const editResetTimezone = ref<string | null>(null)
|
||
const openAIWSModeOptions = computed(() => [
|
||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||
{ value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') },
|
||
{ value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
|
||
])
|
||
const openaiResponsesWebSocketV2Mode = computed({
|
||
get: () => {
|
||
if (props.account?.type === 'apikey') {
|
||
return openaiAPIKeyResponsesWebSocketV2Mode.value
|
||
}
|
||
return openaiOAuthResponsesWebSocketV2Mode.value
|
||
},
|
||
set: (mode: OpenAIWSMode) => {
|
||
if (props.account?.type === 'apikey') {
|
||
openaiAPIKeyResponsesWebSocketV2Mode.value = mode
|
||
return
|
||
}
|
||
openaiOAuthResponsesWebSocketV2Mode.value = mode
|
||
}
|
||
})
|
||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
|
||
)
|
||
const codexImageGenerationBridgeOptions = computed<Array<{
|
||
value: CodexImageGenerationBridgeMode
|
||
label: string
|
||
description: string
|
||
}>>(() => [
|
||
{
|
||
value: 'inherit',
|
||
label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'),
|
||
description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc')
|
||
},
|
||
{
|
||
value: 'enabled',
|
||
label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'),
|
||
description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc')
|
||
},
|
||
{
|
||
value: 'disabled',
|
||
label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'),
|
||
description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc')
|
||
}
|
||
])
|
||
const codexImageGenerationBridgeBadgeLabel = computed(() => {
|
||
switch (codexImageGenerationBridgeMode.value) {
|
||
case 'enabled':
|
||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled')
|
||
case 'disabled':
|
||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled')
|
||
default:
|
||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit')
|
||
}
|
||
})
|
||
const codexImageGenerationBridgeBadgeClass = computed(() => {
|
||
switch (codexImageGenerationBridgeMode.value) {
|
||
case 'enabled':
|
||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||
case 'disabled':
|
||
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
|
||
default:
|
||
return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300'
|
||
}
|
||
})
|
||
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 openAIResponsesModeOptions = computed(() => [
|
||
{ value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
|
||
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
||
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
|
||
])
|
||
const openAITextEndpointCapabilityLabel = computed(() => {
|
||
if (openAIResponsesMode.value === 'force_responses') {
|
||
return t('admin.accounts.openai.capabilityResponses')
|
||
}
|
||
if (openAIResponsesMode.value === 'force_chat_completions') {
|
||
return t('admin.accounts.openai.capabilityChatCompletions')
|
||
}
|
||
const extra = props.account?.extra as Record<string, unknown> | undefined
|
||
if (extra?.openai_responses_supported === true) {
|
||
return t('admin.accounts.openai.capabilityResponsesAuto')
|
||
}
|
||
if (extra?.openai_responses_supported === false) {
|
||
return t('admin.accounts.openai.capabilityChatCompletionsAuto')
|
||
}
|
||
return t('admin.accounts.openai.capabilityTextAuto')
|
||
})
|
||
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
|
||
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
|
||
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
|
||
])
|
||
const openAITextGenerationCapabilityEnabled = computed(() =>
|
||
openAIEndpointCapabilities.value.includes('chat_completions')
|
||
)
|
||
|
||
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
|
||
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
|
||
const selected = allowed.filter((value) => values.includes(value))
|
||
return selected.length > 0 ? selected : allowed
|
||
}
|
||
|
||
const readOpenAIEndpointCapabilities = (credentials?: Record<string, unknown>): OpenAIEndpointCapability[] => {
|
||
const raw = credentials?.openai_capabilities
|
||
if (Array.isArray(raw)) {
|
||
return normalizeOpenAIEndpointCapabilities(
|
||
raw.filter((value): value is OpenAIEndpointCapability =>
|
||
value === 'chat_completions' || value === 'embeddings'
|
||
)
|
||
)
|
||
}
|
||
if (raw !== null && typeof raw === 'object') {
|
||
const capabilityMap = raw as Record<string, unknown>
|
||
return normalizeOpenAIEndpointCapabilities(
|
||
openAIEndpointCapabilityOptions.value
|
||
.map((option) => option.value)
|
||
.filter((value) => capabilityMap[value] === true)
|
||
)
|
||
}
|
||
return ['chat_completions', 'embeddings']
|
||
}
|
||
|
||
const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => {
|
||
if (openAIEndpointCapabilities.value.includes(capability)) {
|
||
if (openAIEndpointCapabilities.value.length <= 1) {
|
||
const input = event?.target as HTMLInputElement | null
|
||
if (input) input.checked = true
|
||
return
|
||
}
|
||
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
|
||
(value) => value !== capability
|
||
)
|
||
if (!openAITextGenerationCapabilityEnabled.value) {
|
||
openAIResponsesMode.value = 'auto'
|
||
}
|
||
return
|
||
}
|
||
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
|
||
...openAIEndpointCapabilities.value,
|
||
capability
|
||
])
|
||
}
|
||
|
||
const applyOpenAIEndpointCapabilities = (credentials: Record<string, unknown>) => {
|
||
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
|
||
if (capabilities.length === 2) {
|
||
delete credentials.openai_capabilities
|
||
return
|
||
}
|
||
credentials.openai_capabilities = capabilities
|
||
}
|
||
const normalizeOpenAIResponsesMode = (mode: unknown): OpenAIResponsesMode => {
|
||
if (mode === 'force_responses' || mode === 'force_chat_completions') {
|
||
return mode
|
||
}
|
||
return 'auto'
|
||
}
|
||
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
|
||
)
|
||
const openAIResponsesStatusKey = computed(() => {
|
||
if (openAIResponsesMode.value === 'force_responses') {
|
||
return 'admin.accounts.openai.responsesStatusForcedResponses'
|
||
}
|
||
if (openAIResponsesMode.value === 'force_chat_completions') {
|
||
return 'admin.accounts.openai.responsesStatusForcedChatCompletions'
|
||
}
|
||
const extra = props.account?.extra as Record<string, unknown> | undefined
|
||
if (extra?.openai_responses_supported === true) {
|
||
return 'admin.accounts.openai.responsesStatusAutoSupported'
|
||
}
|
||
if (extra?.openai_responses_supported === false) {
|
||
return 'admin.accounts.openai.responsesStatusAutoUnsupported'
|
||
}
|
||
return 'admin.accounts.openai.responsesStatusAutoUnknown'
|
||
})
|
||
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.compactAuto'
|
||
})
|
||
|
||
// Computed: current preset mappings based on platform
|
||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||
const tempUnschedPresets = computed(() => [
|
||
{
|
||
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
|
||
rule: {
|
||
error_code: 529,
|
||
keywords: 'overloaded, too many',
|
||
duration_minutes: 60,
|
||
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
|
||
}
|
||
},
|
||
{
|
||
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
|
||
rule: {
|
||
error_code: 429,
|
||
keywords: 'rate limit, too many requests',
|
||
duration_minutes: 10,
|
||
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
|
||
}
|
||
},
|
||
{
|
||
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
|
||
rule: {
|
||
error_code: 503,
|
||
keywords: 'unavailable, maintenance',
|
||
duration_minutes: 30,
|
||
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
|
||
}
|
||
}
|
||
])
|
||
|
||
// Computed: default base URL based on platform
|
||
const defaultBaseUrl = computed(() => {
|
||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||
return 'https://api.anthropic.com'
|
||
})
|
||
|
||
const mixedChannelWarningMessageText = computed(() => {
|
||
if (mixedChannelWarningDetails.value) {
|
||
return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value)
|
||
}
|
||
return mixedChannelWarningRawMessage.value
|
||
})
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
notes: '',
|
||
proxy_id: null as number | null,
|
||
concurrency: 1,
|
||
load_factor: null as number | null,
|
||
priority: 1,
|
||
rate_multiplier: 1,
|
||
status: 'active' as 'active' | 'inactive' | 'error',
|
||
group_ids: [] as number[],
|
||
expires_at: null as number | null
|
||
})
|
||
|
||
const statusOptions = computed(() => {
|
||
const options = [
|
||
{ value: 'active', label: t('common.active') },
|
||
{ value: 'inactive', label: t('common.inactive') }
|
||
]
|
||
if (form.status === 'error') {
|
||
options.push({ value: 'error', label: t('admin.accounts.status.error') })
|
||
}
|
||
return options
|
||
})
|
||
|
||
const expiresAtInput = computed({
|
||
get: () => formatDateTimeLocal(form.expires_at),
|
||
set: (value: string) => {
|
||
form.expires_at = parseDateTimeLocal(value)
|
||
}
|
||
})
|
||
|
||
// Watchers
|
||
const normalizePoolModeRetryCount = (value: number) => {
|
||
if (!Number.isFinite(value)) {
|
||
return DEFAULT_POOL_MODE_RETRY_COUNT
|
||
}
|
||
const normalized = Math.trunc(value)
|
||
if (normalized < 0) {
|
||
return 0
|
||
}
|
||
if (normalized > MAX_POOL_MODE_RETRY_COUNT) {
|
||
return MAX_POOL_MODE_RETRY_COUNT
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
const loadModelRestrictionFromMapping = (rawMapping?: Record<string, unknown>) => {
|
||
const parsed = splitModelMappingObject(rawMapping)
|
||
allowedModels.value = parsed.allowedModels
|
||
modelMappings.value = parsed.modelMappings
|
||
modelRestrictionMode.value =
|
||
parsed.modelMappings.length > 0 && parsed.allowedModels.length === 0
|
||
? 'mapping'
|
||
: 'whitelist'
|
||
}
|
||
|
||
const buildModelRestrictionMapping = () =>
|
||
buildModelMappingObject('combined', allowedModels.value, modelMappings.value)
|
||
|
||
const syncFormFromAccount = (newAccount: Account | null) => {
|
||
if (!newAccount) {
|
||
return
|
||
}
|
||
antigravityMixedChannelConfirmed.value = false
|
||
showMixedChannelWarning.value = false
|
||
mixedChannelWarningDetails.value = null
|
||
mixedChannelWarningRawMessage.value = ''
|
||
mixedChannelWarningAction.value = null
|
||
form.name = newAccount.name
|
||
form.notes = newAccount.notes || ''
|
||
form.proxy_id = newAccount.proxy_id
|
||
form.concurrency = newAccount.concurrency
|
||
form.load_factor = newAccount.load_factor ?? null
|
||
form.priority = newAccount.priority
|
||
form.rate_multiplier = newAccount.rate_multiplier ?? 1
|
||
form.status = (newAccount.status === 'active' || newAccount.status === 'inactive' || newAccount.status === 'error')
|
||
? newAccount.status
|
||
: 'active'
|
||
form.group_ids = newAccount.group_ids || []
|
||
form.expires_at = newAccount.expires_at ?? null
|
||
|
||
// Load intercept warmup requests setting (applies to all account types)
|
||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
|
||
editVertexProjectId.value = ''
|
||
editVertexClientEmail.value = ''
|
||
editVertexLocation.value = 'us-central1'
|
||
|
||
// Load mixed scheduling setting (only for antigravity accounts)
|
||
mixedScheduling.value = false
|
||
allowOverages.value = false
|
||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||
allowOverages.value = extra?.allow_overages === true
|
||
autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null
|
||
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
|
||
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
||
autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true
|
||
|
||
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||
openaiPassthroughEnabled.value = false
|
||
openAICompactMode.value = 'auto'
|
||
openAIResponsesMode.value = 'auto'
|
||
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||
openAICompactModelMappings.value = []
|
||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||
codexCLIOnlyEnabled.value = false
|
||
codexCLIOnlyAllowClaudeCodeEnabled.value = false
|
||
codexImageGenerationBridgeMode.value = 'inherit'
|
||
anthropicPassthroughEnabled.value = false
|
||
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'
|
||
if (newAccount.type === 'apikey') {
|
||
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
|
||
openAIEndpointCapabilities.value = readOpenAIEndpointCapabilities(
|
||
newAccount.credentials as Record<string, unknown> | undefined
|
||
)
|
||
if (!openAITextGenerationCapabilityEnabled.value) {
|
||
openAIResponsesMode.value = 'auto'
|
||
}
|
||
}
|
||
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
|
||
? extra.codex_image_generation_bridge
|
||
: extra?.codex_image_generation_bridge_enabled
|
||
if (codexImageGenerationBridgeValue === true) {
|
||
codexImageGenerationBridgeMode.value = 'enabled'
|
||
} else if (codexImageGenerationBridgeValue === false) {
|
||
codexImageGenerationBridgeMode.value = 'disabled'
|
||
}
|
||
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||
modeKey: 'openai_oauth_responses_websockets_v2_mode',
|
||
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
|
||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
|
||
defaultMode: OPENAI_WS_MODE_OFF
|
||
})
|
||
openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||
modeKey: 'openai_apikey_responses_websockets_v2_mode',
|
||
enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
|
||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
|
||
defaultMode: OPENAI_WS_MODE_OFF
|
||
})
|
||
if (newAccount.type === 'oauth') {
|
||
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
|
||
codexCLIOnlyAllowClaudeCodeEnabled.value =
|
||
Array.isArray(extra?.codex_cli_only_allowed_clients) &&
|
||
(extra.codex_cli_only_allowed_clients as unknown[]).includes('claude_code')
|
||
}
|
||
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
|
||
// 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool
|
||
const wsVal = extra?.web_search_emulation
|
||
if (wsVal === 'enabled' || wsVal === 'disabled') {
|
||
webSearchEmulationMode.value = wsVal
|
||
} else if (wsVal === true) {
|
||
webSearchEmulationMode.value = 'enabled'
|
||
} else {
|
||
webSearchEmulationMode.value = 'default'
|
||
}
|
||
}
|
||
|
||
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
|
||
if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
|
||
const quotaVal = extra?.quota_limit as number | undefined
|
||
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
|
||
const dailyVal = extra?.quota_daily_limit as number | undefined
|
||
editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null
|
||
const weeklyVal = extra?.quota_weekly_limit as number | undefined
|
||
editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null
|
||
// Load quota reset mode config
|
||
editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null
|
||
editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null
|
||
editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null
|
||
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
|
||
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
|
||
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
|
||
// Load quota notify config
|
||
loadQuotaNotifyFromExtra(extra)
|
||
} else {
|
||
editQuotaLimit.value = null
|
||
editQuotaDailyLimit.value = null
|
||
editQuotaWeeklyLimit.value = null
|
||
editDailyResetMode.value = null
|
||
editDailyResetHour.value = null
|
||
editWeeklyResetMode.value = null
|
||
editWeeklyResetDay.value = null
|
||
editWeeklyResetHour.value = null
|
||
editResetTimezone.value = null
|
||
resetQuotaNotify()
|
||
}
|
||
|
||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||
if (newAccount.platform === 'antigravity') {
|
||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||
|
||
// Antigravity 始终使用映射模式
|
||
antigravityModelRestrictionMode.value = 'mapping'
|
||
antigravityWhitelistModels.value = []
|
||
|
||
// 从 model_mapping 读取映射配置
|
||
const rawAgMapping = credentials?.model_mapping as Record<string, string> | undefined
|
||
if (rawAgMapping && typeof rawAgMapping === 'object') {
|
||
const entries = Object.entries(rawAgMapping)
|
||
// 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表
|
||
antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to }))
|
||
} else {
|
||
// 兼容旧数据:从 model_whitelist 读取,转换为映射格式
|
||
const rawWhitelist = credentials?.model_whitelist
|
||
if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) {
|
||
antigravityModelMappings.value = rawWhitelist
|
||
.map((v) => String(v).trim())
|
||
.filter((v) => v.length > 0)
|
||
.map((m) => ({ from: m, to: m }))
|
||
} else {
|
||
antigravityModelMappings.value = []
|
||
}
|
||
}
|
||
} else {
|
||
antigravityModelRestrictionMode.value = 'mapping'
|
||
antigravityWhitelistModels.value = []
|
||
antigravityModelMappings.value = []
|
||
}
|
||
|
||
// Load quota control settings (Anthropic OAuth/SetupToken only)
|
||
loadQuotaControlSettings(newAccount)
|
||
|
||
loadTempUnschedRules(credentials)
|
||
|
||
// Initialize API Key fields for apikey type
|
||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||
const credentials = newAccount.credentials as Record<string, unknown>
|
||
const platformDefaultUrl =
|
||
newAccount.platform === 'openai'
|
||
? 'https://api.openai.com'
|
||
: newAccount.platform === 'gemini'
|
||
? 'https://generativelanguage.googleapis.com'
|
||
: 'https://api.anthropic.com'
|
||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||
|
||
// Load model mappings and detect mode
|
||
loadModelRestrictionFromMapping(credentials.model_mapping as Record<string, unknown> | undefined)
|
||
|
||
// Load pool mode
|
||
poolModeEnabled.value = credentials.pool_mode === true
|
||
poolModeRetryCount.value = normalizePoolModeRetryCount(
|
||
Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
|
||
)
|
||
poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(credentials.pool_mode_retry_status_codes)
|
||
|
||
// Load custom error codes
|
||
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
|
||
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
|
||
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
|
||
selectedErrorCodes.value = [...existingErrorCodes]
|
||
} else {
|
||
selectedErrorCodes.value = []
|
||
}
|
||
} else if (newAccount.type === 'bedrock' && newAccount.credentials) {
|
||
const bedrockCreds = newAccount.credentials as Record<string, unknown>
|
||
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
|
||
editBedrockRegion.value = (bedrockCreds.aws_region as string) || ''
|
||
editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true'
|
||
|
||
if (authMode === 'apikey') {
|
||
editBedrockApiKeyValue.value = ''
|
||
} else {
|
||
editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || ''
|
||
editBedrockSecretAccessKey.value = ''
|
||
editBedrockSessionToken.value = ''
|
||
}
|
||
|
||
// Load pool mode for bedrock
|
||
poolModeEnabled.value = bedrockCreds.pool_mode === true
|
||
const retryCount = bedrockCreds.pool_mode_retry_count
|
||
poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
|
||
poolModeRetryStatusCodesInput.value = formatPoolModeRetryStatusCodes(bedrockCreds.pool_mode_retry_status_codes)
|
||
|
||
// Load quota limits for bedrock
|
||
const bedrockExtra = (newAccount.extra as Record<string, unknown>) || {}
|
||
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
|
||
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
|
||
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
|
||
// Load quota notify for bedrock
|
||
loadQuotaNotifyFromExtra(bedrockExtra)
|
||
|
||
// Load model mappings for bedrock
|
||
loadModelRestrictionFromMapping(bedrockCreds.model_mapping as Record<string, unknown> | undefined)
|
||
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
||
const credentials = newAccount.credentials as Record<string, unknown>
|
||
editBaseUrl.value = (credentials.base_url as string) || ''
|
||
} else if ((newAccount.platform === 'gemini' || newAccount.platform === 'anthropic') && newAccount.type === 'service_account' && newAccount.credentials) {
|
||
const credentials = newAccount.credentials as Record<string, unknown>
|
||
editVertexProjectId.value = (credentials.project_id as string) || ''
|
||
editVertexClientEmail.value = (credentials.client_email as string) || ''
|
||
editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1'
|
||
|
||
// Load model mappings for service_account
|
||
loadModelRestrictionFromMapping(credentials.model_mapping as Record<string, unknown> | undefined)
|
||
} else {
|
||
const platformDefaultUrl =
|
||
newAccount.platform === 'openai'
|
||
? 'https://api.openai.com'
|
||
: newAccount.platform === 'gemini'
|
||
? 'https://generativelanguage.googleapis.com'
|
||
: 'https://api.anthropic.com'
|
||
editBaseUrl.value = platformDefaultUrl
|
||
|
||
// Load model mappings for OpenAI OAuth accounts
|
||
if (newAccount.platform === 'openai' && newAccount.credentials) {
|
||
const oauthCredentials = newAccount.credentials as Record<string, unknown>
|
||
loadModelRestrictionFromMapping(oauthCredentials.model_mapping as Record<string, unknown> | undefined)
|
||
} else {
|
||
modelRestrictionMode.value = 'whitelist'
|
||
modelMappings.value = []
|
||
allowedModels.value = []
|
||
}
|
||
poolModeEnabled.value = false
|
||
poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
|
||
poolModeRetryStatusCodesInput.value = ''
|
||
customErrorCodesEnabled.value = false
|
||
selectedErrorCodes.value = []
|
||
}
|
||
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]) => {
|
||
if (!show || !newAccount) {
|
||
return
|
||
}
|
||
if (!wasShow || newAccount !== previousAccount) {
|
||
syncFormFromAccount(newAccount)
|
||
loadTLSProfiles()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
// Model mapping helpers
|
||
const addModelMapping = () => {
|
||
modelMappings.value.push({ from: '', to: '' })
|
||
}
|
||
|
||
const removeModelMapping = (index: number) => {
|
||
modelMappings.value.splice(index, 1)
|
||
}
|
||
|
||
const addPresetMapping = (from: string, to: string) => {
|
||
const exists = modelMappings.value.some((m) => m.from === from)
|
||
if (exists) {
|
||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||
return
|
||
}
|
||
modelMappings.value.push({ from, to })
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
const addAntigravityPresetMapping = (from: string, to: string) => {
|
||
const exists = antigravityModelMappings.value.some((m) => m.from === from)
|
||
if (exists) {
|
||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||
return
|
||
}
|
||
antigravityModelMappings.value.push({ from, to })
|
||
}
|
||
|
||
const syncAntigravityUpstreamModels = async () => {
|
||
if (!props.account?.id || isSyncingAntigravityUpstream.value) return
|
||
|
||
isSyncingAntigravityUpstream.value = true
|
||
try {
|
||
const result = await adminAPI.accounts.syncUpstreamModels(props.account.id)
|
||
const upstreamModels = result.models.map((model) => model.trim()).filter(Boolean)
|
||
if (upstreamModels.length === 0) {
|
||
appStore.showInfo(t('admin.accounts.syncUpstreamModelsEmpty'))
|
||
return
|
||
}
|
||
|
||
let addedCount = 0
|
||
for (const model of upstreamModels) {
|
||
const exists = antigravityModelMappings.value.some((mapping) => mapping.from === model)
|
||
if (!exists) {
|
||
antigravityModelMappings.value.push({ from: model, to: model })
|
||
addedCount += 1
|
||
}
|
||
}
|
||
|
||
if (addedCount > 0) {
|
||
appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length }))
|
||
} else {
|
||
appStore.showInfo(t('admin.accounts.syncUpstreamModelsNoChanges', { count: upstreamModels.length }))
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : t('admin.accounts.syncUpstreamModelsFailed')
|
||
appStore.showError(t('admin.accounts.syncUpstreamModelsError', { message }))
|
||
} finally {
|
||
isSyncingAntigravityUpstream.value = false
|
||
}
|
||
}
|
||
|
||
// Error code toggle helper
|
||
const toggleErrorCode = (code: number) => {
|
||
const index = selectedErrorCodes.value.indexOf(code)
|
||
if (index === -1) {
|
||
// Adding code - check for 429/529 warning
|
||
if (code === 429) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||
return
|
||
}
|
||
} else if (code === 529) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||
return
|
||
}
|
||
}
|
||
selectedErrorCodes.value.push(code)
|
||
} else {
|
||
selectedErrorCodes.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
// Add custom error code from input
|
||
const addCustomErrorCode = () => {
|
||
const code = customErrorCodeInput.value
|
||
if (code === null || code < 100 || code > 599) {
|
||
appStore.showError(t('admin.accounts.invalidErrorCode'))
|
||
return
|
||
}
|
||
if (selectedErrorCodes.value.includes(code)) {
|
||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||
return
|
||
}
|
||
// Check for 429/529 warning
|
||
if (code === 429) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||
return
|
||
}
|
||
} else if (code === 529) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||
return
|
||
}
|
||
}
|
||
selectedErrorCodes.value.push(code)
|
||
customErrorCodeInput.value = null
|
||
}
|
||
|
||
// Remove error code
|
||
const removeErrorCode = (code: number) => {
|
||
const index = selectedErrorCodes.value.indexOf(code)
|
||
if (index !== -1) {
|
||
selectedErrorCodes.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
|
||
if (preset) {
|
||
tempUnschedRules.value.push({ ...preset })
|
||
return
|
||
}
|
||
tempUnschedRules.value.push({
|
||
error_code: null,
|
||
keywords: '',
|
||
duration_minutes: 30,
|
||
description: ''
|
||
})
|
||
}
|
||
|
||
const removeTempUnschedRule = (index: number) => {
|
||
tempUnschedRules.value.splice(index, 1)
|
||
}
|
||
|
||
const moveTempUnschedRule = (index: number, direction: number) => {
|
||
const target = index + direction
|
||
if (target < 0 || target >= tempUnschedRules.value.length) return
|
||
const rules = tempUnschedRules.value
|
||
const current = rules[index]
|
||
rules[index] = rules[target]
|
||
rules[target] = current
|
||
}
|
||
|
||
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
|
||
const out: Array<{
|
||
error_code: number
|
||
keywords: string[]
|
||
duration_minutes: number
|
||
description: string
|
||
}> = []
|
||
|
||
for (const rule of rules) {
|
||
const errorCode = Number(rule.error_code)
|
||
const duration = Number(rule.duration_minutes)
|
||
const keywords = splitTempUnschedKeywords(rule.keywords)
|
||
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
|
||
continue
|
||
}
|
||
if (!Number.isFinite(duration) || duration <= 0) {
|
||
continue
|
||
}
|
||
if (keywords.length === 0) {
|
||
continue
|
||
}
|
||
out.push({
|
||
error_code: Math.trunc(errorCode),
|
||
keywords,
|
||
duration_minutes: Math.trunc(duration),
|
||
description: rule.description.trim()
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
|
||
if (!tempUnschedEnabled.value) {
|
||
delete credentials.temp_unschedulable_enabled
|
||
delete credentials.temp_unschedulable_rules
|
||
return true
|
||
}
|
||
|
||
const rules = buildTempUnschedRules(tempUnschedRules.value)
|
||
if (rules.length === 0) {
|
||
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
|
||
return false
|
||
}
|
||
|
||
credentials.temp_unschedulable_enabled = true
|
||
credentials.temp_unschedulable_rules = rules
|
||
return true
|
||
}
|
||
|
||
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
|
||
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
|
||
const rawRules = credentials?.temp_unschedulable_rules
|
||
if (!Array.isArray(rawRules)) {
|
||
tempUnschedRules.value = []
|
||
return
|
||
}
|
||
|
||
tempUnschedRules.value = rawRules.map((rule) => {
|
||
const entry = rule as Record<string, unknown>
|
||
return {
|
||
error_code: toPositiveNumber(entry.error_code),
|
||
keywords: formatTempUnschedKeywords(entry.keywords),
|
||
duration_minutes: toPositiveNumber(entry.duration_minutes),
|
||
description: typeof entry.description === 'string' ? entry.description : ''
|
||
}
|
||
})
|
||
}
|
||
|
||
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
|
||
function loadQuotaControlSettings(account: Account) {
|
||
// Reset all quota control state first
|
||
windowCostEnabled.value = false
|
||
windowCostLimit.value = null
|
||
windowCostStickyReserve.value = null
|
||
sessionLimitEnabled.value = false
|
||
maxSessions.value = null
|
||
sessionIdleTimeout.value = null
|
||
rpmLimitEnabled.value = false
|
||
baseRpm.value = null
|
||
rpmStrategy.value = 'tiered'
|
||
rpmStickyBuffer.value = null
|
||
userMsgQueueMode.value = ''
|
||
tlsFingerprintEnabled.value = false
|
||
tlsFingerprintProfileId.value = null
|
||
sessionIdMaskingEnabled.value = false
|
||
cacheTTLOverrideEnabled.value = false
|
||
cacheTTLOverrideTarget.value = '5m'
|
||
customBaseUrlEnabled.value = false
|
||
customBaseUrl.value = ''
|
||
|
||
// Remaining quota control settings only apply to Anthropic accounts
|
||
if (account.platform !== 'anthropic') {
|
||
return
|
||
}
|
||
|
||
// Window cost / session limit only apply to Anthropic OAuth/SetupToken accounts
|
||
if (account.type !== 'oauth' && account.type !== 'setup-token') {
|
||
return
|
||
}
|
||
|
||
// Load from extra field (via backend DTO fields)
|
||
if (account.window_cost_limit != null && account.window_cost_limit > 0) {
|
||
windowCostEnabled.value = true
|
||
windowCostLimit.value = account.window_cost_limit
|
||
windowCostStickyReserve.value = account.window_cost_sticky_reserve ?? 10
|
||
}
|
||
|
||
if (account.max_sessions != null && account.max_sessions > 0) {
|
||
sessionLimitEnabled.value = true
|
||
maxSessions.value = account.max_sessions
|
||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||
}
|
||
|
||
// RPM limit
|
||
if (account.base_rpm != null && account.base_rpm > 0) {
|
||
rpmLimitEnabled.value = true
|
||
baseRpm.value = account.base_rpm
|
||
rpmStrategy.value = (account.rpm_strategy as 'tiered' | 'sticky_exempt') || 'tiered'
|
||
rpmStickyBuffer.value = account.rpm_sticky_buffer ?? null
|
||
}
|
||
|
||
// UMQ mode(独立于 RPM 加载,防止编辑无 RPM 账号时丢失已有配置)
|
||
userMsgQueueMode.value = account.user_msg_queue_mode ?? ''
|
||
|
||
// Load TLS fingerprint setting
|
||
if (account.enable_tls_fingerprint === true) {
|
||
tlsFingerprintEnabled.value = true
|
||
}
|
||
tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
|
||
|
||
// Load session ID masking setting
|
||
if (account.session_id_masking_enabled === true) {
|
||
sessionIdMaskingEnabled.value = true
|
||
}
|
||
|
||
// Load cache TTL override setting
|
||
if (account.cache_ttl_override_enabled === true) {
|
||
cacheTTLOverrideEnabled.value = true
|
||
cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m'
|
||
}
|
||
|
||
// Load custom base URL setting
|
||
if (account.custom_base_url_enabled === true) {
|
||
customBaseUrlEnabled.value = true
|
||
customBaseUrl.value = account.custom_base_url || ''
|
||
}
|
||
}
|
||
|
||
function formatTempUnschedKeywords(value: unknown) {
|
||
if (Array.isArray(value)) {
|
||
return value
|
||
.filter((item): item is string => typeof item === 'string')
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length > 0)
|
||
.join(', ')
|
||
}
|
||
if (typeof value === 'string') {
|
||
return value
|
||
}
|
||
return ''
|
||
}
|
||
|
||
const splitTempUnschedKeywords = (value: string) => {
|
||
return value
|
||
.split(/[,;]/)
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length > 0)
|
||
}
|
||
|
||
function toPositiveNumber(value: unknown) {
|
||
const num = Number(value)
|
||
if (!Number.isFinite(num) || num <= 0) {
|
||
return null
|
||
}
|
||
return Math.trunc(num)
|
||
}
|
||
|
||
const needsMixedChannelCheck = () => props.account?.platform === 'antigravity' || props.account?.platform === 'anthropic'
|
||
|
||
const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => {
|
||
const details = resp?.details
|
||
if (!details) {
|
||
return null
|
||
}
|
||
return {
|
||
groupName: details.group_name || 'Unknown',
|
||
currentPlatform: details.current_platform || 'Unknown',
|
||
otherPlatform: details.other_platform || 'Unknown'
|
||
}
|
||
}
|
||
|
||
const clearMixedChannelDialog = () => {
|
||
showMixedChannelWarning.value = false
|
||
mixedChannelWarningDetails.value = null
|
||
mixedChannelWarningRawMessage.value = ''
|
||
mixedChannelWarningAction.value = null
|
||
}
|
||
|
||
const openMixedChannelDialog = (opts: {
|
||
response?: CheckMixedChannelResponse
|
||
message?: string
|
||
onConfirm: () => Promise<void>
|
||
}) => {
|
||
mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response)
|
||
mixedChannelWarningRawMessage.value =
|
||
opts.message || opts.response?.message || t('admin.accounts.failedToUpdate')
|
||
mixedChannelWarningAction.value = opts.onConfirm
|
||
showMixedChannelWarning.value = true
|
||
}
|
||
|
||
const withAntigravityConfirmFlag = (payload: Record<string, unknown>) => {
|
||
if (needsMixedChannelCheck() && antigravityMixedChannelConfirmed.value) {
|
||
return {
|
||
...payload,
|
||
confirm_mixed_channel_risk: true
|
||
}
|
||
}
|
||
const cloned = { ...payload }
|
||
delete cloned.confirm_mixed_channel_risk
|
||
return cloned
|
||
}
|
||
|
||
const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<void>): Promise<boolean> => {
|
||
if (!needsMixedChannelCheck()) {
|
||
return true
|
||
}
|
||
if (antigravityMixedChannelConfirmed.value) {
|
||
return true
|
||
}
|
||
if (!props.account) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const result = await adminAPI.accounts.checkMixedChannelRisk({
|
||
platform: props.account.platform,
|
||
group_ids: form.group_ids,
|
||
account_id: props.account.id
|
||
})
|
||
if (!result.has_risk) {
|
||
return true
|
||
}
|
||
openMixedChannelDialog({
|
||
response: result,
|
||
onConfirm: async () => {
|
||
antigravityMixedChannelConfirmed.value = true
|
||
await onConfirm()
|
||
}
|
||
})
|
||
return false
|
||
} catch (error: any) {
|
||
appStore.showError(error.message || t('admin.accounts.failedToUpdate'))
|
||
return false
|
||
}
|
||
}
|
||
|
||
const formatDateTimeLocal = formatDateTimeLocalInput
|
||
const parseDateTimeLocal = parseDateTimeLocalInput
|
||
|
||
// Methods
|
||
const handleClose = () => {
|
||
antigravityMixedChannelConfirmed.value = false
|
||
clearMixedChannelDialog()
|
||
emit('close')
|
||
}
|
||
|
||
const submitUpdateAccount = async (accountID: number, updatePayload: Record<string, unknown>) => {
|
||
submitting.value = true
|
||
try {
|
||
const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
|
||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||
emit('updated', updatedAccount)
|
||
handleClose()
|
||
} catch (error: any) {
|
||
if (error.status === 409 && error.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
|
||
openMixedChannelDialog({
|
||
message: error.message,
|
||
onConfirm: async () => {
|
||
antigravityMixedChannelConfirmed.value = true
|
||
await submitUpdateAccount(accountID, updatePayload)
|
||
}
|
||
})
|
||
return
|
||
}
|
||
appStore.showError(error.message || t('admin.accounts.failedToUpdate'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (!props.account) return
|
||
const accountID = props.account.id
|
||
|
||
if (form.status !== 'active' && form.status !== 'inactive' && form.status !== 'error') {
|
||
appStore.showError(t('admin.accounts.pleaseSelectStatus'))
|
||
return
|
||
}
|
||
|
||
const updatePayload: Record<string, unknown> = { ...form }
|
||
try {
|
||
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
||
if (updatePayload.proxy_id === null) {
|
||
updatePayload.proxy_id = 0
|
||
}
|
||
if (form.expires_at === null) {
|
||
updatePayload.expires_at = 0
|
||
}
|
||
// load_factor: 空值/NaN/0/负数 时发送 0(后端约定 <= 0 = 清除)
|
||
const lf = form.load_factor
|
||
if (lf == null || Number.isNaN(lf) || lf <= 0) {
|
||
updatePayload.load_factor = 0
|
||
}
|
||
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
|
||
|
||
// For apikey type, handle credentials update
|
||
if (props.account.type === 'apikey') {
|
||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
|
||
const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value)
|
||
|
||
// Always update credentials for apikey type to handle model mapping changes
|
||
const newCredentials: Record<string, unknown> = {
|
||
...currentCredentials,
|
||
base_url: newBaseUrl
|
||
}
|
||
|
||
// Handle API key
|
||
// 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。
|
||
// 用户填入新值则覆盖;留空时优先看 credentials_status.has_api_key;
|
||
// 若后端尚未升级(无 credentials_status),回退读旧结构 currentCredentials.api_key。
|
||
// 两者都无才报错。
|
||
const hasExistingApiKey =
|
||
props.account.credentials_status?.has_api_key ?? Boolean(currentCredentials.api_key)
|
||
if (editApiKey.value.trim()) {
|
||
newCredentials.api_key = editApiKey.value.trim()
|
||
} else if (!hasExistingApiKey) {
|
||
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
|
||
return
|
||
}
|
||
|
||
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
|
||
if (shouldApplyModelMapping) {
|
||
const modelMapping = buildModelRestrictionMapping()
|
||
if (modelMapping) {
|
||
newCredentials.model_mapping = modelMapping
|
||
} else {
|
||
delete newCredentials.model_mapping
|
||
}
|
||
} else if (currentCredentials.model_mapping) {
|
||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||
}
|
||
if (props.account.platform === 'openai') {
|
||
applyOpenAIEndpointCapabilities(newCredentials)
|
||
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) {
|
||
newCredentials.pool_mode = true
|
||
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
|
||
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
|
||
if (parsedRetryStatusCodes.length > 0) {
|
||
newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
|
||
} else {
|
||
delete newCredentials.pool_mode_retry_status_codes
|
||
}
|
||
} else {
|
||
delete newCredentials.pool_mode
|
||
delete newCredentials.pool_mode_retry_count
|
||
delete newCredentials.pool_mode_retry_status_codes
|
||
}
|
||
|
||
// Add custom error codes if enabled
|
||
if (customErrorCodesEnabled.value) {
|
||
newCredentials.custom_error_codes_enabled = true
|
||
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
|
||
} else {
|
||
delete newCredentials.custom_error_codes_enabled
|
||
delete newCredentials.custom_error_codes
|
||
}
|
||
|
||
// Add intercept warmup requests setting
|
||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||
if (!applyTempUnschedConfig(newCredentials)) {
|
||
return
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
} else if (props.account.type === 'upstream') {
|
||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
|
||
newCredentials.base_url = editBaseUrl.value.trim()
|
||
|
||
if (editApiKey.value.trim()) {
|
||
newCredentials.api_key = editApiKey.value.trim()
|
||
}
|
||
|
||
// Add intercept warmup requests setting
|
||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||
|
||
if (!applyTempUnschedConfig(newCredentials)) {
|
||
return
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
} else if ((props.account.platform === 'gemini' || props.account.platform === 'anthropic') && props.account.type === 'service_account') {
|
||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
|
||
if (!editVertexProjectId.value.trim()) {
|
||
appStore.showError(t('admin.accounts.vertexSaJsonMissingProjectId'))
|
||
return
|
||
}
|
||
if (!editVertexClientEmail.value.trim()) {
|
||
appStore.showError(t('admin.accounts.vertexSaJsonMissingClientEmail'))
|
||
return
|
||
}
|
||
if (!editVertexLocation.value.trim()) {
|
||
appStore.showError(t('admin.accounts.vertexLocationRequired'))
|
||
return
|
||
}
|
||
|
||
// SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。
|
||
// 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。
|
||
const credentialsStatus = props.account.credentials_status
|
||
const hasExistingServiceAccountJson = credentialsStatus
|
||
? Boolean(
|
||
credentialsStatus.has_service_account_json || credentialsStatus.has_service_account
|
||
)
|
||
: Boolean(currentCredentials.service_account_json || currentCredentials.service_account)
|
||
if (!hasExistingServiceAccountJson) {
|
||
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
|
||
return
|
||
}
|
||
newCredentials.project_id = editVertexProjectId.value.trim()
|
||
newCredentials.client_email = editVertexClientEmail.value.trim()
|
||
newCredentials.location = editVertexLocation.value.trim()
|
||
newCredentials.tier_id = 'vertex'
|
||
|
||
// Add model mapping if configured
|
||
const modelMapping = buildModelRestrictionMapping()
|
||
if (modelMapping) {
|
||
newCredentials.model_mapping = modelMapping
|
||
} else {
|
||
delete newCredentials.model_mapping
|
||
}
|
||
|
||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||
if (!applyTempUnschedConfig(newCredentials)) {
|
||
return
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
} else if (props.account.type === 'bedrock') {
|
||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
|
||
newCredentials.aws_region = editBedrockRegion.value.trim()
|
||
if (editBedrockForceGlobal.value) {
|
||
newCredentials.aws_force_global = 'true'
|
||
} else {
|
||
delete newCredentials.aws_force_global
|
||
}
|
||
|
||
if (isBedrockAPIKeyMode.value) {
|
||
// API Key mode: only update api_key if user provided new value
|
||
if (editBedrockApiKeyValue.value.trim()) {
|
||
newCredentials.api_key = editBedrockApiKeyValue.value.trim()
|
||
}
|
||
} else {
|
||
// SigV4 mode
|
||
newCredentials.aws_access_key_id = editBedrockAccessKeyId.value.trim()
|
||
if (editBedrockSecretAccessKey.value.trim()) {
|
||
newCredentials.aws_secret_access_key = editBedrockSecretAccessKey.value.trim()
|
||
}
|
||
if (editBedrockSessionToken.value.trim()) {
|
||
newCredentials.aws_session_token = editBedrockSessionToken.value.trim()
|
||
}
|
||
}
|
||
|
||
// Pool mode
|
||
if (poolModeEnabled.value) {
|
||
newCredentials.pool_mode = true
|
||
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
|
||
const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value)
|
||
if (parsedRetryStatusCodes.length > 0) {
|
||
newCredentials.pool_mode_retry_status_codes = parsedRetryStatusCodes
|
||
} else {
|
||
delete newCredentials.pool_mode_retry_status_codes
|
||
}
|
||
} else {
|
||
delete newCredentials.pool_mode
|
||
delete newCredentials.pool_mode_retry_count
|
||
delete newCredentials.pool_mode_retry_status_codes
|
||
}
|
||
|
||
// Model mapping
|
||
const modelMapping = buildModelRestrictionMapping()
|
||
if (modelMapping) {
|
||
newCredentials.model_mapping = modelMapping
|
||
} else {
|
||
delete newCredentials.model_mapping
|
||
}
|
||
|
||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||
if (!applyTempUnschedConfig(newCredentials)) {
|
||
return
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
} else {
|
||
// For oauth/setup-token types, only update intercept_warmup_requests if changed
|
||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
|
||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||
if (!applyTempUnschedConfig(newCredentials)) {
|
||
return
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
}
|
||
|
||
// OpenAI OAuth: persist model mapping to credentials
|
||
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
|
||
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
|
||
((props.account.credentials as Record<string, unknown>) || {})
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
|
||
|
||
if (shouldApplyModelMapping) {
|
||
const modelMapping = buildModelRestrictionMapping()
|
||
if (modelMapping) {
|
||
newCredentials.model_mapping = modelMapping
|
||
} else {
|
||
delete newCredentials.model_mapping
|
||
}
|
||
} else if (currentCredentials.model_mapping) {
|
||
// 透传模式保留现有映射
|
||
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
|
||
}
|
||
|
||
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
|
||
// Antigravity 只支持映射模式
|
||
if (props.account.platform === 'antigravity') {
|
||
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
|
||
((props.account.credentials as Record<string, unknown>) || {})
|
||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||
|
||
// 移除旧字段
|
||
delete newCredentials.model_whitelist
|
||
delete newCredentials.model_mapping
|
||
|
||
// 只使用映射模式
|
||
const antigravityModelMapping = buildModelMappingObject(
|
||
'mapping',
|
||
[],
|
||
antigravityModelMappings.value
|
||
)
|
||
if (antigravityModelMapping) {
|
||
newCredentials.model_mapping = antigravityModelMapping
|
||
}
|
||
|
||
updatePayload.credentials = newCredentials
|
||
}
|
||
|
||
// For antigravity accounts, handle mixed_scheduling and allow_overages in extra
|
||
if (props.account.platform === 'antigravity') {
|
||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||
if (mixedScheduling.value) {
|
||
newExtra.mixed_scheduling = true
|
||
} else {
|
||
delete newExtra.mixed_scheduling
|
||
}
|
||
if (allowOverages.value) {
|
||
newExtra.allow_overages = true
|
||
} else {
|
||
delete newExtra.allow_overages
|
||
}
|
||
updatePayload.extra = newExtra
|
||
}
|
||
|
||
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
|
||
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
|
||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||
|
||
// Window cost limit settings
|
||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||
newExtra.window_cost_limit = windowCostLimit.value
|
||
newExtra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||
} else {
|
||
delete newExtra.window_cost_limit
|
||
delete newExtra.window_cost_sticky_reserve
|
||
}
|
||
|
||
// Session limit settings
|
||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||
newExtra.max_sessions = maxSessions.value
|
||
newExtra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||
} else {
|
||
delete newExtra.max_sessions
|
||
delete newExtra.session_idle_timeout_minutes
|
||
}
|
||
|
||
// RPM limit settings
|
||
if (rpmLimitEnabled.value) {
|
||
const DEFAULT_BASE_RPM = 15
|
||
newExtra.base_rpm = (baseRpm.value != null && baseRpm.value > 0)
|
||
? baseRpm.value
|
||
: DEFAULT_BASE_RPM
|
||
newExtra.rpm_strategy = rpmStrategy.value
|
||
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||
newExtra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||
} else {
|
||
delete newExtra.rpm_sticky_buffer
|
||
}
|
||
} else {
|
||
delete newExtra.base_rpm
|
||
delete newExtra.rpm_strategy
|
||
delete newExtra.rpm_sticky_buffer
|
||
}
|
||
|
||
// UMQ mode(独立于 RPM 保存)
|
||
if (userMsgQueueMode.value) {
|
||
newExtra.user_msg_queue_mode = userMsgQueueMode.value
|
||
} else {
|
||
delete newExtra.user_msg_queue_mode
|
||
}
|
||
delete newExtra.user_msg_queue_enabled // 清理旧字段
|
||
|
||
// TLS fingerprint setting
|
||
if (tlsFingerprintEnabled.value) {
|
||
newExtra.enable_tls_fingerprint = true
|
||
if (tlsFingerprintProfileId.value) {
|
||
newExtra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||
} else {
|
||
delete newExtra.tls_fingerprint_profile_id
|
||
}
|
||
} else {
|
||
delete newExtra.enable_tls_fingerprint
|
||
delete newExtra.tls_fingerprint_profile_id
|
||
}
|
||
|
||
// Session ID masking setting
|
||
if (sessionIdMaskingEnabled.value) {
|
||
newExtra.session_id_masking_enabled = true
|
||
} else {
|
||
delete newExtra.session_id_masking_enabled
|
||
}
|
||
|
||
// Cache TTL override setting
|
||
if (cacheTTLOverrideEnabled.value) {
|
||
newExtra.cache_ttl_override_enabled = true
|
||
newExtra.cache_ttl_override_target = cacheTTLOverrideTarget.value
|
||
} else {
|
||
delete newExtra.cache_ttl_override_enabled
|
||
delete newExtra.cache_ttl_override_target
|
||
}
|
||
|
||
// Custom base URL relay setting
|
||
if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
|
||
newExtra.custom_base_url_enabled = true
|
||
newExtra.custom_base_url = customBaseUrl.value.trim()
|
||
} else {
|
||
delete newExtra.custom_base_url_enabled
|
||
delete newExtra.custom_base_url
|
||
}
|
||
|
||
updatePayload.extra = newExtra
|
||
}
|
||
|
||
// For Anthropic API Key accounts, handle passthrough mode + web search emulation in extra
|
||
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
|
||
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
|
||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||
if (anthropicPassthroughEnabled.value) {
|
||
newExtra.anthropic_passthrough = true
|
||
} else {
|
||
delete newExtra.anthropic_passthrough
|
||
}
|
||
if (webSearchEmulationMode.value === 'default') {
|
||
delete newExtra.web_search_emulation
|
||
} else {
|
||
newExtra.web_search_emulation = webSearchEmulationMode.value
|
||
}
|
||
updatePayload.extra = newExtra
|
||
}
|
||
|
||
// For OpenAI OAuth/API Key accounts, handle passthrough mode in extra
|
||
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
|
||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
|
||
if (props.account.type === 'oauth') {
|
||
newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
|
||
newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
|
||
} else if (props.account.type === 'apikey') {
|
||
newExtra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
|
||
newExtra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
|
||
}
|
||
delete newExtra.responses_websockets_v2_enabled
|
||
delete newExtra.openai_ws_enabled
|
||
if (openaiPassthroughEnabled.value) {
|
||
newExtra.openai_passthrough = true
|
||
} else {
|
||
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 === 'apikey') {
|
||
if (!openAITextGenerationCapabilityEnabled.value || openAIResponsesMode.value === 'auto') {
|
||
delete newExtra.openai_responses_mode
|
||
} else {
|
||
newExtra.openai_responses_mode = openAIResponsesMode.value
|
||
}
|
||
}
|
||
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {
|
||
newExtra.auto_pause_5h_threshold = autoPause5hThreshold.value / 100
|
||
} else {
|
||
delete newExtra.auto_pause_5h_threshold
|
||
}
|
||
if (autoPause7dThreshold.value != null && autoPause7dThreshold.value > 0) {
|
||
newExtra.auto_pause_7d_threshold = autoPause7dThreshold.value / 100
|
||
} else {
|
||
delete newExtra.auto_pause_7d_threshold
|
||
}
|
||
if (autoPause5hDisabled.value) {
|
||
newExtra.auto_pause_5h_disabled = true
|
||
} else {
|
||
delete newExtra.auto_pause_5h_disabled
|
||
}
|
||
if (autoPause7dDisabled.value) {
|
||
newExtra.auto_pause_7d_disabled = true
|
||
} else {
|
||
delete newExtra.auto_pause_7d_disabled
|
||
}
|
||
|
||
delete newExtra.codex_image_generation_bridge_enabled
|
||
if (codexImageGenerationBridgeMode.value === 'inherit') {
|
||
delete newExtra.codex_image_generation_bridge
|
||
} else {
|
||
newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled'
|
||
}
|
||
|
||
if (props.account.type === 'oauth') {
|
||
if (codexCLIOnlyEnabled.value) {
|
||
newExtra.codex_cli_only = true
|
||
} else if (hadCodexCLIOnlyEnabled) {
|
||
// 关闭时显式写 false,避免 extra 为空被后端忽略导致旧值无法清除
|
||
newExtra.codex_cli_only = false
|
||
} else {
|
||
delete newExtra.codex_cli_only
|
||
}
|
||
// 仅当 codex_cli_only 开启且子开关开启时写入 Claude Code 插件白名单,否则清除避免孤立字段
|
||
if (codexCLIOnlyEnabled.value && codexCLIOnlyAllowClaudeCodeEnabled.value) {
|
||
newExtra.codex_cli_only_allowed_clients = ['claude_code']
|
||
} else {
|
||
delete newExtra.codex_cli_only_allowed_clients
|
||
}
|
||
}
|
||
|
||
updatePayload.extra = newExtra
|
||
}
|
||
|
||
// For apikey/bedrock accounts, handle quota_limit in extra
|
||
if (props.account.type === 'apikey' || props.account.type === 'bedrock') {
|
||
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
|
||
(props.account.extra as Record<string, unknown>) || {}
|
||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||
// Total quota
|
||
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
|
||
newExtra.quota_limit = editQuotaLimit.value
|
||
} else {
|
||
delete newExtra.quota_limit
|
||
}
|
||
// Daily quota
|
||
if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) {
|
||
newExtra.quota_daily_limit = editQuotaDailyLimit.value
|
||
} else {
|
||
delete newExtra.quota_daily_limit
|
||
delete newExtra.quota_daily_used
|
||
delete newExtra.quota_daily_start
|
||
}
|
||
// Weekly quota
|
||
if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) {
|
||
newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value
|
||
} else {
|
||
delete newExtra.quota_weekly_limit
|
||
delete newExtra.quota_weekly_used
|
||
delete newExtra.quota_weekly_start
|
||
}
|
||
// Quota reset mode config
|
||
if (editDailyResetMode.value === 'fixed') {
|
||
newExtra.quota_daily_reset_mode = 'fixed'
|
||
newExtra.quota_daily_reset_hour = editDailyResetHour.value ?? 0
|
||
} else {
|
||
delete newExtra.quota_daily_reset_mode
|
||
delete newExtra.quota_daily_reset_hour
|
||
}
|
||
if (editWeeklyResetMode.value === 'fixed') {
|
||
newExtra.quota_weekly_reset_mode = 'fixed'
|
||
newExtra.quota_weekly_reset_day = editWeeklyResetDay.value ?? 1
|
||
newExtra.quota_weekly_reset_hour = editWeeklyResetHour.value ?? 0
|
||
} else {
|
||
delete newExtra.quota_weekly_reset_mode
|
||
delete newExtra.quota_weekly_reset_day
|
||
delete newExtra.quota_weekly_reset_hour
|
||
}
|
||
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
|
||
newExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
|
||
} else {
|
||
delete newExtra.quota_reset_timezone
|
||
}
|
||
// Quota notify config
|
||
writeQuotaNotifyToExtra(newExtra, 'update')
|
||
updatePayload.extra = newExtra
|
||
}
|
||
|
||
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
|
||
await submitUpdateAccount(accountID, updatePayload)
|
||
})
|
||
if (!canContinue) {
|
||
return
|
||
}
|
||
|
||
await submitUpdateAccount(accountID, updatePayload)
|
||
} catch (error: any) {
|
||
appStore.showError(error.message || t('admin.accounts.failedToUpdate'))
|
||
}
|
||
}
|
||
|
||
// Handle mixed channel warning confirmation
|
||
const handleMixedChannelConfirm = async () => {
|
||
const action = mixedChannelWarningAction.value
|
||
if (!action) {
|
||
clearMixedChannelDialog()
|
||
return
|
||
}
|
||
clearMixedChannelDialog()
|
||
submitting.value = true
|
||
try {
|
||
await action()
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
const handleMixedChannelCancel = () => {
|
||
clearMixedChannelDialog()
|
||
}
|
||
</script>
|