From 5017002938162352c34f51e370e18763d6f641c5 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 3 Apr 2026 23:10:17 +0800 Subject: [PATCH] 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 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/model_fetch.rs | 18 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/model_fetch.rs | 179 ++++++++++++++++++ .../providers/forms/ClaudeFormFields.tsx | 65 ++++++- .../providers/forms/CodexFormFields.tsx | 71 +++++-- .../providers/forms/GeminiFormFields.tsx | 67 ++++++- .../providers/forms/OpenClawFormFields.tsx | 152 +++++++++++++-- .../providers/forms/OpenCodeFormFields.tsx | 150 +++++++++++++-- .../forms/shared/ModelInputWithFetch.tsx | 146 ++++++++++++++ .../providers/forms/shared/index.ts | 1 + src/i18n/locales/en.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/zh.json | 7 +- src/lib/api/model-fetch.ts | 20 ++ 16 files changed, 825 insertions(+), 70 deletions(-) create mode 100644 src-tauri/src/commands/model_fetch.rs create mode 100644 src-tauri/src/services/model_fetch.rs create mode 100644 src/components/providers/forms/shared/ModelInputWithFetch.tsx create mode 100644 src/lib/api/model-fetch.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 04bb6da3a..a751fb54a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs new file mode 100644 index 000000000..f152da002 --- /dev/null +++ b/src-tauri/src/commands/model_fetch.rs @@ -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, +) -> Result, String> { + model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1be714b44..cf06fa896 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c13d2880b..e84de582e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs new file mode 100644 index 000000000..25c192867 --- /dev/null +++ b/src-tauri/src/services/model_fetch.rs @@ -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, +} + +/// OpenAI 兼容的 /v1/models 响应格式 +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + id: String, + owned_by: Option, +} + +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, 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 = 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 { + 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()); + } +} diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index bdcfb8ebb..c916de3ac 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -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([]); const [modelsLoading, setModelsLoading] = useState(false); + // 通用模型获取(非 Copilot 供应商) + const [fetchedModels, setFetchedModels] = useState([]); + 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 ( - 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({ {/* 模型映射 */}
- {t("providerForm.modelMappingLabel")} +
+ {t("providerForm.modelMappingLabel")} + {!isCopilotPreset && ( + + )} +

{t("providerForm.modelMappingHint")}

diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 38434fc69..dea13df71 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -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([]); + 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 && (
- - + + +
+ 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} />

{modelName.trim() diff --git a/src/components/providers/forms/GeminiFormFields.tsx b/src/components/providers/forms/GeminiFormFields.tsx index b0400ec66..293b974f8 100644 --- a/src/components/providers/forms/GeminiFormFields.tsx +++ b/src/components/providers/forms/GeminiFormFields.tsx @@ -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([]); + 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 && ( -

- - {t("provider.form.gemini.model", { defaultValue: "模型" })} - - +
+ + {t("provider.form.gemini.model", { defaultValue: "模型" })} + + +
+ onModelChange(e.target.value)} + onChange={onModelChange} placeholder="gemini-3-pro-preview" + fetchedModels={fetchedModels} + isLoading={isFetchingModels} />
)} diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 314f7024a..4291a73f5 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -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>( {}, ); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); // Stable key tracking for models list const modelKeysRef = useRef([]); @@ -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({ {t("openclaw.models", { defaultValue: "模型列表" })} - +
+ + +
{models.length === 0 ? ( @@ -283,15 +344,66 @@ export function OpenClawFormFields({ - - handleModelChange(index, "id", e.target.value) - } - placeholder={t("openclaw.modelIdPlaceholder", { - defaultValue: "claude-3-sonnet", - })} - /> +
+ + handleModelChange(index, "id", e.target.value) + } + placeholder={t("openclaw.modelIdPlaceholder", { + defaultValue: "claude-3-sonnet", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + + + + + {Object.entries( + fetchedModels.reduce( + (acc, m) => { + const v = m.ownedBy || "Other"; + if (!acc[v]) acc[v] = []; + acc[v].push(m); + return acc; + }, + {} as Record, + ), + ) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([vendor, vModels], vi) => ( +
+ {vi > 0 && } + + {vendor} + + {vModels.map((m) => ( + + handleModelChange(index, "id", m.id) + } + > + {m.id} + + ))} +
+ ))} +
+
+ )} +
{Object.keys(models).length === 0 ? ( @@ -600,13 +704,21 @@ export function OpenCodeFormFields({ )} /> - handleModelIdChange(key, newId)} - placeholder={t("opencode.modelId", { - defaultValue: "Model ID", - })} - /> +
+ handleModelIdChange(key, newId)} + placeholder={t("opencode.modelId", { + defaultValue: "Model ID", + })} + /> + {fetchedModels.length > 0 && ( + handleModelIdChange(key, id)} + /> + )} +
handleModelNameChange(key, e.target.value)} diff --git a/src/components/providers/forms/shared/ModelInputWithFetch.tsx b/src/components/providers/forms/shared/ModelInputWithFetch.tsx new file mode 100644 index 000000000..2cba10d16 --- /dev/null +++ b/src/components/providers/forms/shared/ModelInputWithFetch.tsx @@ -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 = {}; + 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 ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + + + + + + {vendors.map((vendor, vi) => ( +
+ {vi > 0 && } + {vendor} + {grouped[vendor].map((model) => ( + onChange(model.id)} + > + {model.id} + + ))} +
+ ))} +
+
+
+ ); + } + + // 加载中: Input + Spinner + if (isLoading) { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + +
+ ); + } + + // 有 onFetch: Input + 获取按钮 + if (onFetch) { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + +
+ ); + } + + // 无 onFetch: 纯 Input + return ( + onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + /> + ); +} diff --git a/src/components/providers/forms/shared/index.ts b/src/components/providers/forms/shared/index.ts index f9b85fb3a..e5be59876 100644 --- a/src/components/providers/forms/shared/index.ts +++ b/src/components/providers/forms/shared/index.ts @@ -1,2 +1,3 @@ export { ApiKeySection } from "./ApiKeySection"; export { EndpointField } from "./EndpointField"; +export { ModelInputWithFetch } from "./ModelInputWithFetch"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 228b474e3..ccd174bd5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bf3189a0c..d59da46bf 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -820,7 +820,12 @@ "retry": "再試行", "copyCode": "コードをコピー", "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", - "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" + "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました", + "fetchModels": "モデル一覧を取得", + "fetchingModels": "取得中...", + "fetchModelsSuccess": "{{count}}件のモデルを取得", + "fetchModelsFailed": "モデル一覧の取得に失敗しました", + "fetchModelsEmpty": "モデルが見つかりません" }, "endpointTest": { "title": "API エンドポイント管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 88b6ce9ad..596b405a9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -820,7 +820,12 @@ "retry": "重试", "copyCode": "复制代码", "migrationFailed": "旧认证数据迁移失败:{{error}}", - "loadModelsFailed": "加载 Copilot 模型列表失败" + "loadModelsFailed": "加载 Copilot 模型列表失败", + "fetchModels": "获取模型列表", + "fetchingModels": "正在获取...", + "fetchModelsSuccess": "获取到 {{count}} 个模型", + "fetchModelsFailed": "获取模型列表失败", + "fetchModelsEmpty": "未找到可用模型" }, "endpointTest": { "title": "请求地址管理", diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts new file mode 100644 index 000000000..c63add582 --- /dev/null +++ b/src/lib/api/model-fetch.ts @@ -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 { + return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl }); +}