mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
This commit is contained in:
committed by
GitHub
parent
2670a0a819
commit
7c080941d1
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user