| <template> |
| <BaseDialog |
| :show="show" |
| :title="t('admin.accounts.editAccount')" |
| width="normal" |
| @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> |
| |
| |
| <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' || account.platform === 'sora' |
| ? '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" |
| :placeholder=" |
| account.platform === 'openai' || account.platform === 'sora' |
| ? 'sk-proj-...' |
| : account.platform === 'gemini' |
| ? 'AIza...' |
| : account.platform === 'antigravity' |
| ? 'sk-...' |
| : 'sk-ant-...' |
| " |
| /> |
| <p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p> |
| </div> |
| |
| |
| <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> |
| |
| <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> |
| |
| |
| <div v-if="modelRestrictionMode === 'whitelist'"> |
| <ModelWhitelistSelector v-model="allowedModels" :platform="account?.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">{{ |
| t('admin.accounts.supportsAllModels') |
| }}</span> |
| </p> |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| <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> |
| |
| |
| <div v-if="modelRestrictionMode === 'whitelist'"> |
| <ModelWhitelistSelector v-model="allowedModels" :platform="account?.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">{{ |
| t('admin.accounts.supportsAllModels') |
| }}</span> |
| </p> |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div v-if="account.type === 'bedrock'" class="space-y-4"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div class="border-t border-gray-200 pt-4 dark:border-dark-600"> |
| <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> |
| |
| |
| <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> |
| |
| |
| <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">{{ t('admin.accounts.supportsAllModels') }}</span> |
| </p> |
| </div> |
| |
| |
| <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> |
| |
| <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> |
| |
| |
| <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> |
| </div> |
| |
| |
| |
| <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> |
| <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="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> |
| |
| |
| <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> |
| |
| |
| <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> |
| <label class="input-label">{{ t('admin.accounts.proxy') }}</label> |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div v-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.quotaLimit') }}</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" |
| @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" |
| /> |
| </div> |
| |
| |
| <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> |
| |
| <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 === '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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| </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> |
| |
| |
| <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> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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 type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } 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 GroupSelector from '@/components/common/GroupSelector.vue' |
| import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' |
| import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' |
| import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' |
| import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' |
| import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' |
| import { |
| |
| OPENAI_WS_MODE_OFF, |
| OPENAI_WS_MODE_PASSTHROUGH, |
| isOpenAIWSModeEnabled, |
| resolveOpenAIWSModeConcurrencyHintKey, |
| type OpenAIWSMode, |
| resolveOpenAIWSModeFromExtra |
| } from '@/utils/openaiWsMode' |
| import { |
| getPresetMappingsByPlatform, |
| commonErrorCodes, |
| buildModelMappingObject, |
| 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() |
| |
| |
| 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')) |
| |
| |
| interface ModelMapping { |
| from: string |
| to: string |
| } |
| |
| interface TempUnschedRuleForm { |
| error_code: number | null |
| keywords: string |
| duration_minutes: number | null |
| description: string |
| } |
| |
| |
| const submitting = ref(false) |
| const editBaseUrl = ref('https://api.anthropic.com') |
| const editApiKey = ref('') |
| |
| const editBedrockAccessKeyId = ref('') |
| const editBedrockSecretAccessKey = ref('') |
| const editBedrockSessionToken = ref('') |
| const editBedrockRegion = ref('') |
| const editBedrockForceGlobal = ref(false) |
| const editBedrockApiKeyValue = ref('') |
| const isBedrockAPIKeyMode = computed(() => |
| props.account?.type === 'bedrock' && |
| (props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey' |
| ) |
| const modelMappings = 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 poolModeEnabled = ref(false) |
| const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT) |
| const customErrorCodesEnabled = ref(false) |
| const selectedErrorCodes = ref<number[]>([]) |
| const customErrorCodeInput = ref<number | null>(null) |
| const interceptWarmupRequests = ref(false) |
| const autoPauseOnExpired = ref(false) |
| const mixedScheduling = ref(false) |
| const allowOverages = ref(false) |
| const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') |
| const antigravityWhitelistModels = ref<string[]>([]) |
| const antigravityModelMappings = ref<ModelMapping[]>([]) |
| const tempUnschedEnabled = ref(false) |
| const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) |
| const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-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) |
| |
| |
| 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 sessionIdMaskingEnabled = ref(false) |
| const cacheTTLOverrideEnabled = ref(false) |
| const cacheTTLOverrideTarget = ref<string>('5m') |
| |
| |
| const openaiPassthroughEnabled = ref(false) |
| const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) |
| const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) |
| const codexCLIOnlyEnabled = ref(false) |
| const anthropicPassthroughEnabled = ref(false) |
| 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_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 isOpenAIModelRestrictionDisabled = computed(() => |
| props.account?.platform === 'openai' && openaiPassthroughEnabled.value |
| ) |
| |
| |
| 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') |
| } |
| } |
| ]) |
| |
| |
| const defaultBaseUrl = computed(() => { |
| if (props.account?.platform === 'openai' || props.account?.platform === 'sora') 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) |
| } |
| }) |
| |
| |
| 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 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 |
| |
| |
| const credentials = newAccount.credentials as Record<string, unknown> | undefined |
| interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true |
| autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true |
| |
| |
| 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 |
| |
| |
| openaiPassthroughEnabled.value = false |
| openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF |
| openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF |
| codexCLIOnlyEnabled.value = false |
| anthropicPassthroughEnabled.value = false |
| if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { |
| openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true |
| 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 |
| } |
| } |
| if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { |
| anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true |
| } |
| |
| |
| 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 |
| |
| 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 |
| } 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 |
| } |
| |
| |
| if (newAccount.platform === 'antigravity') { |
| const credentials = newAccount.credentials as Record<string, unknown> | undefined |
| |
| |
| antigravityModelRestrictionMode.value = 'mapping' |
| antigravityWhitelistModels.value = [] |
| |
| |
| const rawAgMapping = credentials?.model_mapping as Record<string, string> | undefined |
| if (rawAgMapping && typeof rawAgMapping === 'object') { |
| const entries = Object.entries(rawAgMapping) |
| |
| antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to })) |
| } else { |
| |
| 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 = [] |
| } |
| |
| |
| loadQuotaControlSettings(newAccount) |
| |
| loadTempUnschedRules(credentials) |
| |
| |
| if (newAccount.type === 'apikey' && newAccount.credentials) { |
| const credentials = newAccount.credentials as Record<string, unknown> |
| const platformDefaultUrl = |
| newAccount.platform === 'openai' || newAccount.platform === 'sora' |
| ? 'https://api.openai.com' |
| : newAccount.platform === 'gemini' |
| ? 'https://generativelanguage.googleapis.com' |
| : 'https://api.anthropic.com' |
| editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl |
| |
| |
| const existingMappings = credentials.model_mapping as Record<string, string> | undefined |
| if (existingMappings && typeof existingMappings === 'object') { |
| const entries = Object.entries(existingMappings) |
| |
| |
| const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) |
| |
| if (isWhitelistMode) { |
| |
| modelRestrictionMode.value = 'whitelist' |
| allowedModels.value = entries.map(([from]) => from) |
| modelMappings.value = [] |
| } else { |
| |
| modelRestrictionMode.value = 'mapping' |
| modelMappings.value = entries.map(([from, to]) => ({ from, to })) |
| allowedModels.value = [] |
| } |
| } else { |
| |
| modelRestrictionMode.value = 'whitelist' |
| modelMappings.value = [] |
| allowedModels.value = [] |
| } |
| |
| |
| poolModeEnabled.value = credentials.pool_mode === true |
| poolModeRetryCount.value = normalizePoolModeRetryCount( |
| Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT) |
| ) |
| |
| |
| 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 = '' |
| } |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined |
| if (existingMappings && typeof existingMappings === 'object') { |
| const entries = Object.entries(existingMappings) |
| const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) |
| if (isWhitelistMode) { |
| modelRestrictionMode.value = 'whitelist' |
| allowedModels.value = entries.map(([from]) => from) |
| modelMappings.value = [] |
| } else { |
| modelRestrictionMode.value = 'mapping' |
| modelMappings.value = entries.map(([from, to]) => ({ from, to })) |
| allowedModels.value = [] |
| } |
| } else { |
| modelRestrictionMode.value = 'whitelist' |
| modelMappings.value = [] |
| allowedModels.value = [] |
| } |
| } else if (newAccount.type === 'upstream' && newAccount.credentials) { |
| const credentials = newAccount.credentials as Record<string, unknown> |
| editBaseUrl.value = (credentials.base_url as string) || '' |
| } else { |
| const platformDefaultUrl = |
| newAccount.platform === 'openai' || newAccount.platform === 'sora' |
| ? 'https://api.openai.com' |
| : newAccount.platform === 'gemini' |
| ? 'https://generativelanguage.googleapis.com' |
| : 'https://api.anthropic.com' |
| editBaseUrl.value = platformDefaultUrl |
| |
| |
| if (newAccount.platform === 'openai' && newAccount.credentials) { |
| const oauthCredentials = newAccount.credentials as Record<string, unknown> |
| const existingMappings = oauthCredentials.model_mapping as Record<string, string> | undefined |
| if (existingMappings && typeof existingMappings === 'object') { |
| const entries = Object.entries(existingMappings) |
| const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) |
| if (isWhitelistMode) { |
| modelRestrictionMode.value = 'whitelist' |
| allowedModels.value = entries.map(([from]) => from) |
| modelMappings.value = [] |
| } else { |
| modelRestrictionMode.value = 'mapping' |
| modelMappings.value = entries.map(([from, to]) => ({ from, to })) |
| allowedModels.value = [] |
| } |
| } else { |
| modelRestrictionMode.value = 'whitelist' |
| modelMappings.value = [] |
| allowedModels.value = [] |
| } |
| } else { |
| modelRestrictionMode.value = 'whitelist' |
| modelMappings.value = [] |
| allowedModels.value = [] |
| } |
| poolModeEnabled.value = false |
| poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT |
| customErrorCodesEnabled.value = false |
| selectedErrorCodes.value = [] |
| } |
| editApiKey.value = '' |
| } |
| |
| watch( |
| [() => props.show, () => props.account], |
| ([show, newAccount], [wasShow, previousAccount]) => { |
| if (!show || !newAccount) { |
| return |
| } |
| if (!wasShow || newAccount !== previousAccount) { |
| syncFormFromAccount(newAccount) |
| } |
| }, |
| { immediate: true } |
| ) |
| |
| |
| 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 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 toggleErrorCode = (code: number) => { |
| const index = selectedErrorCodes.value.indexOf(code) |
| if (index === -1) { |
| |
| 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) |
| } |
| } |
| |
| |
| 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 |
| } |
| |
| 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 |
| } |
| |
| |
| 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 : '' |
| } |
| }) |
| } |
| |
| |
| function loadQuotaControlSettings(account: Account) { |
| |
| 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 |
| sessionIdMaskingEnabled.value = false |
| cacheTTLOverrideEnabled.value = false |
| cacheTTLOverrideTarget.value = '5m' |
| |
| |
| if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { |
| return |
| } |
| |
| |
| 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 |
| } |
| |
| |
| 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 |
| } |
| |
| |
| userMsgQueueMode.value = account.user_msg_queue_mode ?? '' |
| |
| |
| if (account.enable_tls_fingerprint === true) { |
| tlsFingerprintEnabled.value = true |
| } |
| |
| |
| if (account.session_id_masking_enabled === true) { |
| sessionIdMaskingEnabled.value = true |
| } |
| |
| |
| if (account.cache_ttl_override_enabled === true) { |
| cacheTTLOverrideEnabled.value = true |
| cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m' |
| } |
| } |
| |
| 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 |
| |
| |
| 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 { |
| |
| if (updatePayload.proxy_id === null) { |
| updatePayload.proxy_id = 0 |
| } |
| if (form.expires_at === null) { |
| updatePayload.expires_at = 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 |
| |
| |
| 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) |
| |
| |
| const newCredentials: Record<string, unknown> = { |
| ...currentCredentials, |
| base_url: newBaseUrl |
| } |
| |
| |
| if (editApiKey.value.trim()) { |
| |
| newCredentials.api_key = editApiKey.value.trim() |
| } else if (currentCredentials.api_key) { |
| |
| newCredentials.api_key = currentCredentials.api_key |
| } else { |
| appStore.showError(t('admin.accounts.apiKeyIsRequired')) |
| return |
| } |
| |
| |
| if (shouldApplyModelMapping) { |
| const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) |
| if (modelMapping) { |
| newCredentials.model_mapping = modelMapping |
| } else { |
| delete newCredentials.model_mapping |
| } |
| } else if (currentCredentials.model_mapping) { |
| newCredentials.model_mapping = currentCredentials.model_mapping |
| } |
| |
| |
| if (poolModeEnabled.value) { |
| newCredentials.pool_mode = true |
| newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value) |
| } else { |
| delete newCredentials.pool_mode |
| delete newCredentials.pool_mode_retry_count |
| } |
| |
| |
| 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 |
| } |
| |
| |
| 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() |
| } |
| |
| |
| 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) { |
| |
| if (editBedrockApiKeyValue.value.trim()) { |
| newCredentials.api_key = editBedrockApiKeyValue.value.trim() |
| } |
| } else { |
| |
| 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() |
| } |
| } |
| |
| |
| if (poolModeEnabled.value) { |
| newCredentials.pool_mode = true |
| newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value) |
| } else { |
| delete newCredentials.pool_mode |
| delete newCredentials.pool_mode_retry_count |
| } |
| |
| |
| const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) |
| if (modelMapping) { |
| newCredentials.model_mapping = modelMapping |
| } else { |
| delete newCredentials.model_mapping |
| } |
| |
| applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') |
| if (!applyTempUnschedConfig(newCredentials)) { |
| return |
| } |
| |
| updatePayload.credentials = newCredentials |
| } else { |
| |
| 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 |
| } |
| |
| |
| 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 = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) |
| if (modelMapping) { |
| newCredentials.model_mapping = modelMapping |
| } else { |
| delete newCredentials.model_mapping |
| } |
| } else if (currentCredentials.model_mapping) { |
| |
| newCredentials.model_mapping = currentCredentials.model_mapping |
| } |
| |
| updatePayload.credentials = newCredentials |
| } |
| |
| |
| |
| 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 |
| } |
| |
| |
| 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 |
| } |
| |
| |
| if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) { |
| const currentExtra = (props.account.extra as Record<string, unknown>) || {} |
| const newExtra: Record<string, unknown> = { ...currentExtra } |
| |
| |
| 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 |
| } |
| |
| |
| 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 |
| } |
| |
| |
| 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 |
| } |
| |
| |
| if (userMsgQueueMode.value) { |
| newExtra.user_msg_queue_mode = userMsgQueueMode.value |
| } else { |
| delete newExtra.user_msg_queue_mode |
| } |
| delete newExtra.user_msg_queue_enabled |
| |
| |
| if (tlsFingerprintEnabled.value) { |
| newExtra.enable_tls_fingerprint = true |
| } else { |
| delete newExtra.enable_tls_fingerprint |
| } |
| |
| |
| if (sessionIdMaskingEnabled.value) { |
| newExtra.session_id_masking_enabled = true |
| } else { |
| delete newExtra.session_id_masking_enabled |
| } |
| |
| |
| 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 |
| } |
| |
| updatePayload.extra = newExtra |
| } |
| |
| |
| if (props.account.platform === 'anthropic' && props.account.type === 'apikey') { |
| const currentExtra = (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 |
| } |
| updatePayload.extra = newExtra |
| } |
| |
| |
| 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 (props.account.type === 'oauth') { |
| if (codexCLIOnlyEnabled.value) { |
| newExtra.codex_cli_only = true |
| } else if (hadCodexCLIOnlyEnabled) { |
| |
| newExtra.codex_cli_only = false |
| } else { |
| delete newExtra.codex_cli_only |
| } |
| } |
| |
| updatePayload.extra = newExtra |
| } |
| |
| |
| 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 } |
| if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { |
| newExtra.quota_limit = editQuotaLimit.value |
| } else { |
| delete newExtra.quota_limit |
| } |
| if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) { |
| newExtra.quota_daily_limit = editQuotaDailyLimit.value |
| } else { |
| delete newExtra.quota_daily_limit |
| } |
| if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { |
| newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value |
| } else { |
| delete newExtra.quota_weekly_limit |
| } |
| |
| 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 |
| } |
| 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')) |
| } |
| } |
| |
| |
| 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> |
| |