Add hourly rate limits (tokens/hour, requests/hour) and missing providers (chutes, cerebras, ollama)

This commit is contained in:
OpenCode Dev
2026-02-10 09:04:44 +00:00
parent 58bab1c5d8
commit 960ccb5742
3 changed files with 44 additions and 6 deletions

View File

@@ -187,6 +187,9 @@
<option value="google">Google</option> <option value="google">Google</option>
<option value="groq">Groq</option> <option value="groq">Groq</option>
<option value="nvidia">NVIDIA</option> <option value="nvidia">NVIDIA</option>
<option value="chutes">Chutes</option>
<option value="cerebras">Cerebras</option>
<option value="ollama">Ollama</option>
<option value="opencode">OpenCode</option> <option value="opencode">OpenCode</option>
</select> </select>
</label> </label>
@@ -207,6 +210,10 @@
Tokens per minute Tokens per minute
<input id="limit-tpm" type="number" min="0" step="1" placeholder="0 = unlimited" /> <input id="limit-tpm" type="number" min="0" step="1" placeholder="0 = unlimited" />
</label> </label>
<label>
Tokens per hour
<input id="limit-tph" type="number" min="0" step="1" placeholder="0 = unlimited" />
</label>
<label> <label>
Tokens per day Tokens per day
<input id="limit-tpd" type="number" min="0" step="1" placeholder="0 = unlimited" /> <input id="limit-tpd" type="number" min="0" step="1" placeholder="0 = unlimited" />
@@ -215,6 +222,10 @@
Requests per minute Requests per minute
<input id="limit-rpm" type="number" min="0" step="1" placeholder="0 = unlimited" /> <input id="limit-rpm" type="number" min="0" step="1" placeholder="0 = unlimited" />
</label> </label>
<label>
Requests per hour
<input id="limit-rph" type="number" min="0" step="1" placeholder="0 = unlimited" />
</label>
<label> <label>
Requests per day Requests per day
<input id="limit-rpd" type="number" min="0" step="1" placeholder="0 = unlimited" /> <input id="limit-rpd" type="number" min="0" step="1" placeholder="0 = unlimited" />

View File

@@ -1,6 +1,6 @@
(() => { (() => {
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'opencode']; const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode'];
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'ollama']; const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama'];
const pageType = document?.body?.dataset?.page || 'build'; const pageType = document?.body?.dataset?.page || 'build';
console.log('Admin JS loaded, pageType:', pageType); console.log('Admin JS loaded, pageType:', pageType);
const state = { const state = {
@@ -68,8 +68,10 @@
limitModel: document.getElementById('limit-model'), limitModel: document.getElementById('limit-model'),
limitModelInput: document.getElementById('limit-model-input'), limitModelInput: document.getElementById('limit-model-input'),
limitTpm: document.getElementById('limit-tpm'), limitTpm: document.getElementById('limit-tpm'),
limitTph: document.getElementById('limit-tph'),
limitTpd: document.getElementById('limit-tpd'), limitTpd: document.getElementById('limit-tpd'),
limitRpm: document.getElementById('limit-rpm'), limitRpm: document.getElementById('limit-rpm'),
limitRph: document.getElementById('limit-rph'),
limitRpd: document.getElementById('limit-rpd'), limitRpd: document.getElementById('limit-rpd'),
limitBackup: document.getElementById('limit-backup'), limitBackup: document.getElementById('limit-backup'),
providerLimitStatus: document.getElementById('provider-limit-status'), providerLimitStatus: document.getElementById('provider-limit-status'),
@@ -1354,8 +1356,10 @@
el.limitModel.value = selectedScope === 'model' ? (modelKey || '') : ''; el.limitModel.value = selectedScope === 'model' ? (modelKey || '') : '';
} }
if (el.limitTpm) el.limitTpm.value = target.tokensPerMinute ?? ''; if (el.limitTpm) el.limitTpm.value = target.tokensPerMinute ?? '';
if (el.limitTph) el.limitTph.value = target.tokensPerHour ?? '';
if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? ''; if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? '';
if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? ''; if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? '';
if (el.limitRph) el.limitRph.value = target.requestsPerHour ?? '';
if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? ''; if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? '';
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || ''; if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
} }
@@ -2304,8 +2308,10 @@
scope, scope,
model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(), model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(),
tokensPerMinute: Number(el.limitTpm.value || 0), tokensPerMinute: Number(el.limitTpm.value || 0),
tokensPerHour: Number(el.limitTph.value || 0),
tokensPerDay: Number(el.limitTpd.value || 0), tokensPerDay: Number(el.limitTpd.value || 0),
requestsPerMinute: Number(el.limitRpm.value || 0), requestsPerMinute: Number(el.limitRpm.value || 0),
requestsPerHour: Number(el.limitRph.value || 0),
requestsPerDay: Number(el.limitRpd.value || 0), requestsPerDay: Number(el.limitRpd.value || 0),
opencodeBackupModel: el.limitBackup.value.trim(), opencodeBackupModel: el.limitBackup.value.trim(),
}; };

View File

@@ -324,8 +324,9 @@ const TOPUP_PRICES = {
const BILLING_CYCLES = ['monthly', 'yearly']; const BILLING_CYCLES = ['monthly', 'yearly'];
const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur']; const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur'];
const MINUTE_MS = 60_000; const MINUTE_MS = 60_000;
const DODO_PRODUCTS_CACHE_TTL_MS = Math.max(30_000, Number(process.env.DODO_PRODUCTS_CACHE_TTL_MS || 5 * MINUTE_MS)); const HOUR_MS = 3_600_000;
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const DODO_PRODUCTS_CACHE_TTL_MS = Math.max(30_000, Number(process.env.DODO_PRODUCTS_CACHE_TTL_MS || 5 * MINUTE_MS));
const FORTY_EIGHT_HOURS_MS = 48 * DAY_MS; const FORTY_EIGHT_HOURS_MS = 48 * DAY_MS;
const AVG_CHARS_PER_TOKEN = 4; // rough heuristic const AVG_CHARS_PER_TOKEN = 4; // rough heuristic
const MAX_JSON_BODY_SIZE = Number(process.env.MAX_JSON_BODY_SIZE || 6_000_000); // 6 MB default for JSON payloads (attachments) const MAX_JSON_BODY_SIZE = Number(process.env.MAX_JSON_BODY_SIZE || 6_000_000); // 6 MB default for JSON payloads (attachments)
@@ -516,7 +517,7 @@ const PLAN_PRICES = {
}; };
const AUTO_MODEL_TOKEN = 'auto'; const AUTO_MODEL_TOKEN = 'auto';
const DEFAULT_PROVIDER_FALLBACK = 'opencode'; const DEFAULT_PROVIDER_FALLBACK = 'opencode';
const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', DEFAULT_PROVIDER_FALLBACK]; const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK];
const PROVIDER_PERSIST_DEBOUNCE_MS = 200; const PROVIDER_PERSIST_DEBOUNCE_MS = 200;
const TOKEN_ESTIMATION_BUFFER = 400; const TOKEN_ESTIMATION_BUFFER = 400;
const BOOST_PACK_SIZE = 500_000; const BOOST_PACK_SIZE = 500_000;
@@ -5728,8 +5729,10 @@ function defaultProviderLimit(provider) {
provider: normalizeProviderName(provider), provider: normalizeProviderName(provider),
scope: 'provider', scope: 'provider',
tokensPerMinute: 0, tokensPerMinute: 0,
tokensPerHour: 0,
tokensPerDay: 0, tokensPerDay: 0,
requestsPerMinute: 0, requestsPerMinute: 0,
requestsPerHour: 0,
requestsPerDay: 0, requestsPerDay: 0,
perModel: {}, perModel: {},
}; };
@@ -5771,16 +5774,20 @@ function ensureProviderLimitDefaults(provider) {
const cfg = providerLimits.limits[key]; const cfg = providerLimits.limits[key];
cfg.scope = cfg.scope === 'model' ? 'model' : 'provider'; cfg.scope = cfg.scope === 'model' ? 'model' : 'provider';
cfg.tokensPerMinute = sanitizeLimitNumber(cfg.tokensPerMinute); cfg.tokensPerMinute = sanitizeLimitNumber(cfg.tokensPerMinute);
cfg.tokensPerHour = sanitizeLimitNumber(cfg.tokensPerHour);
cfg.tokensPerDay = sanitizeLimitNumber(cfg.tokensPerDay); cfg.tokensPerDay = sanitizeLimitNumber(cfg.tokensPerDay);
cfg.requestsPerMinute = sanitizeLimitNumber(cfg.requestsPerMinute); cfg.requestsPerMinute = sanitizeLimitNumber(cfg.requestsPerMinute);
cfg.requestsPerHour = sanitizeLimitNumber(cfg.requestsPerHour);
cfg.requestsPerDay = sanitizeLimitNumber(cfg.requestsPerDay); cfg.requestsPerDay = sanitizeLimitNumber(cfg.requestsPerDay);
cfg.perModel = cfg.perModel && typeof cfg.perModel === 'object' ? cfg.perModel : {}; cfg.perModel = cfg.perModel && typeof cfg.perModel === 'object' ? cfg.perModel : {};
Object.keys(cfg.perModel).forEach((model) => { Object.keys(cfg.perModel).forEach((model) => {
const entry = cfg.perModel[model] || {}; const entry = cfg.perModel[model] || {};
cfg.perModel[model] = { cfg.perModel[model] = {
tokensPerMinute: sanitizeLimitNumber(entry.tokensPerMinute), tokensPerMinute: sanitizeLimitNumber(entry.tokensPerMinute),
tokensPerHour: sanitizeLimitNumber(entry.tokensPerHour),
tokensPerDay: sanitizeLimitNumber(entry.tokensPerDay), tokensPerDay: sanitizeLimitNumber(entry.tokensPerDay),
requestsPerMinute: sanitizeLimitNumber(entry.requestsPerMinute), requestsPerMinute: sanitizeLimitNumber(entry.requestsPerMinute),
requestsPerHour: sanitizeLimitNumber(entry.requestsPerHour),
requestsPerDay: sanitizeLimitNumber(entry.requestsPerDay), requestsPerDay: sanitizeLimitNumber(entry.requestsPerDay),
}; };
}); });
@@ -6927,13 +6934,16 @@ function summarizeProviderUsage(provider, model) {
const key = normalizeUsageProvider(provider, model); const key = normalizeUsageProvider(provider, model);
const now = Date.now(); const now = Date.now();
const minuteAgo = now - MINUTE_MS; const minuteAgo = now - MINUTE_MS;
const hourAgo = now - HOUR_MS;
const dayAgo = now - DAY_MS; const dayAgo = now - DAY_MS;
const entries = ensureProviderUsageBucket(key); const entries = ensureProviderUsageBucket(key);
const filterByModel = !!(model && providerLimits.limits[key] && providerLimits.limits[key].scope === 'model'); const filterByModel = !!(model && providerLimits.limits[key] && providerLimits.limits[key].scope === 'model');
const result = { const result = {
tokensLastMinute: 0, tokensLastMinute: 0,
tokensLastHour: 0,
tokensLastDay: 0, tokensLastDay: 0,
requestsLastMinute: 0, requestsLastMinute: 0,
requestsLastHour: 0,
requestsLastDay: 0, requestsLastDay: 0,
perModel: {}, perModel: {},
}; };
@@ -6943,22 +6953,31 @@ function summarizeProviderUsage(provider, model) {
const matchesModel = !filterByModel || (entry.model && model && entry.model === model); const matchesModel = !filterByModel || (entry.model && model && entry.model === model);
const targetKey = entry.model || 'unknown'; const targetKey = entry.model || 'unknown';
const isMinute = entry.ts >= minuteAgo; const isMinute = entry.ts >= minuteAgo;
const isHour = entry.ts >= hourAgo;
const isDay = entry.ts >= dayAgo; const isDay = entry.ts >= dayAgo;
if (isMinute && matchesModel) { if (isMinute && matchesModel) {
result.tokensLastMinute += Number(entry.tokens || 0); result.tokensLastMinute += Number(entry.tokens || 0);
result.requestsLastMinute += Number(entry.requests || 0); result.requestsLastMinute += Number(entry.requests || 0);
} }
if (isHour && matchesModel) {
result.tokensLastHour += Number(entry.tokens || 0);
result.requestsLastHour += Number(entry.requests || 0);
}
if (isDay && matchesModel) { if (isDay && matchesModel) {
result.tokensLastDay += Number(entry.tokens || 0); result.tokensLastDay += Number(entry.tokens || 0);
result.requestsLastDay += Number(entry.requests || 0); result.requestsLastDay += Number(entry.requests || 0);
} }
if (!result.perModel[targetKey]) { if (!result.perModel[targetKey]) {
result.perModel[targetKey] = { tokensLastMinute: 0, tokensLastDay: 0, requestsLastMinute: 0, requestsLastDay: 0 }; result.perModel[targetKey] = { tokensLastMinute: 0, tokensLastHour: 0, tokensLastDay: 0, requestsLastMinute: 0, requestsLastHour: 0, requestsLastDay: 0 };
} }
if (isMinute) { if (isMinute) {
result.perModel[targetKey].tokensLastMinute += Number(entry.tokens || 0); result.perModel[targetKey].tokensLastMinute += Number(entry.tokens || 0);
result.perModel[targetKey].requestsLastMinute += Number(entry.requests || 0); result.perModel[targetKey].requestsLastMinute += Number(entry.requests || 0);
} }
if (isHour) {
result.perModel[targetKey].tokensLastHour += Number(entry.tokens || 0);
result.perModel[targetKey].requestsLastHour += Number(entry.requests || 0);
}
if (isDay) { if (isDay) {
result.perModel[targetKey].tokensLastDay += Number(entry.tokens || 0); result.perModel[targetKey].tokensLastDay += Number(entry.tokens || 0);
result.perModel[targetKey].requestsLastDay += Number(entry.requests || 0); result.perModel[targetKey].requestsLastDay += Number(entry.requests || 0);
@@ -6975,8 +6994,10 @@ function isProviderLimited(provider, model) {
const modelCfg = (cfg.scope === 'model' && model && cfg.perModel[model]) ? cfg.perModel[model] : cfg; const modelCfg = (cfg.scope === 'model' && model && cfg.perModel[model]) ? cfg.perModel[model] : cfg;
const checks = [ const checks = [
['tokensPerMinute', usage.tokensLastMinute, 'minute tokens'], ['tokensPerMinute', usage.tokensLastMinute, 'minute tokens'],
['tokensPerHour', usage.tokensLastHour, 'hourly tokens'],
['tokensPerDay', usage.tokensLastDay, 'daily tokens'], ['tokensPerDay', usage.tokensLastDay, 'daily tokens'],
['requestsPerMinute', usage.requestsLastMinute, 'minute requests'], ['requestsPerMinute', usage.requestsLastMinute, 'minute requests'],
['requestsPerHour', usage.requestsLastHour, 'hourly requests'],
['requestsPerDay', usage.requestsLastDay, 'daily requests'], ['requestsPerDay', usage.requestsLastDay, 'daily requests'],
]; ];
for (const [field, used, label] of checks) { for (const [field, used, label] of checks) {
@@ -14530,7 +14551,7 @@ async function handleAdminProviderLimitsPost(req, res) {
const targetModel = (body.model || '').trim(); const targetModel = (body.model || '').trim();
const target = cfg.scope === 'model' && targetModel ? (cfg.perModel[targetModel] = cfg.perModel[targetModel] || {}) : cfg; const target = cfg.scope === 'model' && targetModel ? (cfg.perModel[targetModel] = cfg.perModel[targetModel] || {}) : cfg;
['tokensPerMinute', 'tokensPerDay', 'requestsPerMinute', 'requestsPerDay'].forEach((field) => { ['tokensPerMinute', 'tokensPerHour', 'tokensPerDay', 'requestsPerMinute', 'requestsPerHour', 'requestsPerDay'].forEach((field) => {
if (body[field] !== undefined) target[field] = sanitizeLimitNumber(body[field]); if (body[field] !== undefined) target[field] = sanitizeLimitNumber(body[field]);
}); });