feat(connections): per-connection disable-cooldown opt-out (#2997) (#3852)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-14 10:32:26 -03:00
committed by GitHub
parent 2670a0a819
commit 7c080941d1
13 changed files with 803 additions and 562 deletions

View File

@@ -113,6 +113,7 @@ export default function EditConnectionModal({
? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData)
: false,
passthroughModels: connection?.providerSpecificData?.passthroughModels === true,
disableCooling: connection?.providerSpecificData?.disableCooling === true,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -267,6 +268,7 @@ export default function EditConnectionModal({
connection.providerSpecificData
),
passthroughModels: connection?.providerSpecificData?.passthroughModels === true,
disableCooling: connection?.providerSpecificData?.disableCooling === true,
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -544,6 +546,11 @@ export default function EditConnectionModal({
),
};
}
// #2997: persist the transient-cooldown opt-out; write only when enabled,
// clear it otherwise so a disabled toggle does not linger as `false`.
if (updates.providerSpecificData) {
updates.providerSpecificData.disableCooling = formData.disableCooling ? true : undefined;
}
const error = (await onSave(updates)) as void | unknown;
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
@@ -647,6 +654,15 @@ export default function EditConnectionModal({
/>
</div>
)}
{/* #2997: per-connection transient-cooldown opt-out (provider-agnostic) */}
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Toggle
checked={formData.disableCooling}
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
label={t("disableCoolingLabel")}
description={t("disableCoolingDescription")}
/>
</div>
{supportsGoogleProjectId && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
{isAntigravity && (

View File

@@ -78,7 +78,10 @@ import {
isAutoFetchModelsEnabled,
persistDiscoveredModels,
} from "@/lib/providerModels/modelDiscovery";
import { parseGeminiModelsList, type GeminiDiscoveryModel } from "@/lib/providerModels/geminiModelsParser";
import {
parseGeminiModelsList,
type GeminiDiscoveryModel,
} from "@/lib/providerModels/geminiModelsParser";
import { getSyncedAvailableModels } from "@/lib/db/models";
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
@@ -750,8 +753,7 @@ export async function GET(
(data.data || data.models || []) as Array<Record<string, unknown>>
)
.map((item) => {
const itemId =
typeof item.id === "string" ? item.id.trim() : "";
const itemId = typeof item.id === "string" ? item.id.trim() : "";
if (!itemId) return null;
const itemName =
typeof item.display_name === "string"
@@ -845,7 +847,9 @@ export async function GET(
}
const registryCatalogModels = providerSyncedModels ?? (getModelsByProviderId(provider) || []);
const specialtyCatalogModels = providerSyncedModels ? [] : (getStaticModelsForProvider(provider) || []);
const specialtyCatalogModels = providerSyncedModels
? []
: getStaticModelsForProvider(provider) || [];
const toLocalCatalogModels = () => {
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
@@ -2118,9 +2122,8 @@ export async function GET(
let queryKey: string | null = null;
let bearerToken: string | null = null;
try {
const { parseSAFromApiKey, getAccessToken } = await import(
"@omniroute/open-sse/executors/vertex.ts"
);
const { parseSAFromApiKey, getAccessToken } =
await import("@omniroute/open-sse/executors/vertex.ts");
if (accessToken) {
bearerToken = accessToken;
} else if (credential) {
@@ -2130,8 +2133,7 @@ export async function GET(
let isServiceAccountJson = false;
try {
const parsed = JSON.parse(credential);
isServiceAccountJson =
!!parsed && typeof parsed === "object" && !Array.isArray(parsed);
isServiceAccountJson = !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
isServiceAccountJson = false;
}

View File

@@ -4438,6 +4438,8 @@
"t3ChatWebCookiePlaceholder": "convex-session-id=abc123...",
"blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.",
"blockClaudeExtraUsageLabel": "Block duplicate Claude usage rows",
"disableCoolingDescription": "Skip the transient cooldown so this connection stays eligible even after recoverable errors (terminal states like banned/expired still apply).",
"disableCoolingLabel": "Disable cooldown for this connection",
"bulkPasteAdded": "{count, plural, one {1 key added} other {# keys added}}",
"bulkPasteDuplicatesIgnored": "{count, plural, one {1 duplicate skipped} other {# duplicates skipped}}",
"bulkPasteHint": "Paste one API key per line. Empty lines are ignored and duplicate keys are skipped.",

View File

@@ -1,91 +1,91 @@
/**
* Parses the Google Generative Language `v1beta/models` listing into discovery models.
*
* Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints:
* - generateContent / generateAnswer → "chat"
* - predict → "images" (Imagen image generation)
* - predictLongRunning → "video" (Veo video generation)
* - embedContent → "embeddings"
* - bidiGenerateContent → "audio" (Live real-time audio)
*
* Model-id heuristics refine the long-running bucket because Google exposes both
* Imagen and Veo via long-running methods on the same endpoint:
* - id contains "veo" → ensure "video"
* - id contains "imagen" → force "images" (never "video")
*
* Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the
* regular `generateContent` path, so they stay "chat" (image output is a chat
* modality) and are intentionally NOT reclassified as "images".
*
* This is shared by the `gemini` discovery config and the `vertex` /
* `vertex-partner` (incl. Vertex AI Express key) discovery branches, so every
* model the account can access — chat, image, video, audio and embeddings —
* surfaces dynamically instead of being limited to the small static registry.
*/
const METHOD_TO_ENDPOINT: Record<string, string> = {
generateContent: "chat",
embedContent: "embeddings",
predict: "images",
predictLongRunning: "video",
bidiGenerateContent: "audio",
generateAnswer: "chat",
};
const IGNORED_METHODS = new Set([
"countTokens",
"countTextTokens",
"createCachedContent",
"batchGenerateContent",
"asyncBatchEmbedContent",
]);
export interface GeminiDiscoveryModel {
id: string;
name: string;
supportedEndpoints: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
[key: string]: unknown;
}
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
return (data?.models || []).map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = new Set<string>(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
);
const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, "");
const lowerId = id.toLowerCase();
// Google exposes Imagen (image) and Veo (video) via long-running methods; the
// method alone can't always distinguish them, so refine by model id.
if (lowerId.includes("veo")) {
endpoints.add("video");
}
if (lowerId.includes("imagen")) {
endpoints.delete("video");
endpoints.add("images");
}
if (endpoints.size === 0) endpoints.add("chat");
return {
...m,
id,
name: (m.displayName as string) || id,
supportedEndpoints: [...endpoints],
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
});
}
/**
* Parses the Google Generative Language `v1beta/models` listing into discovery models.
*
* Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints:
* - generateContent / generateAnswer → "chat"
* - predict → "images" (Imagen image generation)
* - predictLongRunning → "video" (Veo video generation)
* - embedContent → "embeddings"
* - bidiGenerateContent → "audio" (Live real-time audio)
*
* Model-id heuristics refine the long-running bucket because Google exposes both
* Imagen and Veo via long-running methods on the same endpoint:
* - id contains "veo" → ensure "video"
* - id contains "imagen" → force "images" (never "video")
*
* Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the
* regular `generateContent` path, so they stay "chat" (image output is a chat
* modality) and are intentionally NOT reclassified as "images".
*
* This is shared by the `gemini` discovery config and the `vertex` /
* `vertex-partner` (incl. Vertex AI Express key) discovery branches, so every
* model the account can access — chat, image, video, audio and embeddings —
* surfaces dynamically instead of being limited to the small static registry.
*/
const METHOD_TO_ENDPOINT: Record<string, string> = {
generateContent: "chat",
embedContent: "embeddings",
predict: "images",
predictLongRunning: "video",
bidiGenerateContent: "audio",
generateAnswer: "chat",
};
const IGNORED_METHODS = new Set([
"countTokens",
"countTextTokens",
"createCachedContent",
"batchGenerateContent",
"asyncBatchEmbedContent",
]);
export interface GeminiDiscoveryModel {
id: string;
name: string;
supportedEndpoints: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
[key: string]: unknown;
}
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
return (data?.models || []).map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = new Set<string>(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
);
const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, "");
const lowerId = id.toLowerCase();
// Google exposes Imagen (image) and Veo (video) via long-running methods; the
// method alone can't always distinguish them, so refine by model id.
if (lowerId.includes("veo")) {
endpoints.add("video");
}
if (lowerId.includes("imagen")) {
endpoints.delete("video");
endpoints.add("images");
}
if (endpoints.size === 0) endpoints.add("chat");
return {
...m,
id,
name: (m.displayName as string) || id,
supportedEndpoints: [...endpoints],
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
});
}

View File

@@ -123,6 +123,11 @@ export function normalizeProviderSpecificData(
delete normalized.blockExtraUsage;
}
// #2997: per-connection transient-cooldown opt-out — only persist a real boolean.
if ("disableCooling" in normalized && typeof normalized.disableCooling !== "boolean") {
delete normalized.disableCooling;
}
if ("autoFetchModels" in normalized && typeof normalized.autoFetchModels !== "boolean") {
delete normalized.autoFetchModels;
}

View File

@@ -1790,6 +1790,15 @@ export async function markAccountUnavailable(
const connectionPassthroughModels = connProviderSpecificData.passthroughModels as
| boolean
| undefined;
// #2997: per-connection opt-out of the TRANSIENT connection cooldown. When set,
// a recoverable failure records lastError/backoff but does NOT cool the
// connection, so getProviderCredentials keeps selecting it. Terminal states
// (banned/expired/credits_exhausted) are unaffected — they are resolved below
// via resolveTerminalConnectionStatus() and still take the connection out.
// NOTE: this first cut scopes the opt-out to the CONNECTION-level cooldown only;
// per-model lockout branches (per-model quota 403/404, codex scope) are left
// as-is — extending disableCooling to model lockout is a follow-up.
const disableCooling = connProviderSpecificData.disableCooling === true;
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
if (
@@ -1998,7 +2007,7 @@ export async function markAccountUnavailable(
await updateProviderConnection(connectionId, {
...baseUpdate,
});
} else if (cooldownMs > 0) {
} else if (cooldownMs > 0 && !disableCooling) {
await updateProviderConnection(connectionId, {
...baseUpdate,
rateLimitedUntil: getUnavailableUntil(cooldownMs),