mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
refactor(pi): certify native inspection boundary
This commit is contained in:
@@ -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<Regex> = 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::<Meta>()
|
||||
.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::<Meta, Token![,]>::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::<Meta>()
|
||||
.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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) type PiHeaderMap = BTreeMap<String, String>;
|
||||
pub(crate) type PiThinkingLevelMap = BTreeMap<String, Value>;
|
||||
|
||||
/// 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<String, Value>,
|
||||
}
|
||||
|
||||
#[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<PiModelCostTier>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -157,6 +162,8 @@ pub(crate) struct PiModelCostOverride {
|
||||
pub cache_write: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tiers: Option<Vec<PiModelCostTier>>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -166,8 +173,8 @@ pub(crate) struct PiManagedModelOverride {
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub thinking_level_map: BTreeMap<String, Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_level_map: Option<PiThinkingLevelMap>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<Vec<PiModelInput>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -196,8 +203,8 @@ pub(crate) struct PiManagedModel {
|
||||
pub api: Option<PiManagedApiId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub thinking_level_map: BTreeMap<String, Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_level_map: Option<PiThinkingLevelMap>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<Vec<PiModelInput>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -252,7 +259,8 @@ pub(crate) struct PiEffectiveModel {
|
||||
pub api_key: Option<String>,
|
||||
pub auth_header: bool,
|
||||
pub reasoning: bool,
|
||||
pub thinking_level_map: BTreeMap<String, Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_level_map: Option<PiThinkingLevelMap>,
|
||||
pub input: Vec<PiModelInput>,
|
||||
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<PiThinkingLevelMap> {
|
||||
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<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||
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<Item = &'a String>,
|
||||
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(
|
||||
|
||||
@@ -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<PiDiagnostic
|
||||
);
|
||||
}
|
||||
}
|
||||
for level in model.thinking_level_map.keys() {
|
||||
if !is_managed_thinking_level(level) {
|
||||
add_reason(
|
||||
&mut reasons,
|
||||
diagnostic_reason(
|
||||
PiDiagnosticLayer::Managed,
|
||||
PiReasonCode::InvalidThinkingLevel,
|
||||
&format!("{pointer}/thinkingLevelMap/{}", escape_json_pointer(level)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (model_id, model_override) in &config.model_overrides {
|
||||
@@ -473,58 +461,10 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec<PiDiagnostic
|
||||
);
|
||||
}
|
||||
}
|
||||
for level in model_override.thinking_level_map.keys() {
|
||||
if !is_managed_thinking_level(level) {
|
||||
add_reason(
|
||||
&mut reasons,
|
||||
diagnostic_reason(
|
||||
PiDiagnosticLayer::Managed,
|
||||
PiReasonCode::InvalidThinkingLevel,
|
||||
&format!("{pointer}/thinkingLevelMap/{}", escape_json_pointer(level)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
reasons
|
||||
}
|
||||
|
||||
fn managed_conversion_pointer(value: &Value) -> 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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user