From 697bcf8e0f5eaffc195d03f668907e26ad4faa84 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Mon, 3 Aug 2026 12:56:58 +0000 Subject: [PATCH] fix(pi): preserve exact native model identifiers --- src-tauri/src/pi_config/native_settings.rs | 29 ++++++++++++++++++- .../providers/forms/PiProviderForm.tsx | 7 +++-- tests/components/PiProviderForm.test.tsx | 25 ++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/pi_config/native_settings.rs b/src-tauri/src/pi_config/native_settings.rs index 91b9a4644..7b0a96a07 100644 --- a/src-tauri/src/pi_config/native_settings.rs +++ b/src-tauri/src/pi_config/native_settings.rs @@ -100,7 +100,9 @@ pub(crate) fn set_pi_native_default_with_receipt( provider_key: &str, model_id: &str, ) -> Result { - if provider_key.trim().is_empty() || model_id.trim().is_empty() { + // Native model identifiers are opaque exact strings under pinned Pi's + // schema. Do not trim a schema-valid whitespace or edge-whitespace ID. + if provider_key.trim().is_empty() || model_id.is_empty() { return Err(AppError::InvalidInput( "Pi default provider and model must be non-empty".to_string(), )); @@ -201,6 +203,31 @@ mod tests { use super::*; use serde_json::json; + #[test] + #[serial_test::serial] + fn native_default_preserves_a_pinned_exact_whitespace_model_id() { + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + + set_pi_native_default_with_receipt("provider", " ") + .expect("pinned exact model id is accepted"); + let defaults = read_pi_native_defaults().expect("native defaults"); + assert_eq!(defaults.default_provider.as_deref(), Some("provider")); + assert_eq!(defaults.default_model.as_deref(), Some(" ")); + assert!(set_pi_native_default_with_receipt("provider", "").is_err()); + } + #[test] fn default_patch_preserves_every_unowned_field() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/components/providers/forms/PiProviderForm.tsx b/src/components/providers/forms/PiProviderForm.tsx index c39d1efe7..e1447a634 100644 --- a/src/components/providers/forms/PiProviderForm.tsx +++ b/src/components/providers/forms/PiProviderForm.tsx @@ -220,10 +220,13 @@ export function PiProviderForm({ ); const seen = new Set(); const normalizedModels = models.map((model, index) => { - const id = model.id.trim(); + // Pinned Pi treats model IDs as opaque, exact strings. In particular, + // its schema accepts whitespace-only and edge-whitespace IDs; trimming + // here would silently rename an imported model. + const id = model.id; const modelApi = model.api.trim(); const modelBaseUrl = model.baseUrl.trim(); - if (!id) { + if (id.length === 0) { throw new Error(t("pi.form.modelIdRequired", { index: index + 1 })); } if (seen.has(id)) { diff --git a/tests/components/PiProviderForm.test.tsx b/tests/components/PiProviderForm.test.tsx index 573087b62..d22df207d 100644 --- a/tests/components/PiProviderForm.test.tsx +++ b/tests/components/PiProviderForm.test.tsx @@ -80,6 +80,31 @@ describe("PiProviderForm", () => { expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input); }); + it("preserves pinned exact model IDs instead of trimming them", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const input = { + name: "Exact IDs", + api: "openai-responses", + baseUrl: "https://api.example.com/v1", + models: [{ id: " " }, { id: " model " }], + }; + + render( + {}} + initialData={{ name: input.name, settingsConfig: input }} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Save exact IDs" })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input); + }); + it("preserves an explicitly false authHeader instead of erasing it", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const input = {