feat: per-account quota threshold protection (#212)
feat: per-account quota threshold protection Resolves #135 - Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global) - New global Minimum Quota Level slider in Settings - Per-account threshold settings via Account Settings modal - Draggable per-account threshold markers on model quota bars - Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config - i18n: quota protection keys for all 5 languages
This commit is contained in:
@@ -182,6 +182,129 @@ window.Components.accountManager = () => ({
|
||||
document.getElementById('quota_modal').showModal();
|
||||
},
|
||||
|
||||
// Threshold settings
|
||||
thresholdDialog: {
|
||||
email: '',
|
||||
quotaThreshold: null, // null means use global
|
||||
modelQuotaThresholds: {},
|
||||
saving: false,
|
||||
addingModel: false,
|
||||
newModelId: '',
|
||||
newModelThreshold: 10
|
||||
},
|
||||
|
||||
openThresholdModal(account) {
|
||||
this.thresholdDialog = {
|
||||
email: account.email,
|
||||
// Convert from fraction (0-1) to percentage (0-99) for display
|
||||
quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null,
|
||||
modelQuotaThresholds: Object.fromEntries(
|
||||
Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)])
|
||||
),
|
||||
saving: false,
|
||||
addingModel: false,
|
||||
newModelId: '',
|
||||
newModelThreshold: 10
|
||||
};
|
||||
document.getElementById('threshold_modal').showModal();
|
||||
},
|
||||
|
||||
async saveAccountThreshold() {
|
||||
const store = Alpine.store('global');
|
||||
this.thresholdDialog.saving = true;
|
||||
|
||||
try {
|
||||
// Convert percentage back to fraction
|
||||
const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== ''
|
||||
? parseFloat(this.thresholdDialog.quotaThreshold) / 100
|
||||
: null;
|
||||
|
||||
// Convert model thresholds from percentage to fraction
|
||||
const modelQuotaThresholds = {};
|
||||
for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) {
|
||||
modelQuotaThresholds[modelId] = parseFloat(pct) / 100;
|
||||
}
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.showToast('Settings saved', 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
document.getElementById('threshold_modal').close();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save settings');
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast('Failed to save settings: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.thresholdDialog.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearAccountThreshold() {
|
||||
this.thresholdDialog.quotaThreshold = null;
|
||||
},
|
||||
|
||||
// Per-model threshold methods
|
||||
addModelThreshold() {
|
||||
this.thresholdDialog.addingModel = true;
|
||||
this.thresholdDialog.newModelId = '';
|
||||
this.thresholdDialog.newModelThreshold = 10;
|
||||
},
|
||||
|
||||
updateModelThreshold(modelId, value) {
|
||||
const numValue = parseInt(value);
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) {
|
||||
this.thresholdDialog.modelQuotaThresholds[modelId] = numValue;
|
||||
}
|
||||
},
|
||||
|
||||
removeModelThreshold(modelId) {
|
||||
delete this.thresholdDialog.modelQuotaThresholds[modelId];
|
||||
},
|
||||
|
||||
confirmAddModelThreshold() {
|
||||
const modelId = this.thresholdDialog.newModelId;
|
||||
const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10;
|
||||
|
||||
if (modelId && threshold >= 0 && threshold <= 99) {
|
||||
this.thresholdDialog.modelQuotaThresholds[modelId] = threshold;
|
||||
this.thresholdDialog.addingModel = false;
|
||||
this.thresholdDialog.newModelId = '';
|
||||
this.thresholdDialog.newModelThreshold = 10;
|
||||
}
|
||||
},
|
||||
|
||||
getAvailableModelsForThreshold() {
|
||||
// Get models from data store, exclude already configured ones
|
||||
const allModels = Alpine.store('data').models || [];
|
||||
const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds);
|
||||
return allModels.filter(m => !configured.includes(m));
|
||||
},
|
||||
|
||||
getEffectiveThreshold(account) {
|
||||
// Return display string for effective threshold
|
||||
if (account.quotaThreshold !== undefined) {
|
||||
return Math.round(account.quotaThreshold * 100) + '%';
|
||||
}
|
||||
// If no per-account threshold, show global value
|
||||
const globalThreshold = Alpine.store('data').globalQuotaThreshold;
|
||||
if (globalThreshold > 0) {
|
||||
return Math.round(globalThreshold * 100) + '% (global)';
|
||||
}
|
||||
return 'Global';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get main model quota for display
|
||||
* Prioritizes flagship models (Opus > Sonnet > Flash)
|
||||
|
||||
@@ -6,6 +6,33 @@
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.models = () => ({
|
||||
// Color palette for per-account threshold markers
|
||||
thresholdColors: [
|
||||
{ bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow
|
||||
{ bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan
|
||||
{ bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple
|
||||
{ bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green
|
||||
{ bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red
|
||||
{ bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange
|
||||
{ bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink
|
||||
{ bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet
|
||||
],
|
||||
|
||||
getThresholdColor(index) {
|
||||
return this.thresholdColors[index % this.thresholdColors.length];
|
||||
},
|
||||
|
||||
// Drag state for threshold markers
|
||||
dragging: {
|
||||
active: false,
|
||||
email: null,
|
||||
modelId: null,
|
||||
barRect: null,
|
||||
currentPct: 0,
|
||||
originalPct: 0
|
||||
},
|
||||
|
||||
// Model editing state (from main)
|
||||
editingModelId: null,
|
||||
newMapping: '',
|
||||
|
||||
@@ -21,6 +48,188 @@ window.Components.models = () => ({
|
||||
this.editingModelId = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start dragging a threshold marker
|
||||
*/
|
||||
startDrag(event, q, row) {
|
||||
// Find the progress bar element (closest .relative container)
|
||||
const markerEl = event.currentTarget;
|
||||
const barContainer = markerEl.parentElement;
|
||||
const barRect = barContainer.getBoundingClientRect();
|
||||
|
||||
this.dragging = {
|
||||
active: true,
|
||||
email: q.fullEmail,
|
||||
modelId: row.modelId,
|
||||
barRect,
|
||||
currentPct: q.thresholdPct,
|
||||
originalPct: q.thresholdPct
|
||||
};
|
||||
|
||||
// Prevent text selection while dragging
|
||||
document.body.classList.add('select-none');
|
||||
|
||||
// Bind document-level listeners for smooth dragging outside the marker
|
||||
this._onDrag = (e) => this.onDrag(e);
|
||||
this._endDrag = () => this.endDrag();
|
||||
document.addEventListener('mousemove', this._onDrag);
|
||||
document.addEventListener('mouseup', this._endDrag);
|
||||
document.addEventListener('touchmove', this._onDrag, { passive: false });
|
||||
document.addEventListener('touchend', this._endDrag);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle drag movement — compute percentage from mouse position
|
||||
*/
|
||||
onDrag(event) {
|
||||
if (!this.dragging.active) return;
|
||||
event.preventDefault();
|
||||
|
||||
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
|
||||
const { left, width } = this.dragging.barRect;
|
||||
let pct = Math.round((clientX - left) / width * 100);
|
||||
pct = Math.max(0, Math.min(99, pct));
|
||||
|
||||
this.dragging.currentPct = pct;
|
||||
},
|
||||
|
||||
/**
|
||||
* End drag — save the new threshold value
|
||||
*/
|
||||
endDrag() {
|
||||
if (!this.dragging.active) return;
|
||||
|
||||
// Clean up listeners
|
||||
document.removeEventListener('mousemove', this._onDrag);
|
||||
document.removeEventListener('mouseup', this._endDrag);
|
||||
document.removeEventListener('touchmove', this._onDrag);
|
||||
document.removeEventListener('touchend', this._endDrag);
|
||||
document.body.classList.remove('select-none');
|
||||
|
||||
const { email, modelId, currentPct, originalPct } = this.dragging;
|
||||
|
||||
// Only save if value actually changed
|
||||
if (currentPct !== originalPct) {
|
||||
// Optimistic in-place update: mutate existing quotaInfo entries directly
|
||||
// to avoid full DOM rebuild from computeQuotaRows()
|
||||
const dataStore = Alpine.store('data');
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
if (account) {
|
||||
if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {};
|
||||
if (currentPct === 0) {
|
||||
delete account.modelQuotaThresholds[modelId];
|
||||
} else {
|
||||
account.modelQuotaThresholds[modelId] = currentPct / 100;
|
||||
}
|
||||
}
|
||||
// Patch quotaRows in-place so Alpine updates without tearing down DOM
|
||||
const rows = dataStore.quotaRows || [];
|
||||
for (const row of rows) {
|
||||
if (row.modelId !== modelId) continue;
|
||||
for (const q of row.quotaInfo) {
|
||||
if (q.fullEmail !== email) continue;
|
||||
q.thresholdPct = currentPct;
|
||||
}
|
||||
// Recompute row-level threshold stats
|
||||
const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0);
|
||||
row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0;
|
||||
row.hasVariedThresholds = new Set(activePcts).size > 1;
|
||||
}
|
||||
this.dragging.active = false;
|
||||
this.saveModelThreshold(email, modelId, currentPct);
|
||||
} else {
|
||||
this.dragging.active = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a per-model threshold for an account via PATCH
|
||||
*/
|
||||
async saveModelThreshold(email, modelId, pct) {
|
||||
const store = Alpine.store('global');
|
||||
const dataStore = Alpine.store('data');
|
||||
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
if (!account) return;
|
||||
|
||||
// Snapshot for rollback on failure
|
||||
const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {};
|
||||
|
||||
// Build full modelQuotaThresholds for API (full replacement, not merge)
|
||||
const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) };
|
||||
|
||||
// Preserve the account-level quotaThreshold
|
||||
const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null;
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
const label = pct === 0 ? 'removed' : pct + '%';
|
||||
store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success');
|
||||
// Skip fetchData() — optimistic update is already applied,
|
||||
// next polling cycle will sync server state
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save threshold');
|
||||
}
|
||||
} catch (e) {
|
||||
// Revert optimistic update on failure
|
||||
account.modelQuotaThresholds = previousModelThresholds;
|
||||
dataStore.computeQuotaRows();
|
||||
store.showToast('Failed to save threshold: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific marker is currently being dragged
|
||||
*/
|
||||
isDragging(q, row) {
|
||||
return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the display percentage for a marker (live during drag, stored otherwise)
|
||||
*/
|
||||
getMarkerPct(q, row) {
|
||||
if (this.isDragging(q, row)) return this.dragging.currentPct;
|
||||
return q.thresholdPct;
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute pixel offset for overlapping markers so stacked ones fan out.
|
||||
* Markers within 2% of each other are considered overlapping.
|
||||
* Returns a CSS pixel offset string (e.g., '6px' or '-6px').
|
||||
*/
|
||||
getMarkerOffset(q, row, qIdx) {
|
||||
const pct = this.getMarkerPct(q, row);
|
||||
const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row));
|
||||
// Find all markers within 2% of this one
|
||||
const cluster = [];
|
||||
visible.forEach((item, idx) => {
|
||||
const itemPct = this.getMarkerPct(item, row);
|
||||
if (Math.abs(itemPct - pct) <= 2) {
|
||||
cluster.push({ item, idx });
|
||||
}
|
||||
});
|
||||
if (cluster.length <= 1) return '0px';
|
||||
// Find position of this marker within its cluster
|
||||
const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail);
|
||||
// Spread markers 10px apart, centered on the base position
|
||||
const spread = 10;
|
||||
const totalWidth = (cluster.length - 1) * spread;
|
||||
return (posInCluster * spread - totalWidth / 2) + 'px';
|
||||
},
|
||||
|
||||
init() {
|
||||
// Ensure data is fetched when this tab becomes active (skip initial trigger)
|
||||
this.$watch('$store.global.activeTab', (val, oldVal) => {
|
||||
|
||||
@@ -250,7 +250,47 @@ window.Components.serverConfig = () => ({
|
||||
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
|
||||
},
|
||||
|
||||
toggleMaxAccounts(value) {
|
||||
toggleGlobalQuotaThreshold(value) {
|
||||
const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
|
||||
const store = Alpine.store('global');
|
||||
const pct = parseInt(value);
|
||||
if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
|
||||
|
||||
// Store as percentage in UI, convert to fraction for backend
|
||||
const fraction = pct / 100;
|
||||
|
||||
if (this.debounceTimers['globalQuotaThreshold']) {
|
||||
clearTimeout(this.debounceTimers['globalQuotaThreshold']);
|
||||
}
|
||||
|
||||
const previousValue = this.serverConfig.globalQuotaThreshold;
|
||||
this.serverConfig.globalQuotaThreshold = fraction;
|
||||
|
||||
this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ globalQuotaThreshold: fraction })
|
||||
}, store.webuiPassword);
|
||||
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
|
||||
await this.fetchServerConfig();
|
||||
} else {
|
||||
throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
|
||||
}
|
||||
} catch (e) {
|
||||
this.serverConfig.globalQuotaThreshold = previousValue;
|
||||
store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
|
||||
}
|
||||
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
|
||||
},
|
||||
|
||||
toggleMaxAccounts(value) {
|
||||
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
|
||||
this.saveConfigField('maxAccounts', value, 'Max Accounts',
|
||||
(v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));
|
||||
|
||||
@@ -87,7 +87,11 @@ window.AppConstants.VALIDATION = {
|
||||
|
||||
// Capacity retries (1 - 10)
|
||||
MAX_CAPACITY_RETRIES_MIN: 1,
|
||||
MAX_CAPACITY_RETRIES_MAX: 10
|
||||
MAX_CAPACITY_RETRIES_MAX: 10,
|
||||
|
||||
// Global quota threshold (0 - 99%)
|
||||
GLOBAL_QUOTA_THRESHOLD_MIN: 0,
|
||||
GLOBAL_QUOTA_THRESHOLD_MAX: 99
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
|
||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||
quotaRows: [], // Filtered view
|
||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||
globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99)
|
||||
maxAccounts: 10, // Maximum number of accounts allowed (from config)
|
||||
loading: false,
|
||||
initialLoad: true, // Track first load for skeleton screen
|
||||
@@ -116,6 +117,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.models = data.models;
|
||||
}
|
||||
this.modelConfig = data.modelConfig || {};
|
||||
this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
|
||||
|
||||
// Store usage history if included (for dashboard)
|
||||
if (data.history) {
|
||||
@@ -236,6 +238,8 @@ document.addEventListener('alpine:init', () => {
|
||||
let totalQuotaSum = 0;
|
||||
let validAccountCount = 0;
|
||||
let minResetTime = null;
|
||||
let maxEffectiveThreshold = 0;
|
||||
const globalThreshold = this.globalQuotaThreshold || 0;
|
||||
|
||||
this.accounts.forEach(acc => {
|
||||
if (acc.enabled === false) return;
|
||||
@@ -255,11 +259,26 @@ document.addEventListener('alpine:init', () => {
|
||||
minResetTime = limit.resetTime;
|
||||
}
|
||||
|
||||
// Resolve effective threshold: per-model > per-account > global
|
||||
const accModelThreshold = acc.modelQuotaThresholds?.[modelId];
|
||||
const accThreshold = acc.quotaThreshold;
|
||||
const effective = accModelThreshold ?? accThreshold ?? globalThreshold;
|
||||
if (effective > maxEffectiveThreshold) {
|
||||
maxEffectiveThreshold = effective;
|
||||
}
|
||||
|
||||
// Determine threshold source for display
|
||||
let thresholdSource = 'global';
|
||||
if (accModelThreshold !== undefined) thresholdSource = 'model';
|
||||
else if (accThreshold !== undefined) thresholdSource = 'account';
|
||||
|
||||
quotaInfo.push({
|
||||
email: acc.email.split('@')[0],
|
||||
fullEmail: acc.email,
|
||||
pct: pct,
|
||||
resetTime: limit.resetTime
|
||||
resetTime: limit.resetTime,
|
||||
thresholdPct: Math.round(effective * 100),
|
||||
thresholdSource
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +287,10 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (!showExhausted && minQuota === 0) return;
|
||||
|
||||
// Check if thresholds vary across accounts
|
||||
const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct));
|
||||
const hasVariedThresholds = uniqueThresholds.size > 1;
|
||||
|
||||
rows.push({
|
||||
modelId,
|
||||
displayName: modelId, // Simplified: no longer using alias
|
||||
@@ -279,7 +302,9 @@ document.addEventListener('alpine:init', () => {
|
||||
quotaInfo,
|
||||
pinned: !!config.pinned,
|
||||
hidden: !!isHidden, // Use computed visibility
|
||||
activeCount: quotaInfo.filter(q => q.pct > 0).length
|
||||
activeCount: quotaInfo.filter(q => q.pct > 0).length,
|
||||
effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100),
|
||||
hasVariedThresholds
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,6 +237,11 @@ window.translations.en = {
|
||||
defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.",
|
||||
maxWaitThreshold: "Max Wait Before Error",
|
||||
maxWaitDesc: "If all accounts are rate-limited longer than this, error immediately instead of waiting.",
|
||||
// Quota Protection
|
||||
quotaProtection: "Quota Protection",
|
||||
minimumQuotaLevel: "Minimum Quota Level",
|
||||
minimumQuotaLevelDesc: "Switch accounts when quota drops below this level. Per-account overrides take priority.",
|
||||
quotaDisabled: "Disabled",
|
||||
// Error Handling Tuning
|
||||
errorHandlingTuning: "Error Handling Tuning",
|
||||
rateLimitDedupWindow: "Rate Limit Dedup Window",
|
||||
|
||||
@@ -270,6 +270,11 @@ window.translations.id = {
|
||||
defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.",
|
||||
maxWaitThreshold: "Batas Tunggu Maksimal",
|
||||
maxWaitDesc: "Jika semua akun terkena rate limit lebih lama dari ini, langsung gagal.",
|
||||
// Perlindungan Kuota
|
||||
quotaProtection: "Perlindungan Kuota",
|
||||
minimumQuotaLevel: "Level Kuota Minimum",
|
||||
minimumQuotaLevelDesc: "Ganti akun ketika kuota turun di bawah level ini. Pengaturan per-akun lebih diutamakan.",
|
||||
quotaDisabled: "Nonaktif",
|
||||
// Error Handling Tuning
|
||||
errorHandlingTuning: "Penyetelan Penanganan Error",
|
||||
rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit",
|
||||
|
||||
@@ -215,6 +215,11 @@ window.translations.pt = {
|
||||
defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.",
|
||||
maxWaitThreshold: "Limiar Máximo de Espera (Sticky)",
|
||||
maxWaitDesc: "Tempo máximo para aguardar uma conta sticky resetar antes de trocar.",
|
||||
// Proteção de Cota
|
||||
quotaProtection: "Proteção de Cota",
|
||||
minimumQuotaLevel: "Nível Mínimo de Cota",
|
||||
minimumQuotaLevelDesc: "Trocar de conta quando a cota cair abaixo deste nível. Configurações por conta têm prioridade.",
|
||||
quotaDisabled: "Desativado",
|
||||
// Ajuste de Tratamento de Erros
|
||||
errorHandlingTuning: "Ajuste de Tratamento de Erros",
|
||||
rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit",
|
||||
|
||||
@@ -219,6 +219,11 @@ window.translations.tr = {
|
||||
defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.",
|
||||
maxWaitThreshold: "Maksimum Bekleme Eşiği (Yapışkan)",
|
||||
maxWaitDesc: "Yapışkan bir hesabın değiştirmeden önce sıfırlanması için beklenecek maksimum süre.",
|
||||
// Kota Koruması
|
||||
quotaProtection: "Kota Koruması",
|
||||
minimumQuotaLevel: "Minimum Kota Seviyesi",
|
||||
minimumQuotaLevelDesc: "Kota bu seviyenin altına düştüğünde hesap değiştir. Hesap bazlı ayarlar önceliklidir.",
|
||||
quotaDisabled: "Devre Dışı",
|
||||
// Hata İşleme Ayarları
|
||||
errorHandlingTuning: "Hata İşleme Ayarları",
|
||||
rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi",
|
||||
|
||||
@@ -237,6 +237,11 @@ window.translations.zh = {
|
||||
defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。",
|
||||
maxWaitThreshold: "最大等待阈值",
|
||||
maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。",
|
||||
// 配额保护
|
||||
quotaProtection: "配额保护",
|
||||
minimumQuotaLevel: "最低配额水平",
|
||||
minimumQuotaLevelDesc: "当配额低于此水平时切换账号。每个账号的单独设置优先。",
|
||||
quotaDisabled: "已禁用",
|
||||
// 错误处理调优
|
||||
errorHandlingTuning: "错误处理调优",
|
||||
rateLimitDedupWindow: "限流去重窗口",
|
||||
|
||||
Reference in New Issue
Block a user