mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Refactor
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
============================================================ */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
// Shared Hooks - Export all
|
||||
export { useTheme } from "./useTheme";
|
||||
export { useModelCaps } from "./useModelCaps";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user