From 9b5a146323f76672af01503f09cca1f38d680764 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 10:12:00 +0000 Subject: [PATCH] refactor(pi): certify native inspection boundary --- src-tauri/src/architecture_tests.rs | 109 +++- src-tauri/src/pi_config/gateway.rs | 100 +++- src-tauri/src/pi_config/mod.rs | 2 + src-tauri/src/pi_config/model.rs | 178 ++++-- src-tauri/src/pi_config/native.rs | 105 +--- .../native_inspection_certification.rs | 528 ++++++++++++++++++ 6 files changed, 876 insertions(+), 146 deletions(-) create mode 100644 src-tauri/src/pi_config/native_inspection_certification.rs diff --git a/src-tauri/src/architecture_tests.rs b/src-tauri/src/architecture_tests.rs index 4705116be..beef8776e 100644 --- a/src-tauri/src/architecture_tests.rs +++ b/src-tauri/src/architecture_tests.rs @@ -9,7 +9,7 @@ use std::sync::LazyLock; use syn::visit::{self, Visit}; use syn::{ Attribute, ExprLit, ImplItem, Item, ItemImpl, ItemStruct, ItemUse, Lit, Meta, Path as SynPath, - Type, UseTree, + Token, Type, UseTree, }; static PROVIDER_DML: LazyLock = LazyLock::new(|| { @@ -30,19 +30,82 @@ struct Violation { detail: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CfgTruth { + True, + False, + Unknown, +} + +impl CfgTruth { + fn not(self) -> Self { + match self { + Self::True => Self::False, + Self::False => Self::True, + Self::Unknown => Self::Unknown, + } + } + + fn and(self, other: Self) -> Self { + match (self, other) { + (Self::False, _) | (_, Self::False) => Self::False, + (Self::True, Self::True) => Self::True, + _ => Self::Unknown, + } + } + + fn or(self, other: Self) -> Self { + match (self, other) { + (Self::True, _) | (_, Self::True) => Self::True, + (Self::False, Self::False) => Self::False, + _ => Self::Unknown, + } + } +} + +/// Evaluate a cfg predicate for a production build where `test = false`. +/// Other target/features are deliberately unknown: the scanner may exclude an +/// item only when the predicate is definitely false in every production +/// configuration. +fn production_cfg_truth(meta: &Meta) -> CfgTruth { + match meta { + Meta::Path(path) if path.is_ident("test") => CfgTruth::False, + Meta::Path(_) | Meta::NameValue(_) => CfgTruth::Unknown, + Meta::List(list) if list.path.is_ident("not") => list + .parse_args::() + .map(|inner| production_cfg_truth(&inner).not()) + .unwrap_or(CfgTruth::Unknown), + Meta::List(list) if list.path.is_ident("all") || list.path.is_ident("any") => { + let Ok(items) = list + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + else { + return CfgTruth::Unknown; + }; + if list.path.is_ident("all") { + items.iter().fold(CfgTruth::True, |truth, item| { + truth.and(production_cfg_truth(item)) + }) + } else { + items.iter().fold(CfgTruth::False, |truth, item| { + truth.or(production_cfg_truth(item)) + }) + } + } + Meta::List(_) => CfgTruth::Unknown, + } +} + fn is_cfg_test(attrs: &[Attribute]) -> bool { attrs.iter().any(|attribute| { - if !attribute.path().is_ident("cfg") { + let Meta::List(list) = &attribute.meta else { + return false; + }; + if !list.path.is_ident("cfg") { return false; } - match &attribute.meta { - Meta::List(list) => list - .tokens - .to_string() - .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') - .any(|token| token == "test"), - _ => false, - } + list.parse_args::() + .map(|predicate| production_cfg_truth(&predicate) == CfgTruth::False) + .unwrap_or(false) }) } @@ -559,4 +622,30 @@ fn architecture_scanner_negative_fixtures_prove_each_guard_fires() { violations.is_empty(), "#[cfg(test)] content must be excluded: {violations:?}" ); + + let production_not_test_fixture = r#" + #[cfg(not(test))] + fn production_escape_attempt() { + save_provider(); + } + "#; + let (violations, _) = scan_source("src/services/production.rs", production_not_test_fixture); + assert!( + violations + .iter() + .any(|violation| violation.kind == "forbidden_provider_symbol"), + "#[cfg(not(test))] production content must remain visible: {violations:?}" + ); + + let test_only_conjunction = r#" + #[cfg(all(test, unix))] + fn test_only() { + save_provider(); + } + "#; + let (violations, _) = scan_source("src/services/test_only.rs", test_only_conjunction); + assert!( + violations.is_empty(), + "a predicate requiring test must stay excluded: {violations:?}" + ); } diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index 095d51ecf..beed88b0d 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -10,21 +10,46 @@ use super::composer::{PiComposedNativeModel, PiComposerStatus, PiNativeCompositi use http::{HeaderMap, HeaderName, HeaderValue}; use url::Url; -const PROTECTED_HEADERS: &[&str] = &[ - "authorization", +/// Headers owned by HTTP framing, the proxy hop, or gateway-generated request +/// identity. Configured providers may not override them. +const GATEWAY_OWNED_HEADERS: &[&str] = &[ "connection", "content-length", + "forwarded", "host", "keep-alive", - "proxy-authenticate", - "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", - "x-api-key", - "x-goog-api-key", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-real-ip", + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "true-client-ip", + "fastly-client-ip", + "x-azure-clientip", + "x-azure-fdid", + "x-azure-ref", + "akamai-origin-hop", + "x-akamai-config-log-detail", + "x-request-id", + "x-correlation-id", + "x-trace-id", + "x-amzn-trace-id", + "x-b3-traceid", + "x-b3-spanid", + "x-b3-parentspanid", + "x-b3-sampled", + "traceparent", + "tracestate", ]; +const CANDIDATE_AUTH_HEADERS: &[&str] = &["authorization", "x-api-key", "x-goog-api-key"]; const PROTOCOL_HEADERS: &[&str] = &[ "anthropic-version", "anthropic-beta", @@ -32,6 +57,27 @@ const PROTOCOL_HEADERS: &[&str] = &[ "openai-version", ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfiguredHeaderClass { + GatewayOwned, + Protocol, + CandidateAuth, + CandidateLocal, +} + +fn configured_header_class(name: &HeaderName) -> ConfiguredHeaderClass { + let name = name.as_str(); + if name.starts_with("proxy-") || GATEWAY_OWNED_HEADERS.contains(&name) { + ConfiguredHeaderClass::GatewayOwned + } else if PROTOCOL_HEADERS.contains(&name) { + ConfiguredHeaderClass::Protocol + } else if CANDIDATE_AUTH_HEADERS.contains(&name) { + ConfiguredHeaderClass::CandidateAuth + } else { + ConfiguredHeaderClass::CandidateLocal + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(super) enum PiGatewayApiFamily { AnthropicMessages, @@ -185,12 +231,12 @@ impl CandidateHeaderPlan { return Err(reasons); } }; - let Some(credential) = model.api_key.as_ref() else { + let credential = model.api_key.as_ref(); + if credential.is_none() { reasons.push(PiGatewayReason { code: PiGatewayReasonCode::MissingCredential, json_pointer: "/apiKey".to_string(), }); - return Err(reasons); }; let mut custom_headers = Vec::new(); @@ -208,7 +254,8 @@ impl CandidateHeaderPlan { }); continue; }; - if PROTECTED_HEADERS.contains(&parsed_name.as_str()) { + let class = configured_header_class(&parsed_name); + if class == ConfiguredHeaderClass::GatewayOwned { reasons.push(PiGatewayReason { code: PiGatewayReasonCode::ProtectedHeader, json_pointer: pointer, @@ -227,7 +274,7 @@ impl CandidateHeaderPlan { pointer, DeferredHeaderValue::new(value.clone()), ); - if PROTOCOL_HEADERS.contains(&planned.0.as_str()) { + if class == ConfiguredHeaderClass::Protocol { protocol_identity_predictable &= !value.starts_with('!'); protocol_headers.push(planned); } else { @@ -237,11 +284,19 @@ impl CandidateHeaderPlan { if !reasons.is_empty() { return Err(reasons); } + let Some(credential) = credential.cloned() else { + // Missing credentials are accumulated above so configured-header + // diagnostics can be returned in the same assessment. + return Err(vec![PiGatewayReason { + code: PiGatewayReasonCode::MissingCredential, + json_pointer: "/apiKey".to_string(), + }]); + }; Ok(Self { family, endpoint, - credential: DeferredHeaderValue::new(credential.clone()), + credential: DeferredHeaderValue::new(credential), auth_header: model.auth_header, custom_headers, protocol_headers, @@ -282,6 +337,20 @@ impl CandidateHeaderPlan { } }; headers.insert(auth_name, auth_value); + // Pinned Anthropic/OpenAI SDKs merge explicit configured headers after + // their synthesized family auth, so an explicit candidate-auth value + // wins when authHeader is disabled. + for (name, pointer, value) in &self.custom_headers { + headers.insert(name.clone(), value.materialize(resolver, pointer)?); + } + for (name, pointer, value) in &self.protocol_headers { + let value = value.materialize(resolver, pointer)?; + protocol_headers.insert(name.clone(), value.clone()); + headers.insert(name.clone(), value); + } + // Pi's provider composer applies authHeader after custom headers. It + // overwrites only Authorization and leaves family auth (for example + // x-api-key) at its already-materialized final value. if self.auth_header { let credential = bearer_credential.to_str().map_err(|_| PiGatewayReason { code: PiGatewayReasonCode::InvalidHeaderValue, @@ -295,15 +364,6 @@ impl CandidateHeaderPlan { })?; headers.insert(HeaderName::from_static("authorization"), bearer); } - - for (name, pointer, value) in &self.custom_headers { - headers.insert(name.clone(), value.materialize(resolver, pointer)?); - } - for (name, pointer, value) in &self.protocol_headers { - let value = value.materialize(resolver, pointer)?; - protocol_headers.insert(name.clone(), value.clone()); - headers.insert(name.clone(), value); - } let host = authority_header(&self.endpoint).ok_or_else(|| PiGatewayReason { code: PiGatewayReasonCode::InvalidEndpoint, json_pointer: "/baseUrl".to_string(), diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs index c9f61e2a0..0d3500214 100644 --- a/src-tauri/src/pi_config/mod.rs +++ b/src-tauri/src/pi_config/mod.rs @@ -9,4 +9,6 @@ mod document; mod gateway; pub(crate) mod model; pub(crate) mod native; +#[cfg(test)] +mod native_inspection_certification; mod raw_schema; diff --git a/src-tauri/src/pi_config/model.rs b/src-tauri/src/pi_config/model.rs index e6681f5ba..c269a85af 100644 --- a/src-tauri/src/pi_config/model.rs +++ b/src-tauri/src/pi_config/model.rs @@ -14,6 +14,7 @@ use thiserror::Error; use url::Url; pub(crate) type PiHeaderMap = BTreeMap; +pub(crate) type PiThinkingLevelMap = BTreeMap; /// Pi uses JavaScript/TypeBox `Number`, not `Integer`, for model limits. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)] @@ -133,6 +134,8 @@ pub(crate) struct PiModelCostTier { pub output: f64, pub cache_read: f64, pub cache_write: f64, + #[serde(flatten)] + pub extra: BTreeMap, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] @@ -142,6 +145,8 @@ pub(crate) struct PiModelCost { pub rates: PiModelCostRates, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tiers: Vec, + #[serde(flatten)] + pub extra: BTreeMap, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] @@ -157,6 +162,8 @@ pub(crate) struct PiModelCostOverride { pub cache_write: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tiers: Option>, + #[serde(flatten)] + pub extra: BTreeMap, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] @@ -166,8 +173,8 @@ pub(crate) struct PiManagedModelOverride { pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub thinking_level_map: BTreeMap>, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_level_map: Option, #[serde(skip_serializing_if = "Option::is_none")] pub input: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -196,8 +203,8 @@ pub(crate) struct PiManagedModel { pub api: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub thinking_level_map: BTreeMap>, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_level_map: Option, #[serde(skip_serializing_if = "Option::is_none")] pub input: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -252,7 +259,8 @@ pub(crate) struct PiEffectiveModel { pub api_key: Option, pub auth_header: bool, pub reasoning: bool, - pub thinking_level_map: BTreeMap>, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_level_map: Option, pub input: Vec, pub cost: PiModelCost, pub context_window: PiNumber, @@ -297,8 +305,8 @@ pub(crate) enum PiConfigError { model_id: String, field: &'static str, }, - #[error("Pi model '{model_id}' contains an unmanaged thinking level '{level}'")] - InvalidThinkingLevel { model_id: String, level: String }, + #[error("Pi model '{model_id}' contains an invalid value for thinking level '{level}'")] + InvalidThinkingLevelValue { model_id: String, level: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -459,7 +467,7 @@ pub(crate) fn validate_pi_managed_provider( validate_compat(model.compat.as_ref(), &format!("/models/{index}/compat"))?; validate_model_limit(model, model.context_window, "contextWindow")?; validate_model_limit(model, model.max_tokens, "maxTokens")?; - validate_thinking_levels(&model.id, model.thinking_level_map.keys())?; + validate_thinking_levels(&model.id, model.thinking_level_map.as_ref())?; let _ = effective_pi_model_unchecked(provider, model)?; } @@ -470,7 +478,7 @@ pub(crate) fn validate_pi_managed_provider( validate_optional_text(model_override.name.as_deref(), "model override name")?; validate_optional_limit(model_id, model_override.context_window, "contextWindow")?; validate_optional_limit(model_id, model_override.max_tokens, "maxTokens")?; - validate_thinking_levels(model_id, model_override.thinking_level_map.keys())?; + validate_thinking_levels(model_id, model_override.thinking_level_map.as_ref())?; validate_compat( model_override.compat.as_ref(), &format!("/modelOverrides/{}/compat", escape_json_pointer(model_id)), @@ -517,10 +525,10 @@ fn effective_pi_model_unchecked( } headers.extend(model.headers.clone()); - let mut thinking_level_map = model.thinking_level_map.clone(); - if let Some(model_override) = model_override { - thinking_level_map.extend(model_override.thinking_level_map.clone()); - } + let thinking_level_map = merge_thinking_level_maps( + model.thinking_level_map.as_ref(), + model_override.and_then(|entry| entry.thinking_level_map.as_ref()), + ); let base_cost = model.cost.clone().unwrap_or_default(); let cost = model_override @@ -571,21 +579,35 @@ fn effective_pi_model_unchecked( }) } -fn apply_cost_override(base: PiModelCost, model_override: &PiModelCostOverride) -> PiModelCost { - PiModelCost { - rates: PiModelCostRates { - input: model_override.input.unwrap_or(base.rates.input), - output: model_override.output.unwrap_or(base.rates.output), - cache_read: model_override.cache_read.unwrap_or(base.rates.cache_read), - cache_write: model_override.cache_write.unwrap_or(base.rates.cache_write), - }, - tiers: model_override - .tiers - .clone() - .unwrap_or_else(|| base.tiers.clone()), +fn merge_thinking_level_maps( + base: Option<&PiThinkingLevelMap>, + overlay: Option<&PiThinkingLevelMap>, +) -> Option { + match (base, overlay) { + (None, None) => None, + (Some(value), None) | (None, Some(value)) => Some(value.clone()), + (Some(base), Some(overlay)) => { + let mut merged = base.clone(); + merged.extend(overlay.clone()); + Some(merged) + } } } +fn apply_cost_override(mut base: PiModelCost, model_override: &PiModelCostOverride) -> PiModelCost { + base.rates = PiModelCostRates { + input: model_override.input.unwrap_or(base.rates.input), + output: model_override.output.unwrap_or(base.rates.output), + cache_read: model_override.cache_read.unwrap_or(base.rates.cache_read), + cache_write: model_override.cache_write.unwrap_or(base.rates.cache_write), + }; + if let Some(tiers) = &model_override.tiers { + base.tiers = tiers.clone(); + } + base.extra.extend(model_override.extra.clone()); + base +} + fn merge_compat(base: Option, overlay: Option) -> Option { match (base, overlay) { (None, None) => None, @@ -679,13 +701,16 @@ fn escape_json_pointer(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } -fn validate_thinking_levels<'a>( +fn validate_thinking_levels( model_id: &str, - levels: impl Iterator, + levels: Option<&PiThinkingLevelMap>, ) -> Result<(), PiConfigError> { - for level in levels { - if !THINKING_LEVELS.contains(&level.as_str()) { - return Err(PiConfigError::InvalidThinkingLevel { + let Some(levels) = levels else { + return Ok(()); + }; + for (level, value) in levels { + if THINKING_LEVELS.contains(&level.as_str()) && !(value.is_string() || value.is_null()) { + return Err(PiConfigError::InvalidThinkingLevelValue { model_id: model_id.to_string(), level: level.clone(), }); @@ -714,7 +739,7 @@ mod tests { base_url: None, api: None, reasoning: None, - thinking_level_map: BTreeMap::new(), + thinking_level_map: None, input: None, cost: None, context_window: None, @@ -841,7 +866,7 @@ mod tests { } #[test] - fn managed_narrowing_rejects_duplicates_or_unknown_thinking_keys() { + fn managed_validation_rejects_duplicates_but_preserves_schema_valid_thinking_maps() { let duplicate = provider(vec![model("same"), model("same")]); assert_eq!( validate_pi_managed_provider(&duplicate), @@ -849,18 +874,93 @@ mod tests { ); let mut future_thinking = model("thinking"); - future_thinking - .thinking_level_map - .insert("future".into(), Some("opaque".into())); + future_thinking.thinking_level_map = Some(BTreeMap::from([ + ("high".into(), json!("native-high")), + ("future".into(), json!({"opaque": true})), + ])); + let future_config = provider(vec![future_thinking]); + validate_pi_managed_provider(&future_config) + .expect("pinned-schema additional keys remain manageable"); assert_eq!( - validate_pi_managed_provider(&provider(vec![future_thinking])), - Err(PiConfigError::InvalidThinkingLevel { - model_id: "thinking".into(), - level: "future".into() + serde_json::to_value(&future_config) + .expect("serialize") + .pointer("/models/0/thinkingLevelMap/future"), + Some(&json!({"opaque": true})) + ); + + let mut invalid_known = model("invalid-known"); + invalid_known.thinking_level_map = Some(BTreeMap::from([("low".into(), json!(2))])); + assert_eq!( + validate_pi_managed_provider(&provider(vec![invalid_known])), + Err(PiConfigError::InvalidThinkingLevelValue { + model_id: "invalid-known".into(), + level: "low".into() }) ); } + #[test] + fn future_cost_members_survive_model_override_and_effective_projection() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "api": "anthropic-messages", + "baseUrl": "https://cost.example", + "models": [{ + "id": "m", + "cost": { + "input": 1.0, + "output": 2.0, + "cacheRead": 0.5, + "cacheWrite": 0.25, + "futureRate": {"opaque": true}, + "tiers": [{ + "inputTokensAbove": 100.0, + "input": 1.0, + "output": 2.0, + "cacheRead": 0.5, + "cacheWrite": 0.25, + "futureTierField": ["preserved"] + }] + } + }], + "modelOverrides": { + "m": { + "cost": { + "output": 3.0, + "futureOverrideRate": "preserved" + } + } + } + })) + .expect("deserialize future cost members"); + validate_pi_managed_provider(&config).expect("future cost members are manageable"); + + let round_trip = serde_json::to_value(&config).expect("serialize managed config"); + assert_eq!( + round_trip.pointer("/models/0/cost/futureRate"), + Some(&json!({"opaque": true})) + ); + assert_eq!( + round_trip.pointer("/models/0/cost/tiers/0/futureTierField"), + Some(&json!(["preserved"])) + ); + let effective = + serde_json::to_value(effective_pi_model(&config, "m").expect("effective model")) + .expect("serialize effective model"); + assert_eq!(effective.pointer("/cost/output"), Some(&json!(3.0))); + assert_eq!( + effective.pointer("/cost/futureRate"), + Some(&json!({"opaque": true})) + ); + assert_eq!( + effective.pointer("/cost/futureOverrideRate"), + Some(&json!("preserved")) + ); + assert_eq!( + effective.pointer("/cost/tiers/0/futureTierField"), + Some(&json!(["preserved"])) + ); + } + #[test] fn diagnostic_reason_serialization_is_structured() { let reason = PiDiagnosticReason::new( diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index f6bd9b2f4..09237c2f6 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -310,7 +310,7 @@ fn assess_managed( reasons: vec![diagnostic_reason( PiDiagnosticLayer::Managed, PiReasonCode::ManagedTypeConversionFailed, - &managed_conversion_pointer(value), + "", )], }; } @@ -432,18 +432,6 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec Vec String { - if let Some(models) = value.get("models").and_then(Value::as_array) { - for (index, model) in models.iter().enumerate() { - if let Some(map) = model.get("thinkingLevelMap").and_then(Value::as_object) { - for (key, value) in map { - if !(value.is_string() || value.is_null()) { - return format!( - "/models/{index}/thinkingLevelMap/{}", - escape_json_pointer(key) - ); - } - } - } - } - } - if let Some(overrides) = value.get("modelOverrides").and_then(Value::as_object) { - for (model_id, model_override) in overrides { - if let Some(map) = model_override - .get("thinkingLevelMap") - .and_then(Value::as_object) - { - for (key, value) in map { - if !(value.is_string() || value.is_null()) { - return format!( - "/modelOverrides/{}/thinkingLevelMap/{}", - escape_json_pointer(model_id), - escape_json_pointer(key) - ); - } - } - } - } - } - String::new() -} - fn managed_validation_reason(error: PiConfigError) -> PiDiagnosticReason { let (code, pointer) = match error { PiConfigError::ProviderHasNoModels => (PiReasonCode::MissingExplicitModels, "/models"), @@ -545,7 +485,7 @@ fn managed_validation_reason(error: PiConfigError) -> PiDiagnosticReason { PiConfigError::NonPositiveModelLimit { .. } => { (PiReasonCode::NonPositiveModelLimit, "/models") } - PiConfigError::InvalidThinkingLevel { .. } => { + PiConfigError::InvalidThinkingLevelValue { .. } => { (PiReasonCode::InvalidThinkingLevel, "/models") } }; @@ -710,13 +650,6 @@ fn valid_http_endpoint(value: &str) -> bool { .is_some_and(|url| matches!(url.scheme(), "http" | "https") && url.host().is_some()) } -fn is_managed_thinking_level(value: &str) -> bool { - matches!( - value, - "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" - ) -} - fn escape_json_pointer(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } @@ -728,6 +661,7 @@ fn fingerprint(raw_source: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::pi_config::model::effective_pi_model; use serde_json::json; use std::fs; @@ -862,7 +796,7 @@ mod tests { } #[test] - fn unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately() { + fn unknown_thinking_shape_is_lossless_through_managed_and_effective_boundaries() { let temp = tempfile::tempdir().expect("tempdir"); let path = temp.path().join("models.json"); fs::write( @@ -899,14 +833,31 @@ mod tests { ); assert_eq!( inspection.diagnostic.managed_assessment, - PiManagedAssessment::Unsupported + PiManagedAssessment::Manageable + ); + let managed = serde_json::to_value( + inspection + .managed_config + .as_ref() + .expect("schema-valid managed config"), + ) + .expect("serialize managed config"); + assert_eq!( + managed.pointer("/models/0/thinkingLevelMap/future"), + Some(&json!({"opaque": true})) + ); + let effective = serde_json::to_value( + effective_pi_model( + inspection.managed_config.as_ref().expect("managed config"), + "m", + ) + .expect("effective model"), + ) + .expect("serialize effective model"); + assert_eq!( + effective.pointer("/thinkingLevelMap/future"), + Some(&json!({"opaque": true})) ); - assert!(has_reason( - &inspection.diagnostic, - PiDiagnosticLayer::Managed, - PiReasonCode::ManagedTypeConversionFailed, - "/models/0/thinkingLevelMap/future" - )); } #[test] diff --git a/src-tauri/src/pi_config/native_inspection_certification.rs b/src-tauri/src/pi_config/native_inspection_certification.rs new file mode 100644 index 000000000..75f8a7113 --- /dev/null +++ b/src-tauri/src/pi_config/native_inspection_certification.rs @@ -0,0 +1,528 @@ +#![cfg(test)] +//! 前置工程 C:只读 Native Inspection 认证测试套件 +//! +//! ## 目标 +//! **pinned Pi 决定什么是合法**。本仓库的 DTO 形状、网关支持范围、头部策略 +//! 都不得成为"合法性"的来源:schema 接受的,managed 不得拒绝也不得丢值; +//! Pi 会发出的,网关不得降级;Pi 不接受的形态,我们也不假装支持。 +//! +//! ## 规则 +//! 实现方不得修改本文件(发现套件有错 → 上报裁决方);实现路径自选;全绿是 +//! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审 +//! 核对为准,不设扫描器。 +//! +//! ## 三条裁决及其上游证据 +//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键 +//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证 +//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与 +//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover +//! 的共同入口)都必须无损,`{}` 与缺席必须保持可区分。 +//! 据此取代两个既有测试中把收窄固化为断言的部分: +//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与 +//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately` +//! (授权改写、可改名;DuplicateModelId 与 composer 无损两个语义由本套件 +//! 直接接管)。若 `InvalidThinkingLevel` 变体因此不再可构造,授权移除。 +//! C2【认证头】authorization / x-api-key / x-goog-api-key 是候选认证头, +//! 不是 protected。取值次序据 pinned SDK 与 composer 源码:authHeader 未 +//! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth → +//! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来 +//! 优先(pinned provider-composer 在自定义头之后写入,且只写 +//! Authorization、不动 x-api-key)。Google 的 header-only 凭证不是 Pi 原生 +//! 可请求形态(adapter 无条件要 apiKey),维持 MissingCredential 降级。 +//! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-` +//! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace / +//! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件 +//! 剥离的集合对齐。 +//! +//! ## 预期红绿 +//! 应红 4:`certify_managed_losslessness_through_effective_boundary`、 +//! `certify_auth_candidate_headers_are_not_protected`、 +//! `certify_auth_header_bearer_overrides_explicit_authorization`、 +//! `certify_transport_owned_headers_stay_protected`。 +//! 应绿 3:夹具冻结、DuplicateModelId 保留、composer 无损。 +//! 偏离(非清单红、应红变绿、编译失败)即上报。 +//! +//! ## 残余 +//! 显式优先只对 Anthropic/OpenAI 两族有 SDK 证据(Google 两值并存的优先级、 +//! OpenAI-Completions、大小写变体未断言);transport oracle 只执行 resolver, +//! 不执行 adapter/SDK 头合并,主工程触碰数据面须先补 request-capture oracle; +//! 其余按盲审 finding 处理。 + +use super::composer::compose_explicit_custom_catalog; +use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode}; +use super::model::{ + effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment, + PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity, +}; +use super::native::inspect_pi_native_entry; +use super::raw_schema::evaluate_provider_value; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn write_catalog(value: &Value) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write(&path, serde_json::to_string_pretty(value).expect("encode")).expect("write"); + (temp, path) +} + +fn composed_catalog(value: Value) -> super::composer::PiNativeComposition { + let raw = evaluate_provider_value(&value); + compose_explicit_custom_catalog( + "candidate", + raw.valid_provider.as_ref().expect("raw-valid input"), + ) +} + +fn has_gateway_reason( + gateway: &super::gateway::PiGatewayAssessment, + code: PiGatewayReasonCode, +) -> bool { + gateway.reasons.iter().any(|reason| reason.code == code) +} + +// --------------------------------------------------------------------------- +// 应绿:pinned 夹具冻结——oracle 是上游出处工件,不得为过测试再生成 +// --------------------------------------------------------------------------- + +const PINNED_FIXTURES: &[(&str, &str)] = &[ + ( + "tests/fixtures/pi/native-oracle/composer-oracle-v1.json", + "f7e54bb84e5fd6d50e5762dc304834410fa73ef608c2f9c42475c5983f8e0cf5", + ), + ( + "tests/fixtures/pi/native-oracle/field-coverage-v1.json", + "b8b85e611cf1dbef86c611df185ba8ac2d64160087d0c6e47747f838a0fafe42", + ), + ( + "tests/fixtures/pi/native-oracle/provenance-v1.json", + "6b2f9570ecc58d54ebe3da094530fee1c8c0d4a8265fa9c3199218582cb8dbcb", + ), + ( + "tests/fixtures/pi/native-oracle/provider-schema.snapshot.json", + "e498c9f1b344eee1bd3c3ba74d1b648dcb835378cfad92800ec80078b825745c", + ), + ( + "tests/fixtures/pi/native-oracle/raw-oracle-v1.json", + "5aaa37160f96a0fe50867d900ca38c73f13aba769e156a883324368d9dbeeb9a", + ), + ( + "tests/fixtures/pi/native-oracle/transport-oracle-v1.json", + "b2c816e53b60da5cd6352d2c23934939e9f6dd0077971488fe9dd36fa723e855", + ), + ( + "tests/fixtures/pi/module-boundaries-v1.json", + "a69ab84fc0db323d5eb8ddc63555a9c69613dda865962f077cd8691639951b4d", + ), +]; + +#[test] +fn certify_pinned_fixtures_are_frozen() { + for (relative, expected) in PINNED_FIXTURES { + let bytes = fs::read(repo_root().join(relative)) + .unwrap_or_else(|e| panic!("read fixture {relative}: {e}")); + assert_eq!( + &format!("{:x}", Sha256::digest(bytes)), + expected, + "pinned fixture '{relative}' drifted; fixtures are upstream provenance \ + artifacts and may only change under adjudication" + ); + } +} + +// --------------------------------------------------------------------------- +// 应绿:被取代测试中必须保留的语义,由本套件直接接管 +// --------------------------------------------------------------------------- + +#[test] +fn certify_duplicate_model_id_rejection_is_preserved() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "api": "anthropic-messages", + "baseUrl": "https://dup.example", + "apiKey": "literal", + "models": [{"id": "same"}, {"id": "same"}] + })) + .expect("deserialize managed provider"); + assert_eq!( + validate_pi_managed_provider(&config), + Err(PiConfigError::DuplicateModelId("same".into())), + "duplicate model ids must keep being rejected" + ); +} + +#[test] +fn certify_composer_thinking_losslessness_guard() { + let odd_map = json!({"high": "h", "future": {"opaque": true}}); + let composition = composed_catalog(json!({ + "api": "anthropic-messages", + "baseUrl": "https://thinking.example", + "apiKey": "literal", + "models": [{"id": "m", "thinkingLevelMap": odd_map}] + })); + assert_eq!( + composition.models[0].thinking_level_map.as_ref(), + Some(&odd_map), + "composer keeps the raw thinkingLevelMap value verbatim" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C1):schema 合法值必须无损直到 effective 边界 +// --------------------------------------------------------------------------- + +#[test] +fn certify_managed_losslessness_through_effective_boundary() { + // 标准键只取 schema 允许的 string|null;额外键覆盖全部 JSON 类型。 + let model_map = json!({ + "high": "native-high", + "medium": null, + "future-level": "textual", + "vendor": {"opaque": {"nested": true}}, + "budget": 42, + "enabled": true + }); + // 与 model_map 共有 "high",用于绑定 override 的覆盖方向。 + let override_map = json!({ + "high": "override-high", + "low": "override-low", + "another-future": [1, "two", null] + }); + let cost = json!({ + "input": 1.5, + "output": 2.5, + "cacheRead": 0.5, + "cacheWrite": 0.25, + "futureRate": 9.0, + "tiers": [{ + "inputTokensAbove": 100.0, + "input": 1.0, + "output": 2.0, + "cacheRead": 0.5, + "cacheWrite": 0.25, + "futureTierField": "opaque" + }] + }); + let catalog = json!({ + "providers": { + "thinking": { + "api": "anthropic-messages", + "baseUrl": "https://thinking.example", + "apiKey": "literal", + "models": [ + {"id": "m", "thinkingLevelMap": model_map.clone(), "cost": cost.clone()}, + {"id": "empty-map", "thinkingLevelMap": {}}, + {"id": "absent-map"} + ], + "modelOverrides": {"m": {"thinkingLevelMap": override_map.clone()}} + } + } + }); + let (_temp, path) = write_catalog(&catalog); + let inspection = inspect_pi_native_entry(&path, "thinking", &BTreeMap::new()) + .expect("inspect") + .expect("entry present"); + + assert_eq!( + inspection.diagnostic.raw_validity, + PiRawNativeValidity::Valid, + "the pinned schema accepts additional thinkingLevelMap and cost members" + ); + assert_eq!( + inspection.diagnostic.managed_assessment, + PiManagedAssessment::Manageable, + "managed must not reject what the executed pin accepts" + ); + assert_eq!( + inspection.diagnostic.management_status, + PiManagementStatus::Importable + ); + // 以序列化后的字符串码断言,便于 InvalidThinkingLevel 变体被整体移除。 + let reasons = serde_json::to_value(&inspection.diagnostic.reasons).expect("serialize reasons"); + assert!( + !reasons + .as_array() + .expect("reasons array") + .iter() + .any(|reason| reason["code"] == "invalid_thinking_level"), + "no invalid_thinking_level reason may fire for schema-valid input" + ); + + let managed = inspection.managed_config.expect("managed config"); + let round_trip = serde_json::to_value(&managed).expect("serialize managed config"); + assert_eq!( + round_trip.pointer("/models/0/thinkingLevelMap"), + Some(&model_map), + "model thinkingLevelMap must round-trip losslessly" + ); + assert_eq!( + round_trip.pointer("/modelOverrides/m/thinkingLevelMap"), + Some(&override_map), + "override thinkingLevelMap must round-trip losslessly" + ); + assert_eq!( + round_trip.pointer("/models/0/cost"), + Some(&cost), + "cost and tier members must round-trip losslessly, including future keys" + ); + // 空对象与缺席是两种原生形态,序列化必须保持可区分。 + assert_eq!( + round_trip.pointer("/models/1/thinkingLevelMap"), + Some(&json!({})), + "an explicitly empty thinkingLevelMap must survive as an empty object" + ); + assert_eq!( + round_trip.pointer("/models/2/thinkingLevelMap"), + None, + "an absent thinkingLevelMap must stay absent" + ); + + // effective 是 projection / runtime / routing / failover 的共同入口: + // DTO 修好后在这里二次收窄同样是丢值。 + let effective = effective_pi_model(&managed, "m").expect("effective model"); + let effective_value = serde_json::to_value(&effective).expect("serialize effective model"); + let mut merged = model_map.as_object().expect("model map").clone(); + for (key, value) in override_map.as_object().expect("override map") { + merged.insert(key.clone(), value.clone()); + } + assert_eq!( + effective_value.pointer("/thinkingLevelMap"), + Some(&Value::Object(merged)), + "the effective model must carry the merged map losslessly, with override \ + entries winning on shared keys" + ); + assert_eq!( + effective_value.pointer("/cost"), + Some(&cost), + "the effective model must not drop cost members either" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C2):候选认证头不是 protected +// --------------------------------------------------------------------------- + +#[test] +fn certify_auth_candidate_headers_are_not_protected() { + // (a) Anthropic:显式 x-api-key 不得被拒,取值优先于 apiKey 合成值。 + let explicit = composed_catalog(json!({ + "api": "anthropic-messages", + "baseUrl": "https://anthropic.example", + "apiKey": "synthesized-secret", + "headers": {"x-api-key": "explicit-secret"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&explicit); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "x-api-key is candidate-auth, not protected" + ); + assert_eq!(gateway.capability, PiGatewayCapability::Proxyable); + let materialized = gateway.plans[0] + .materialize(&|_: &str| None) + .expect("materialize literal candidate"); + assert_eq!( + materialized.headers[&http::HeaderName::from_static("x-api-key")], + http::HeaderValue::from_static("explicit-secret"), + "explicit config header value takes precedence over synthesized family auth" + ); + // 认证头永远不进 failover 协议身份。 + if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() { + assert!( + !protocol_headers.contains_key(&http::HeaderName::from_static("x-api-key")), + "auth headers must stay out of the failover protocol identity" + ); + } + + // (b) OpenAI-Responses:显式 authorization 同理。 + let bearer = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "synthesized-secret", + "headers": {"authorization": "Bearer configured-token"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&bearer); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "authorization is candidate-auth, not protected" + ); + assert_eq!(gateway.capability, PiGatewayCapability::Proxyable); + assert_eq!( + gateway.plans[0] + .materialize(&|_: &str| None) + .expect("materialize") + .headers[&http::HeaderName::from_static("authorization")], + http::HeaderValue::from_static("Bearer configured-token") + ); + + // (c) Google:显式认证头与 apiKey 并存,不得拒绝、不得降级 + // (取值优先级不断言——Google SDK 顺序无上游证据)。 + let google = composed_catalog(json!({ + "api": "google-generative-ai", + "baseUrl": "https://gemini.example", + "apiKey": "literal", + "headers": {"x-goog-api-key": "explicit-secret"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&google); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "x-goog-api-key is candidate-auth, not protected" + ); + assert_eq!(gateway.capability, PiGatewayCapability::Proxyable); + + // (d) Google header-only 不是 Pi 原生可请求形态:维持降级,但认证头 + // 依然不得被报为 ProtectedHeader。 + let header_only = composed_catalog(json!({ + "api": "google-generative-ai", + "baseUrl": "https://gemini.example", + "headers": {"x-goog-api-key": "header-secret"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&header_only); + assert_eq!( + gateway.capability, + PiGatewayCapability::DirectOnly, + "header-only credentials stay DirectOnly, mirroring pinned Pi" + ); + assert!(has_gateway_reason( + &gateway, + PiGatewayReasonCode::MissingCredential + )); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "an auth-candidate header must not be reported as protected even when the \ + credential is missing" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C2):authHeader:true 时合成 Bearer 覆盖显式 Authorization +// --------------------------------------------------------------------------- + +#[test] +fn certify_auth_header_bearer_overrides_explicit_authorization() { + let composition = composed_catalog(json!({ + "api": "anthropic-messages", + "baseUrl": "https://anthropic.example", + "apiKey": "synthesized-secret", + "authHeader": true, + "headers": {"authorization": "Bearer explicit-token"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&composition); + assert_eq!( + gateway.capability, + PiGatewayCapability::Proxyable, + "an explicit authorization header must not downgrade an authHeader model" + ); + let materialized = gateway.plans[0] + .materialize(&|_: &str| None) + .expect("materialize literal candidate"); + assert_eq!( + materialized.headers[&http::HeaderName::from_static("authorization")], + http::HeaderValue::from_static("Bearer synthesized-secret"), + "with authHeader:true the synthesized Bearer wins (pinned composer writes it \ + after the explicit headers)" + ); + assert_eq!( + materialized.headers[&http::HeaderName::from_static("x-api-key")], + http::HeaderValue::from_static("synthesized-secret"), + "the Bearer step only rewrites Authorization; family auth stays synthesized" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C3):传输层与网关自有身份头 +// --------------------------------------------------------------------------- + +/// 逐跳/传输头。末四项是合成名字:精确枚举无法覆盖,必须按 `proxy-` 前缀拒绝。 +const HOP_BY_HOP_HEADERS: &[&str] = &[ + "host", + "connection", + "content-length", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "proxy-future-extension", + "proxy-tenant-routing", + "proxy-x9", +]; + +/// Gateway/HTTP owned:proxy trace / CDN 客户端身份 / 分布式追踪。 +/// 与生产 forwarder 无条件剥离的集合对齐,两侧同进退。 +const GATEWAY_OWNED_HEADERS: &[&str] = &[ + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-real-ip", + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "true-client-ip", + "fastly-client-ip", + "x-azure-clientip", + "x-azure-fdid", + "x-azure-ref", + "akamai-origin-hop", + "x-akamai-config-log-detail", + "x-request-id", + "x-correlation-id", + "x-trace-id", + "x-amzn-trace-id", + "x-b3-traceid", + "x-b3-spanid", + "x-b3-parentspanid", + "x-b3-sampled", + "traceparent", + "tracestate", +]; + +#[test] +fn certify_transport_owned_headers_stay_protected() { + let cases = HOP_BY_HOP_HEADERS + .iter() + .map(|header| ("hop-by-hop", *header)) + .chain( + GATEWAY_OWNED_HEADERS + .iter() + .map(|header| ("gateway-owned", *header)), + ); + for (class, header) in cases { + let composition = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "literal", + "headers": {header: "value"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&composition); + assert!( + has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "{class} header '{header}' must be reported as ProtectedHeader" + ); + assert_eq!( + gateway.capability, + PiGatewayCapability::DirectOnly, + "{class} header '{header}' must keep the model DirectOnly" + ); + } +}