Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions gui/src/components/provider-workspace/ProviderDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ export default function ProviderDetails({
)}
{tab === "models" && (
<ProviderModels
key={item.name}
item={item}
apiBase={apiBase}
availableModels={availableModels}
selectedModels={selectedModels}
modelsLoading={modelsLoading}
Expand Down
101 changes: 98 additions & 3 deletions gui/src/components/provider-workspace/ProviderModels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { filterModels } from "../../provider-workspace/report";

export default function ProviderModels({
item,
apiBase,
availableModels,
selectedModels,
modelsLoading = false,
Expand All @@ -19,6 +20,7 @@ export default function ProviderModels({
onOpenAccounts,
}: {
item: WorkspaceItem;
apiBase: string;
availableModels: string[];
selectedModels: string[];
modelsLoading?: boolean;
Expand All @@ -30,15 +32,54 @@ export default function ProviderModels({
}) {
const t = useT();
const [query, setQuery] = useState("");
const [customModelId, setCustomModelId] = useState("");
const [customSaving, setCustomSaving] = useState(false);
const [customError, setCustomError] = useState("");
const [customSuccess, setCustomSuccess] = useState("");
const [customModelIds, setCustomModelIds] = useState<string[]>([]);
const [customModelsReady, setCustomModelsReady] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyResetRef = useRef<number | null>(null);
const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]);
const configuredModels = useMemo(() => item.models ?? [], [item.models]);
const trimmedCustomModelId = customModelId.trim();
const customModelInvalid = !customModelsReady
|| !trimmedCustomModelId
|| trimmedCustomModelId.includes("/")
|| availableModels.includes(trimmedCustomModelId)
|| customModelIds.includes(trimmedCustomModelId)
|| configuredModels.includes(trimmedCustomModelId)
|| item.defaultModel === trimmedCustomModelId;
const models = useMemo(
() => filterModels(availableModels, item.defaultModel, query, configuredModels),
[availableModels, item.defaultModel, query, configuredModels],
() => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds),
[availableModels, item.defaultModel, query, configuredModels, customModelIds],
);

useEffect(() => {
let active = true;
void fetch(`${apiBase}/api/custom-models`)
.then(response => {
if (!response.ok) throw new Error();
return response.json();
})
.then((rows: unknown) => {
if (!Array.isArray(rows)) throw new Error("Invalid custom model list");
if (!active) return;
setCustomModelIds(rows.flatMap(row => {
if (!row || typeof row !== "object") return [];
const model = row as { provider?: unknown; modelId?: unknown };
return model.provider === item.name && typeof model.modelId === "string" ? [model.modelId] : [];
}));
setCustomModelsReady(true);
})
.catch(() => {
if (!active) return;
setCustomModelIds([]);
setCustomError(t("models.networkError"));
});
return () => { active = false; };
}, [apiBase, item.name, t]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => () => {
if (copyResetRef.current != null) window.clearTimeout(copyResetRef.current);
}, []);
Expand All @@ -57,7 +98,36 @@ export default function ProviderModels({
}
};

const emptyBase = availableModels.length === 0 && configuredModels.length === 0 && !item.defaultModel;
const addCustomModel = async () => {
if (customModelInvalid || customSaving) return;
setCustomSaving(true);
setCustomError("");
setCustomSuccess("");
try {
const response = await fetch(`${apiBase}/api/custom-models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: item.name, modelId: trimmedCustomModelId }),
});
if (response.ok) {
setCustomModelIds(ids => ids.includes(trimmedCustomModelId) ? ids : [...ids, trimmedCustomModelId]);
setCustomModelId("");
setCustomSuccess(t("models.customAdded"));
onRetryModels?.();
} else {
setCustomError(t("models.customSaveFailed"));
}
} catch {
setCustomError(t("models.networkError"));
} finally {
setCustomSaving(false);
}
};

const emptyBase = availableModels.length === 0
&& configuredModels.length === 0
&& customModelIds.length === 0
&& !item.defaultModel;
const showingConfiguredFallback = availableModels.length === 0 && configuredModels.length > 0;
// Aggregators (OpenRouter etc.) can return thousands of ids; capping the mounted
// chips keeps the tab responsive. Filtering narrows the list, so the cap only
Expand Down Expand Up @@ -87,6 +157,31 @@ export default function ProviderModels({
{showingConfiguredFallback && !needsReauth && (
<p className="muted text-label" style={{ marginBottom: 10 }}>{t("pws.modelsConfiguredFallback")}</p>
)}
<label className="text-label pws-custom-model-label" htmlFor={`pws-custom-model-${item.name}`}>
{t("models.customAdd")}
</label>
<div className="row pws-custom-model-row">
<input
id={`pws-custom-model-${item.name}`}
className="input"
value={customModelId}
onChange={event => setCustomModelId(event.target.value)}
onKeyDown={event => { if (event.key === "Enter") void addCustomModel(); }}
placeholder={t("models.customFieldModelIdPlaceholder")}
aria-label={t("models.customAdd")}
disabled={customSaving}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => { void addCustomModel(); }}
disabled={customSaving || customModelInvalid}
>
{customSaving ? t("models.customSaving") : t("models.customAddBtn")}
</button>
</div>
{customSuccess && <p className="muted text-label" role="status">{customSuccess}</p>}
{customError && <p className="pws-inline-error" role="alert">{customError}</p>}
{!emptyBase && (
<input
type="search"
Expand Down
13 changes: 9 additions & 4 deletions gui/src/provider-workspace/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,17 @@ export function filterModels(
defaultModel: string | undefined,
query: string,
configuredModels?: string[],
customModels: string[] = [],
): string[] {
const list = base.length > 0
const customSet = new Set(customModels);
const hasLiveModels = base.some(modelId => !customSet.has(modelId));
const fallback = configuredModels && configuredModels.length > 0
? configuredModels
: defaultModel ? [defaultModel] : [];
const primary = base.length > 0 && (hasLiveModels || customSet.size === 0)
? base
: (configuredModels && configuredModels.length > 0)
? configuredModels
: defaultModel ? [defaultModel] : [];
: fallback;
const list = [...new Set([...primary, ...customModels])];
const q = query.trim().toLowerCase();
if (!q) return list;
return list.filter(id => id.toLowerCase().includes(q));
Expand Down
15 changes: 15 additions & 0 deletions gui/src/styles/provider-workspace-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,21 @@
margin-bottom: 18px;
}

.pws-custom-model-label {
display: block;
margin-bottom: 6px;
}

.pws-custom-model-row {
gap: 8px;
margin-bottom: 12px;
}

.pws-custom-model-row .input {
flex: 1;
min-width: 0;
}

.pws-model-list {
display: flex;
flex-wrap: wrap;
Expand Down
Loading
Loading