feat(providers): add a search button to the provider page header

Provider search could only be opened with Cmd/Ctrl+F, so users with many
providers did not know it existed.

- Add a search button next to "Add provider" in the page header. It
  toggles the existing search panel; the panel closes when switching apps
  or leaving the provider page.
- Match the API address as well as name, notes, and website. The address
  is read from every app's settingsConfig shape by a new shared
  extractProviderBaseUrl, which ProviderCard now also uses.
- Only filter while the panel is open. Closing it with Esc used to leave
  the list silently filtered.
- Portal the search panel to document.body. As a child of the list's
  space-y container it counted as a sibling, so opening it pushed every
  provider card down by 1rem.
- Add a "Show provider search" setting (on by default, device-local).
  Hiding the button keeps the Cmd/Ctrl+F shortcut.
This commit is contained in:
Jason committed 2026-10-09 21:50:56 +08:00
1 parent dc93c4962d
commit c5efe0baaa
14 files changed
+319 -116

No files matched your search

+5
View File
@@ -394,6 +394,10 @@ pub struct AppSettings {
/// Whether to show the project profile switcher on the main page header
#[serde(default)]
pub show_profile_switcher: bool,
/// Show the provider search button in the provider page header (on by default;
/// Cmd/Ctrl+F still opens the search when it is hidden)
#[serde(default = "default_true")]
pub show_provider_search: bool,
/// Check installed CLI tools for new versions at startup (off by default:
/// many users do not want to chase every release).
#[serde(default)]
@@ -554,6 +558,7 @@ impl Default for AppSettings {
session_auto_sync_enabled: true,
enable_failover_toggle: false,
show_profile_switcher: false,
show_provider_search: true,
check_tool_updates_on_startup: false,
preserve_codex_official_auth_on_switch: false,
unify_codex_session_history: false,
+34 -2
View File
@@ -10,7 +10,13 @@ import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import { ExternalLink, KeyRound, MoreHorizontal, Plus } from "lucide-react";
import {
ExternalLink,
KeyRound,
MoreHorizontal,
Plus,
Search,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
import { KNOWN_APP_TYPES, type AppTypeFilter } from "@/types/usage";
@@ -48,7 +54,7 @@ import {
} from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { deepClone } from "@/utils/deepClone";
import { isLinux, isWindows } from "@/lib/platform";
import { isLinux, isMac, isWindows } from "@/lib/platform";
import {
APP_STORAGE_KEY,
appPageBelongsTo,
@@ -166,6 +172,11 @@ function App() {
PROMPT_APP_IDS.includes(sharedFeatureApp) ? sharedFeatureApp : "claude",
);
const [isAddOpen, setIsAddOpen] = useState(false);
// 供应商搜索面板:页头按钮和 ⌘F 都能打开;换应用或离开供应商页就收起
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
useEffect(() => {
setProviderSearchOpen(false);
}, [activeApp, currentView]);
// 供应商页顶部正在看的那格(直连 / 路由 / 聚合),由 SwitchModePanel 报上来。打开新增、
// 编辑时记下当时那格,表单按它选布局:在聚合那格打开就是聚合的简化表单。
const [providerModeView, setProviderModeView] = useState<{
@@ -1174,6 +1185,25 @@ function App() {
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
{currentView === "providers" &&
(settingsData?.showProviderSearch ?? true) && (
<HoverTip
content={t("provider.searchButtonTip", {
shortcut: isMac() ? "⌘F" : "Ctrl+F",
})}
>
<Button
variant="quiet"
size="icon-compact"
className="h-8 w-8"
aria-label={t("provider.searchAriaLabel")}
aria-pressed={providerSearchOpen}
onClick={() => setProviderSearchOpen((open) => !open)}
>
<Search className="h-4 w-4" />
</Button>
</HoverTip>
)}
{currentView === "providers" && (
<Button
variant="solid"
@@ -1242,6 +1272,8 @@ function App() {
onOpenWebsite: handleOpenWebsite,
onOpenTerminal: activeApp === "claude" ? handleOpenTerminal : undefined,
onCreate: () => openAddProvider(currentModeView),
searchOpen: providerSearchOpen,
onSearchOpenChange: setProviderSearchOpen,
};
const renderProviderList = () => {
+2 -38
View File
@@ -24,8 +24,8 @@ import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
import XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter";
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractProviderBaseUrl,
} from "@/utils/providerConfigUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { resolveCodexOfficialIdentity } from "@/utils/providerCapabilities";
@@ -155,43 +155,7 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => {
return provider.websiteUrl;
}
const config = provider.settingsConfig;
if (config && typeof config === "object") {
const object = config as Record<string, any>;
const envBase =
object?.env?.ANTHROPIC_BASE_URL || object?.env?.GOOGLE_GEMINI_BASE_URL;
if (typeof envBase === "string" && envBase.trim()) {
return envBase;
}
const directBaseUrl =
object.baseUrl ||
object.base_url ||
object.options?.baseURL ||
(Array.isArray(object.models)
? object.models.find(
(model: unknown) =>
model &&
typeof model === "object" &&
typeof (model as Record<string, unknown>).baseUrl === "string",
)?.baseUrl
: undefined);
if (typeof directBaseUrl === "string" && directBaseUrl.trim()) {
return directBaseUrl;
}
const baseUrl = object.config;
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
const extractedBaseUrl = extractCodexBaseUrl(baseUrl);
if (extractedBaseUrl) {
return extractedBaseUrl;
}
}
}
return fallbackText;
return extractProviderBaseUrl(provider.settingsConfig) ?? fallbackText;
};
export function ProviderCard({
+98 -68
View File
@@ -13,6 +13,7 @@ import {
useState,
type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -43,6 +44,7 @@ import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Notice } from "@/components/ui/notice";
import { isTextEditableTarget } from "@/utils/domUtils";
import { extractProviderBaseUrl } from "@/utils/providerConfigUtils";
import { usePiCurrentState } from "@/lib/query/pi";
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
import {
@@ -78,6 +80,9 @@ interface ProviderListProps {
/** 切换式应用必传:按模式 tab 算卡片 */
switchMode?: SwitchModeProps;
isLoading?: boolean;
/** 搜索面板开关由页头按钮控制时传入;不传则列表自己管(只能 ⌘F 打开) */
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
}
export function ProviderList({
@@ -98,6 +103,8 @@ export function ProviderList({
onSetAsDefault,
switchMode,
isLoading = false,
searchOpen,
onSearchOpenChange,
}: ProviderListProps) {
const { t } = useTranslation();
const { checkProvider, isChecking } = useStreamCheck(appId);
@@ -216,7 +223,9 @@ export function ProviderList({
});
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [innerSearchOpen, setInnerSearchOpen] = useState(false);
const isSearchOpen = searchOpen ?? innerSearchOpen;
const setIsSearchOpen = onSearchOpenChange ?? setInnerSearchOpen;
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -240,7 +249,7 @@ export function ProviderList({
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, []);
}, [setIsSearchOpen]);
useEffect(() => {
if (isSearchOpen) {
@@ -252,16 +261,32 @@ export function ProviderList({
}
}, [isSearchOpen]);
// 每家可被搜到的文字:名称、备注、官网、请求地址(请求地址要解析配置,只随列表变化重算)
const searchIndex = useMemo(
() =>
sortedProviders.map((provider) => ({
provider,
text: [
provider.name,
provider.notes,
provider.websiteUrl,
extractProviderBaseUrl(provider.settingsConfig),
]
.filter(Boolean)
.join("\n")
.toLowerCase(),
})),
[sortedProviders],
);
// 关掉面板就不再过滤:搜索词留着,下次打开还在,但列表不会悄悄少几家
const filteredProviders = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
const keyword = isSearchOpen ? searchTerm.trim().toLowerCase() : "";
if (!keyword) return sortedProviders;
return sortedProviders.filter((provider) => {
const fields = [provider.name, provider.notes, provider.websiteUrl];
return fields.some((field) =>
field?.toString().toLowerCase().includes(keyword),
);
});
}, [searchTerm, sortedProviders]);
return searchIndex
.filter((entry) => entry.text.includes(keyword))
.map((entry) => entry.provider);
}, [isSearchOpen, searchTerm, searchIndex, sortedProviders]);
const sections = useMemo<ProviderSection[]>(() => {
if (switchMode) {
@@ -427,69 +452,74 @@ export function ProviderList({
return (
<div className="space-y-4">
{piStateErrorNotice}
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed end-6 top-[6.5rem] z-40 w-[min(90vw,26rem)]"
>
<div className="space-y-3 rounded-panel border border-border bg-surface p-4 shadow-v7-lg">
<div className="relative flex items-center gap-2">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t("provider.searchPlaceholder", {
defaultValue: "Search name, notes, or URL...",
})}
aria-label={t("provider.searchAriaLabel", {
defaultValue: "Search providers",
})}
className="pe-16 ps-9"
/>
{searchTerm && (
{/* 面板挂到 body:留在 space-y 容器里会被算作兄弟元素,下面的列表多出 margin-top 往下挤 */}
{createPortal(
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed end-6 top-[6.5rem] z-40 w-[min(90vw,26rem)]"
>
<div className="space-y-3 rounded-panel border border-border bg-surface p-4 shadow-v7-lg">
<div className="relative flex items-center gap-2">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t("provider.searchPlaceholder", {
defaultValue: "Search name, notes, or URL...",
})}
aria-label={t("provider.searchAriaLabel", {
defaultValue: "Search providers",
})}
className="pe-16 ps-9"
/>
{searchTerm && (
<Button
variant="quiet"
size="compact"
className="absolute end-11 top-1/2 -translate-y-1/2"
onClick={() => setSearchTerm("")}
>
{t("common.clear", { defaultValue: "Clear" })}
</Button>
)}
<Button
variant="quiet"
size="compact"
className="absolute end-11 top-1/2 -translate-y-1/2"
onClick={() => setSearchTerm("")}
size="icon-compact"
className="ms-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
{t("common.clear", { defaultValue: "Clear" })}
<X className="h-4 w-4" />
</Button>
)}
<Button
variant="quiet"
size="icon-compact"
className="ms-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-caption text-fg-3">
<span>
{t("provider.searchScopeHint", {
defaultValue:
"Matches provider name, notes, website, and API address.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-caption text-fg-3">
<span>
{t("provider.searchScopeHint", {
defaultValue: "Matches provider name, notes, and URL.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
{filteredProviders.length === 0 ? (
<div className="rounded-panel border border-dashed border-border px-6 py-8 text-center text-body text-fg-2">
@@ -48,6 +48,8 @@ type ListCallbacks = Pick<
| "onOpenWebsite"
| "onOpenTerminal"
| "onCreate"
| "searchOpen"
| "onSearchOpenChange"
>;
interface SwitchModePanelProps extends ListCallbacks {
@@ -125,6 +125,17 @@ export function GeneralSection({
void onAutoSave({ showProfileSwitcher: value })
}
/>
<SettingsSwitchRow
label={t("settings.appVisibility.showProviderSearch")}
help={{
title: t("settings.appVisibility.showProviderSearch"),
body: t("settings.appVisibility.showProviderSearchDescription"),
}}
checked={settings.showProviderSearch ?? true}
onCheckedChange={(value) =>
void onAutoSave({ showProviderSearch: value })
}
/>
</SettingsCard>
</SettingsBlock>
+5 -2
View File
@@ -158,7 +158,8 @@
"dragHandle": "Drag to reorder",
"searchPlaceholder": "Search name, notes, or URL...",
"searchAriaLabel": "Search providers",
"searchScopeHint": "Matches provider name, notes, and URL.",
"searchScopeHint": "Matches provider name, notes, website, and API address.",
"searchButtonTip": "Search providers ({{shortcut}})",
"searchCloseHint": "Press Esc to close",
"searchCloseAriaLabel": "Close provider search",
"noSearchResults": "No providers match your search.",
@@ -827,7 +828,9 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI",
"showProfileSwitcher": "Show project switcher",
"showProfileSwitcherDescription": "Show the project switcher in the main page header"
"showProfileSwitcherDescription": "Show the project switcher in the main page header",
"showProviderSearch": "Show provider search",
"showProviderSearchDescription": "Show a search button at the top of the provider page to find providers by name, notes, website, or API address. When hidden, ⌘F / Ctrl+F still opens search"
},
"terminal": {
"title": "Preferred Terminal",
+5 -2
View File
@@ -158,7 +158,8 @@
"dragHandle": "ドラッグで並べ替え",
"searchPlaceholder": "名前・メモ・URLで検索...",
"searchAriaLabel": "プロバイダーを検索",
"searchScopeHint": "名前・メモ・URL を対象に検索します。",
"searchScopeHint": "名前・メモ・公式サイト・API アドレスを対象に検索します。",
"searchButtonTip": "プロバイダーを検索({{shortcut}})",
"searchCloseHint": "Esc で閉じる",
"searchCloseAriaLabel": "検索を閉じる",
"noSearchResults": "一致するプロバイダーがありません。",
@@ -827,7 +828,9 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI",
"showProfileSwitcher": "プロジェクト切り替えを表示",
"showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します"
"showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します",
"showProviderSearch": "プロバイダー検索を表示",
"showProviderSearchDescription": "プロバイダー画面の上部に検索ボタンを表示し、名前・メモ・公式サイト・API アドレスで検索できます。非表示にしても ⌘F / Ctrl+F で検索を開けます"
},
"terminal": {
"title": "優先ターミナル",
+5 -2
View File
@@ -158,7 +158,8 @@
"dragHandle": "拖曳排序",
"searchPlaceholder": "依名稱/備註/網址搜尋供應商...",
"searchAriaLabel": "搜尋供應商",
"searchScopeHint": "根據名稱、備註和官網連結比對結果。",
"searchScopeHint": "根據名稱、備註、官網和請求位址比對結果。",
"searchButtonTip": "搜尋供應商({{shortcut}})",
"searchCloseHint": "按 Esc 關閉",
"searchCloseAriaLabel": "關閉供應商搜尋",
"noSearchResults": "沒有符合搜尋條件的供應商。",
@@ -827,7 +828,9 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI",
"showProfileSwitcher": "顯示專案切換",
"showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口"
"showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口",
"showProviderSearch": "顯示供應商搜尋",
"showProviderSearchDescription": "在供應商頁頂部顯示搜尋按鈕,可依名稱、備註、官網和請求位址查找。關閉後仍可用 ⌘F / Ctrl+F 開啟搜尋"
},
"terminal": {
"title": "首選終端機",
+5 -2
View File
@@ -158,7 +158,8 @@
"dragHandle": "拖拽排序",
"searchPlaceholder": "按名称/备注/网址搜索供应商...",
"searchAriaLabel": "搜索供应商",
"searchScopeHint": "根据名称、备注和官网链接匹配结果。",
"searchScopeHint": "根据名称、备注、官网和请求地址匹配结果。",
"searchButtonTip": "搜索供应商({{shortcut}})",
"searchCloseHint": "按 Esc 关闭",
"searchCloseAriaLabel": "关闭供应商搜索",
"noSearchResults": "没有符合搜索条件的供应商。",
@@ -827,7 +828,9 @@
"geminiDesc": "Google Gemini CLI",
"opencodeDesc": "OpenCode CLI",
"showProfileSwitcher": "显示项目切换",
"showProfileSwitcherDescription": "在主页面顶部显示项目切换入口"
"showProfileSwitcherDescription": "在主页面顶部显示项目切换入口",
"showProviderSearch": "显示供应商搜索",
"showProviderSearchDescription": "在供应商页顶部显示搜索按钮,可按名称、备注、官网和请求地址查找。关掉后仍可用 ⌘F / Ctrl+F 打开搜索"
},
"terminal": {
"title": "首选终端",
+2
View File
@@ -401,6 +401,8 @@ export interface Settings {
enableFailoverToggle?: boolean;
// Whether to show the project profile switcher on the main page header
showProfileSwitcher?: boolean;
// 供应商页页头显示搜索按钮(默认开;关掉后 ⌘F 仍可用)
showProviderSearch?: boolean;
// 启动时检查已安装的命令行应用有没有新版本(默认关)
checkToolUpdatesOnStartup?: boolean;
// Preserve Codex ChatGPT login in auth.json when switching third-party providers
+37
View File
@@ -1057,6 +1057,43 @@ export const getCodexBaseUrl = (
}
};
// 从任一应用的 settingsConfig 中取出请求地址:各应用存法不同(env 变量 / baseUrl /
// base_url / options.baseURL / models[].baseUrl / Codex 风格 TOML),按已知形态依次找
export const extractProviderBaseUrl = (
settingsConfig: unknown,
): string | undefined => {
if (!settingsConfig || typeof settingsConfig !== "object") return undefined;
const object = settingsConfig as Record<string, any>;
const envBase =
object.env?.ANTHROPIC_BASE_URL || object.env?.GOOGLE_GEMINI_BASE_URL;
if (typeof envBase === "string" && envBase.trim()) {
return envBase;
}
const directBaseUrl =
object.baseUrl ||
object.base_url ||
object.options?.baseURL ||
(Array.isArray(object.models)
? object.models.find(
(model: unknown) =>
model &&
typeof model === "object" &&
typeof (model as Record<string, unknown>).baseUrl === "string",
)?.baseUrl
: undefined);
if (typeof directBaseUrl === "string" && directBaseUrl.trim()) {
return directBaseUrl;
}
if (typeof object.config === "string" && object.config.includes("base_url")) {
return extractCodexBaseUrl(object.config) || undefined;
}
return undefined;
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
+55
View File
@@ -385,6 +385,61 @@ describe("ProviderList Component", () => {
).toBeInTheDocument();
});
it("matches the API address and stops filtering once the panel closes", () => {
const relay = createProvider({
id: "relay",
name: "My Relay",
settingsConfig: {
env: { ANTHROPIC_BASE_URL: "https://api.relay-example.com" },
},
});
const other = createProvider({ id: "other", name: "Other" });
useDragSortMock.mockReturnValue({
sortedProviders: [relay, other],
sensors: [],
handleDragEnd: vi.fn(),
});
const props = {
providers: { relay, other },
currentProviderId: "",
appId: "claude" as const,
onSwitch: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onDuplicate: vi.fn(),
onOpenWebsite: vi.fn(),
};
const onSearchOpenChange = vi.fn();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const ui = (searchOpen: boolean) => (
<QueryClientProvider client={queryClient}>
<ProviderList
{...props}
searchOpen={searchOpen}
onSearchOpenChange={onSearchOpenChange}
/>
</QueryClientProvider>
);
const { rerender } = render(ui(true));
fireEvent.change(
screen.getByPlaceholderText("Search name, notes, or URL..."),
{ target: { value: "relay-example" } },
);
expect(screen.getByTestId("provider-card-relay")).toBeInTheDocument();
expect(screen.queryByTestId("provider-card-other")).not.toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(onSearchOpenChange).toHaveBeenCalledWith(false);
rerender(ui(false));
expect(screen.getByTestId("provider-card-other")).toBeInTheDocument();
});
it("does not manufacture a Pi selection summary card", async () => {
server.use(
http.post(`${TAURI_ENDPOINT}/get_pi_current_state`, () =>
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { extractProviderBaseUrl } from "@/utils/providerConfigUtils";
describe("extractProviderBaseUrl", () => {
it.each([
[
"claude / claude-desktop",
{ env: { ANTHROPIC_BASE_URL: "https://claude.example.com" } },
"https://claude.example.com",
],
[
"gemini",
{ env: { GOOGLE_GEMINI_BASE_URL: "https://gemini.example.com" } },
"https://gemini.example.com",
],
[
"codex / grokbuild",
{
auth: {},
config: [
'model_provider = "custom"',
"",
"[model_providers.custom]",
'base_url = "https://codex.example.com/v1"',
].join("\n"),
},
"https://codex.example.com/v1",
],
[
"opencode / mcode",
{ options: { baseURL: "https://opencode.example.com/v1" } },
"https://opencode.example.com/v1",
],
[
"openclaw / pi",
{ baseUrl: "https://openclaw.example.com/v1" },
"https://openclaw.example.com/v1",
],
[
"hermes",
{ base_url: "https://hermes.example.com/v1" },
"https://hermes.example.com/v1",
],
])("reads the %s shape", (_app, settingsConfig, expected) => {
expect(extractProviderBaseUrl(settingsConfig)).toBe(expected);
});
it("returns undefined for official providers without an address", () => {
expect(extractProviderBaseUrl({ env: {} })).toBeUndefined();
expect(extractProviderBaseUrl({ auth: {}, config: "" })).toBeUndefined();
expect(extractProviderBaseUrl(undefined)).toBeUndefined();
});
});