feat(pi): move rarely used provider settings into a collapsed advanced section

Request headers, omp model discovery and compat options now sit in a
collapsed "Advanced Options" section below the model list, matching the
Claude Code and Codex forms. It stays collapsed even when a preset fills
compat values.

The compat and discovery hints are rewritten in plain language, and the
discovery types get readable labels instead of raw omp identifiers. The
discovery select ignores an empty value, which Radix can report when it
mounts inside a form.
This commit is contained in:
Jason committed 2026-10-24 22:56:15 +08:00
1 parent f67cb33dda
commit 5f6426400a
7 files changed
+192 -85

No files matched your search

+100 -64
View File
@@ -15,6 +15,11 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Popover,
PopoverAnchor,
@@ -109,15 +114,17 @@ const OMP_ROOT_CONTROLLED_KEYS = new Set(
);
// omp 的 `discovery.type`(docs/models.md):运行时从端点拉模型列表,可以不写 models。
// proxy 每个模型自己判断接口格式,其余类型要求供应商级 api(后端校验)。
const OMP_DISCOVERY_TYPES = [
"openai-models-list",
"proxy",
"ollama",
"llama.cpp",
"lm-studio",
"litellm",
"apple-foundation-models",
];
// 值里有点号,不能直接当 i18n key,所以另配一个 key。
const OMP_DISCOVERY_TYPES: ReadonlyArray<{ value: string; labelKey: string }> =
[
{ value: "openai-models-list", labelKey: "openaiModelsList" },
{ value: "proxy", labelKey: "proxy" },
{ value: "ollama", labelKey: "ollama" },
{ value: "llama.cpp", labelKey: "llamaCpp" },
{ value: "lm-studio", labelKey: "lmStudio" },
{ value: "litellm", labelKey: "litellm" },
{ value: "apple-foundation-models", labelKey: "appleFoundationModels" },
];
const OMP_DISCOVERY_OFF = "__off__";
const MODEL_CONTROLLED_KEYS = new Set([
"id",
@@ -593,6 +600,8 @@ export function PiProviderForm({
>(() => objectWithout(initialConfig, rootControlledKeys));
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// 请求头、自动发现和兼容性很少需要动,默认收起;预设填了值也不自动展开。
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const modelFetchGenerationRef = useRef(0);
const [formError, setFormError] = useState<string | null>(null);
const initialModels = useMemo<PiModelDraft[]>(() => {
@@ -1221,6 +1230,8 @@ export function PiProviderForm({
);
const handleOmpDiscoveryChange = useCallback(
(value: string) => {
// 下拉在 <form> 里随折叠区挂载,Radix 可能回报一次空串,不能当成「关闭」
if (!value) return;
const next = { ...providerPassthrough };
const { type: _previous, injectV1, ...extra } = asObject(next.discovery);
if (value === "openai-models-list" && injectV1 !== undefined) {
@@ -1654,41 +1665,6 @@ export function PiProviderForm({
</Select>
</Field>
{isOmp && (
<Field
label={t("omp.form.discoveryType")}
htmlFor="omp-discovery-type"
>
<Select
value={ompDiscoveryType || OMP_DISCOVERY_OFF}
onValueChange={handleOmpDiscoveryChange}
>
<SelectTrigger id="omp-discovery-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={OMP_DISCOVERY_OFF}>
{t("omp.form.discoveryOff")}
</SelectItem>
{OMP_DISCOVERY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
{ompDiscoveryType &&
!OMP_DISCOVERY_TYPES.includes(ompDiscoveryType) && (
<SelectItem value={ompDiscoveryType}>
{ompDiscoveryType}
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("omp.form.discoveryHint")}
</p>
</Field>
)}
<ApiKeySection
id="pi-api-key"
label={t("pi.form.credential")}
@@ -1711,26 +1687,6 @@ export function PiProviderForm({
/>
</div>
<RequestHeadersEditor
headers={providerHeaders}
onHeadersChange={handleProviderHeadersChange}
/>
<StructuredOptionsEditor
id="pi-provider-compat"
title={t("pi.form.compatibility")}
hint={t("pi.form.compatibilityHint")}
addLabel={t("pi.form.addCompatibilityOption")}
emptyLabel={t("pi.form.noCompatibilityOptions")}
keyLabel={t("pi.form.optionKey")}
valueLabel={t("pi.form.optionValue")}
keyPlaceholder="supportsDeveloperRole"
valuePlaceholder="false"
removeLabel={t("pi.form.removeCompatibilityOption")}
options={providerCompat}
onOptionsChange={handleProviderCompatChange}
/>
<div
id="pi-models-section"
tabIndex={-1}
@@ -2209,6 +2165,86 @@ export function PiProviderForm({
</div>
)}
</div>
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
className="rounded-lg border border-border p-4"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-fg-1 hover:opacity-70 active:scale-100"
>
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("providerForm.advancedOptionsToggle")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-6 pt-3">
<RequestHeadersEditor
headers={providerHeaders}
onHeadersChange={handleProviderHeadersChange}
/>
{isOmp && (
<Field
label={t("omp.form.discoveryType")}
htmlFor="omp-discovery-type"
>
<Select
value={ompDiscoveryType || OMP_DISCOVERY_OFF}
onValueChange={handleOmpDiscoveryChange}
>
<SelectTrigger id="omp-discovery-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={OMP_DISCOVERY_OFF}>
{t("omp.form.discoveryOff")}
</SelectItem>
{OMP_DISCOVERY_TYPES.map(({ value, labelKey }) => (
<SelectItem key={value} value={value}>
{t(`omp.form.discoveryTypes.${labelKey}`)}
</SelectItem>
))}
{ompDiscoveryType &&
!OMP_DISCOVERY_TYPES.some(
({ value }) => value === ompDiscoveryType,
) && (
<SelectItem value={ompDiscoveryType}>
{ompDiscoveryType}
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("omp.form.discoveryHint")}
</p>
</Field>
)}
<StructuredOptionsEditor
id="pi-provider-compat"
title={t("pi.form.compatibility")}
hint={t("pi.form.compatibilityHint")}
addLabel={t("pi.form.addCompatibilityOption")}
emptyLabel={t("pi.form.noCompatibilityOptions")}
keyLabel={t("pi.form.optionKey")}
valueLabel={t("pi.form.optionValue")}
keyPlaceholder="supportsDeveloperRole"
valuePlaceholder="false"
removeLabel={t("pi.form.removeCompatibilityOption")}
options={providerCompat}
onOptionsChange={handleProviderCompatChange}
/>
</CollapsibleContent>
</Collapsible>
</fieldset>
)}
+12 -3
View File
@@ -1164,9 +1164,9 @@
"contextWindow": "Context window",
"maxTokens": "Max output tokens",
"compatibility": "Compatibility",
"compatibilityHint": "Adjust request behavior for compatible or local endpoints.",
"compatibilityHint": "Only needed when an endpoint claims to be OpenAI-compatible but rejects some requests. Each option describes one quirk of the endpoint: for example, supportsDeveloperRole = false means it doesn't accept the developer role, and maxTokensField = max_tokens means it only accepts the older field name. Presets fill in what they need; leave it empty to use the defaults.",
"addCompatibilityOption": "Add",
"noCompatibilityOptions": "No compatibility options configured",
"noCompatibilityOptions": "Not set — defaults apply",
"optionKey": "Option",
"optionValue": "Value",
"removeCompatibilityOption": "Remove compatibility option",
@@ -5004,7 +5004,16 @@
"form": {
"discoveryType": "Model discovery",
"discoveryOff": "Off",
"discoveryHint": "Oh My Pi periodically fetches the model list from the endpoint and applies metadata such as the context window. Manually added models still apply, and with discovery on the model list may be left empty."
"discoveryHint": "When on, Oh My Pi asks the endpoint which models it offers, so you don't have to list them by hand and new models show up on their own. Most online services use “OpenAI model list”; for models running on your machine, pick the matching app. Anthropic-format endpoints usually have no model list, so leave this off. Manually added models still apply.",
"discoveryTypes": {
"openaiModelsList": "OpenAI model list (most online services)",
"proxy": "Proxy (each model sets its own format)",
"ollama": "Ollama (local)",
"llamaCpp": "llama.cpp (local)",
"lmStudio": "LM Studio (local)",
"litellm": "LiteLLM gateway",
"appleFoundationModels": "Apple on-device models (macOS)"
}
},
"errors": {
"fileTooLarge": "Oh My Pi configuration exceeds the 1 MiB limit.",
+12 -3
View File
@@ -1164,9 +1164,9 @@
"contextWindow": "コンテキストウィンドウ",
"maxTokens": "最大出力トークン数",
"compatibility": "互換性",
"compatibilityHint": "互換エンドポイントやローカル環境向けにリクエスト動作を調整します。",
"compatibilityHint": "OpenAI 互換をうたうエンドポイントが一部のリクエストを拒否する場合にだけ設定します。各項目はそのエンドポイントの癖を一つ表します。たとえば supportsDeveloperRole を false にすると developer ロールを受け付けないこと、maxTokensField を max_tokens にすると古いフィールド名しか受け付けないことを示します。プリセットを選ぶと必要な項目は自動で入ります。空のままならデフォルトで動作します。",
"addCompatibilityOption": "追加",
"noCompatibilityOptions": "互換性オプションはありません",
"noCompatibilityOptions": "未設定(デフォルトで動作)",
"optionKey": "オプション",
"optionValue": "値",
"removeCompatibilityOption": "互換性オプションを削除",
@@ -5004,7 +5004,16 @@
"form": {
"discoveryType": "モデルの自動検出",
"discoveryOff": "使用しない",
"discoveryHint": "Oh My Pi がエンドポイントからモデルリストを定期的に取得し、コンテキスト長などのメタデータを自動的に適用します。手動で追加したモデルも有効です。有効にするとモデルを設定しなくても構いません。"
"discoveryHint": "オンにすると、Oh My Pi がエンドポイントに利用できるモデルを問い合わせるので、モデルを一つずつ入力する必要がなく、新しいモデルも自動で表示されます。オンラインサービスの多くは「OpenAI モデル一覧」を、ローカルで動かすモデルは対応するアプリを選んでください。Anthropic 形式のエンドポイントは通常モデル一覧がないので、「使用しない」のままにします。手動で追加したモデルも有効です。",
"discoveryTypes": {
"openaiModelsList": "OpenAI モデル一覧(多くのオンラインサービス)",
"proxy": "プロキシ(モデルごとに形式を指定)",
"ollama": "Ollama(ローカル)",
"llamaCpp": "llama.cpp(ローカル)",
"lmStudio": "LM Studio(ローカル)",
"litellm": "LiteLLM ゲートウェイ",
"appleFoundationModels": "Apple オンデバイスモデル(macOS)"
}
},
"errors": {
"fileTooLarge": "Oh My Pi の設定が 1 MiB の上限を超えています。",
+3 -3
View File
@@ -1164,9 +1164,9 @@
"contextWindow": "Bağlam penceresi",
"maxTokens": "Maksimum çıkış tokenları",
"compatibility": "Uyumluluk",
"compatibilityHint": "Uyumlu veya yerel uç noktalar için istek davranışını ayarlayın.",
"compatibilityHint": "Yalnızca OpenAI uyumlu olduğunu söyleyen bir uç nokta bazı istekleri reddettiğinde gerekir. Her seçenek uç noktanın bir farkını belirtir: örneğin supportsDeveloperRole = false, developer rolünü kabul etmediği; maxTokensField = max_tokens, yalnızca eski alan adını kabul ettiği anlamına gelir. Hazır ayarlar gerekenleri kendisi doldurur; boş bırakılırsa varsayılanlar kullanılır.",
"addCompatibilityOption": "Ekle",
"noCompatibilityOptions": "Hiçbir uyumluluk seçeneği yapılandırılmadı",
"noCompatibilityOptions": "Ayarlanmadı — varsayılanlar kullanılır",
"optionKey": "Seçenek",
"optionValue": "Değer",
"removeCompatibilityOption": "Uyumluluk seçeneğini kaldır",
@@ -4992,7 +4992,7 @@
"form": {
"discoveryType": "Model keşfi",
"discoveryOff": "Kapalı",
"discoveryHint": "Oh My Pi model listesini uç noktadan düzenli olarak alır ve bağlam penceresi gibi meta verileri uygular. Elle eklenen modeller geçerliliğini korur; keşif açıkken model listesi boş bırakılabilir."
"discoveryHint": "Açıkken Oh My Pi uç noktaya hangi modelleri sunduğunu sorar; modelleri tek tek yazmanız gerekmez ve yeni modeller kendiliğinden görünür. Çevrimiçi servislerin çoğunda “OpenAI model list”, kendi bilgisayarınızda çalışan modellerde ilgili uygulamayı seçin. Anthropic biçimli uç noktalarda genellikle model listesi yoktur, kapalı bırakın. Elle eklenen modeller geçerliliğini korur."
},
"errors": {
"fileTooLarge": "Oh My Pi yapılandırması 1 MiB sınırını aşıyor.",
+12 -3
View File
@@ -1165,9 +1165,9 @@
"contextWindow": "上下文長度",
"maxTokens": "最大輸出 Token 數",
"compatibility": "介面相容性",
"compatibilityHint": "調整相容端點或本機服務的請求行為。",
"compatibilityHint": "只有在介面自稱相容 OpenAI、卻拒收某些請求時才需要填。每一項說明這家介面的一個特殊之處,例如 supportsDeveloperRole 填 false 表示它不接受 developer 角色,maxTokensField 填 max_tokens 表示它只認舊的欄位名稱。選用預設供應商時,需要的項目會自動填好;留空則採用預設行為。",
"addCompatibilityOption": "新增",
"noCompatibilityOptions": "暫無相容性選項",
"noCompatibilityOptions": "未設定,採用預設行為",
"optionKey": "選項",
"optionValue": "值",
"removeCompatibilityOption": "移除相容性選項",
@@ -5005,7 +5005,16 @@
"form": {
"discoveryType": "模型自動探索",
"discoveryOff": "不自動探索",
"discoveryHint": "由 Oh My Pi 定期從端點擷取模型列表,端點回傳的上下文長度等中繼資料會自動套用;手動新增的模型仍然生效。開啟後可以不設定模型。"
"discoveryHint": "開啟後,Oh My Pi 會自己向介面查詢有哪些模型,不必逐一手動填寫,新上架的模型也會自動出現。線上服務大多選「OpenAI 模型列表」,本機執行的模型選對應的軟體;Anthropic 格式的介面通常查不到列表,維持「不自動探索」即可。手動新增的模型仍然生效。",
"discoveryTypes": {
"openaiModelsList": "OpenAI 模型列表(大多數線上服務)",
"proxy": "代理(每個模型自帶格式)",
"ollama": "Ollama(本機)",
"llamaCpp": "llama.cpp(本機)",
"lmStudio": "LM Studio(本機)",
"litellm": "LiteLLM 閘道",
"appleFoundationModels": "Apple 裝置端模型(macOS)"
}
},
"errors": {
"fileTooLarge": "Oh My Pi 設定超過 1 MiB 大小限制。",
+12 -3
View File
@@ -1164,9 +1164,9 @@
"contextWindow": "上下文长度",
"maxTokens": "最大输出 Token 数",
"compatibility": "接口兼容性",
"compatibilityHint": "调整兼容端点或本地服务的请求行为。",
"compatibilityHint": "只在接口自称兼容 OpenAI、却拒收某些请求时才需要填。每一项说明这家接口的一个特殊之处,比如 supportsDeveloperRole 填 false 表示它不认 developer 角色,maxTokensField 填 max_tokens 表示它只认旧的字段名。选预设时需要的项会自动填好;留空就按默认处理。",
"addCompatibilityOption": "添加",
"noCompatibilityOptions": "暂无兼容性选项",
"noCompatibilityOptions": "未设置,按默认处理",
"optionKey": "选项",
"optionValue": "值",
"removeCompatibilityOption": "移除兼容性选项",
@@ -5004,7 +5004,16 @@
"form": {
"discoveryType": "模型自动发现",
"discoveryOff": "不自动发现",
"discoveryHint": "由 Oh My Pi 定期从端点拉取模型列表,端点返回的上下文长度等元数据会自动套用;手动添加的模型仍然生效。开启后可以不配置模型。"
"discoveryHint": "开启后,Oh My Pi 会自己去接口查有哪些模型,不用逐个手填,新上的模型也会自动出现。在线服务大多选「OpenAI 模型列表」,本机运行的模型选对应的软件;Anthropic 格式的接口通常查不到列表,保持「不自动发现」即可。手动添加的模型仍然生效。",
"discoveryTypes": {
"openaiModelsList": "OpenAI 模型列表(大多数在线服务)",
"proxy": "代理(每个模型自带格式)",
"ollama": "Ollama(本机)",
"llamaCpp": "llama.cpp(本机)",
"lmStudio": "LM Studio(本机)",
"litellm": "LiteLLM 网关",
"appleFoundationModels": "Apple 端侧模型(macOS)"
}
},
"errors": {
"fileTooLarge": "Oh My Pi 配置超过 1 MiB 大小限制。",
+41 -6
View File
@@ -58,6 +58,13 @@ vi.mock("@/components/JsonEditor", () => ({
),
}));
// 请求头、模型自动发现和接口兼容性收在默认折叠的「高级选项」里
function openAdvancedOptions() {
fireEvent.click(
screen.getByRole("button", { name: "providerForm.advancedOptionsToggle" }),
);
}
describe("PiProviderForm", () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
@@ -183,8 +190,9 @@ describe("PiProviderForm", () => {
const apiFormat = document.querySelector("#pi-provider-api-select");
const apiKey = screen.getByLabelText("pi.form.credential");
const baseUrl = screen.getByLabelText("Base URL");
const headers = screen.getByText("Headers");
const compatibility = screen.getByText("pi.form.compatibility");
const advanced = screen.getByRole("button", {
name: "providerForm.advancedOptionsToggle",
});
const models = screen.getByText("模型配置");
expect(apiFormat).toHaveAttribute("role", "combobox");
@@ -194,13 +202,25 @@ describe("PiProviderForm", () => {
expect(apiKey.compareDocumentPosition(baseUrl)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(baseUrl.compareDocumentPosition(headers)).toBe(
expect(baseUrl.compareDocumentPosition(models)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// 高级选项和 Claude Code、Codex 一样排在模型配置后面
expect(models.compareDocumentPosition(advanced)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// 请求头和兼容性收在高级选项里,默认不展开
expect(advanced).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Headers")).not.toBeInTheDocument();
expect(screen.queryByText("pi.form.compatibility")).not.toBeInTheDocument();
openAdvancedOptions();
const headers = screen.getByText("Headers");
const compatibility = screen.getByText("pi.form.compatibility");
expect(headers.compareDocumentPosition(compatibility)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(compatibility.compareDocumentPosition(models)).toBe(
expect(models.compareDocumentPosition(headers)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
@@ -231,6 +251,7 @@ describe("PiProviderForm", () => {
screen.getByRole("button", { name: "providerPreset.custom" }),
);
openAdvancedOptions();
expect(screen.getByText("Headers")).toBeInTheDocument();
expect(
screen.getByText("No custom headers configured"),
@@ -286,6 +307,7 @@ describe("PiProviderForm", () => {
/>,
);
openAdvancedOptions();
const compatibilitySection = document.querySelector("#pi-provider-compat");
expect(compatibilitySection).not.toBeNull();
@@ -531,6 +553,7 @@ describe("PiProviderForm", () => {
},
});
openAdvancedOptions();
await waitFor(() => {
expect(screen.getByLabelText("pi.form.credential")).toHaveValue(
"json-key",
@@ -764,6 +787,7 @@ describe("PiProviderForm", () => {
target: { value: "16384" },
});
openAdvancedOptions();
await user.click(screen.getByRole("button", { name: "Add header" }));
const headerName = screen.getByLabelText("Header");
fireEvent.change(headerName, {
@@ -807,6 +831,7 @@ describe("PiProviderForm", () => {
/>,
);
openAdvancedOptions();
expect(
document.querySelector("#pi-header-identity"),
).not.toBeInTheDocument();
@@ -889,8 +914,13 @@ describe("PiProviderForm", () => {
) as HTMLTextAreaElement;
// omp 只允许 openai-models-list 带 injectV1,其余子键照旧保留
openAdvancedOptions();
await user.click(document.querySelector("#omp-discovery-type")!);
await user.click(await screen.findByRole("option", { name: "ollama" }));
await user.click(
await screen.findByRole("option", {
name: "omp.form.discoveryTypes.ollama",
}),
);
expect(JSON.parse(configEditor.value).discovery).toEqual({
type: "ollama",
timeoutMs: 500,
@@ -921,9 +951,13 @@ describe("PiProviderForm", () => {
expect(model).not.toHaveProperty("thinkingLevelMap");
}
expect(document.querySelector("#omp-discovery-type")).toBeNull();
openAdvancedOptions();
await user.click(document.querySelector("#omp-discovery-type")!);
await user.click(
await screen.findByRole("option", { name: "openai-models-list" }),
await screen.findByRole("option", {
name: "omp.form.discoveryTypes.openaiModelsList",
}),
);
expect(JSON.parse(configEditor.value).discovery).toEqual({
type: "openai-models-list",
@@ -1139,6 +1173,7 @@ describe("PiProviderForm", () => {
fireEvent.change(screen.getByLabelText("pi.form.maxTokens"), {
target: { value: "777" },
});
openAdvancedOptions();
await user.click(screen.getByRole("button", { name: "Add header" }));
const headerName = screen.getByLabelText("Header");
fireEvent.change(headerName, { target: { value: "user-agent" } });