From d2ad7995a3bab3dfee7a58cbe32866b2edd485b8 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 16:16:14 +0000 Subject: [PATCH] fix(pi): fail closed on unrepresentable compat spread --- scripts/pi-transport-capture.mjs | 112 ++++++++++++++++ src-tauri/src/architecture_tests.rs | 190 ++++++++++++++++++++++++++-- src-tauri/src/pi_config/composer.rs | 72 ++++++++++- src-tauri/src/pi_config/mod.rs | 66 +++++++--- src-tauri/src/pi_config/model.rs | 52 +++++++- src-tauri/src/pi_config/native.rs | 53 ++++++++ 6 files changed, 511 insertions(+), 34 deletions(-) diff --git a/scripts/pi-transport-capture.mjs b/scripts/pi-transport-capture.mjs index aac80b77b..c17b996d1 100644 --- a/scripts/pi-transport-capture.mjs +++ b/scripts/pi-transport-capture.mjs @@ -91,6 +91,7 @@ writeFileSync( `export { streamSimple as openaiResponses } from "${PI}/packages/ai/src/api/openai-responses.ts";`, `export { streamSimple as openaiCompletions } from "${PI}/packages/ai/src/api/openai-completions.ts";`, `export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`, + `export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`, ].join("\n"), ); const bundlePath = join(harnessDirectory, "bundle.mjs"); @@ -242,6 +243,115 @@ const compatProvider = adapters.composeModelProvider( ); const compatSpread = compatProvider.getModels()[0].compat; +function jsonSafeJavaScriptValue(value) { + if (typeof value === "string") { + const codeUnits = Array.from({ length: value.length }, (_, index) => + value.charCodeAt(index), + ); + const hasLoneSurrogate = codeUnits.some((unit, index) => { + if (unit >= 0xd800 && unit <= 0xdbff) { + return !( + index + 1 < codeUnits.length && + codeUnits[index + 1] >= 0xdc00 && + codeUnits[index + 1] <= 0xdfff + ); + } + if (unit >= 0xdc00 && unit <= 0xdfff) { + return !( + index > 0 && + codeUnits[index - 1] >= 0xd800 && + codeUnits[index - 1] <= 0xdbff + ); + } + return false; + }); + return hasLoneSurrogate + ? { + $javascriptStringUtf16: codeUnits.map((unit) => + unit.toString(16).padStart(4, "0"), + ), + } + : value; + } + if (Array.isArray(value)) { + return value.map(jsonSafeJavaScriptValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + jsonSafeJavaScriptValue(child), + ]), + ); + } + return value; +} + +function captureCompatSpread(label, baseValue, overlayValue) { + const providerId = `compat-${label}`; + const provider = adapters.composeModelProvider( + providerId, + undefined, + { + getProvider(candidate) { + if (candidate !== providerId) return undefined; + return { + api: "openai-responses", + baseUrl: "https://compat.example/v1", + apiKey: "literal", + compat: { chatTemplateKwargs: baseValue }, + models: [{ id: "m" }], + modelOverrides: { + m: { compat: { chatTemplateKwargs: overlayValue } }, + }, + }; + }, + }, + undefined, + ); + return { + label, + baseValue, + overlayValue, + result: jsonSafeJavaScriptValue( + provider.getModels()[0].compat.chatTemplateKwargs, + ), + }; +} + +const compatEdgeCases = [ + captureCompatSpread("ascii-string-to-string", "ab", "cd"), + captureCompatSpread("astral-string-to-object", "😀", { named: true }), + captureCompatSpread("string-to-array", "ab", ["first", "second"]), +]; + +const resolverInputs = [ + "literal-secret", + "cash$money", + "café$literal", + "$$literal-$!bang", + "prefix-${PI_CAPTURE_MISSING}-suffix", +]; +const resolverCases = resolverInputs.map((input) => { + try { + return { + input, + status: "success", + result: adapters.resolveConfigValueOrThrow( + input, + "transport capture", + {}, + ), + }; + } catch (error) { + return { + input, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +}); + server.close(); console.log( JSON.stringify( @@ -252,6 +362,8 @@ console.log( baseUrl, results, compatSpread, + compatEdgeCases, + resolverCases, }, null, 2, diff --git a/src-tauri/src/architecture_tests.rs b/src-tauri/src/architecture_tests.rs index aad8e6788..87718db65 100644 --- a/src-tauri/src/architecture_tests.rs +++ b/src-tauri/src/architecture_tests.rs @@ -144,6 +144,7 @@ fn path_segments(path: &SynPath) -> Vec { struct ArchitectureVisitor<'a> { path: &'a str, + source_module: Option<&'static str>, violations: Vec, internal_edges: BTreeSet, } @@ -159,7 +160,7 @@ fn pi_config_source_module(path: &str) -> Option<&'static str> { impl ArchitectureVisitor<'_> { fn record_dependency(&mut self, segments: &[String]) { - let Some(source_module) = pi_config_source_module(self.path) else { + let Some(source_module) = self.source_module else { return; }; let qualified_internal_path = segments.len() > 1 @@ -346,6 +347,14 @@ fn provider_dml_path_allowed(path: &str) -> bool { } fn scan_source(path: &str, source: &str) -> (Vec, BTreeSet) { + scan_source_as_module(path, source, pi_config_source_module(path)) +} + +fn scan_source_as_module( + path: &str, + source: &str, + source_module: Option<&'static str>, +) -> (Vec, BTreeSet) { let syntax = match syn::parse_file(source) { Ok(syntax) => syntax, Err(error) => { @@ -366,6 +375,7 @@ fn scan_source(path: &str, source: &str) -> (Vec, BTreeSet) { } let mut visitor = ArchitectureVisitor { path, + source_module, violations: Vec::new(), internal_edges: BTreeSet::new(), }; @@ -394,23 +404,142 @@ fn rust_sources(root: &Path) -> Vec { output } +fn module_path_attribute(attrs: &[Attribute]) -> Option { + attrs.iter().find_map(|attribute| { + if !attribute.path().is_ident("path") { + return None; + } + let Meta::NameValue(value) = &attribute.meta else { + return None; + }; + let syn::Expr::Lit(expression) = &value.value else { + return None; + }; + let Lit::Str(path) = &expression.lit else { + return None; + }; + Some(PathBuf::from(path.value())) + }) +} + +fn collect_path_module_targets(items: &[Item], source_path: &Path, output: &mut Vec) { + for item in items { + let Item::Mod(module) = item else { + continue; + }; + if is_cfg_test(&module.attrs) { + continue; + } + if let Some(relative) = module_path_attribute(&module.attrs) { + output.push( + source_path + .parent() + .expect("Rust source has a parent directory") + .join(relative), + ); + } + if let Some((_, nested)) = &module.content { + collect_path_module_targets(nested, source_path, output); + } + } +} + +fn inherited_module_owners( + sources: &[(PathBuf, String, String)], +) -> BTreeMap> { + let mut owners = BTreeMap::>::new(); + for (path, relative, _) in sources { + if let Some(owner) = pi_config_source_module(relative) { + owners + .entry(fs::canonicalize(path).expect("canonicalize Rust source")) + .or_default() + .insert(owner); + } + } + + loop { + let mut changed = false; + for (path, _, source) in sources { + let canonical = fs::canonicalize(path).expect("canonicalize Rust source"); + let source_owners = owners.get(&canonical).cloned().unwrap_or_default(); + if source_owners.is_empty() { + continue; + } + let Ok(syntax) = syn::parse_file(source) else { + continue; + }; + if is_cfg_test(&syntax.attrs) { + continue; + } + let mut targets = Vec::new(); + collect_path_module_targets(&syntax.items, path, &mut targets); + for target in targets { + let Ok(target) = fs::canonicalize(target) else { + continue; + }; + let target_owners = owners.entry(target).or_default(); + let before = target_owners.len(); + target_owners.extend(source_owners.iter().copied()); + changed |= target_owners.len() != before; + } + } + if !changed { + return owners; + } + } +} + fn scan_production_tree( manifest_dir: &Path, source_root: &Path, ) -> (Vec, BTreeSet) { let mut violations = Vec::new(); let mut edges = BTreeSet::new(); - for path in rust_sources(source_root) { - let relative = path - .strip_prefix(manifest_dir) - .expect("source is under manifest") - .to_string_lossy() - .replace('\\', "/"); - let source = fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); - let (mut source_violations, source_edges) = scan_source(&relative, &source); - violations.append(&mut source_violations); - edges.extend(source_edges); + let sources = rust_sources(source_root) + .into_iter() + .map(|path| { + let relative = path + .strip_prefix(manifest_dir) + .expect("source is under manifest") + .to_string_lossy() + .replace('\\', "/"); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + (path, relative, source) + }) + .collect::>(); + let owners = inherited_module_owners(&sources); + let scanned_paths = sources + .iter() + .map(|(path, _, _)| fs::canonicalize(path).expect("canonicalize Rust source")) + .collect::>(); + for (target, target_owners) in &owners { + if !scanned_paths.contains(target) { + violations.push(Violation { + kind: "module_path_outside_scan", + path: target.display().to_string(), + detail: format!( + "a #[path] module owned by {} escapes the scanned source tree", + target_owners.iter().copied().collect::>().join(",") + ), + }); + } + } + for (path, relative, source) in sources { + let canonical = fs::canonicalize(&path).expect("canonicalize Rust source"); + let inherited = owners.get(&canonical).cloned().unwrap_or_default(); + if inherited.is_empty() { + let (mut source_violations, source_edges) = scan_source(&relative, &source); + violations.append(&mut source_violations); + edges.extend(source_edges); + } else { + for owner in inherited { + let (mut source_violations, source_edges) = + scan_source_as_module(&relative, &source, Some(owner)); + violations.append(&mut source_violations); + edges.extend(source_edges); + } + } } (violations, edges) } @@ -670,4 +799,41 @@ fn architecture_scanner_negative_fixtures_prove_each_guard_fires() { {expected_kind}: {violations:?}" ); } + + let custom_path_root = tempfile::tempdir().expect("temp custom-path architecture tree"); + let composer = custom_path_root.path().join("src/pi_config/composer.rs"); + let shared = custom_path_root.path().join("src/shared/helper.rs"); + fs::create_dir_all(composer.parent().expect("composer parent")).expect("create pi_config"); + fs::create_dir_all(shared.parent().expect("shared parent")).expect("create shared"); + fs::write( + &composer, + r#" + #[path = "../shared/helper.rs"] + mod helper; + "#, + ) + .expect("write custom-path parent"); + fs::write( + &shared, + r#" + use crate::pi_config::gateway::PiGatewayApiFamily; + fn production_escape_attempt() { + save_provider(); + } + "#, + ) + .expect("write custom-path child"); + let (violations, _) = scan_production_tree( + custom_path_root.path(), + &custom_path_root.path().join("src"), + ); + for expected_kind in ["cross_layer_import", "forbidden_provider_symbol"] { + assert!( + violations.iter().any(|violation| { + violation.kind == expected_kind && violation.path.ends_with("src/shared/helper.rs") + }), + "#[path] modules must inherit composer ownership and trigger \ + {expected_kind}: {violations:?}" + ); + } } diff --git a/src-tauri/src/pi_config/composer.rs b/src-tauri/src/pi_config/composer.rs index b2c8fa3ce..a38eece7c 100644 --- a/src-tauri/src/pi_config/composer.rs +++ b/src-tauri/src/pi_config/composer.rs @@ -65,6 +65,7 @@ pub(crate) enum PiComposerReasonCode { MissingEffectiveApi, MissingEffectiveEndpoint, NonPositiveModelLimit, + UnrepresentableCompat, CompositionFailed, } @@ -165,6 +166,21 @@ impl PiNativeComposition { }], } } + + fn unknown(code: PiComposerReasonCode, pointer: impl Into) -> Self { + Self { + status: PiComposerStatus::Unknown, + provider_id: None, + provider_name: None, + provider_base_url: None, + models: Vec::new(), + ignored_override_keys: Vec::new(), + reasons: vec![PiComposerReason { + code, + json_pointer: pointer.into(), + }], + } + } } pub(super) fn compose_explicit_custom_catalog( @@ -274,6 +290,16 @@ pub(super) fn compose_explicit_custom_catalog( } } + let compat = + match merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()) { + Ok(compat) => compat, + Err(_) => { + return PiNativeComposition::unknown( + PiComposerReasonCode::UnrepresentableCompat, + format!("/models/{index}/compat"), + ) + } + }; let model = PiComposedNativeModel { id: id.to_string(), name: definition @@ -305,7 +331,7 @@ pub(super) fn compose_explicit_custom_catalog( headers: BTreeMap::new(), provider_headers: provider_header_entries.clone(), model_headers: Vec::new(), - compat: merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()), + compat, api_key: api_key.clone(), oauth: oauth.clone(), auth_header, @@ -400,8 +426,18 @@ pub(super) fn compose_explicit_custom_catalog( if let Some(max_tokens) = model_override.get("maxTokens") { model.max_tokens = max_tokens.clone(); } - model.compat = - merge_pi_compat(model.compat.clone(), model_override.get("compat").cloned()); + model.compat = match merge_pi_compat( + model.compat.clone(), + model_override.get("compat").cloned(), + ) { + Ok(compat) => compat, + Err(_) => { + return PiNativeComposition::unknown( + PiComposerReasonCode::UnrepresentableCompat, + format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)), + ) + } + }; model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS); } } @@ -830,6 +866,36 @@ mod tests { ); } + #[test] + fn compat_spread_fails_closed_when_pinned_output_requires_lone_surrogates() { + let value = json!({ + "api": "openai-responses", + "baseUrl": "https://compat.example/v1", + "apiKey": "literal", + "compat": {"chatTemplateKwargs": "😀"}, + "models": [{"id": "m"}], + "modelOverrides": { + "m": {"compat": {"chatTemplateKwargs": {"named": true}}} + } + }); + let raw = evaluate_provider_value(&value); + let composition = compose_explicit_custom_catalog( + "compat-surrogate", + raw.valid_provider.as_ref().expect("raw-valid"), + ); + + assert_eq!(composition.status, PiComposerStatus::Unknown); + assert_eq!( + composition.reasons, + vec![PiComposerReason { + code: PiComposerReasonCode::UnrepresentableCompat, + json_pointer: "/modelOverrides/m/compat".to_string(), + }], + "capture records UTF-16 d83d/de00 as two lone-surrogate values, which \ + serde_json::Value cannot represent" + ); + } + #[test] fn header_layers_retain_runtime_precedence_and_source_pointers() { let value = json!({ diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs index f062d9988..a47eb74bd 100644 --- a/src-tauri/src/pi_config/mod.rs +++ b/src-tauri/src/pi_config/mod.rs @@ -21,22 +21,34 @@ const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [ "chatTemplateKwargs", ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PiCompatMergeError; + /// Mirror pinned Pi's `mergeCompat` JavaScript object-spread semantics. /// /// Arrays expose numeric enumerable properties, strings expose character /// properties, objects expose their own fields, and the remaining JSON /// primitives expose none. Existing key positions are retained when an /// overlay replaces their values, matching object spread. -fn merge_pi_compat(base: Option, overlay: Option) -> Option { +/// +/// A JavaScript string is indexed by UTF-16 code unit. Spreading an astral +/// character therefore creates lone-surrogate string values, which cannot be +/// represented by Rust `String` or `serde_json::Value`. That shape is rejected +/// explicitly so callers can fail closed instead of emitting a different +/// composed model. +fn merge_pi_compat( + base: Option, + overlay: Option, +) -> Result, PiCompatMergeError> { let Some(overlay) = overlay else { - return base; + return Ok(base); }; if !javascript_truthy(&overlay) { - return base; + return Ok(base); } - let mut merged = javascript_object_spread(base.as_ref()); - merged.extend(javascript_object_spread(Some(&overlay))); + let mut merged = javascript_object_spread(base.as_ref())?; + merged.extend(javascript_object_spread(Some(&overlay))?); for key in PI_COMPAT_NESTED_SPREAD_KEYS { let base_value = javascript_property(base.as_ref(), key); @@ -44,12 +56,12 @@ fn merge_pi_compat(base: Option, overlay: Option) -> Option if base_value.is_some_and(javascript_is_object) || overlay_value.is_some_and(javascript_is_object) { - let mut nested = javascript_object_spread(base_value); - nested.extend(javascript_object_spread(overlay_value)); + let mut nested = javascript_object_spread(base_value)?; + nested.extend(javascript_object_spread(overlay_value)?); merged.insert(key.to_string(), Value::Object(nested)); } } - Some(Value::Object(merged)) + Ok(Some(Value::Object(merged))) } fn javascript_truthy(value: &Value) -> bool { @@ -69,21 +81,29 @@ fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Va value.and_then(Value::as_object)?.get(key) } -fn javascript_object_spread(value: Option<&Value>) -> Map { - match value { +fn javascript_object_spread( + value: Option<&Value>, +) -> Result, PiCompatMergeError> { + let spread = match value { Some(Value::Object(object)) => object.clone(), Some(Value::Array(values)) => values .iter() .enumerate() .map(|(index, value)| (index.to_string(), value.clone())) .collect(), - Some(Value::String(value)) => value - .chars() - .enumerate() - .map(|(index, value)| (index.to_string(), Value::String(value.to_string()))) - .collect(), + Some(Value::String(value)) => { + if value.chars().any(|character| character.len_utf16() != 1) { + return Err(PiCompatMergeError); + } + value + .chars() + .enumerate() + .map(|(index, value)| (index.to_string(), Value::String(value.to_string()))) + .collect() + } Some(Value::Null | Value::Bool(_) | Value::Number(_)) | None => Map::new(), - } + }; + Ok(spread) } #[cfg(test)] @@ -105,6 +125,7 @@ mod compat_spread_tests { "overlayOnly": true })), ) + .expect("representable compat spread") .expect("truthy overlay produces an object"); assert_eq!( @@ -121,6 +142,17 @@ mod compat_spread_tests { #[test] fn compat_falsy_overlay_returns_base_without_spreading() { let base = Some(json!({"openRouterRouting": ["kept"]})); - assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), base); + assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), Ok(base)); + } + + #[test] + fn compat_spread_rejects_unrepresentable_javascript_surrogates() { + assert_eq!( + merge_pi_compat( + Some(json!({"chatTemplateKwargs": "😀"})), + Some(json!({"chatTemplateKwargs": {"named": true}})), + ), + Err(PiCompatMergeError) + ); } } diff --git a/src-tauri/src/pi_config/model.rs b/src-tauri/src/pi_config/model.rs index e1570c81b..2d0441a78 100644 --- a/src-tauri/src/pi_config/model.rs +++ b/src-tauri/src/pi_config/model.rs @@ -311,6 +311,10 @@ pub(crate) enum PiConfigError { UnknownModelOverride(String), #[error("Pi compat at '{json_pointer}' must be an object")] InvalidCompat { json_pointer: String }, + #[error( + "Pi compat at '{json_pointer}' requires JavaScript UTF-16 values that cannot be represented" + )] + UnrepresentableCompat { json_pointer: String }, #[error("Pi {field} cannot be empty when present")] EmptyOptionalField { field: &'static str }, #[error("Pi model '{model_id}' {field} must be greater than zero")] @@ -401,6 +405,7 @@ pub(crate) enum PiReasonCode { MissingEffectiveEndpoint, InvalidEndpoint, InvalidCompat, + UnrepresentableCompat, NonPositiveModelLimit, InvalidThinkingLevel, CompositionFailed, @@ -552,10 +557,18 @@ fn effective_pi_model_unchecked( .map(|entry| apply_cost_override(base_cost.clone(), entry)) .unwrap_or(base_cost); + let compat = merge_pi_compat(provider.compat.clone(), model.compat.clone()).map_err(|_| { + PiConfigError::UnrepresentableCompat { + json_pointer: "/compat".to_string(), + } + })?; let compat = merge_pi_compat( - merge_pi_compat(provider.compat.clone(), model.compat.clone()), + compat, model_override.and_then(|entry| entry.compat.clone()), - ); + ) + .map_err(|_| PiConfigError::UnrepresentableCompat { + json_pointer: format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)), + })?; Ok(PiEffectiveModel { id: model.id.clone(), @@ -908,6 +921,27 @@ mod tests { ); } + #[test] + fn effective_compat_rejects_unrepresentable_javascript_surrogate_spread() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "api": "openai-responses", + "baseUrl": "https://compat.example/v1", + "compat": {"chatTemplateKwargs": "😀"}, + "models": [{"id": "m"}], + "modelOverrides": { + "m": {"compat": {"chatTemplateKwargs": {"named": true}}} + } + })) + .expect("deserialize pinned compat vector"); + + assert_eq!( + effective_pi_model(&config, "m"), + Err(PiConfigError::UnrepresentableCompat { + json_pointer: "/modelOverrides/m/compat".to_string(), + }) + ); + } + #[test] fn fractional_pi_numbers_survive_managed_round_trip() { let mut fractional = model("fractional"); @@ -1092,5 +1126,19 @@ mod tests { "jsonPointer": "/apiKey" }) ); + + let compat_reason = PiDiagnosticReason::new( + PiDiagnosticLayer::Composition, + PiReasonCode::UnrepresentableCompat, + Some("/modelOverrides/m/compat".into()), + ); + assert_eq!( + serde_json::to_value(compat_reason).expect("serialize"), + json!({ + "layer": "composition", + "code": "unrepresentable_compat", + "jsonPointer": "/modelOverrides/m/compat" + }) + ); } } diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index c6f3fb6d4..4f270300b 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -466,6 +466,13 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec PiDiagnosticReason { + if let PiConfigError::UnrepresentableCompat { json_pointer } = &error { + return diagnostic_reason( + PiDiagnosticLayer::Managed, + PiReasonCode::UnrepresentableCompat, + json_pointer, + ); + } let (code, pointer) = match error { PiConfigError::ProviderHasNoModels => (PiReasonCode::MissingExplicitModels, "/models"), PiConfigError::EmptyApiId => (PiReasonCode::ManagedTypeConversionFailed, "/api"), @@ -481,6 +488,9 @@ fn managed_validation_reason(error: PiConfigError) -> PiDiagnosticReason { (PiReasonCode::UnknownModelOverride, "/modelOverrides") } PiConfigError::InvalidCompat { .. } => (PiReasonCode::InvalidCompat, "/compat"), + PiConfigError::UnrepresentableCompat { .. } => { + unreachable!("handled before the exhaustive mapping") + } PiConfigError::EmptyOptionalField { .. } => (PiReasonCode::EmptyOptionalField, ""), PiConfigError::NonPositiveModelLimit { .. } => { (PiReasonCode::NonPositiveModelLimit, "/models") @@ -585,6 +595,9 @@ fn map_composer_reasons(composition: &PiNativeComposition) -> Vec { PiReasonCode::NonPositiveModelLimit } + PiComposerReasonCode::UnrepresentableCompat => { + PiReasonCode::UnrepresentableCompat + } PiComposerReasonCode::CompositionFailed => PiReasonCode::CompositionFailed, }, &reason.json_pointer, @@ -760,6 +773,46 @@ mod tests { assert_eq!(malformed.gateway_status, PiGatewayStatus::Unknown); } + #[test] + fn public_inspection_fails_closed_for_unrepresentable_compat_spread() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write( + &path, + r#"{"providers":{"surrogate":{ + "api":"openai-responses", + "baseUrl":"https://compat.example/v1", + "apiKey":"literal", + "compat":{"chatTemplateKwargs":"😀"}, + "models":[{"id":"m"}], + "modelOverrides":{"m":{"compat":{"chatTemplateKwargs":{"named":true}}}} +}}}"#, + ) + .expect("write"); + + let diagnostic = + &PiNativeInspectionService::inspect_catalog(&path, &BTreeMap::new()).unwrap()[0]; + assert_eq!(diagnostic.raw_validity, PiRawNativeValidity::Valid); + assert_eq!( + diagnostic.managed_assessment, + PiManagedAssessment::Unsupported + ); + assert_eq!(diagnostic.composition_status, PiCompositionStatus::Unknown); + assert_eq!(diagnostic.gateway_status, PiGatewayStatus::Unknown); + assert!(has_reason( + diagnostic, + PiDiagnosticLayer::Managed, + PiReasonCode::UnrepresentableCompat, + "/modelOverrides/m/compat" + )); + assert!(has_reason( + diagnostic, + PiDiagnosticLayer::Composition, + PiReasonCode::UnrepresentableCompat, + "/modelOverrides/m/compat" + )); + } + #[test] fn managed_rejection_does_not_control_raw_composition() { let temp = tempfile::tempdir().expect("tempdir");