mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -820,7 +820,12 @@
|
||||
"retry": "再試行",
|
||||
"copyCode": "コードをコピー",
|
||||
"migrationFailed": "旧認証データの移行に失敗しました: {{error}}",
|
||||
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました"
|
||||
"loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました",
|
||||
"fetchModels": "モデル一覧を取得",
|
||||
"fetchingModels": "取得中...",
|
||||
"fetchModelsSuccess": "{{count}}件のモデルを取得",
|
||||
"fetchModelsFailed": "モデル一覧の取得に失敗しました",
|
||||
"fetchModelsEmpty": "モデルが見つかりません"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "API エンドポイント管理",
|
||||
|
||||
@@ -820,7 +820,12 @@
|
||||
"retry": "重试",
|
||||
"copyCode": "复制代码",
|
||||
"migrationFailed": "旧认证数据迁移失败:{{error}}",
|
||||
"loadModelsFailed": "加载 Copilot 模型列表失败"
|
||||
"loadModelsFailed": "加载 Copilot 模型列表失败",
|
||||
"fetchModels": "获取模型列表",
|
||||
"fetchingModels": "正在获取...",
|
||||
"fetchModelsSuccess": "获取到 {{count}} 个模型",
|
||||
"fetchModelsFailed": "获取模型列表失败",
|
||||
"fetchModelsEmpty": "未找到可用模型"
|
||||
},
|
||||
"endpointTest": {
|
||||
"title": "请求地址管理",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user