Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ export default function ProviderDetailPageClient() {
setShowEditModal={setShowEditModal}
selectedConnection={selectedConnection}
handleUpdateConnection={handleUpdateConnection}
handleCompatibleImportWithProgress={handleCompatibleImportWithProgress}
showEditNodeModal={showEditNodeModal}
setShowEditNodeModal={setShowEditNodeModal}
providerNode={providerNode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type CompatModelRow,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels";
import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -125,6 +126,8 @@ export default function CompatibleModelsSection({
const [importing, setImporting] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all");
const [sortFreeFirst, setSortFreeFirst] = useState(false);
const notify = useNotificationStore();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);

Expand Down Expand Up @@ -165,7 +168,8 @@ export default function CompatibleModelsSection({
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""),
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") ||
isFreeModel(providerStorageAlias, { id: model.id }),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
Expand Down Expand Up @@ -199,7 +203,8 @@ export default function CompatibleModelsSection({
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""),
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") ||
isFreeModel(providerStorageAlias, { id: modelId }),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
Expand Down Expand Up @@ -229,8 +234,13 @@ export default function CompatibleModelsSection({
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
const matchesFreeFilter =
freeFilter === "all" ? true : freeFilter === "free" ? model.isFree : !model.isFree;
return matchesQuery && matchesVisibility && matchesFreeFilter;
});
const displayModels = sortFreeFirst
? sortModelsFreeFirst(filteredModels, { isFree: (m) => m.isFree, key: (m) => m.modelId })
: filteredModels;
const activeCount = allModels.filter((model) => !model.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
Expand Down Expand Up @@ -399,6 +409,10 @@ export default function CompatibleModelsSection({
deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending}
visibilityFilter={visibilityFilter}
onVisibilityFilterChange={setVisibilityFilter}
freeFilter={freeFilter}
onFreeFilterChange={setFreeFilter}
sortFreeFirst={sortFreeFirst}
onSortFreeFirstChange={setSortFreeFirst}
onTestAll={() => {
const targets = filteredModels
.filter((m) => !m.isHidden)
Expand All @@ -414,7 +428,7 @@ export default function CompatibleModelsSection({
onAutoHideFailedChange={onAutoHideFailedChange}
/>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => {
{displayModels.map(({ modelId, alias, isHidden, source, isFree }) => {
const fullModel = `${providerDisplayAlias}/${modelId}`;
return (
<PassthroughModelRow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export interface ModelVisibilityToolbarProps {
onVisibilityFilterChange?: (filter: "all" | "visible" | "hidden") => void;
autoHideFailed?: boolean;
onAutoHideFailedChange?: (v: boolean) => void;
freeFilter?: "all" | "free" | "paid";
onFreeFilterChange?: (filter: "all" | "free" | "paid") => void;
sortFreeFirst?: boolean;
onSortFreeFirstChange?: (v: boolean) => void;
}

export function ModelVisibilityToolbar({
Expand All @@ -112,6 +116,10 @@ export function ModelVisibilityToolbar({
onVisibilityFilterChange,
autoHideFailed,
onAutoHideFailedChange,
freeFilter,
onFreeFilterChange,
sortFreeFirst,
onSortFreeFirstChange,
}: ModelVisibilityToolbarProps) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
Expand Down Expand Up @@ -148,6 +156,42 @@ export function ModelVisibilityToolbar({
))}
</div>
)}
{freeFilter !== undefined && onFreeFilterChange && (
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
{(["all", "free", "paid"] as const).map((f) => (
<button
key={f}
onClick={() => onFreeFilterChange(f)}
className={`rounded px-2 py-1 text-xs ${
freeFilter === f
? "bg-primary text-primary-foreground"
: "text-text-muted hover:text-text-main"
}`}
>
{f === "all"
? providerText(t, "freeFilterAll", "All")
: f === "free"
? providerText(t, "freeFilterFreeOnly", "Free only")
: providerText(t, "freeFilterPaidOnly", "Paid only")}
</button>
))}
</div>
)}
{onSortFreeFirstChange && (
<button
onClick={() => onSortFreeFirstChange(!sortFreeFirst)}
aria-pressed={!!sortFreeFirst}
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] ${
sortFreeFirst
? "border-primary bg-primary/10 text-primary"
: "border-border bg-transparent text-text-main"
}`}
title={providerText(t, "sortFreeFirst", "Free first")}
>
<span className="material-symbols-outlined text-[16px]">sort</span>
<span>{providerText(t, "sortFreeFirst", "Free first")}</span>
</button>
)}
{onAutoHideFailedChange && (
<label
className="flex items-center gap-1.5 text-xs text-text-muted"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels";
import PassthroughModelRow from "./PassthroughModelRow";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -125,6 +126,8 @@ export default function PassthroughModelsSection({
autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed;
const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed;
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all");
const [sortFreeFirst, setSortFreeFirst] = useState(false);
const notify = useNotificationStore();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);

Expand Down Expand Up @@ -241,7 +244,8 @@ export default function PassthroughModelsSection({
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""),
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") ||
isFreeModel(providerId, { id: model.id }),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
Expand Down Expand Up @@ -272,7 +276,8 @@ export default function PassthroughModelsSection({
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""),
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") ||
isFreeModel(providerId, { id: modelId }),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
Expand All @@ -286,6 +291,7 @@ export default function PassthroughModelsSection({
isModelHidden,
providerAlias,
providerAliases,
providerId,
]);

const filteredModels = allModels.filter((model) => {
Expand All @@ -303,8 +309,14 @@ export default function PassthroughModelsSection({
? !model.isHidden
: model.isHidden;

return matchesQuery && matchesVisibility;
const matchesFreeFilter =
freeFilter === "all" ? true : freeFilter === "free" ? model.isFree : !model.isFree;

return matchesQuery && matchesVisibility && matchesFreeFilter;
});
const displayModels = sortFreeFirst
? sortModelsFreeFirst(filteredModels, { isFree: (m) => m.isFree, key: (m) => m.modelId })
: filteredModels;
const activeCount = allModels.filter((model) => !model.isHidden).length;

// Generate default alias from modelId (last part after /)
Expand Down Expand Up @@ -389,9 +401,13 @@ export default function PassthroughModelsSection({
onVisibilityFilterChange={setVisibilityFilter}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
freeFilter={freeFilter}
onFreeFilterChange={setFreeFilter}
sortFreeFirst={sortFreeFirst}
onSortFreeFirstChange={setSortFreeFirst}
/>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => (
{displayModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => (
<PassthroughModelRow
key={fullModel as string}
modelId={modelId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ interface ProviderModalsPanelProps {
setShowEditModal: (open: boolean) => void;
selectedConnection: ConnectionRowConnection | null;
handleUpdateConnection: (data: any) => Promise<string | null>;
handleCompatibleImportWithProgress: (
connectionId: string,
mode?: "import" | "sync"
) => Promise<void>;
// Edit compatible node
showEditNodeModal: boolean;
setShowEditNodeModal: (open: boolean) => void;
Expand Down Expand Up @@ -186,6 +190,7 @@ export default function ProviderModalsPanel({
setShowEditModal,
selectedConnection,
handleUpdateConnection,
handleCompatibleImportWithProgress,
showEditNodeModal,
setShowEditNodeModal,
providerNode,
Expand Down Expand Up @@ -316,6 +321,7 @@ export default function ProviderModalsPanel({
connection={selectedConnection}
providerId={providerId}
onSave={handleUpdateConnection}
onResyncModels={(id) => handleCompatibleImportWithProgress(id, "sync")}
onClose={() => setShowEditModal(false)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
* Cycle-safe: no import from ProviderDetailPageClient.
*/

import { useState } from "react";
import { Button } from "@/shared/components";
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
import { isFreeModel, sortModelsFreeFirst } from "@/shared/utils/freeModels";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import ModelRow, { ModelVisibilityToolbar } from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection";
Expand Down Expand Up @@ -166,6 +168,8 @@ export default function ProviderModelsSection({
getUpstreamHeadersRecordForModel,
t,
}: ProviderModelsSectionProps) {
const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all");
const [sortFreeFirst, setSortFreeFirst] = useState(false);
const autoSyncToggle = compatibleSupportsModelImport && canImportModels && (
<button
onClick={handleToggleAutoSync}
Expand Down Expand Up @@ -372,6 +376,7 @@ export default function ProviderModelsSection({
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
isFree: isFreeModel(providerId, { id: model.id }),
}));
const filteredModels = modelsWithVisibility.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
Expand All @@ -385,8 +390,13 @@ export default function ProviderModelsSection({
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
const matchesFreeFilter =
freeFilter === "all" ? true : freeFilter === "free" ? model.isFree : !model.isFree;
return matchesQuery && matchesVisibility && matchesFreeFilter;
});
const displayModels = sortFreeFirst
? sortModelsFreeFirst(filteredModels, { isFree: (m) => m.isFree, key: (m) => m.id })
: filteredModels;
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
Expand Down Expand Up @@ -427,10 +437,14 @@ export default function ProviderModelsSection({
onVisibilityFilterChange={setVisibilityFilter}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
freeFilter={freeFilter}
onFreeFilterChange={setFreeFilter}
sortFreeFirst={sortFreeFirst}
onSortFreeFirstChange={setSortFreeFirst}
/>
)}
<div className="flex flex-wrap gap-3">
{filteredModels.map((model) => {
{displayModels.map((model) => {
return (
<ModelRow
key={model.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Toggle } from "@/shared/components";
import { providerAllowsOptionalApiKey, supportsBulkApiKey } from "@/shared/constants/providers";
import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser";
import { providerHasFreeModels } from "@/shared/utils/freeModels";
import {
isBaseUrlConfigurableProvider,
getProviderBaseUrlDefault,
Expand Down Expand Up @@ -65,6 +66,7 @@ export default function AddApiKeyModal({
onClose,
}: AddApiKeyModalProps) {
const t = useTranslations("providers");
const showFreeModelsToggle = providerHasFreeModels(provider);
const usesBaseUrl = isBaseUrlConfigurableProvider(provider);
const defaultBaseUrl = getProviderBaseUrlDefault(provider);
const isVertex = provider === "vertex" || provider === "vertex-partner";
Expand Down Expand Up @@ -113,6 +115,7 @@ export default function AddApiKeyModal({
ccCompatibleContext1m: false,
ccCompatibleRedactThinking: false,
passthroughModels: false,
importFreeModelsOnly: false,
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
Expand Down Expand Up @@ -294,6 +297,9 @@ export default function AddApiKeyModal({
if (formData.passthroughModels) {
providerSpecificData.passthroughModels = true;
}
if (showFreeModelsToggle && formData.importFreeModelsOnly) {
providerSpecificData.importFreeModelsOnly = true;
}
if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) {
providerSpecificData.consoleApiKey = formData.consoleApiKey.trim();
}
Expand Down Expand Up @@ -350,6 +356,9 @@ export default function AddApiKeyModal({
bulkProviderSpecificData.baseUrl = checked.value;
}
openRouterPreset.applyTo(bulkProviderSpecificData);
if (showFreeModelsToggle && formData.importFreeModelsOnly) {
bulkProviderSpecificData.importFreeModelsOnly = true;
}
const providerSpecificData =
Object.keys(bulkProviderSpecificData).length > 0 ? bulkProviderSpecificData : undefined;

Expand Down Expand Up @@ -384,6 +393,16 @@ export default function AddApiKeyModal({

if (!provider) return null;

const freeModelsToggle = showFreeModelsToggle ? (
<Toggle
size="sm"
checked={formData.importFreeModelsOnly}
onChange={(checked) => setFormData({ ...formData, importFreeModelsOnly: checked })}
label={t("importFreeModelsOnlyLabel")}
description={t("importFreeModelsOnlyHint")}
/>
) : null;

return (
<Modal
isOpen={isOpen}
Expand Down Expand Up @@ -429,6 +448,7 @@ export default function AddApiKeyModal({
<div className="flex flex-col gap-3">
<p className="text-xs text-text-muted">{t("bulkAddFormatHint")}</p>
{openRouterPreset.input}
{freeModelsToggle}
<textarea
className="w-full rounded border border-border bg-background p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={"name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named"}
Expand Down Expand Up @@ -689,6 +709,7 @@ export default function AddApiKeyModal({
{openRouterPreset.input}
</div>
)}
{freeModelsToggle}
{isCompatible && !isCcCompatible && (
<p className="text-xs text-text-muted">
{isAnthropic
Expand Down
Loading
Loading