This commit is contained in:
decolua
2026-06-15 18:18:04 +07:00
parent 8ab5af0052
commit b282f05549
66 changed files with 2328 additions and 213 deletions
@@ -347,10 +347,10 @@ export default function ClaudeToolCard({
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input type="checkbox" checked={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
<span className="text-xs text-text-muted">Filter naming requests</span>
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</label>
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</div>
</div>
+17 -6
View File
@@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal } from "@/shared/components";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal, CapacityBadges } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
@@ -19,6 +19,7 @@ export default function CombosPage() {
const [editingCombo, setEditingCombo] = useState(null);
const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({});
const [modelCaps, setModelCaps] = useState({});
const [confirmState, setConfirmState] = useState(null);
const { copied, copy } = useCopyToClipboard();
@@ -28,10 +29,11 @@ export default function CombosPage() {
const fetchData = async () => {
try {
const [combosRes, providersRes, settingsRes] = await Promise.all([
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
fetch("/api/combos"),
fetch("/api/providers"),
fetch("/api/settings"),
fetch("/api/models"),
]);
const combosData = await combosRes.json();
const providersData = await providersRes.json();
@@ -42,6 +44,13 @@ export default function CombosPage() {
if (providersRes.ok) {
setActiveProviders(providersData.connections || []);
}
if (modelsRes.ok) {
const md = await modelsRes.json();
// Build fullModel -> caps map for badge lookup
const map = {};
for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps;
setModelCaps(map);
}
setComboStrategies(settingsData.comboStrategies || {});
} catch (error) {
console.log("Error fetching data:", error);
@@ -143,7 +152,7 @@ export default function CombosPage() {
<div className="min-w-0">
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with fallback support
Create model combos with fallback support auto-adapts per request: routes images to vision models and web search to search-capable models.
</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto">
@@ -171,6 +180,7 @@ export default function CombosPage() {
<ComboCard
key={combo.id}
combo={combo}
modelCaps={modelCaps}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
@@ -214,7 +224,7 @@ export default function CombosPage() {
);
}
function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) {
function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) {
return (
<Card padding="sm" className="group">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -229,8 +239,9 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled,
<span className="text-xs text-text-muted italic">No models</span>
) : (
combo.models.slice(0, 3).map((model, index) => (
<code key={index} className="max-w-full truncate rounded bg-black/5 px-1.5 py-0.5 font-mono text-[10px] text-text-muted dark:bg-white/5 sm:max-w-[220px]">
{model}
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
<span>{model}</span>
<CapacityBadges caps={modelCaps[model]} />
</code>
))
)}
@@ -1,6 +1,7 @@
import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable }) {
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -24,7 +25,10 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{fullModel}</code>
{model.name && <span className="truncate pl-1 text-[9px] italic text-text-muted/70">{model.name}</span>}
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
</span>
</div>
{onTest && (
<div className="relative shrink-0 group/btn">
@@ -92,4 +96,5 @@ ModelRow.propTypes = {
onTest: PropTypes.func,
isTesting: PropTypes.bool,
onDisable: PropTypes.func,
caps: PropTypes.object,
};
@@ -8,6 +8,7 @@ import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthW
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { translate } from "@/i18n/runtime";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
import ModelRow from "./ModelRow";
@@ -29,6 +30,7 @@ export default function ProviderDetailPage() {
const params = useParams();
const router = useRouter();
const providerId = params.id;
const { getCaps } = useModelCaps();
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [providerNode, setProviderNode] = useState(null);
@@ -953,6 +955,7 @@ export default function ProviderDetailPage() {
isTesting={testingModelId === model.id}
isCustom
isFree={false}
caps={getCaps(`${providerId}/${model.id}`)}
/>
))}
@@ -977,6 +980,7 @@ export default function ProviderDetailPage() {
isTesting={testingModelId === model.id}
isFree={model.isFree}
onDisable={() => handleDisableModel(model.id)}
caps={getCaps(`${providerId}/${model.id}`)}
/>
);
})}
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import ProviderIcon from "@/shared/components/ProviderIcon";
import QuotaTable from "./QuotaTable";
import Toggle from "@/shared/components/Toggle";
import Tooltip from "@/shared/components/Tooltip";
import {
parseQuotaData,
calculatePercentage,
@@ -34,9 +35,15 @@ import {
QUOTA_SORT_OPTIONS,
} from "./utils";
import Card from "@/shared/components/Card";
import { EditConnectionModal } from "@/shared/components";
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
function getCodexResetCreditCount(quota) {
const value = quota?.raw?.resetCredits?.availableCount;
const count = typeof value === "number" ? value : Number(value);
return Number.isFinite(count) ? Math.max(0, count) : 0;
}
export default function ProviderLimits() {
const [connections, setConnections] = useState([]);
const [quotaData, setQuotaData] = useState({});
@@ -50,6 +57,8 @@ export default function ProviderLimits() {
const [connectionsLoading, setConnectionsLoading] = useState(true);
const [deletingId, setDeletingId] = useState(null);
const [togglingId, setTogglingId] = useState(null);
const [resettingLimitId, setResettingLimitId] = useState(null);
const [resetConfirmState, setResetConfirmState] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
@@ -207,6 +216,32 @@ export default function ProviderLimits() {
[fetchQuota],
);
const handleResetCodexLimit = useCallback(
async (connectionId, provider) => {
if (provider !== "codex" || resettingLimitId) return;
setResettingLimitId(connectionId);
setErrors((prev) => ({ ...prev, [connectionId]: null }));
try {
const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit");
}
await fetchQuota(connectionId, provider);
setLastUpdated(new Date());
} catch (error) {
setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" }));
} finally {
setResettingLimitId(null);
}
},
[fetchQuota, resettingLimitId],
);
const handleDeleteConnection = useCallback(
async (id) => {
if (!confirm("Delete this connection?")) return;
@@ -791,7 +826,10 @@ export default function ProviderLimits() {
// Use table layout for all providers
const isInactive = conn.isActive === false;
const rowBusy = deletingId === conn.id || togglingId === conn.id;
const isCodex = conn.provider === "codex";
const resetCreditCount = getCodexResetCreditCount(quota);
const isResettingLimit = resettingLimitId === conn.id;
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
return (
<Card
@@ -822,53 +860,90 @@ export default function ProviderLimits() {
{getConnectionLabel(conn)}
</p>
) : null}
{isCodex && (
<p className="text-[11px] text-text-muted truncate">
Reset eligible: {resetCreditCount}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading || rowBusy}
aria-label="Refresh quota"
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
<span
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
{isCodex && (
<Tooltip text={`Codex reset credits remaining: ${resetCreditCount}`}>
<div
className={`hidden h-8 items-center gap-1 rounded-lg border px-2 text-[11px] sm:flex ${
resetCreditCount > 0
? "border-primary/30 bg-primary/5 text-primary"
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
}`}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
<span className="tabular-nums">{resetCreditCount}</span>
</div>
</Tooltip>
)}
{isCodex && resetCreditCount > 0 && (
<Tooltip text={`Use one Codex reset credit. Available: ${resetCreditCount}`}>
<button
type="button"
onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })}
disabled={isLoading || rowBusy}
className="flex h-8 items-center gap-1 rounded-lg border border-primary/30 px-2 text-[11px] text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
>
<span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}>
{isResettingLimit ? "progress_activity" : "bolt"}
</span>
<span className="hidden lg:inline">Reset limit</span>
</button>
</Tooltip>
)}
<Tooltip text="Refresh quota">
<button
type="button"
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading || rowBusy}
aria-label="Refresh quota"
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
>
refresh
</span>
</button>
<button
type="button"
onClick={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
disabled={rowBusy}
aria-label="Edit connection"
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
title="Edit connection"
>
<span className="material-symbols-outlined text-[18px]">
edit
</span>
</button>
<button
type="button"
onClick={() => handleDeleteConnection(conn.id)}
disabled={rowBusy}
aria-label="Delete connection"
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
title="Delete connection"
>
<span
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
<span
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</Tooltip>
<Tooltip text="Edit connection">
<button
type="button"
onClick={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
disabled={rowBusy}
aria-label="Edit connection"
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
>
delete
</span>
</button>
<span className="material-symbols-outlined text-[18px]">
edit
</span>
</button>
</Tooltip>
<Tooltip text="Delete connection">
<button
type="button"
onClick={() => handleDeleteConnection(conn.id)}
disabled={rowBusy}
aria-label="Delete connection"
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
>
<span
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
>
delete
</span>
</button>
</Tooltip>
<div
className="inline-flex items-center pl-0.5"
title={
@@ -1047,6 +1122,25 @@ export default function ProviderLimits() {
</div>
</div>
<ConfirmModal
isOpen={Boolean(resetConfirmState)}
onClose={() => {
if (!resettingLimitId) setResetConfirmState(null);
}}
onConfirm={async () => {
const connection = resetConfirmState?.connection;
if (!connection) return;
await handleResetCodexLimit(connection.id, connection.provider);
setResetConfirmState(null);
}}
title="Reset Codex limit?"
message={`Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`}
confirmText="Reset limit"
cancelText="Cancel"
variant="danger"
loading={Boolean(resettingLimitId)}
/>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
+3
View File
@@ -3,6 +3,7 @@ import { getModelAliases, setModelAlias } from "@/models";
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { AI_MODELS } from "@/shared/constants/config";
import { getProviderAlias } from "@/shared/constants/providers";
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
// GET /api/models - Get models with aliases
export async function GET() {
@@ -18,10 +19,12 @@ export async function GET() {
})
.map((m) => {
const fullModel = `${m.provider}/${m.model}`;
const c = getCapabilitiesForModel(m.provider, m.model);
return {
...m,
fullModel,
alias: modelAliases[fullModel] || m.model,
caps: { vision: c.vision, search: c.search, reasoning: c.reasoning },
};
});
@@ -0,0 +1,104 @@
// Ensure proxyFetch is loaded to patch globalThis.fetch
import "open-sse/index.js";
import { getProviderConnectionById } from "@/lib/localDb";
import { consumeCodexRateLimitResetCredit } from "open-sse/services/usage.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { refreshAndUpdateCredentials } from "../route.js";
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
function isAuthExpiredResult(result) {
const values = [result?.message, result?.code, result?.raw?.detail, result?.raw?.error]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
return values.some((value) => AUTH_EXPIRED_PATTERNS.some((pattern) => value.includes(pattern)));
}
function getResponseForConsumeResult(result, redeemRequestId) {
if (result.ok) {
return Response.json({
code: result.code,
reset: true,
windows_reset: result.windowsReset,
redeemRequestId,
credit: result.raw?.credit || null,
});
}
if (result.noCredit) {
return Response.json({
code: "no_credit",
reset: false,
windows_reset: result.windowsReset,
message: "No Codex reset credits available.",
}, { status: 409 });
}
return Response.json({
code: result.code || "unknown_response",
reset: false,
windows_reset: result.windowsReset,
message: result.message || "Codex reset credit consume returned an unexpected response.",
}, { status: result.status >= 400 && result.status < 500 ? result.status : 502 });
}
export async function POST(request, { params }) {
let connection;
try {
const { connectionId } = await params;
connection = await getProviderConnectionById(connectionId);
if (!connection) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
if (connection.provider !== "codex") {
return Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 });
}
const isOAuth = connection.authType === "oauth";
const isAccessToken = connection.authType === "access_token";
if (!isOAuth && !isAccessToken) {
return Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 });
}
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
if (isOAuth) {
try {
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
connection = result.connection;
} catch (refreshError) {
console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError);
return Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 });
}
}
// Server-generated redeem id prevents client-controlled replay
const redeemRequestId = crypto.randomUUID();
let consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions);
if (isOAuth && isAuthExpiredResult(consumeResult) && connection.refreshToken) {
try {
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
connection = retryResult.connection;
consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions);
} catch (retryError) {
console.warn(`[Codex Reset Credits] force refresh failed: ${retryError.message}`);
}
}
return getResponseForConsumeResult(consumeResult, redeemRequestId);
} catch (error) {
const provider = connection?.provider ?? "unknown";
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
return Response.json({ error: error.message }, { status: 500 });
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ function isAuthExpiredMessage(usage) {
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
* @returns Promise<{ connection, refreshed: boolean }>
*/
async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
const executor = getExecutor(connection.provider);
// Build credentials object from connection
+14
View File
@@ -324,6 +324,20 @@ button {
user-select: none;
}
/* Tailwind v4 dropped default button cursor — restore globally */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
label[for],
summary,
select {
cursor: pointer;
}
button:disabled,
[role="button"][aria-disabled="true"] {
cursor: not-allowed;
}
/* ============================================================
Animations
============================================================ */
+9 -2
View File
@@ -32,7 +32,7 @@ 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"];
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
// Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [
@@ -90,7 +90,14 @@ function isLoopbackHostname(h) {
}
export function isLocalRequest(request) {
if (!isLoopbackHostname(request.headers.get("host"))) return false;
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) {
if (!isLoopbackHostname(realIp)) return false;
} else if (!isLoopbackHostname(request.headers.get("host"))) {
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
return false;
}
const origin = request.headers.get("origin");
if (origin) {
try {
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { CAPACITY_META } from "@/shared/constants/models";
import Tooltip from "./Tooltip";
// Render small icon badges for a model's capabilities (only those set true).
// colorOverride: force a single color class for all badges (default: per-cap color).
// size: icon font-size in px (default 16).
export default function CapacityBadges({ caps, className = "", colorOverride, size = 16 }) {
if (!caps) return null;
const active = Object.keys(CAPACITY_META).filter((k) => caps[k]);
if (active.length === 0) return null;
return (
<span className={`inline-flex items-center gap-0.5 ${className}`}>
{active.map((k) => (
<Tooltip key={k} text={`${CAPACITY_META[k].label}${CAPACITY_META[k].desc}`}>
<span
className={`material-symbols-outlined leading-none cursor-help ${colorOverride || CAPACITY_META[k].color}`}
style={{ fontSize: `${size}px` }}
>
{CAPACITY_META[k].icon}
</span>
</Tooltip>
))}
</span>
);
}
+8 -1
View File
@@ -4,6 +4,8 @@ import { useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "./Modal";
import ProviderIcon from "./ProviderIcon";
import CapacityBadges from "./CapacityBadges";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers";
@@ -40,6 +42,7 @@ export default function ModelSelectModal({
return kinds.includes(kindFilter);
});
}, [activeProviders, kindFilter]);
const { getCaps } = useModelCaps();
const [searchQuery, setSearchQuery] = useState("");
const [combos, setCombos] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
@@ -499,9 +502,13 @@ export default function ModelSelectModal({
<>
{model.name}
<span className="text-[9px] opacity-60 font-normal">custom</span>
<CapacityBadges caps={getCaps(model.value)} />
</>
) : (
model.name
<>
{model.name}
<CapacityBadges caps={getCaps(model.value)} />
</>
)}
</span>
</button>
+2 -2
View File
@@ -12,10 +12,10 @@ export default function Tooltip({ text, children, position = "top", color }) {
const bgClass = color ? "" : "bg-gray-900";
return (
<div className="relative inline-flex group">
<div className="relative inline-flex group/tt">
{children}
<div
className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug ${bgClass} text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-normal`}
className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug ${bgClass} text-white opacity-0 group-hover/tt:opacity-100 transition-opacity duration-150 whitespace-normal`}
style={bgStyle}
>
{text}
+1
View File
@@ -36,6 +36,7 @@ export { default as NoAuthProxyCard } from "./NoAuthProxyCard";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";
export { default as ProviderInfoCard } from "./ProviderInfoCard";
export { default as CapacityBadges } from "./CapacityBadges";
// Layouts
export * from "./layouts";
+7
View File
@@ -38,3 +38,10 @@ export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
);
export const getModelKind = (m, fallback = null) => m?.kind || m?.type || fallback;
// Capacity metadata for UI badges — icon + label + color per capability.
export const CAPACITY_META = {
vision: { icon: "visibility", label: "Vision", desc: "Supports image input", color: "text-blue-500" },
// search: temporarily hidden (feature not wired yet)
reasoning: { icon: "neurology", label: "Reasoning", desc: "Supports reasoning / thinking", color: "text-amber-500" },
};
+4 -1
View File
@@ -1,5 +1,6 @@
// Provider definitions
import REGISTRY from "open-sse/providers/registry/index.js";
import { RISK_NOTICE } from "@/shared/constants/providersDisplay";
const MEDIA_ENTRY_KEYS = [
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
@@ -15,8 +16,10 @@ function buildProviderEntry(r) {
for (const k of MEDIA_ENTRY_KEYS) {
if (r[k] !== undefined) mediaFields[k] = r[k];
}
const display = { ...(r.display || {}) };
if (display.deprecationNotice === "RISK_NOTICE") display.deprecationNotice = RISK_NOTICE;
return {
...(r.display || {}),
...display,
id: r.id,
alias: r.uiAlias || r.alias,
...(r.hidden ? { hidden: true } : {}),
+1
View File
@@ -1,2 +1,3 @@
// Shared Hooks - Export all
export { useTheme } from "./useTheme";
export { useModelCaps } from "./useModelCaps";
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState, useEffect } from "react";
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
export function useModelCaps() {
const [byFull, setByFull] = useState({});
const [byId, setById] = useState({});
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch("/api/models");
if (!res.ok) return;
const data = await res.json();
const full = {};
const id = {};
for (const m of data.models || []) {
if (!m.caps) continue;
if (m.fullModel) full[m.fullModel] = m.caps;
if (m.model) id[m.model] = m.caps;
}
if (alive) { setByFull(full); setById(id); }
} catch { /* ignore */ }
})();
return () => { alive = false; };
}, []);
// Resolve caps from a "provider/model" string or a bare model id.
const getCaps = (key) => {
if (!key) return null;
if (byFull[key]) return byFull[key];
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
return byId[bare] || null;
};
return { getCaps };
}