Merge remote-tracking branch 'upstream/master'
This commit is contained in:
@@ -1,38 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { readKeyPresets, upsertKeyPreset, deleteKeyPreset, subscribeKeyPresets } from "./cliEndpointPresets";
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
const SAVE_VALUE = "__save_key__";
|
||||
|
||||
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
|
||||
const isCustom = !apiKeys.some((k) => k.key === value) && value !== "";
|
||||
const [mode, setMode] = useState(() => {
|
||||
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE;
|
||||
if (apiKeys.some((k) => k.key === value)) return value;
|
||||
return CUSTOM_VALUE;
|
||||
});
|
||||
const [customInput, setCustomInput] = useState(isCustom ? value : "");
|
||||
const [savedKeys, setSavedKeys] = useState([]);
|
||||
// Custom mode is sticky once the user types, so an emptied input doesn't jump back to a dropdown option
|
||||
const [customMode, setCustomMode] = useState(false);
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setSavedKeys(readKeyPresets());
|
||||
sync();
|
||||
return subscribeKeyPresets(sync);
|
||||
}, []);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
...apiKeys.map((k) => ({ value: k.key, label: k.key })),
|
||||
...savedKeys.map((p) => ({ value: `saved:${p.name}`, label: p.key, url: p.key, saved: true })),
|
||||
{ value: CUSTOM_VALUE, label: "Custom...", url: "" },
|
||||
],
|
||||
[apiKeys, savedKeys]
|
||||
);
|
||||
|
||||
// Derive the active option from value — no sync effects needed when the parent updates it
|
||||
const matched = value ? options.find((o) => o.value === value || o.url === value) : null;
|
||||
const mode = matched ? matched.value : (customMode || value ? CUSTOM_VALUE : (options[0]?.value ?? CUSTOM_VALUE));
|
||||
const inputValue = customMode ? customInput : (value || "");
|
||||
const isSaved = typeof mode === "string" && mode.startsWith("saved:");
|
||||
const isCustom = mode === CUSTOM_VALUE;
|
||||
const canSave = isCustom && (value || "").trim().length > 0 && !apiKeys.some((k) => k.key === value);
|
||||
const noKeys = apiKeys.length === 0 && savedKeys.length === 0 && !customMode && !value;
|
||||
|
||||
const handleSelect = (e) => {
|
||||
const next = e.target.value;
|
||||
setMode(next);
|
||||
if (next === SAVE_VALUE) {
|
||||
upsertKeyPreset((value || "").trim());
|
||||
return;
|
||||
}
|
||||
if (next === CUSTOM_VALUE) {
|
||||
setCustomMode(true);
|
||||
setCustomInput("");
|
||||
onChange("");
|
||||
} else {
|
||||
onChange(next);
|
||||
return;
|
||||
}
|
||||
setCustomMode(false);
|
||||
setCustomInput("");
|
||||
const opt = options.find((o) => o.value === next);
|
||||
if (opt) onChange(opt.url ?? opt.value);
|
||||
};
|
||||
|
||||
const handleCustomInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setCustomMode(true);
|
||||
setCustomInput(v);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE;
|
||||
const handleDeleteSaved = () => {
|
||||
if (!isSaved) return;
|
||||
deleteKeyPreset(mode.slice(6));
|
||||
setCustomMode(false);
|
||||
setCustomInput("");
|
||||
const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode);
|
||||
onChange(fallback ? (fallback.url ?? fallback.value) : "");
|
||||
};
|
||||
|
||||
if (noKeys && mode !== CUSTOM_VALUE) {
|
||||
if (noKeys) {
|
||||
return (
|
||||
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
||||
@@ -42,20 +80,27 @@ export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabl
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={handleSelect}
|
||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
>
|
||||
{apiKeys.map((k) => (
|
||||
<option key={k.id} value={k.key}>{k.key}</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>Custom...</option>
|
||||
</select>
|
||||
{mode === CUSTOM_VALUE && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={handleSelect}
|
||||
className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
{canSave && <option value={SAVE_VALUE}>+ Save current as...</option>}
|
||||
</select>
|
||||
{isSaved && (
|
||||
<button type="button" onClick={handleDeleteSaved} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors shrink-0" title="Delete saved key">
|
||||
<span className="material-symbols-outlined text-[14px]">delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
value={inputValue}
|
||||
onChange={handleCustomInput}
|
||||
placeholder="sk-..."
|
||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||
|
||||
@@ -131,9 +131,9 @@ export default function ClaudeToolCard({
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
// Restore key from settings.json; ApiKeySelect matches it against saved presets
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
|
||||
if (tokenFromFile) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,79 @@
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
// Browser-local endpoint presets shared by every CLI tool card
|
||||
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
||||
const CHANGE_EVENT = "9router:endpoint-presets-changed";
|
||||
// Browser-local preset stores (endpoints, API keys) shared by every CLI tool card
|
||||
function createStore({ storageKey, changeEvent, itemField, normalize = (v) => v, defaultName = (v) => v }) {
|
||||
const read = () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(storageKey) || "[]");
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((p) => p?.name && p?.[itemField]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const write = (items) => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items));
|
||||
window.dispatchEvent(new CustomEvent(changeEvent));
|
||||
};
|
||||
|
||||
return {
|
||||
read,
|
||||
subscribe: (handler) => {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(changeEvent, handler);
|
||||
return () => window.removeEventListener(changeEvent, handler);
|
||||
},
|
||||
// Adds or replaces a preset; returns the stored name, or null when skipped
|
||||
upsert: (value, name) => {
|
||||
const v = normalize(value);
|
||||
if (!v) return null;
|
||||
|
||||
const items = read();
|
||||
const existing = items.find((p) => normalize(p[itemField]) === v);
|
||||
if (existing && !name) return existing.name;
|
||||
|
||||
const finalName = (name || defaultName(v)).trim();
|
||||
if (!finalName) return null;
|
||||
|
||||
const next = [...items.filter((p) => p.name !== finalName && normalize(p[itemField]) !== v), { name: finalName, [itemField]: v }]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
write(next);
|
||||
return finalName;
|
||||
},
|
||||
remove: (name) => write(read().filter((p) => p.name !== name)),
|
||||
};
|
||||
}
|
||||
|
||||
const stripSlash = (url) => (url || "").replace(/\/+$/, "");
|
||||
|
||||
export function readPresets() {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((p) => p?.name && p?.baseUrl);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const endpoints = createStore({
|
||||
storageKey: "9router.cliToolEndpointPresets",
|
||||
changeEvent: "9router:endpoint-presets-changed",
|
||||
itemField: "baseUrl",
|
||||
normalize: stripSlash,
|
||||
defaultName: (url) => {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
},
|
||||
});
|
||||
|
||||
function writePresets(presets) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
|
||||
}
|
||||
const apiKeys = createStore({
|
||||
storageKey: "9router.cliToolApiKeyPresets",
|
||||
changeEvent: "9router:api-key-presets-changed",
|
||||
itemField: "key",
|
||||
});
|
||||
|
||||
export function subscribePresets(handler) {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(CHANGE_EVENT, handler);
|
||||
}
|
||||
export const readPresets = endpoints.read;
|
||||
export const subscribePresets = endpoints.subscribe;
|
||||
export const upsertPreset = endpoints.upsert;
|
||||
export const deletePreset = endpoints.remove;
|
||||
|
||||
function defaultNameFor(url) {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
|
||||
// Adds or replaces a preset; returns the stored name, or null when skipped
|
||||
export function upsertPreset(baseUrl, name) {
|
||||
const url = stripSlash(baseUrl);
|
||||
if (!url) return null;
|
||||
|
||||
const presets = readPresets();
|
||||
const existing = presets.find((p) => stripSlash(p.baseUrl) === url);
|
||||
if (existing && !name) return existing.name;
|
||||
|
||||
const finalName = (name || defaultNameFor(url)).trim();
|
||||
if (!finalName) return null;
|
||||
|
||||
const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
writePresets(next);
|
||||
return finalName;
|
||||
}
|
||||
export const readKeyPresets = apiKeys.read;
|
||||
export const subscribeKeyPresets = apiKeys.subscribe;
|
||||
export const upsertKeyPreset = apiKeys.upsert;
|
||||
export const deleteKeyPreset = apiKeys.remove;
|
||||
|
||||
// Save an applied endpoint unless it exactly matches a built-in dropdown option
|
||||
export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) {
|
||||
@@ -64,8 +88,4 @@ export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloud
|
||||
return upsertPreset(url);
|
||||
}
|
||||
|
||||
export function deletePreset(name) {
|
||||
writePresets(readPresets().filter((p) => p.name !== name));
|
||||
}
|
||||
|
||||
export { stripSlash };
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import { Button, Modal, Toggle } from "@/shared/components";
|
||||
import { CAPACITY_META } from "@/shared/constants/models";
|
||||
|
||||
const defaultCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false]));
|
||||
|
||||
export default function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) {
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [caps, setCaps] = useState(defaultCaps);
|
||||
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
|
||||
const [testError, setTestError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
|
||||
if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); }
|
||||
}, [isOpen]);
|
||||
|
||||
// Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider)
|
||||
@@ -46,7 +50,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
||||
if (!cleanId || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(cleanId);
|
||||
await onSave(cleanId, caps);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -86,6 +90,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Capabilities</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(CAPACITY_META).map(([key, meta]) => (
|
||||
<Toggle
|
||||
key={key}
|
||||
checked={!!caps[key]}
|
||||
onChange={(v) => setCaps((prev) => ({ ...prev, [key]: v }))}
|
||||
label={meta.label}
|
||||
description={meta.desc}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testStatus === "ok" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
|
||||
@@ -588,12 +588,12 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => {
|
||||
const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias, caps) => {
|
||||
try {
|
||||
const res = await fetch("/api/models/custom", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type }),
|
||||
body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type, ...(caps ? { caps } : {}) }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchCustomModels();
|
||||
@@ -1006,7 +1006,7 @@ export default function ProviderDetailPage() {
|
||||
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
|
||||
|
||||
const connectionsList = (
|
||||
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
<div className="flex min-w-0 flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] max-h-[500px] overflow-y-auto pr-1">
|
||||
{connections
|
||||
.map((conn, index) => (
|
||||
<div key={conn.id} className="flex min-w-0 items-stretch">
|
||||
@@ -1876,8 +1876,8 @@ export default function ProviderDetailPage() {
|
||||
isOpen={showAddCustomModel}
|
||||
providerAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
onSave={async (modelId) => {
|
||||
await handleAddCustomModel(modelId, "llm", providerStorageAlias);
|
||||
onSave={async (modelId, caps) => {
|
||||
await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps);
|
||||
setShowAddCustomModel(false);
|
||||
}}
|
||||
onClose={() => setShowAddCustomModel(false)}
|
||||
|
||||
@@ -434,7 +434,7 @@ export default function ConnectionsCard({ providerId, isOAuth }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] max-h-[500px] overflow-y-auto pr-1">
|
||||
{connections.map((conn, idx) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
import AddCompatibleModal from "./components/AddCompatibleModal";
|
||||
import { STATUS_FILTER_OPTIONS, matchesStatusFilter } from "./utils";
|
||||
|
||||
function getStatusDisplay(connected, error, errorCode) {
|
||||
const parts = [];
|
||||
@@ -105,6 +106,7 @@ export default function ProvidersPage() {
|
||||
useState(false);
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const notify = useNotificationStore();
|
||||
const searchQuery = useHeaderSearchStore((s) => s.query);
|
||||
const registerSearch = useHeaderSearchStore((s) => s.register);
|
||||
@@ -212,6 +214,9 @@ export default function ProvidersPage() {
|
||||
return { connected, error, total, errorCode, errorTime, allDisabled };
|
||||
};
|
||||
|
||||
const matchStatus = (stats, isNoAuth) =>
|
||||
matchesStatusFilter(statusFilter, stats, isNoAuth);
|
||||
|
||||
// Toggle all connections for a provider on/off. authType may be a single
|
||||
// string or an array (kiro counts oauth + api_key/apikey together).
|
||||
const handleToggleProvider = async (providerId, authType, newActive) => {
|
||||
@@ -267,7 +272,9 @@ export default function ProvidersPage() {
|
||||
textIcon: "OC",
|
||||
apiType: node.apiType,
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
.filter(
|
||||
(p) => matchSearch(p.name) && matchStatus(getProviderStats(p.id, "apikey")),
|
||||
);
|
||||
|
||||
const anthropicCompatibleProviders = providerNodes
|
||||
.filter((node) => node.type === "anthropic-compatible")
|
||||
@@ -277,7 +284,9 @@ export default function ProvidersPage() {
|
||||
color: "#D97757",
|
||||
textIcon: "AC",
|
||||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
.filter(
|
||||
(p) => matchSearch(p.name) && matchStatus(getProviderStats(p.id, "apikey")),
|
||||
);
|
||||
|
||||
// Dual-auth providers (oauth + apikey) store API keys as authType "apikey"
|
||||
// (and sometimes "api_key"). Card stats must count both so totals match detail.
|
||||
@@ -298,21 +307,32 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const oauthEntries = sortByPriority(
|
||||
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)),
|
||||
Object.entries(OAUTH_PROVIDERS).filter(
|
||||
([key, info]) =>
|
||||
!info.hidden &&
|
||||
matchSearch(info.name) &&
|
||||
matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth),
|
||||
),
|
||||
"oauth",
|
||||
);
|
||||
const freeEntries = Object.entries(FREE_PROVIDERS)
|
||||
.filter(([, info]) => !info.hidden && matchSearch(info.name))
|
||||
.filter(
|
||||
([key, info]) =>
|
||||
!info.hidden &&
|
||||
matchSearch(info.name) &&
|
||||
matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth),
|
||||
)
|
||||
.sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
|
||||
// Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via
|
||||
// dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth
|
||||
// connections are invisible here (mismatch with the detail page).
|
||||
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS)
|
||||
.filter(
|
||||
([, info]) =>
|
||||
([key, info]) =>
|
||||
!info.hidden &&
|
||||
matchSearch(info.name) &&
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm"),
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") &&
|
||||
matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth),
|
||||
)
|
||||
.sort(([ka, a], [kb, b]) => {
|
||||
const pa = a.priority ?? 999;
|
||||
@@ -328,10 +348,11 @@ export default function ProvidersPage() {
|
||||
// API Key: connected providers first, then alphabetical by name
|
||||
const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
|
||||
.filter(
|
||||
([, info]) =>
|
||||
([key, info]) =>
|
||||
!info.hidden &&
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") &&
|
||||
matchSearch(info.name),
|
||||
matchSearch(info.name) &&
|
||||
matchStatus(getProviderStats(key, "apikey"), info.noAuth),
|
||||
)
|
||||
.sort(([ka, a], [kb, b]) => {
|
||||
const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1;
|
||||
@@ -339,7 +360,7 @@ export default function ProvidersPage() {
|
||||
if (ca !== cb) return ca - cb;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
const isApikeySearching = !!searchQuery.trim();
|
||||
const isApikeySearching = !!searchQuery.trim() || statusFilter !== "all";
|
||||
const visibleApikeyEntries =
|
||||
isApikeySearching || showAllApikey
|
||||
? apikeyEntries
|
||||
@@ -365,12 +386,29 @@ export default function ProvidersPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||
<div className="flex items-center justify-end">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
|
||||
aria-label="Filter providers by connection status"
|
||||
>
|
||||
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!hasAnyResult && (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-xl">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-text-muted text-sm">No providers match your search</p>
|
||||
<p className="text-text-muted text-sm">
|
||||
No providers match your search or filters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const STATUS_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Inactive" },
|
||||
{ value: "none", label: "No connection" },
|
||||
];
|
||||
|
||||
// noAuth providers (e.g. free proxies) are always usable even though they
|
||||
// never have a stored connection record, so they never fall into "none".
|
||||
export function getConnectionStatus(stats, isNoAuth = false) {
|
||||
if (isNoAuth) return "active";
|
||||
if (!stats || stats.total === 0) return "none";
|
||||
return stats.allDisabled ? "inactive" : "active";
|
||||
}
|
||||
|
||||
export function matchesStatusFilter(statusFilter, stats, isNoAuth = false) {
|
||||
if (statusFilter === "all") return true;
|
||||
return getConnectionStatus(stats, isNoAuth) === statusFilter;
|
||||
}
|
||||
@@ -559,6 +559,21 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "groq":
|
||||
// Requests/Tokens rate-limit windows from response headers — absolute
|
||||
// used/total (calculatePercentage derives the bar), like Codex/Kiro.
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]) => {
|
||||
normalizedQuotas.push({
|
||||
name,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "ollama":
|
||||
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
|
||||
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
|
||||
import { CAPACITY_META } from "@/shared/constants/models";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Whitelist capability keys to boolean values — ignore anything else
|
||||
function sanitizeCaps(caps) {
|
||||
if (!caps || typeof caps !== "object") return null;
|
||||
const clean = {};
|
||||
for (const key of Object.keys(CAPACITY_META)) {
|
||||
if (typeof caps[key] === "boolean") clean[key] = caps[key];
|
||||
}
|
||||
return Object.keys(clean).length ? clean : null;
|
||||
}
|
||||
|
||||
// GET /api/models/custom - List all custom models
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -17,11 +28,12 @@ export async function GET() {
|
||||
// POST /api/models/custom - Add custom model
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { providerAlias, id, type, name } = await request.json();
|
||||
const { providerAlias, id, type, name, caps } = await request.json();
|
||||
if (!providerAlias || !id) {
|
||||
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
||||
}
|
||||
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name });
|
||||
const cleanCaps = sanitizeCaps(caps);
|
||||
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) });
|
||||
return NextResponse.json({ success: true, added });
|
||||
} catch (error) {
|
||||
console.log("Error adding custom model:", error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias } from "@/models";
|
||||
import { getModelAliases, setModelAlias, getCustomModels } from "@/models";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
@@ -37,6 +37,33 @@ export async function GET() {
|
||||
};
|
||||
});
|
||||
|
||||
// Custom models ride along; their stored caps override the name heuristic
|
||||
const seenFull = new Set(models.map((m) => m.fullModel));
|
||||
const customModels = (await getCustomModels()).filter((m) => {
|
||||
if (!m?.id || (m.kind || m.type || "llm") !== "llm") return false;
|
||||
return !seenFull.has(`${m.providerAlias}/${m.id}`);
|
||||
});
|
||||
for (const m of customModels) {
|
||||
const fullModel = `${m.providerAlias}/${m.id}`;
|
||||
const c = getCapabilitiesForModel(m.providerAlias, m.id);
|
||||
models.push({
|
||||
provider: m.providerAlias,
|
||||
model: m.id,
|
||||
name: m.name || m.id,
|
||||
fullModel,
|
||||
routedModel: fullModel,
|
||||
alias: modelAliases[fullModel] || m.id,
|
||||
caps: {
|
||||
vision: c.vision,
|
||||
search: c.search,
|
||||
reasoning: c.reasoning,
|
||||
contextWindow: c.contextWindow,
|
||||
maxOutput: c.maxOutput,
|
||||
...(m.caps || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Free OpenCode models that don't use the "-free" id suffix
|
||||
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
||||
|
||||
// Upstream returns "Model is unavailable" for this id (2026-09-02) — re-enable when fixed
|
||||
const DEAD_FREE_OPENCODE_MODELS = new Set(["deepseek-v4-flash-free"]);
|
||||
|
||||
export const FILTERS = {
|
||||
"openrouter-free": (models) =>
|
||||
models
|
||||
@@ -15,7 +18,7 @@ export const FILTERS = {
|
||||
|
||||
"opencode-free": (models) =>
|
||||
models
|
||||
.filter((m) => m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id))
|
||||
.filter((m) => (m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) && !DEAD_FREE_OPENCODE_MODELS.has(m.id))
|
||||
.map((m) => ({ id: m.id, name: m.id })),
|
||||
|
||||
// models.dev returns a large catalog; keep only mimo models
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { buildModelsList } from "../route.js";
|
||||
|
||||
// URL slug → service kind(s). `web` covers both webSearch and webFetch.
|
||||
const KIND_SLUG_MAP = {
|
||||
"image": ["image"],
|
||||
"tts": ["tts"],
|
||||
"stt": ["stt"],
|
||||
"embedding": ["embedding"],
|
||||
"image-to-text": ["imageToText"],
|
||||
"web": ["webSearch", "webFetch"],
|
||||
};
|
||||
|
||||
const LLM_KIND = "llm";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function json(data, options = {}) {
|
||||
return Response.json(data, {
|
||||
...options,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability.
|
||||
* GET /v1/models/{provider}/{model} - OpenAI-compatible single model lookup.
|
||||
* Supported kinds: image, tts, stt, embedding, image-to-text, web.
|
||||
*/
|
||||
export async function GET(_request, { params }) {
|
||||
try {
|
||||
const { model } = await params;
|
||||
const path = Array.isArray(model) ? model : [model];
|
||||
const identifier = path.filter(Boolean).join("/");
|
||||
const kindFilter = path.length === 1 ? KIND_SLUG_MAP[identifier] : null;
|
||||
|
||||
if (kindFilter) {
|
||||
const data = await buildModelsList(kindFilter);
|
||||
return json({ object: "list", data });
|
||||
}
|
||||
|
||||
// Match the same LLM catalog exposed by GET /v1/models. A catch-all
|
||||
// parameter is required because provider-prefixed IDs contain a slash.
|
||||
const models = await buildModelsList([LLM_KIND]);
|
||||
const matchedModel = models.find((candidate) => candidate.id === identifier);
|
||||
|
||||
if (!matchedModel) {
|
||||
return json(
|
||||
{
|
||||
error: {
|
||||
message: `The model '${identifier}' does not exist or you do not have access to it.`,
|
||||
type: "invalid_request_error",
|
||||
code: "model_not_found",
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return json(matchedModel);
|
||||
} catch (error) {
|
||||
console.log("Error fetching model:", error);
|
||||
return json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { buildModelsList } from "../route.js";
|
||||
|
||||
// URL slug → service kind(s). `web` covers both webSearch and webFetch.
|
||||
const KIND_SLUG_MAP = {
|
||||
"image": ["image"],
|
||||
"tts": ["tts"],
|
||||
"stt": ["stt"],
|
||||
"embedding": ["embedding"],
|
||||
"image-to-text": ["imageToText"],
|
||||
"web": ["webSearch", "webFetch"],
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability.
|
||||
* Supported kinds: image, tts, stt, embedding, image-to-text, web.
|
||||
*/
|
||||
export async function GET(_request, { params }) {
|
||||
try {
|
||||
const { kind } = await params;
|
||||
const kindFilter = KIND_SLUG_MAP[kind];
|
||||
|
||||
if (!kindFilter) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `Unknown model kind: ${kind}. Supported: ${Object.keys(KIND_SLUG_MAP).join(", ")}`,
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await buildModelsList(kindFilter);
|
||||
return Response.json({ object: "list", data }, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching models by kind:", error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,14 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Apply persisted theme before first paint so a reload does not flash the
|
||||
default (light) theme before the client store hydrates. Mirrors the
|
||||
zustand-persist "theme" key and the `dark` class applyTheme() sets. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var s=localStorage.getItem('theme');var t=s?(JSON.parse(s).state||{}).theme:'system';t=t||'system';var m=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(t==='system'&&m)){document.documentElement.classList.add('dark')}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `var d=document,r=d.documentElement,f=function(){r.classList.add('fonts-loaded')};if(d.fonts&&d.fonts.load){d.fonts.load('24px "Material Symbols Outlined"').then(f).catch(f);setTimeout(f,3000)}else{f()}`,
|
||||
|
||||
@@ -34,7 +34,8 @@ const PUBLIC_API_PATHS = [
|
||||
];
|
||||
|
||||
// Public top-level prefixes (LLM API endpoints with their own API key auth).
|
||||
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
|
||||
// Keep root-level rewrites here too: middleware runs before Next.js rewrites.
|
||||
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex", "/responses"];
|
||||
|
||||
// Always require JWT token regardless of requireLogin setting
|
||||
const ALWAYS_PROTECTED = [
|
||||
|
||||
@@ -29,15 +29,21 @@ export async function getCustomModels() {
|
||||
return Object.values(all);
|
||||
}
|
||||
|
||||
// Atomic check-then-insert inside transaction to prevent duplicate races
|
||||
export async function addCustomModel({ providerAlias, id, type = "llm", name }) {
|
||||
// Atomic upsert inside transaction to prevent duplicate races.
|
||||
// Re-adding an existing model updates caps/name without resetting omitted fields.
|
||||
export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) {
|
||||
const k = customKey(providerAlias, id, type);
|
||||
const db = await getAdapter();
|
||||
let added = false;
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT 1 FROM kv WHERE scope = 'customModels' AND key = ?`, [k]);
|
||||
if (row) return;
|
||||
const value = stringifyJson({ providerAlias, id, type, name: name || id });
|
||||
const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]);
|
||||
if (row) {
|
||||
const prev = parseJson(row.value) || {};
|
||||
const next = { ...prev, ...(name ? { name } : {}), ...(caps ? { caps } : {}) };
|
||||
db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]);
|
||||
return;
|
||||
}
|
||||
const value = stringifyJson({ providerAlias, id, type, name: name || id, ...(caps ? { caps } : {}) });
|
||||
db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]);
|
||||
added = true;
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
// Rewrite Antigravity IDE markers so upstream AG 2.x backend accepts the request.
|
||||
// User-Agent header (antigravity/<old>) and body.metadata.ideVersion are forced
|
||||
// to a known-good IDE version. Hardcoded MVP — toggle/version configurable later.
|
||||
// Rewrite Antigravity IDE markers on generation requests so upstream AG 2.x
|
||||
// backend accepts them. Catalog and other passthrough requests retain the
|
||||
// client's current identity. Hardcoded MVP — toggle/version configurable later.
|
||||
|
||||
const ANTIGRAVITY_IDE_VERSION = "1.23.2";
|
||||
const ANTIGRAVITY_IDE_VERSION = "2.11.0";
|
||||
const ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED = true;
|
||||
|
||||
function shouldRewriteMetadata(metadata) {
|
||||
@@ -19,8 +19,10 @@ function rewriteAntigravityUserAgent(userAgent, version) {
|
||||
return userAgent.replace(/antigravity\/[^\s]+/, `antigravity/${version}`);
|
||||
}
|
||||
|
||||
function applyAntigravityIdeVersionOverride(bodyBuffer, headers) {
|
||||
if (!ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED) {
|
||||
function applyAntigravityIdeVersionOverride(bodyBuffer, headers, requestUrl) {
|
||||
const isGenerationEndpoint = requestUrl?.includes(":generateContent") ||
|
||||
requestUrl?.includes(":streamGenerateContent");
|
||||
if (!ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED || !isGenerationEndpoint) {
|
||||
return { bodyBuffer, headers, applied: false, version: ANTIGRAVITY_IDE_VERSION };
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -55,7 +55,11 @@ const MODEL_SYNONYMS = {
|
||||
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
|
||||
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
|
||||
"gemini-3.8-flash": "gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
||||
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
|
||||
"gemini-3.1-pro-high": "gemini-pro-agent",
|
||||
@@ -135,8 +139,8 @@ function extractModel(url, body) {
|
||||
}
|
||||
const model = urlModel || parsed.model || null;
|
||||
const cleanModelName = String(model).replace(/^models\//, "");
|
||||
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered") {
|
||||
const ver = cleanModelName.includes("3.7") ? "3.7" : "3.6";
|
||||
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered" || cleanModelName === "gemini-3.8-flash-tiered") {
|
||||
const ver = cleanModelName.includes("3.8") ? "3.8" : cleanModelName.includes("3.7") ? "3.7" : "3.6";
|
||||
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|
||||
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
|
||||
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
|
||||
|
||||
@@ -180,6 +180,14 @@ function withInitialFrame(state, frames) {
|
||||
|
||||
// ─── CodeWhisperer → OpenAI conversion ───────────────────────────────────────
|
||||
|
||||
const INLINE_IMAGE_MIME_BY_FORMAT = new Map([
|
||||
["png", "image/png"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Safely stringify a tool-call input value.
|
||||
* OpenAI expects `function.arguments` to be a JSON string, never an object.
|
||||
@@ -214,9 +222,32 @@ function convertUserInputMessage(uim) {
|
||||
});
|
||||
}
|
||||
|
||||
const images = Array.isArray(uim.images) ? uim.images : [];
|
||||
const imageParts = images
|
||||
.filter(image => image !== null
|
||||
&& typeof image === "object"
|
||||
&& !Array.isArray(image)
|
||||
&& image.source !== null
|
||||
&& typeof image.source === "object"
|
||||
&& !Array.isArray(image.source)
|
||||
&& INLINE_IMAGE_MIME_BY_FORMAT.has(image.format)
|
||||
&& typeof image.source.bytes === "string"
|
||||
&& image.source.bytes.length > 0)
|
||||
.map(image => ({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${INLINE_IMAGE_MIME_BY_FORMAT.get(image.format)};base64,${image.source.bytes}`,
|
||||
},
|
||||
}));
|
||||
|
||||
// Emit user text only if it exists alongside OR when there are no tool results
|
||||
const text = (uim.content || "").trim();
|
||||
if (text || toolResults.length === 0) {
|
||||
if (imageParts.length > 0) {
|
||||
const content = [];
|
||||
if (text) content.push({ type: "text", text });
|
||||
content.push(...imageParts);
|
||||
out.push({ role: "user", content });
|
||||
} else if (text || toolResults.length === 0) {
|
||||
out.push({ role: "user", content: text });
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ async function passthrough(req, res, bodyBuffer, onResponse) {
|
||||
|
||||
const tool = getToolForHost(req.headers.host);
|
||||
const versionOverride = tool === "antigravity"
|
||||
? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers)
|
||||
? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers, req.url)
|
||||
: { bodyBuffer, headers: req.headers };
|
||||
const bodyForForwarding = versionOverride.bodyBuffer;
|
||||
const headersForForwarding = { ...versionOverride.headers, host: targetHost };
|
||||
|
||||
@@ -8,8 +8,11 @@ export const MITM_TOOLS = {
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
mitmDomain: "daily-cloudcode-pa.googleapis.com",
|
||||
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
modelAliases: ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low", "gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
defaultModels: [
|
||||
{ id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)", alias: "gemini-3.8-flash-high" },
|
||||
{ id: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)", alias: "gemini-3.8-flash-medium" },
|
||||
{ id: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)", alias: "gemini-3.8-flash-low" },
|
||||
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", alias: "gemini-3.7-flash-high" },
|
||||
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", alias: "gemini-3.7-flash-medium" },
|
||||
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", alias: "gemini-3.7-flash-low" },
|
||||
|
||||
@@ -59,16 +59,25 @@ export function useModelCaps() {
|
||||
const [byId, setById] = useState(() => cache?.byId || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (cache) {
|
||||
setByFull(cache.byFull);
|
||||
setById(cache.byId);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
loadModelCaps().then((maps) => {
|
||||
const sync = (maps) => {
|
||||
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
|
||||
});
|
||||
return () => { alive = false; };
|
||||
};
|
||||
if (cache) {
|
||||
sync(cache);
|
||||
} else {
|
||||
loadModelCaps().then(sync);
|
||||
}
|
||||
// Custom models change at runtime — drop the shared cache and refetch
|
||||
const invalidate = () => {
|
||||
cache = null;
|
||||
loadModelCaps().then(sync);
|
||||
};
|
||||
window.addEventListener("customModelChanged", invalidate);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.removeEventListener("customModelChanged", invalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getCaps = useCallback(
|
||||
|
||||
+175
-16
@@ -1,4 +1,24 @@
|
||||
// SSRF guard: block internal/private/metadata targets for server-side fetch.
|
||||
//
|
||||
// Three layers, each closing a distinct bypass class documented in #3714:
|
||||
// 1. assertPublicUrl - synchronous literal-IP/hostname checks (cheap, for
|
||||
// immediate rejection of obviously-bad input at request-build time).
|
||||
// 2. assertPublicUrlResolved - adds DNS resolution so a hostname that merely
|
||||
// *resolves* to a private/loopback address (e.g. a
|
||||
// nip.io/sslip.io wildcard-DNS domain, or an attacker's
|
||||
// own domain pointed at 127.0.0.1) is also rejected.
|
||||
// 3. fetchPublic - wraps fetch() with manual redirect handling so a
|
||||
// validated public URL can't 30x its way to an
|
||||
// internal target without the redirect target being
|
||||
// re-validated through layer 2 first.
|
||||
//
|
||||
// Layer 1 alone previously had matching bugs, not just missing coverage: hostname
|
||||
// checks ran on the raw string without normalizing a trailing dot ("localhost."),
|
||||
// and the IPv6 check only recognized one textual representation of an IPv4-mapped
|
||||
// address (dotted "::ffff:a.b.c.d") while Node/WHATWG URL parsing can normalize the
|
||||
// same address to hex form ("::ffff:7f00:1") — a mismatch, not an oversight.
|
||||
|
||||
import dns from "node:dns";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
||||
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
|
||||
@@ -21,36 +41,175 @@ function ipv4ToInt(host) {
|
||||
const BLOCKED_V4_RANGES = [
|
||||
[ipv4ToInt("0.0.0.0"), 8],
|
||||
[ipv4ToInt("10.0.0.0"), 8],
|
||||
[ipv4ToInt("100.64.0.0"), 10], // CGNAT — also used by some cloud metadata proxies
|
||||
[ipv4ToInt("127.0.0.0"), 8],
|
||||
[ipv4ToInt("169.254.0.0"), 16],
|
||||
[ipv4ToInt("169.254.0.0"), 16], // includes 169.254.169.254 cloud metadata
|
||||
[ipv4ToInt("172.16.0.0"), 12],
|
||||
[ipv4ToInt("192.168.0.0"), 16],
|
||||
];
|
||||
|
||||
function isBlockedIpv4(host) {
|
||||
const ip = ipv4ToInt(host);
|
||||
if (ip === null) return false;
|
||||
function isBlockedIpv4Int(ip) {
|
||||
return BLOCKED_V4_RANGES.some(([base, bits]) => {
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||
return (ip & mask) === (base & mask);
|
||||
});
|
||||
}
|
||||
|
||||
function isBlockedIpv6(host) {
|
||||
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4Mapped) return isBlockedIpv4(v4Mapped[1]);
|
||||
if (h === "::1" || h === "::") return true;
|
||||
return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd");
|
||||
function isBlockedIpv4(host) {
|
||||
const ip = ipv4ToInt(host);
|
||||
if (ip === null) return false;
|
||||
return isBlockedIpv4Int(ip);
|
||||
}
|
||||
|
||||
// Throw if URL targets a non-public host. Caller should map to 400.
|
||||
function parseHextets(s) {
|
||||
if (s === "") return [];
|
||||
const segs = s.split(":");
|
||||
const out = [];
|
||||
for (const seg of segs) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(seg)) return null;
|
||||
out.push(parseInt(seg, 16));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse any textual IPv6 representation (including an embedded dotted-IPv4 tail,
|
||||
// "::" compression in any position, and full/partial forms) into 8 16-bit groups.
|
||||
// Returns null if the string isn't a valid IPv6 literal. Parsing into groups once
|
||||
// and reasoning about the numeric value — rather than pattern-matching the source
|
||||
// string — is what makes this immune to "which textual form did the URL parser
|
||||
// pick" bugs: "::ffff:127.0.0.1" and "::ffff:7f00:1" produce identical groups.
|
||||
function parseIPv6ToGroups(rawHost) {
|
||||
let host = rawHost.toLowerCase();
|
||||
|
||||
const v4TailMatch = host.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
let v4Groups = null;
|
||||
if (v4TailMatch) {
|
||||
const v4Int = ipv4ToInt(v4TailMatch[1]);
|
||||
if (v4Int === null) return null;
|
||||
v4Groups = [(v4Int >>> 16) & 0xffff, v4Int & 0xffff];
|
||||
host = host.slice(0, host.length - v4TailMatch[1].length);
|
||||
if (host.endsWith("::")) {
|
||||
// "::" compression marker itself — leave both colons, the removed IPv4
|
||||
// fills the gap it represents.
|
||||
} else if (host.endsWith(":")) {
|
||||
host = host.slice(0, -1); // was just the "prevgroup:ipv4" separator
|
||||
}
|
||||
}
|
||||
|
||||
const doubleColonParts = host.split("::");
|
||||
if (doubleColonParts.length > 2) return null;
|
||||
|
||||
let groups;
|
||||
if (doubleColonParts.length === 2) {
|
||||
const head = parseHextets(doubleColonParts[0]);
|
||||
const tail = parseHextets(doubleColonParts[1]);
|
||||
if (head === null || tail === null) return null;
|
||||
const v4Len = v4Groups ? v4Groups.length : 0;
|
||||
const missing = 8 - head.length - tail.length - v4Len;
|
||||
if (missing < 0) return null;
|
||||
groups = [...head, ...new Array(missing).fill(0), ...tail, ...(v4Groups || [])];
|
||||
} else {
|
||||
const all = parseHextets(host);
|
||||
if (all === null) return null;
|
||||
groups = [...all, ...(v4Groups || [])];
|
||||
}
|
||||
return groups.length === 8 ? groups : null;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Groups(g) {
|
||||
const isZero = (n) => g[n] === 0;
|
||||
// loopback ::1
|
||||
if ([0, 1, 2, 3, 4, 5, 6].every(isZero) && g[7] === 1) return true;
|
||||
// unspecified ::
|
||||
if (g.every((x) => x === 0)) return true;
|
||||
// link-local fe80::/10
|
||||
if ((g[0] & 0xffc0) === 0xfe80) return true;
|
||||
// unique local fc00::/7
|
||||
if ((g[0] & 0xfe00) === 0xfc00) return true;
|
||||
// IPv4-mapped ::ffff:0:0/96 (0:0:0:0:0:ffff:a.b.c.d — the 0xffff marker is
|
||||
// group index 5) and NAT64 well-known prefix 64:ff9b::/96 — both embed a
|
||||
// real IPv4 address in the low 32 bits; check it against the same IPv4
|
||||
// blocklist regardless of which prefix wraps it.
|
||||
const low32 = ((g[6] << 16) | g[7]) >>> 0;
|
||||
if ([0, 1, 2, 3, 4].every(isZero) && g[5] === 0xffff) return isBlockedIpv4Int(low32);
|
||||
if (g[0] === 0x0064 && g[1] === 0xff9b && [2, 3, 4, 5].every(isZero)) return isBlockedIpv4Int(low32);
|
||||
// IPv4-compatible ::a.b.c.d/96 (deprecated, still parseable) — excludes :: and ::1
|
||||
// which already matched above.
|
||||
if ([0, 1, 2, 3, 4, 5].every(isZero) && low32 !== 0 && low32 !== 1) return isBlockedIpv4Int(low32);
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeHost(hostname) {
|
||||
// A trailing dot marks an FQDN and is semantically insignificant
|
||||
// ("localhost." and "localhost" are the same host) but was being compared
|
||||
// as a literal character, letting it slip past every string-based check.
|
||||
return hostname.toLowerCase().replace(/\.+$/, "");
|
||||
}
|
||||
|
||||
function isBlockedHost(host) {
|
||||
if (BLOCKED_HOSTNAMES.has(host)) return true;
|
||||
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) return true;
|
||||
if (isBlockedIpv4(host)) return true;
|
||||
if (host.includes(":")) {
|
||||
const groups = parseIPv6ToGroups(host.replace(/^\[|\]$/g, ""));
|
||||
if (groups && isBlockedIpv6Groups(groups)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Throw if URL targets a non-public host by literal hostname/IP alone (no DNS
|
||||
// resolution — see assertPublicUrlResolved for that). Caller should map to 400.
|
||||
export function assertPublicUrl(rawUrl) {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
if (isBlockedHost(host)) throw new Error("Blocked URL: internal host");
|
||||
}
|
||||
|
||||
if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host");
|
||||
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host");
|
||||
if (isBlockedIpv4(host)) throw new Error("Blocked URL: private IP");
|
||||
if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP");
|
||||
// Async: assertPublicUrl plus DNS resolution of non-literal hostnames, so a
|
||||
// domain that merely *resolves* to a private/loopback/metadata address (wildcard-DNS
|
||||
// services like nip.io/sslip.io, or an attacker-controlled domain with an A record
|
||||
// pointed at 127.0.0.1) is rejected too, not just IPs typed directly into the URL.
|
||||
export async function assertPublicUrlResolved(rawUrl) {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
if (isBlockedHost(host)) throw new Error("Blocked URL: internal host");
|
||||
|
||||
// Already a literal IPv4/IPv6 address — isBlockedHost above already covered it,
|
||||
// no DNS lookup applies (and dns.lookup would just echo it back anyway).
|
||||
const bracketless = host.replace(/^\[|\]$/g, "");
|
||||
if (ipv4ToInt(bracketless) !== null || bracketless.includes(":")) return;
|
||||
|
||||
let addresses;
|
||||
try {
|
||||
addresses = await dns.promises.lookup(host, { all: true, verbatim: true });
|
||||
} catch {
|
||||
// Resolution failure isn't an SSRF signal by itself — let the subsequent
|
||||
// fetch() fail with its own (clearer) network error.
|
||||
return;
|
||||
}
|
||||
for (const { address, family } of addresses) {
|
||||
if (family === 4 ? isBlockedIpv4(address) : isBlockedIpv6Groups(parseIPv6ToGroups(address) || [])) {
|
||||
throw new Error("Blocked URL: hostname resolves to an internal host");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetch() with SSRF-safe manual redirect handling: each hop's target is
|
||||
// re-validated through assertPublicUrlResolved before being followed, so a
|
||||
// validated public URL can't 30x its way to an internal target. Bounded to
|
||||
// maxRedirects hops (fetch's own default following behavior has no bound
|
||||
// relevant here since we never let it auto-follow).
|
||||
export async function fetchPublic(url, init = {}, { maxRedirects = 5 } = {}) {
|
||||
await assertPublicUrlResolved(url);
|
||||
let currentUrl = url;
|
||||
for (let hop = 0; ; hop++) {
|
||||
const res = await fetch(currentUrl, { ...init, redirect: "manual" });
|
||||
const isRedirect = res.status >= 300 && res.status < 400;
|
||||
const location = isRedirect ? res.headers.get("location") : null;
|
||||
if (!location) return res;
|
||||
if (hop >= maxRedirects) throw new Error("Blocked URL: too many redirects");
|
||||
const nextUrl = new URL(location, currentUrl).toString();
|
||||
await assertPublicUrlResolved(nextUrl);
|
||||
currentUrl = nextUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { handleAntigravityQuotaError } from "../services/antigravityQuota.js";
|
||||
import { handleAntigravityQuotaError, clearAntigravityStrikes } from "../services/antigravityQuota.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||
@@ -24,6 +24,7 @@ import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { getProjectIdForConnection } from "open-sse/services/projectId.js";
|
||||
import { stripModelContextMarker } from "open-sse/utils/modelMarkers.js";
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -48,7 +49,11 @@ export async function handleChat(request, clientRawRequest = null) {
|
||||
headers: Object.fromEntries(request.headers.entries())
|
||||
};
|
||||
}
|
||||
const modelStr = body.model;
|
||||
// Claude Code marks a 1M-context request as `<model>[1m]`; the marker matches
|
||||
// no combo, alias or provider/model pair, so it must not reach resolution.
|
||||
// The capability travels in the anthropic-beta header, forwarded as-is.
|
||||
const { model: modelStr, contextMarker } = stripModelContextMarker(body.model);
|
||||
if (contextMarker) body.model = modelStr;
|
||||
|
||||
// Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account)
|
||||
|
||||
@@ -233,7 +238,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
const status = HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
@@ -315,6 +320,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials, model);
|
||||
// "Consecutive" strikes: a success clears the breaker for this pair.
|
||||
clearAntigravityStrikes(credentials.connectionId, model);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js";
|
||||
import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js";
|
||||
import { assertPublicUrlResolved } from "@/shared/utils/ssrfGuard.js";
|
||||
|
||||
/**
|
||||
* Handle web fetch (URL extraction) request for the SSE/Next.js server.
|
||||
@@ -79,9 +79,10 @@ export async function handleFetch(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format");
|
||||
}
|
||||
|
||||
// SSRF guard: reject internal/private/metadata targets
|
||||
// SSRF guard: reject internal/private/metadata targets, including
|
||||
// hostnames that merely resolve to one (DNS lookup, not just literal checks).
|
||||
try {
|
||||
assertPublicUrl(targetUrl);
|
||||
await assertPublicUrlResolved(targetUrl);
|
||||
} catch (err) {
|
||||
log.warn("FETCH", "Blocked URL", { url: targetUrl });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);
|
||||
@@ -158,8 +159,13 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
// Keep web-fetch failures scoped to this capability. Providers such as
|
||||
// Ollama use the same connection for chat and fetch, so an upstream fetch
|
||||
// failure must not take the account offline for LLM requests.
|
||||
const fetchLockKey = `webfetch:${providerId}`;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
|
||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds, fetchLockKey);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
@@ -199,13 +205,19 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
await clearAccountError(credentials.connectionId, credentials, fetchLockKey);
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||
});
|
||||
}
|
||||
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
|
||||
const { shouldFallback } = await markAccountUnavailable(
|
||||
credentials.connectionId,
|
||||
result.status,
|
||||
result.error,
|
||||
providerId,
|
||||
fetchLockKey,
|
||||
);
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||
|
||||
@@ -17,6 +17,56 @@ const inflightRefresh = new Map();
|
||||
|
||||
const MIN_REFRESH_INTERVAL_MS = 30_000; // 30s between refreshes per connection
|
||||
|
||||
// Strike-based circuit breaker (#3681): Google's quota API can report remaining
|
||||
// quota while generation endpoints keep returning 429 (sprint/weekly dual-pool
|
||||
// mismatch). After STRIKE_THRESHOLD 429s within the window for the same
|
||||
// connection+model, treat the optimistic quota reading as untrusted and
|
||||
// cache-block that pair instead of retry-storming upstream.
|
||||
const STRIKE_WINDOW_MS = 60_000; // strikes older than this reset the count
|
||||
const STRIKE_THRESHOLD = 3;
|
||||
const STRIKE_BLOCK_MS = 15 * 60_000;
|
||||
const strikeCounts = new Map(); // "connectionId|model" → { count, windowStart (anchored at first strike) }
|
||||
const strikeBlocks = new Map(); // "connectionId|model" → blockedUntil ms
|
||||
|
||||
/**
|
||||
* Re-apply active strike blocks onto a fresh quotas snapshot so the auth
|
||||
* pre-filter (which reads this cache) keeps skipping the blocked pair across
|
||||
* requests until the block expires — same channel as the exhausted-0% path.
|
||||
*/
|
||||
function applyActiveStrikeBlocks(connectionId, quotas) {
|
||||
const now = Date.now();
|
||||
for (const [key, until] of strikeBlocks) {
|
||||
if (!key.startsWith(`${connectionId}|`)) continue;
|
||||
if (until <= now) {
|
||||
strikeBlocks.delete(key);
|
||||
continue;
|
||||
}
|
||||
quotas[key.slice(connectionId.length + 1)] = {
|
||||
remainingPercentage: 0,
|
||||
resetAt: new Date(until).toISOString(),
|
||||
};
|
||||
}
|
||||
return quotas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear strike state for a connection|model after a successful request, so
|
||||
* "consecutive" strikes means consecutive. Only removes a synthesized cache
|
||||
* entry (resetAt == our block deadline); a real upstream 0% reading stays.
|
||||
*/
|
||||
export function clearAntigravityStrikes(connectionId, model) {
|
||||
const key = `${connectionId}|${model}`;
|
||||
strikeCounts.delete(key);
|
||||
const until = strikeBlocks.get(key);
|
||||
if (until === undefined) return;
|
||||
strikeBlocks.delete(key);
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached?.[model]?.resetAt === new Date(until).toISOString()) {
|
||||
delete cached[model];
|
||||
quotaCache.set(connectionId, cached);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quota cache (read-only reference for auth.js pre-filter).
|
||||
*/
|
||||
@@ -69,7 +119,9 @@ async function _doRefresh(connectionId, accessToken, providerSpecificData, now)
|
||||
if (!usage?.quotas || usage.message) return null;
|
||||
|
||||
// Update in-memory cache. Caller logs CACHE_BLOCK only if requested model is exhausted.
|
||||
quotaCache.set(connectionId, usage.quotas);
|
||||
// Strike blocks are re-asserted after every refresh so an optimistic
|
||||
// upstream reading cannot resurrect a pair we just circuit-broke.
|
||||
quotaCache.set(connectionId, applyActiveStrikeBlocks(connectionId, usage.quotas));
|
||||
|
||||
return usage.quotas;
|
||||
} catch (e) {
|
||||
@@ -89,7 +141,43 @@ export async function handleAntigravityQuotaError(connectionId, status, model, a
|
||||
// Throttle applies to error paths too: one quota request per account/30s.
|
||||
// The first 409/429 populates cache; concurrent or repeated errors reuse it.
|
||||
const quota = (await refreshAntigravityQuota(connectionId, accessToken, providerSpecificData))?.[model];
|
||||
if (!quota || quota.remainingPercentage > 0 || !quota.resetAt) return null;
|
||||
|
||||
// Strike breaker: count every 429 whose quota reading is either optimistic
|
||||
// (remaining > 0) or unavailable (quota API 403/error). 3 within the window
|
||||
// => the pair is unhealthy regardless of what the API claims; block 15m.
|
||||
// 409 counts too by design: Antigravity signals pool exhaustion with 409 as
|
||||
// well (see #3561 — "skip exhausted account/model quota before upstream
|
||||
// retry" was motivated by 409/429 pairs), and poisoning by transient 409s
|
||||
// requires 3 of them inside 60 seconds on the same pair.
|
||||
if (!quota || quota.remainingPercentage > 0) {
|
||||
const key = `${connectionId}|${model}`;
|
||||
const now = Date.now();
|
||||
const strike = strikeCounts.get(key);
|
||||
// Fixed window anchored at the FIRST qualifying strike: three 429s must
|
||||
// all land within 60s of that first one, not within 60s of each other.
|
||||
const windowStart = strike && now - strike.windowStart <= STRIKE_WINDOW_MS ? strike.windowStart : now;
|
||||
const count = strike && windowStart === strike.windowStart ? strike.count + 1 : 1;
|
||||
strikeCounts.set(key, { count, windowStart });
|
||||
if (count >= STRIKE_THRESHOLD) {
|
||||
strikeCounts.delete(key);
|
||||
const blockedUntil = now + STRIKE_BLOCK_MS;
|
||||
const reading = quota ? `${Math.round(quota.remainingPercentage)}%` : "unknown";
|
||||
log.warn("AG_QUOTA", `${connectionId.slice(0, 8)} | STRIKE_${status} ${model} — ${count}x 429 (quota ${reading}); CACHE_BLOCK 15m`);
|
||||
// Synthesize a 0% entry in the shared cache so the auth pre-filter skips
|
||||
// this pair on subsequent requests too, not just the current retry loop
|
||||
// (the chat handler does not persist modelLock_* for this path).
|
||||
const cached = quotaCache.get(connectionId) || {};
|
||||
cached[model] = { remainingPercentage: 0, resetAt: new Date(blockedUntil).toISOString() };
|
||||
quotaCache.set(connectionId, cached);
|
||||
strikeBlocks.set(key, blockedUntil);
|
||||
return blockedUntil;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Healthy-but-exhausted reading: clear strikes and use the exact resetAt.
|
||||
strikeCounts.delete(`${connectionId}|${model}`);
|
||||
if (!quota.resetAt) return null;
|
||||
|
||||
const resetMs = new Date(quota.resetAt).getTime();
|
||||
if (resetMs <= Date.now()) return null;
|
||||
|
||||
Reference in New Issue
Block a user