Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# Unreleased

## Features
- **Gemini**: park 429-exhausted API keys for the upstream-reported delay instead of
retrying with blind exponential backoff — `RetryInfo.retryDelay` / `Please retry in Xs`
become an exact per-key cooldown (`modelLock___all`), so key rotation skips the key on
every request path (chat, native v1beta, TTS/STT). Daily-quota exhaustion (`PerDay`
violations) and hintless 429s park the key 1-3h, since a seconds-scale hint never clears
those
- **Dashboard**: parked keys show an orange `on hold · 22s` badge with a live countdown
(previously a misleading green `active`), an `N On hold` count on provider and media
overview cards, and hold state — return time, parked models, last error — in the edit
connection modal. Keys re-enable automatically on the next success

# v0.5.86 (2026-09-23)

## Features
Expand Down
8 changes: 8 additions & 0 deletions open-sse/config/errorConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export const TRANSIENT_COOLDOWN_MS = 30 * 1000;
// Hard cap for provider-reported rate limit cooldown (e.g. codex resets_at can be 5-6h)
export const MAX_RATE_LIMIT_COOLDOWN_MS = 30 * 60 * 1000;

// Gemini free-tier 429 cooldown: upstream reports RetryInfo.retryDelay ("22s") and
// "Please retry in 22.23s" in the message. When upstream gives no hint (key fully
// out of quota), park the key 1-3h instead of hammering it with backoff retries.
export const GEMINI_RETRY = {
noHintMinMs: 60 * 60 * 1000,
noHintMaxMs: 3 * 60 * 60 * 1000,
};

// Cooldown durations (ms)
const COOLDOWN = {
long: 2 * 60 * 1000,
Expand Down
31 changes: 31 additions & 0 deletions open-sse/executors/gemini.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DefaultExecutor } from "./default.js";
import { PROVIDERS } from "../config/providers.js";
import { resolveGeminiCooldownMs } from "../utils/geminiRetry.js";

// Gemini API-key executor. DefaultExecutor handles the request shape; this adds
// 429 parsing: RetryInfo.retryDelay / "Please retry in Xs" → resetsAtMs so the
// key is parked (modelLock___all) instead of retried with backoff. Daily quota
// exhaustion (or a 429 with no usable hint) parks the key 1-3h.
export class GeminiExecutor extends DefaultExecutor {
constructor() {
super("gemini");
this.config = PROVIDERS["gemini"] || this.config;
}

parseError(response, bodyText) {
const base = super.parseError(response, bodyText);
if (response.status !== 429 || !bodyText) return base;
let message = base.message;
try {
const parsed = JSON.parse(bodyText);
if (typeof parsed?.error?.message === "string" && parsed.error.message) {
message = parsed.error.message;
}
} catch {
// keep raw bodyText as message
}
return { status: response.status, message, resetsAtMs: Date.now() + resolveGeminiCooldownMs(bodyText) };
}
}

export default GeminiExecutor;
3 changes: 3 additions & 0 deletions open-sse/executors/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AntigravityExecutor } from "./antigravity.js";
import { AzureExecutor } from "./azure.js";
import { GeminiCLIExecutor } from "./gemini-cli.js";
import { GeminiExecutor } from "./gemini.js";
import { GithubExecutor } from "./github.js";
import { IFlowExecutor } from "./iflow.js";
import { QoderExecutor } from "./qoder.js";
Expand Down Expand Up @@ -32,6 +33,7 @@ const executors = {
antigravity: new AntigravityExecutor(),
azure: new AzureExecutor(),
"gemini-cli": new GeminiCLIExecutor(),
gemini: new GeminiExecutor(),
github: new GithubExecutor(),
iflow: new IFlowExecutor(),
qoder: new QoderExecutor(),
Expand Down Expand Up @@ -81,6 +83,7 @@ export { BaseExecutor } from "./base.js";
export { AntigravityExecutor } from "./antigravity.js";
export { AzureExecutor } from "./azure.js";
export { GeminiCLIExecutor } from "./gemini-cli.js";
export { GeminiExecutor } from "./gemini.js";
export { GithubExecutor } from "./github.js";
export { IFlowExecutor } from "./iflow.js";
export { QoderExecutor } from "./qoder.js";
Expand Down
18 changes: 17 additions & 1 deletion open-sse/utils/error.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/errorConfig.js";
import { parseRetryDelayToMs as parseGoogleRetryDelayToMs } from "./geminiRetry.js";

/**
* Build OpenAI-compatible error response body
Expand Down Expand Up @@ -75,17 +76,32 @@ export async function parseUpstreamError(response, executor = null) {
}

let message = "";
let resetsAtMs;
try {
const json = JSON.parse(bodyText);
message = json.error?.message || json.message || json.error || bodyText;
// Generic Google RetryInfo surface: executors without parseError (embeddings,
// image, systemone cores) still park the key for the reported delay.
const details = json.error?.details;
if (response.status === 429 && Array.isArray(details)) {
for (const d of details) {
if (d?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" && d?.retryDelay != null) {
const ms = parseGoogleRetryDelayToMs(d.retryDelay);
if (ms != null) {
resetsAtMs = Date.now() + ms;
break;
}
}
}
}
} catch {
message = bodyText;
}

const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`;

return { statusCode: response.status, message: finalMessage };
return { statusCode: response.status, message: finalMessage, resetsAtMs };
}

/**
Expand Down
103 changes: 103 additions & 0 deletions open-sse/utils/geminiRetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { GEMINI_RETRY } from "../config/errorConfig.js";

const RETRY_INFO_TYPE = "type.googleapis.com/google.rpc.RetryInfo";

// "22s" | "22.23s" | "500ms" | "2m" | "1.5h" | "1d" → ms (null when unparseable)
export function parseRetryDelayToMs(raw) {
if (raw == null) return null;
const m = String(raw).trim().match(/^([\d.]+)\s*(ms|s|m|h|d)$/i);
if (!m) return null;
const value = Number(m[1]);
if (!Number.isFinite(value) || value < 0) return null;
const unit = m[2].toLowerCase();
const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
const ms = Math.round(value * factor);
return Number.isSafeInteger(ms) ? ms : null;
}

function retryDelayFromDetails(details) {
if (!Array.isArray(details)) return null;
for (const d of details) {
if (d?.["@type"] === RETRY_INFO_TYPE && d?.retryDelay != null) {
const ms = parseRetryDelayToMs(d.retryDelay);
if (ms != null) return ms;
}
}
return null;
}

// Extract Google 429 cooldown from a Gemini error body/message.
// Prefers details[].RetryInfo.retryDelay, falls back to "Please retry in Xs".
// Returns ms, or null when the body carries no usable hint.
export function parseGeminiRetryDelayMs(bodyText) {
if (!bodyText) return null;
const text = typeof bodyText === "string" ? bodyText : JSON.stringify(bodyText);
try {
const parsed = JSON.parse(text);
const details = parsed?.error?.details;
const fromDetails = retryDelayFromDetails(details);
if (fromDetails != null) return fromDetails;
const message = parsed?.error?.message;
if (typeof message === "string") {
const m = message.match(/please retry in\s+([\d.]+\s*(?:ms|s|m|h|d))/i);
if (m) {
const ms = parseRetryDelayToMs(m[1]);
if (ms != null) return ms;
}
}
} catch {
// not JSON — try raw-text patterns below
}
const rawDelay = text.match(/"retryDelay"\s*:\s*"([^"]+)"/)?.[1];
if (rawDelay) {
const ms = parseRetryDelayToMs(rawDelay);
if (ms != null) return ms;
}
const retryIn = text.match(/please retry in\s+([\d.]+\s*(?:ms|s|m|h|d))/i)?.[1];
if (retryIn) {
const ms = parseRetryDelayToMs(retryIn);
if (ms != null) return ms;
}
return null;
}

// Random 1-3h park for Gemini 429s with no upstream hint (exhausted free key).
// Uses Math.random — no crypto needed for a cooldown jitter.
export function randomGeminiNoHintCooldownMs() {
const { noHintMinMs, noHintMaxMs } = GEMINI_RETRY;
return noHintMinMs + Math.floor(Math.random() * (noHintMaxMs - noHintMinMs));
}

const QUOTA_FAILURE_TYPE = "type.googleapis.com/google.rpc.QuotaFailure";

// True when the 429 body reports a *daily* quota violation
// (quotaId like "...PerDay..."). Daily exhaustion won't clear when the
// seconds-scale RetryInfo delay expires, so the key needs an hours-scale park.
export function hasDailyQuotaViolation(bodyText) {
if (!bodyText) return false;
const text = typeof bodyText === "string" ? bodyText : JSON.stringify(bodyText);
try {
const parsed = JSON.parse(text);
const details = parsed?.error?.details;
if (Array.isArray(details)) {
for (const d of details) {
if (d?.["@type"] !== QUOTA_FAILURE_TYPE) continue;
const violations = Array.isArray(d?.violations) ? d.violations : [];
if (violations.some((v) => String(v?.quotaId || "").toLowerCase().includes("perday"))) {
return true;
}
}
return false;
}
} catch {
// not JSON — try raw-text pattern below
}
return /"quotaId"\s*:\s*"[^"]*perday[^"]*"/i.test(text);
}

// Full Gemini 429 cooldown policy in one place: daily exhaustion (or no hint
// at all) parks the key 1-3h; pure per-minute rate limiting honors RetryInfo.
export function resolveGeminiCooldownMs(bodyText) {
if (hasDailyQuotaViolation(bodyText)) return randomGeminiNoHintCooldownMs();
return parseGeminiRetryDelayMs(bodyText) ?? randomGeminiNoHintCooldownMs();
}
5 changes: 4 additions & 1 deletion public/i18n/literals/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "انقر للإضافة، انقر مرة أخرى للإزالة. يتم حفظ التغييرات تلقائيًا.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ تنبيه مخاطر: يستخدم هذا الموفر اشتراكًا/جلسة OAuth غير مرخصة رسميًا للاستخدام عبر البروكسي/الراوتر. قد يتم تقييد الحساب أو حظره. الاستخدام على مسؤوليتك الخاصة.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ يعترض MITM حركة مرور HTTPS لأدوات IDE (Antigravity، GitHub Copilot، Kiro) عبر CA محلية لإعادة توجيه الطلبات إلى مزوديك. قد ينتهك شروط الخدمة → خطر حظر الحساب. الاستخدام على مسؤوليتك الخاصة.",
"Endpoint is exposed without an API key.": "نقطة النهاية مكشوفة بدون مفتاح API."
"Endpoint is exposed without an API key.": "نقطة النهاية مكشوفة بدون مفتاح API.",
"on hold": "موقوف مؤقتًا",
"all models": "جميع النماذج",
"Cooldown expired — the key will be retried on the next request.": "انتهت فترة التهدئة — ستُعاد محاولة المفتاح في الطلب التالي."
}
5 changes: 4 additions & 1 deletion public/i18n/literals/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "যোগ করতে ক্লিক করুন, সরাতে আবার ক্লিক করুন। পরিবর্তনগুলি স্বয়ংক্রিয়ভাবে সংরক্ষিত হয়।",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ ঝুঁকি বিজ্ঞপ্তি: এই প্রদানকারী একটি সাবস্ক্রিপশন/OAuth সেশন ব্যবহার করে যা প্রক্সি/রাউটার ব্যবহারের জন্য আনুষ্ঠানিকভাবে লাইসেন্সপ্রাপ্ত নয়। অ্যাকাউন্ট সীমাবদ্ধ বা নিষিদ্ধ হতে পারে। নিজের ঝুঁকিতে ব্যবহার করুন।",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM স্থানীয় CA এর মাধ্যমে IDE টুলগুলির (Antigravity, GitHub Copilot, Kiro) HTTPS ট্রাফিক ইন্টারসেপ্ট করে আপনার প্রদানকারীদের কাছে অনুরোধ পুনঃনির্দেশ করতে। ToS লঙ্ঘন করতে পারে → অ্যাকাউন্ট নিষিদ্ধ ঝুঁকি। নিজের ঝুঁকিতে ব্যবহার করুন।",
"Endpoint is exposed without an API key.": "এপিআই কী ছাড়াই এন্ডপয়েন্ট উন্মুক্ত।"
"Endpoint is exposed without an API key.": "এপিআই কী ছাড়াই এন্ডপয়েন্ট উন্মুক্ত।",
"on hold": "স্থগিত",
"all models": "সব মডেল",
"Cooldown expired — the key will be retried on the next request.": "কুলডাউন শেষ — পরবর্তী অনুরোধে কীটি আবার চেষ্টা করা হবে।"
}
5 changes: 4 additions & 1 deletion public/i18n/literals/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "Kliknutím přidáte, dalším kliknutím odeberete. Změny se ukládají automaticky.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Upozornění na riziko: Tento poskytovatel používá předplatné/OAuth relaci, která není oficiálně licencována pro použití přes proxy/router. Účet může být omezen nebo zablokován. Používejte na vlastní riziko.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM zachytává HTTPS provoz IDE nástrojů (Antigravity, GitHub Copilot, Kiro) přes místní CA pro přesměrování požadavků na vaše poskytovatele. Může porušit ToS → riziko zákazu účtu. Používejte na vlastní riziko.",
"Endpoint is exposed without an API key.": "Koncový bod je vystaven bez API klíče."
"Endpoint is exposed without an API key.": "Koncový bod je vystaven bez API klíče.",
"on hold": "pozastaveno",
"all models": "všechny modely",
"Cooldown expired — the key will be retried on the next request.": "Cooldown vypršel — klíč bude znovu vyzkoušen při příštím požadavku."
}
5 changes: 4 additions & 1 deletion public/i18n/literals/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "Klik for at tilføje, klik igen for at fjerne. Ændringer gemmes automatisk.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Risikomeddelelse: Denne udbyder bruger et abonnement/OAuth-session, der ikke er officielt licenseret til proxy/router-brug. Kontoen kan blive begrænset eller forbudt. Brug på eget ansvar.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM opfanger HTTPS-trafik fra IDE-værktøjer (Antigravity, GitHub Copilot, Kiro) via lokal CA for at omdirigere anmodninger til dine udbydere. Kan overtræde ToS → risiko for kontoforbud. Brug på eget ansvar.",
"Endpoint is exposed without an API key.": "Endpointet er eksponeret uden en API-nøgle."
"Endpoint is exposed without an API key.": "Endpointet er eksponeret uden en API-nøgle.",
"on hold": "sat på pause",
"all models": "alle modeller",
"Cooldown expired — the key will be retried on the next request.": "Nedkøling udløb — nøglen prøves igen ved den næste anmodning."
}
5 changes: 4 additions & 1 deletion public/i18n/literals/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "Klicken zum Hinzufügen, erneut klicken zum Entfernen. Änderungen werden automatisch gespeichert.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Risikohinweis: Dieser Anbieter verwendet eine Abonnement-/OAuth-Sitzung, die nicht offiziell für die Proxy-/Router-Nutzung lizenziert ist. Das Konto kann eingeschränkt oder gesperrt werden. Nutzung auf eigene Gefahr.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM fängt HTTPS-Verkehr von IDE-Tools (Antigravity, GitHub Copilot, Kiro) über lokale CA ab, um Anfragen an Ihre Anbieter umzuleiten. Kann gegen ToS verstoßen → Risiko der Kontosperrung. Nutzung auf eigene Gefahr.",
"Endpoint is exposed without an API key.": "Der Endpunkt ist ohne API-Schlüssel offengelegt."
"Endpoint is exposed without an API key.": "Der Endpunkt ist ohne API-Schlüssel offengelegt.",
"on hold": "pausiert",
"all models": "alle Modelle",
"Cooldown expired — the key will be retried on the next request.": "Abklingzeit abgelaufen — der Schlüssel wird bei der nächsten Anfrage erneut versucht."
}
5 changes: 4 additions & 1 deletion public/i18n/literals/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,8 @@
"Click to add, click again to remove. Changes are saved automatically.": "Κάντε κλικ για προσθήκη, κάντε ξανά κλικ για αφαίρεση. Οι αλλαγές αποθηκεύονται αυτόματα.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Ειδοποίηση κινδύνου: Αυτός ο πάροχος χρησιμοποιεί συνδρομή/συνεδρία OAuth που δεν έχει επίσημη άδεια για χρήση μέσω proxy/router. Ο λογαριασμός ενδέχεται να περιοριστεί ή να αποκλειστεί. Χρήση με δική σας ευθύνη.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ Το MITM υποκλέπτει την κίνηση HTTPS των εργαλείων IDE (Antigravity, GitHub Copilot, Kiro) μέσω τοπικού CA για ανακατεύθυνση αιτημάτων στους παρόχους σας. Μπορεί να παραβιάσει τους ToS → κίνδυνος αποκλεισμού λογαριασμού. Χρήση με δική σας ευθύνη.",
"Endpoint is exposed without an API key.": "Το τελικό σημείο είναι εκτεθειμένο χωρίς κλειδί API."
"Endpoint is exposed without an API key.": "Το τελικό σημείο είναι εκτεθειμένο χωρίς κλειδί API.",
"on hold": "σε αναμονή",
"all models": "όλα τα μοντέλα",
"Cooldown expired — the key will be retried on the next request.": "Λήξη ψύξης — το κλειδί θα δοκιμαστεί ξανά στο επόμενο αίτημα."
}
Loading