feat: add auto-fetch models from provider's /v1/models endpoint

Add ability to fetch available models from third-party aggregation
providers (SiliconFlow, OpenRouter, etc.) via OpenAI-compatible
GET /v1/models endpoint. Users can click "Fetch Models" button in
the provider form, then select models from a dropdown on each
model input field.

- Backend: new model_fetch service + Tauri command (Rust)
- Frontend: ModelInputWithFetch shared component
- Integrated into all 5 app forms (Claude/Codex/Gemini/OpenCode/OpenClaw)
- i18n support for zh/en/ja
This commit is contained in:
Jason
2026-04-03 23:10:17 +08:00
parent de49f6fbbe
commit 5017002938
16 changed files with 825 additions and 70 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
@@ -10,8 +10,25 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import {
ChevronDown,
Download,
Plus,
Trash2,
ChevronRight,
Loader2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiKeySection } from "./shared";
import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch";
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
import { cn } from "@/lib/utils";
import {
@@ -136,6 +153,49 @@ function ModelOptionKeyInput({
);
}
/** Dropdown button to select from fetched models */
function ModelDropdown({
models,
onSelect,
}: {
models: FetchedModel[];
onSelect: (id: string) => void;
}) {
const grouped: Record<string, FetchedModel[]> = {};
for (const model of models) {
const vendor = model.ownedBy || "Other";
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(model);
}
const vendors = Object.keys(grouped).sort();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto z-[200]"
>
{vendors.map((vendor, vi) => (
<div key={vendor}>
{vi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
{grouped[vendor].map((m) => (
<DropdownMenuItem key={m.id} onSelect={() => onSelect(m.id)}>
{m.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface OpenCodeFormFieldsProps {
// NPM Package
npm: string;
@@ -182,6 +242,33 @@ export function OpenCodeFormFields({
}: OpenCodeFormFieldsProps) {
const { t } = useTranslation();
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const handleFetchModels = useCallback(() => {
if (!baseUrl || !apiKey) {
toast.error(t("providerForm.fetchModelsFailed"));
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(baseUrl, apiKey)
.then((models) => {
setFetchedModels(models);
if (models.length === 0) {
toast.info(t("providerForm.fetchModelsEmpty"));
} else {
toast.success(
t("providerForm.fetchModelsSuccess", { count: models.length }),
);
}
})
.catch((err) => {
console.warn("[ModelFetch] Failed:", err);
toast.error(t("providerForm.fetchModelsFailed"));
})
.finally(() => setIsFetchingModels(false));
}, [baseUrl, apiKey, t]);
// Track which models have expanded options panel
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
@@ -552,16 +639,33 @@ export function OpenCodeFormFields({
<FormLabel>
{t("opencode.models", { defaultValue: "Models" })}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("opencode.addModel", { defaultValue: "Add" })}
</Button>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={isFetchingModels}
className="h-7 gap-1"
>
{isFetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("providerForm.fetchModels")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("opencode.addModel", { defaultValue: "Add" })}
</Button>
</div>
</div>
{Object.keys(models).length === 0 ? (
@@ -600,13 +704,21 @@ export function OpenCodeFormFields({
)}
/>
</Button>
<ModelIdInput
modelId={key}
onChange={(newId) => handleModelIdChange(key, newId)}
placeholder={t("opencode.modelId", {
defaultValue: "Model ID",
})}
/>
<div className="flex gap-1 flex-1">
<ModelIdInput
modelId={key}
onChange={(newId) => handleModelIdChange(key, newId)}
placeholder={t("opencode.modelId", {
defaultValue: "Model ID",
})}
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) => handleModelIdChange(key, id)}
/>
)}
</div>
<Input
value={model.name}
onChange={(e) => handleModelNameChange(key, e.target.value)}