diff --git a/src/crates/contracts/config-contracts/src/conversion.rs b/src/crates/contracts/config-contracts/src/conversion.rs index 02e2c0b859..cd21f90caf 100644 --- a/src/crates/contracts/config-contracts/src/conversion.rs +++ b/src/crates/contracts/config-contracts/src/conversion.rs @@ -81,7 +81,11 @@ impl TryFrom for AIConfig { )); } - let custom_request_body = if let Some(body_str) = &other.custom_request_body { + let custom_request_body = if let Some(body_str) = other + .custom_request_body + .as_deref() + .filter(|body| !body.trim().is_empty()) + { match serde_json::from_str::(body_str) { Ok(value) => Some(value), Err(e) => { @@ -268,6 +272,55 @@ mod tests { assert_eq!(config.model, "test-model"); } + #[test] + fn accepts_legacy_missing_null_and_blank_custom_request_bodies() { + for body in [ + None, + Some(serde_json::Value::Null), + Some("".into()), + Some(" \n\t ".into()), + ] { + let mut payload = serde_json::to_value(base_model_config()).unwrap(); + payload + .as_object_mut() + .unwrap() + .remove("custom_request_body"); + if let Some(body) = body { + payload["custom_request_body"] = body; + } + let model: AIModelConfig = serde_json::from_value(payload).unwrap(); + let round_trip: AIModelConfig = + serde_json::from_value(serde_json::to_value(model).unwrap()).unwrap(); + let config = + AIConfig::try_from(round_trip).expect("legacy config should remain usable"); + assert_eq!(config.custom_request_body, None); + } + } + + #[test] + fn preserves_non_empty_custom_request_body_and_mode() { + let mut model = base_model_config(); + model.custom_request_body = Some(" \n{\"temperature\":0.2}\n ".to_string()); + model.custom_request_body_mode = Some("trim".to_string()); + + let config = AIConfig::try_from(model).expect("conversion should succeed"); + assert_eq!( + config.custom_request_body, + Some(serde_json::json!({"temperature": 0.2})) + ); + assert_eq!(config.custom_request_body_mode.as_deref(), Some("trim")); + } + + #[test] + fn ignores_malformed_non_empty_custom_request_body() { + let mut model = base_model_config(); + model.custom_request_body = Some("{".to_string()); + + let config = + AIConfig::try_from(model).expect("existing parse fallback should remain usable"); + assert_eq!(config.custom_request_body, None); + } + #[test] fn derives_the_largest_output_tier_within_one_quarter_of_context() { for (context_window, expected_max_tokens) in [ diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index c997f1412b..25721200bd 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -28,6 +28,7 @@ import { import { ModelDiscoveryCoordinator } from '@/infrastructure/config/components/modelDiscoveryCoordinator'; import { getModelEditorRequestSettings, + prepareModelEditorRequestSettingsForSave, updateModelEditorRequestSettings, type ModelEditorRequestSettings, } from '@/infrastructure/config/components/modelEditorRequestSettings'; @@ -1995,7 +1996,7 @@ const ModelSettingsPage: React.FC = () => { [PROVIDER_INSTANCE_METADATA_KEY]: providerInstanceId, }, reasoning: draft.reasoning, - ...draft.requestSettings, + ...prepareModelEditorRequestSettingsForSave(draft.requestSettings), auth: providerConfig.auth || { type: 'api_key' }, }; }); diff --git a/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.test.ts b/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.test.ts index d760aacdfc..a47f759f1d 100644 --- a/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.test.ts +++ b/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.test.ts @@ -1,7 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { getModelEditorRequestSettings, updateModelEditorRequestSettings } from './modelEditorRequestSettings'; +import { getModelEditorRequestSettings, prepareModelEditorRequestSettingsForSave, updateModelEditorRequestSettings } from './modelEditorRequestSettings'; describe('model editor request drafts', () => { + it.each(['', ' \n\t '])('clears a previously saved custom body when the editor contains %j', body => { + const persisted = { custom_request_body: '{"temperature":0.2}', custom_request_body_mode: 'trim' as const }; + const drafts = [{ key: 'model', requestSettings: getModelEditorRequestSettings(persisted) }]; + const [edited] = updateModelEditorRequestSettings(drafts, { custom_request_body: body }, 'model'); + const saved = { ...persisted, ...prepareModelEditorRequestSettingsForSave(edited.requestSettings) }; + const reloaded = JSON.parse(JSON.stringify(saved)); + + expect(reloaded).not.toHaveProperty('custom_request_body'); + expect(reloaded.custom_request_body_mode).toBe('trim'); + expect(getModelEditorRequestSettings(reloaded).custom_request_body).toBeUndefined(); + expect(edited.requestSettings.custom_request_body).toBe(body); + expect(persisted.custom_request_body).toBe('{"temperature":0.2}'); + }); + + it('preserves non-empty custom JSON verbatim on save', () => { + const body = ' \n{"temperature":0.2}\n '; + expect(prepareModelEditorRequestSettingsForSave(getModelEditorRequestSettings({ + custom_request_body: body, + })).custom_request_body).toBe(body); + }); + it('accepts older configurations with no optional request settings and isolates header edits', () => { expect(getModelEditorRequestSettings()).toMatchObject({ inline_think_in_text: true, diff --git a/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.ts b/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.ts index c2e06656b6..004ca9b894 100644 --- a/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.ts +++ b/src/web-ui/src/infrastructure/config/components/modelEditorRequestSettings.ts @@ -20,6 +20,13 @@ export function getModelEditorRequestSettings(config: Partial = { }; } +export function prepareModelEditorRequestSettingsForSave(settings: ModelEditorRequestSettings): ModelEditorRequestSettings { + return { + ...settings, + custom_request_body: settings.custom_request_body?.trim() ? settings.custom_request_body : undefined, + }; +} + export function updateModelEditorRequestSettings( drafts: T[], updates: Partial,