Merge remote-tracking branch 'upstream/master'

This commit is contained in:
MUH. IQRAM BAHRING
2026-09-05 23:20:53 +08:00
97 changed files with 4244 additions and 571 deletions
@@ -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}
+48 -10
View File
@@ -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 %).
+14 -2
View File
@@ -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);
+28 -1
View File
@@ -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
+78
View File
@@ -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 },
);
}
}
-55
View File
@@ -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 }
);
}
}
+8
View File
@@ -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()}`,
+2 -1
View File
@@ -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 = [
+11 -5
View File
@@ -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;
});
+8 -6
View File
@@ -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
View File
@@ -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())
+32 -1
View File
@@ -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
View File
@@ -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 };
+4 -1
View File
@@ -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" },
+17 -8
View File
@@ -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
View File
@@ -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;
}
}
+10 -3
View File
@@ -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);
}
});
+18 -6
View File
@@ -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`);
+90 -2
View File
@@ -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;