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
+2
View File
@@ -10,6 +10,7 @@ mod global_proxy;
mod import_export;
mod mcp;
mod misc;
mod model_fetch;
mod omo;
mod openclaw;
mod plugin;
@@ -37,6 +38,7 @@ pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use model_fetch::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
+18
View File
@@ -0,0 +1,18 @@
//! 模型列表获取命令
//!
//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。
use crate::services::model_fetch::{self, FetchedModel};
/// 获取供应商的可用模型列表
///
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
#[tauri::command(rename_all = "camelCase")]
pub async fn fetch_models_for_config(
base_url: String,
api_key: String,
is_full_url: Option<bool>,
) -> Result<Vec<FetchedModel>, String> {
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
}
+2
View File
@@ -906,6 +906,8 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// model list fetch (OpenAI-compatible /v1/models)
commands::fetch_models_for_config,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
+1
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod model_fetch;
pub mod omo;
pub mod prompt;
pub mod provider;
+179
View File
@@ -0,0 +1,179 @@
//! 模型列表获取服务
//!
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// 获取到的模型信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchedModel {
pub id: String,
pub owned_by: Option<String>,
}
/// OpenAI 兼容的 /v1/models 响应格式
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Option<Vec<ModelEntry>>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
id: String,
owned_by: Option<String>,
}
const FETCH_TIMEOUT_SECS: u64 = 15;
/// 获取供应商的可用模型列表
///
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
pub async fn fetch_models(
base_url: &str,
api_key: &str,
is_full_url: bool,
) -> Result<Vec<FetchedModel>, String> {
if api_key.is_empty() {
return Err("API Key is required to fetch models".to_string());
}
let models_url = build_models_url(base_url, is_full_url)?;
let client = crate::proxy::http_client::get_for_provider(None);
let response = client
.get(&models_url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("HTTP {status}: {body}"));
}
let resp: ModelsResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
let mut models: Vec<FetchedModel> = resp
.data
.unwrap_or_default()
.into_iter()
.map(|m| FetchedModel {
id: m.id,
owned_by: m.owned_by,
})
.collect();
models.sort_by(|a, b| a.id.cmp(&b.id));
Ok(models)
}
/// 构造 /v1/models 的完整 URL
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err("Base URL is empty".to_string());
}
if is_full_url {
// 尝试从完整端点 URL 推导 API 根路径
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
if let Some(idx) = trimmed.find("/v1/") {
return Ok(format!("{}/v1/models", &trimmed[..idx]));
}
// 如果没有 /v1/ 路径,直接去掉最后一段路径
if let Some(idx) = trimmed.rfind('/') {
let root = &trimmed[..idx];
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
return Ok(format!("{root}/v1/models"));
}
}
return Err("Cannot derive models endpoint from full URL".to_string());
}
// 常规情况: base_url 是 API 根路径
// 如果已经包含 /v1 路径,直接追加 /models
if trimmed.ends_with("/v1") {
return Ok(format!("{trimmed}/models"));
}
Ok(format!("{trimmed}/v1/models"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_models_url_basic() {
assert_eq!(
build_models_url("https://api.siliconflow.cn", false).unwrap(),
"https://api.siliconflow.cn/v1/models"
);
}
#[test]
fn test_build_models_url_trailing_slash() {
assert_eq!(
build_models_url("https://api.example.com/", false).unwrap(),
"https://api.example.com/v1/models"
);
}
#[test]
fn test_build_models_url_with_v1() {
assert_eq!(
build_models_url("https://api.example.com/v1", false).unwrap(),
"https://api.example.com/v1/models"
);
}
#[test]
fn test_build_models_url_full_url() {
assert_eq!(
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
"https://proxy.example.com/v1/models"
);
}
#[test]
fn test_build_models_url_empty() {
assert!(build_models_url("", false).is_err());
}
#[test]
fn test_parse_response() {
let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#;
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
let data = resp.data.unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data[0].id, "gpt-4");
assert_eq!(data[0].owned_by.as_deref(), Some("openai"));
assert_eq!(data[1].id, "claude-3-sonnet");
}
#[test]
fn test_parse_response_no_owned_by() {
let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#;
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
let data = resp.data.unwrap();
assert_eq!(data[0].id, "my-model");
assert!(data[0].owned_by.is_none());
}
#[test]
fn test_parse_response_empty_data() {
let json = r#"{"object":"list","data":[]}"#;
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
assert!(resp.data.unwrap().is_empty());
}
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@@ -24,15 +24,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
import { CopilotAuthSection } from "./CopilotAuthSection";
import {
copilotGetModels,
copilotGetModelsForAccount,
} from "@/lib/api/copilot";
import type { CopilotModel } from "@/lib/api/copilot";
import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch";
import type {
ProviderCategory,
ClaudeApiFormat,
@@ -179,6 +180,34 @@ export function ClaudeFormFields({
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
// 通用模型获取(非 Copilot 供应商)
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, isFullUrl)
.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, isFullUrl, t]);
// 当 Copilot 预设且已认证时,加载可用模型
useEffect(() => {
// 如果不是 Copilot 预设或未认证,清空模型列表
@@ -298,14 +327,15 @@ export function ClaudeFormFields({
);
}
// 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
return (
<Input
<ModelInputWithFetch
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
onChange={(v) => onModelChange(field, v)}
placeholder={placeholder}
autoComplete="off"
fetchedModels={fetchedModels}
isLoading={isFetchingModels}
/>
);
};
@@ -502,7 +532,26 @@ export function ClaudeFormFields({
{/* 模型映射 */}
<div className="space-y-1 pt-2 border-t">
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
{!isCopilotPreset && (
<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>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("providerForm.modelMappingHint")}
</p>
@@ -1,6 +1,11 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Download, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch";
import type { ProviderCategory } from "@/types";
interface EndpointCandidate {
@@ -65,6 +70,33 @@ export function CodexFormFields({
}: CodexFormFieldsProps) {
const { t } = useTranslation();
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const handleFetchModels = useCallback(() => {
if (!codexBaseUrl || !codexApiKey) {
toast.error(t("providerForm.fetchModelsFailed"));
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl)
.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));
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
return (
<>
{/* Codex API Key 输入框 */}
@@ -107,21 +139,38 @@ export function CodexFormFields({
{/* Codex Model Name 输入框 */}
{shouldShowModelField && onModelNameChange && (
<div className="space-y-2">
<label
htmlFor="codexModelName"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label>
<input
<div className="flex items-center justify-between">
<label
htmlFor="codexModelName"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label>
<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>
</div>
<ModelInputWithFetch
id="codexModelName"
type="text"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
onChange={(v) => onModelNameChange!(v)}
placeholder={t("codexConfig.modelNamePlaceholder", {
defaultValue: "例如: gpt-5.4",
})}
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
fetchedModels={fetchedModels}
isLoading={isFetchingModels}
/>
<p className="text-xs text-muted-foreground">
{modelName.trim()
@@ -1,9 +1,12 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Info } from "lucide-react";
import { Download, Info, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch";
import type { ProviderCategory } from "@/types";
interface EndpointCandidate {
@@ -66,6 +69,33 @@ export function GeminiFormFields({
}: GeminiFormFieldsProps) {
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]);
// 检测是否为 Google 官方(使用 OAuth
const isGoogleOfficial =
partnerPromotionKey?.toLowerCase() === "google-official";
@@ -123,15 +153,34 @@ export function GeminiFormFields({
{/* Model 输入框 */}
{shouldShowModelField && (
<div>
<FormLabel htmlFor="gemini-model">
{t("provider.form.gemini.model", { defaultValue: "模型" })}
</FormLabel>
<Input
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel htmlFor="gemini-model">
{t("provider.form.gemini.model", { defaultValue: "模型" })}
</FormLabel>
<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>
</div>
<ModelInputWithFetch
id="gemini-model"
value={model}
onChange={(e) => onModelChange(e.target.value)}
onChange={onModelChange}
placeholder="gemini-3-pro-preview"
fetchedModels={fetchedModels}
isLoading={isFetchingModels}
/>
</div>
)}
@@ -16,9 +16,26 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import {
Download,
Plus,
Trash2,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { ApiKeySection } from "./shared";
import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch";
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
import type { ProviderCategory, OpenClawModel } from "@/types";
@@ -70,6 +87,8 @@ export function OpenClawFormFields({
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// Stable key tracking for models list
const modelKeysRef = useRef<string[]>([]);
@@ -107,6 +126,31 @@ export function OpenClawFormFields({
]);
};
// Fetch models from API
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]);
// Remove a model entry
const handleRemoveModel = (index: number) => {
modelKeysRef.current.splice(index, 1);
@@ -234,16 +278,33 @@ export function OpenClawFormFields({
<FormLabel>
{t("openclaw.models", { defaultValue: "模型列表" })}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("openclaw.addModel", { defaultValue: "添加模型" })}
</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("openclaw.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
</div>
{models.length === 0 ? (
@@ -283,15 +344,66 @@ export function OpenClawFormFields({
<label className="text-xs text-muted-foreground">
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
</label>
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
/>
<div className="flex gap-1">
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<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]"
>
{Object.entries(
fetchedModels.reduce(
(acc, m) => {
const v = m.ownedBy || "Other";
if (!acc[v]) acc[v] = [];
acc[v].push(m);
return acc;
},
{} as Record<string, FetchedModel[]>,
),
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([vendor, vModels], vi) => (
<div key={vendor}>
{vi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>
{vendor}
</DropdownMenuLabel>
{vModels.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() =>
handleModelChange(index, "id", m.id)
}
>
{m.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
@@ -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)}
@@ -0,0 +1,146 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, Download, Loader2 } from "lucide-react";
import type { FetchedModel } from "@/lib/api/model-fetch";
interface ModelInputWithFetchProps {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
fetchedModels: FetchedModel[];
isLoading: boolean;
/** 传入时显示获取按钮;不传时只在有数据后显示下拉 */
onFetch?: () => void;
}
export function ModelInputWithFetch({
id,
value,
onChange,
placeholder,
fetchedModels,
isLoading,
onFetch,
}: ModelInputWithFetchProps) {
const { t } = useTranslation();
// 有模型数据: Input + DropdownMenu
if (fetchedModels.length > 0) {
const grouped: Record<string, FetchedModel[]> = {};
for (const model of fetchedModels) {
const vendor = model.ownedBy || "Other";
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(model);
}
const vendors = Object.keys(grouped).sort();
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<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((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onChange(model.id)}
>
{model.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// 加载中: Input + Spinner
if (isLoading) {
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<Button variant="outline" size="icon" className="shrink-0" disabled>
<Loader2 className="h-4 w-4 animate-spin" />
</Button>
</div>
);
}
// 有 onFetch: Input + 获取按钮
if (onFetch) {
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<Button
variant="outline"
size="icon"
className="shrink-0"
type="button"
onClick={onFetch}
title={t("providerForm.fetchModels")}
>
<Download className="h-4 w-4" />
</Button>
</div>
);
}
// 无 onFetch: 纯 Input
return (
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
/>
);
}
@@ -1,2 +1,3 @@
export { ApiKeySection } from "./ApiKeySection";
export { EndpointField } from "./EndpointField";
export { ModelInputWithFetch } from "./ModelInputWithFetch";
+6 -1
View File
@@ -820,7 +820,12 @@
"retry": "Retry",
"copyCode": "Copy code",
"migrationFailed": "Legacy auth migration failed: {{error}}",
"loadModelsFailed": "Failed to load Copilot models"
"loadModelsFailed": "Failed to load Copilot models",
"fetchModels": "Fetch Models",
"fetchingModels": "Fetching...",
"fetchModelsSuccess": "Found {{count}} models",
"fetchModelsFailed": "Failed to fetch models",
"fetchModelsEmpty": "No models found"
},
"endpointTest": {
"title": "API Endpoint Management",
+6 -1
View File
@@ -820,7 +820,12 @@
"retry": "再試行",
"copyCode": "コードをコピー",
"migrationFailed": "旧認証データの移行に失敗しました: {{error}}",
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました"
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました",
"fetchModels": "モデル一覧を取得",
"fetchingModels": "取得中...",
"fetchModelsSuccess": "{{count}}件のモデルを取得",
"fetchModelsFailed": "モデル一覧の取得に失敗しました",
"fetchModelsEmpty": "モデルが見つかりません"
},
"endpointTest": {
"title": "API エンドポイント管理",
+6 -1
View File
@@ -820,7 +820,12 @@
"retry": "重试",
"copyCode": "复制代码",
"migrationFailed": "旧认证数据迁移失败:{{error}}",
"loadModelsFailed": "加载 Copilot 模型列表失败"
"loadModelsFailed": "加载 Copilot 模型列表失败",
"fetchModels": "获取模型列表",
"fetchingModels": "正在获取...",
"fetchModelsSuccess": "获取到 {{count}} 个模型",
"fetchModelsFailed": "获取模型列表失败",
"fetchModelsEmpty": "未找到可用模型"
},
"endpointTest": {
"title": "请求地址管理",
+20
View File
@@ -0,0 +1,20 @@
import { invoke } from "@tauri-apps/api/core";
export interface FetchedModel {
id: string;
ownedBy: string | null;
}
/**
* 从供应商获取可用模型列表
*
* 使用 OpenAI 兼容的 GET /v1/models 端点。
* 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
*/
export async function fetchModelsForConfig(
baseUrl: string,
apiKey: string,
isFullUrl?: boolean,
): Promise<FetchedModel[]> {
return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl });
}