diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39edcf098..a493e3338 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod model_capabilities; mod openclaw_config; mod opencode_config; mod panic_hook; +mod pi_config; mod prompt; mod prompt_files; mod provider; diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs new file mode 100644 index 000000000..26198e369 --- /dev/null +++ b/src-tauri/src/pi_config/mod.rs @@ -0,0 +1,7 @@ +//! Pi Coding Agent integration boundaries. +//! +//! This module deliberately separates the managed control-plane model from +//! Pi's shared files and from the proxy data plane. Callers must use the +//! typed model resolver rather than reimplementing provider/model inheritance. + +pub(crate) mod model; diff --git a/src-tauri/src/pi_config/model.rs b/src-tauri/src/pi_config/model.rs new file mode 100644 index 000000000..e6681f5ba --- /dev/null +++ b/src-tauri/src/pi_config/model.rs @@ -0,0 +1,880 @@ +//! Managed Pi control-plane types. +//! +//! API identifiers are intentionally opaque here. The closed set of API +//! families that the gateway can proxy belongs to `gateway.rs`; admitting a +//! new Pi API identifier into the managed control plane must not require a +//! gateway enum update. + +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; +use thiserror::Error; +use url::Url; + +pub(crate) type PiHeaderMap = BTreeMap; + +/// Pi uses JavaScript/TypeBox `Number`, not `Integer`, for model limits. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub(crate) struct PiNumber(f64); + +impl PiNumber { + pub(crate) const DEFAULT_CONTEXT_WINDOW: Self = Self(128_000.0); + pub(crate) const DEFAULT_MAX_TOKENS: Self = Self(16_384.0); + + pub(crate) fn new(value: f64) -> Result { + if value.is_finite() { + Ok(Self(value)) + } else { + Err(PiNumberError) + } + } + + pub(crate) const fn get(self) -> f64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for PiNumber { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = f64::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +impl TryFrom for PiNumber { + type Error = PiNumberError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + +impl From for f64 { + fn from(value: PiNumber) -> Self { + value.get() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("Pi Number must be finite")] +pub(crate) struct PiNumberError; + +/// Opaque managed API identifier. +/// +/// This is not the gateway support enum. Any non-empty Pi-valid identifier +/// round-trips through this type, including identifiers introduced after the +/// pinned gateway implementation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub(crate) struct PiManagedApiId(String); + +impl PiManagedApiId { + pub(crate) fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(PiConfigError::EmptyApiId); + } + Ok(Self(value)) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PiManagedApiId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum PiModelInput { + Text, + Image, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiModelCostRates { + pub input: f64, + pub output: f64, + pub cache_read: f64, + pub cache_write: f64, +} + +impl Default for PiModelCostRates { + fn default() -> Self { + Self { + input: 0.0, + output: 0.0, + cache_read: 0.0, + cache_write: 0.0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiModelCostTier { + pub input_tokens_above: f64, + pub input: f64, + pub output: f64, + pub cache_read: f64, + pub cache_write: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiModelCost { + #[serde(flatten)] + pub rates: PiModelCostRates, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tiers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiModelCostOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tiers: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiManagedModelOverride { + #[serde(skip_serializing_if = "Option::is_none")] + 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 input: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: PiHeaderMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub compat: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiManagedModel { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + 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 input: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: PiHeaderMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub compat: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiManagedProviderConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub api: Option, + /// Literal or deferred transport material. The managed layer never + /// executes env/command/file/network resolution. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: PiHeaderMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_header: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub model_overrides: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub compat: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiEffectiveModel { + pub id: String, + pub name: String, + pub api: PiManagedApiId, + pub base_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + pub auth_header: bool, + pub reasoning: bool, + pub thinking_level_map: BTreeMap>, + pub input: Vec, + pub cost: PiModelCost, + pub context_window: PiNumber, + pub max_tokens: PiNumber, + pub headers: PiHeaderMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub compat: Option, + pub provider_extra: BTreeMap, + pub model_extra: BTreeMap, + pub override_extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub(crate) enum PiConfigError { + #[error("Pi provider must declare at least one managed model")] + ProviderHasNoModels, + #[error("Pi API id cannot be empty")] + EmptyApiId, + #[error("Pi model id cannot be empty")] + EmptyModelId, + #[error("Pi provider contains duplicate model id '{0}'")] + DuplicateModelId(String), + #[error("Pi model '{0}' does not exist in this provider")] + ModelNotFound(String), + #[error("Pi model '{model_id}' has no effective API id")] + MissingEffectiveApi { model_id: String }, + #[error("Pi model '{model_id}' has no effective endpoint")] + MissingEffectiveEndpoint { model_id: String }, + #[error("Pi endpoint at '{json_pointer}' must be an absolute HTTP(S) URL: {reason}")] + InvalidEndpoint { + json_pointer: String, + reason: String, + }, + #[error("Pi model override '{0}' does not match an explicit model id")] + UnknownModelOverride(String), + #[error("Pi compat at '{json_pointer}' must be an object")] + InvalidCompat { 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")] + NonPositiveModelLimit { + model_id: String, + field: &'static str, + }, + #[error("Pi model '{model_id}' contains an unmanaged thinking level '{level}'")] + InvalidThinkingLevel { model_id: String, level: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiNativeEntryKind { + BuiltInOverlay, + CustomCatalog, + ExtensionOverlay, + UnknownShape, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiRawNativeValidity { + Valid, + Invalid, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiManagedAssessment { + Manageable, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiCompositionStatus { + Composed, + Failed, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub(crate) enum PiManagementStatus { + Importable, + Managed { + #[serde(rename = "providerId")] + provider_id: String, + }, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiGatewayStatus { + Proxyable, + DirectOnly, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiDiagnosticLayer { + RawSchema, + Managed, + Composition, + Gateway, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiReasonCode { + RawSchemaMismatch, + RawSchemaUnsupportedOperator, + RawSchemaPinDrift, + RawSchemaAmbiguous, + CatalogRequired, + ModelOverridesOnly, + MissingExplicitModels, + ManagedTypeConversionFailed, + EmptyOptionalField, + EmptyModelId, + DuplicateModelId, + UnknownModelOverride, + MissingEffectiveApi, + MissingEffectiveEndpoint, + InvalidEndpoint, + InvalidCompat, + NonPositiveModelLimit, + InvalidThinkingLevel, + CompositionFailed, + GatewayCredentialUnavailable, + UnsupportedGatewayFamily, + InvalidHeaderName, + InvalidHeaderValue, + ProtectedHeader, + DeferredValueUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiDiagnosticReason { + pub layer: PiDiagnosticLayer, + pub code: PiReasonCode, + #[serde(skip_serializing_if = "Option::is_none")] + pub json_pointer: Option, +} + +impl PiDiagnosticReason { + pub(crate) fn new( + layer: PiDiagnosticLayer, + code: PiReasonCode, + json_pointer: Option, + ) -> Self { + Self { + layer, + code, + json_pointer, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiNativeDiagnostic { + pub provider_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub fingerprint: String, + pub kind: PiNativeEntryKind, + pub raw_validity: PiRawNativeValidity, + pub managed_assessment: PiManagedAssessment, + pub composition_status: PiCompositionStatus, + pub management_status: PiManagementStatus, + pub gateway_status: PiGatewayStatus, + pub reasons: Vec, +} + +const THINKING_LEVELS: [&str; 7] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +pub(crate) fn validate_pi_managed_provider( + provider: &PiManagedProviderConfig, +) -> Result<(), PiConfigError> { + if provider.models.is_empty() { + return Err(PiConfigError::ProviderHasNoModels); + } + validate_optional_text(provider.name.as_deref(), "provider name")?; + validate_optional_text(provider.api_key.as_deref(), "provider apiKey")?; + validate_present_endpoint(provider.base_url.as_deref(), "/baseUrl")?; + validate_compat(provider.compat.as_ref(), "/compat")?; + + let mut model_ids = HashSet::with_capacity(provider.models.len()); + for (index, model) in provider.models.iter().enumerate() { + if model.id.trim().is_empty() { + return Err(PiConfigError::EmptyModelId); + } + if !model_ids.insert(model.id.as_str()) { + return Err(PiConfigError::DuplicateModelId(model.id.clone())); + } + validate_optional_text(model.name.as_deref(), "model name")?; + validate_present_endpoint( + model.base_url.as_deref(), + &format!("/models/{index}/baseUrl"), + )?; + 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())?; + let _ = effective_pi_model_unchecked(provider, model)?; + } + + for (model_id, model_override) in &provider.model_overrides { + if !model_ids.contains(model_id.as_str()) { + return Err(PiConfigError::UnknownModelOverride(model_id.clone())); + } + 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_compat( + model_override.compat.as_ref(), + &format!("/modelOverrides/{}/compat", escape_json_pointer(model_id)), + )?; + } + Ok(()) +} + +pub(crate) fn effective_pi_model( + provider: &PiManagedProviderConfig, + model_id: &str, +) -> Result { + let model = provider + .models + .iter() + .find(|model| model.id == model_id) + .ok_or_else(|| PiConfigError::ModelNotFound(model_id.to_string()))?; + validate_pi_managed_provider(provider)?; + effective_pi_model_unchecked(provider, model) +} + +fn effective_pi_model_unchecked( + provider: &PiManagedProviderConfig, + model: &PiManagedModel, +) -> Result { + let api = model + .api + .clone() + .or_else(|| provider.api.clone()) + .ok_or_else(|| PiConfigError::MissingEffectiveApi { + model_id: model.id.clone(), + })?; + let base_url = model + .base_url + .as_ref() + .or(provider.base_url.as_ref()) + .ok_or_else(|| PiConfigError::MissingEffectiveEndpoint { + model_id: model.id.clone(), + })?; + let model_override = provider.model_overrides.get(&model.id); + let mut headers = provider.headers.clone(); + if let Some(model_override) = model_override { + headers.extend(model_override.headers.clone()); + } + 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 base_cost = model.cost.clone().unwrap_or_default(); + let cost = model_override + .and_then(|entry| entry.cost.as_ref()) + .map(|entry| apply_cost_override(base_cost.clone(), entry)) + .unwrap_or(base_cost); + + let compat = merge_compat( + merge_compat(provider.compat.clone(), model.compat.clone()), + model_override.and_then(|entry| entry.compat.clone()), + ); + + Ok(PiEffectiveModel { + id: model.id.clone(), + name: model_override + .and_then(|entry| entry.name.clone()) + .or_else(|| model.name.clone()) + .unwrap_or_else(|| model.id.clone()), + api, + base_url: base_url.clone(), + api_key: provider.api_key.clone(), + auth_header: provider.auth_header.unwrap_or(false), + reasoning: model_override + .and_then(|entry| entry.reasoning) + .or(model.reasoning) + .unwrap_or(false), + thinking_level_map, + input: model_override + .and_then(|entry| entry.input.clone()) + .or_else(|| model.input.clone()) + .unwrap_or_else(|| vec![PiModelInput::Text]), + cost, + context_window: model_override + .and_then(|entry| entry.context_window) + .or(model.context_window) + .unwrap_or(PiNumber::DEFAULT_CONTEXT_WINDOW), + max_tokens: model_override + .and_then(|entry| entry.max_tokens) + .or(model.max_tokens) + .unwrap_or(PiNumber::DEFAULT_MAX_TOKENS), + headers, + compat, + provider_extra: provider.extra.clone(), + model_extra: model.extra.clone(), + override_extra: model_override + .map(|entry| entry.extra.clone()) + .unwrap_or_default(), + }) +} + +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_compat(base: Option, overlay: Option) -> Option { + match (base, overlay) { + (None, None) => None, + (value, None) | (None, value) => value, + (Some(Value::Object(mut base)), Some(Value::Object(overlay))) => { + for (key, value) in overlay { + if matches!( + key.as_str(), + "openRouterRouting" | "vercelGatewayRouting" | "chatTemplateKwargs" + ) { + if let Some(Value::Object(base_nested)) = base.get_mut(&key) { + if let Value::Object(overlay_nested) = &value { + base_nested.extend(overlay_nested.clone()); + continue; + } + } + } + base.insert(key, value); + } + Some(Value::Object(base)) + } + (Some(_), Some(overlay)) => Some(overlay), + } +} + +fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), PiConfigError> { + if value.is_some_and(|value| value.trim().is_empty()) { + return Err(PiConfigError::EmptyOptionalField { field }); + } + Ok(()) +} + +fn validate_model_limit( + model: &PiManagedModel, + value: Option, + field: &'static str, +) -> Result<(), PiConfigError> { + validate_optional_limit(&model.id, value, field) +} + +fn validate_optional_limit( + model_id: &str, + value: Option, + field: &'static str, +) -> Result<(), PiConfigError> { + if value.is_some_and(|value| value.get() <= 0.0) { + return Err(PiConfigError::NonPositiveModelLimit { + model_id: model_id.to_string(), + field, + }); + } + Ok(()) +} + +fn validate_present_endpoint( + endpoint: Option<&str>, + json_pointer: &str, +) -> Result<(), PiConfigError> { + let Some(endpoint) = endpoint else { + return Ok(()); + }; + if endpoint.trim().is_empty() { + return Err(PiConfigError::InvalidEndpoint { + json_pointer: json_pointer.to_string(), + reason: "endpoint cannot be empty".to_string(), + }); + } + let parsed = Url::parse(endpoint).map_err(|error| PiConfigError::InvalidEndpoint { + json_pointer: json_pointer.to_string(), + reason: error.to_string(), + })?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + return Err(PiConfigError::InvalidEndpoint { + json_pointer: json_pointer.to_string(), + reason: "endpoint must be an absolute HTTP(S) URL".to_string(), + }); + } + Ok(()) +} + +fn validate_compat(compat: Option<&Value>, json_pointer: &str) -> Result<(), PiConfigError> { + if compat.is_some_and(|value| !value.is_object()) { + return Err(PiConfigError::InvalidCompat { + json_pointer: json_pointer.to_string(), + }); + } + Ok(()) +} + +fn escape_json_pointer(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn validate_thinking_levels<'a>( + model_id: &str, + levels: impl Iterator, +) -> Result<(), PiConfigError> { + for level in levels { + if !THINKING_LEVELS.contains(&level.as_str()) { + return Err(PiConfigError::InvalidThinkingLevel { + model_id: model_id.to_string(), + level: level.clone(), + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn api(value: &str) -> PiManagedApiId { + PiManagedApiId::new(value).expect("non-empty API id") + } + + fn number(value: f64) -> PiNumber { + PiNumber::new(value).expect("finite Pi Number") + } + + fn model(id: &str) -> PiManagedModel { + PiManagedModel { + id: id.to_string(), + name: None, + base_url: None, + api: None, + reasoning: None, + thinking_level_map: BTreeMap::new(), + input: None, + cost: None, + context_window: None, + max_tokens: None, + headers: BTreeMap::new(), + compat: None, + extra: BTreeMap::new(), + } + } + + fn provider(models: Vec) -> PiManagedProviderConfig { + PiManagedProviderConfig { + name: Some("Example".into()), + base_url: Some("https://example.com/api".into()), + api: Some(api("anthropic-messages")), + api_key: Some("$PI_KEY".into()), + headers: BTreeMap::new(), + auth_header: None, + models, + model_overrides: BTreeMap::new(), + compat: None, + extra: BTreeMap::new(), + } + } + + #[test] + fn opaque_api_ids_round_trip_without_gateway_narrowing() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "baseUrl": "https://future.example/v9", + "api": "future-wire-v9", + "models": [{"id": "future"}] + })) + .expect("opaque API id"); + validate_pi_managed_provider(&config).expect("future API is manageable"); + let effective = effective_pi_model(&config, "future").expect("effective future model"); + assert_eq!(effective.api.as_str(), "future-wire-v9"); + assert_eq!( + serde_json::to_value(&config).expect("serialize")["api"], + "future-wire-v9" + ); + } + + #[test] + fn provider_and_model_level_inheritance_is_exactly_two_levels() { + let inherited = effective_pi_model(&provider(vec![model("claude")]), "claude") + .expect("provider defaults"); + assert_eq!(inherited.api.as_str(), "anthropic-messages"); + assert_eq!(inherited.base_url, "https://example.com/api"); + + let mut self_contained = model("future"); + self_contained.api = Some(api("future-api")); + self_contained.base_url = Some("https://model.example/v2".into()); + let mut config = provider(vec![self_contained]); + config.api = None; + config.base_url = None; + let effective = effective_pi_model(&config, "future").expect("model defaults"); + assert_eq!(effective.api.as_str(), "future-api"); + assert_eq!(effective.base_url, "https://model.example/v2"); + } + + #[test] + fn override_precedence_and_nested_compat_are_stable() { + let mut base_model = model("m"); + base_model.reasoning = Some(false); + base_model.headers = BTreeMap::from([ + ("layer".into(), "model".into()), + ("model".into(), "yes".into()), + ]); + base_model.compat = Some(json!({ + "supportsStore": true, + "openRouterRouting": {"only": ["model"], "zdr": true} + })); + let mut config = provider(vec![base_model]); + config.headers = BTreeMap::from([ + ("layer".into(), "provider".into()), + ("provider".into(), "yes".into()), + ]); + config.compat = Some(json!({"supportsDeveloperRole": true})); + config.model_overrides.insert( + "m".into(), + PiManagedModelOverride { + reasoning: Some(true), + headers: BTreeMap::from([ + ("layer".into(), "override".into()), + ("override".into(), "yes".into()), + ]), + compat: Some(json!({ + "supportsStore": false, + "openRouterRouting": {"order": ["override"]} + })), + ..Default::default() + }, + ); + + let effective = effective_pi_model(&config, "m").expect("effective"); + assert!(effective.reasoning); + assert_eq!(effective.headers["layer"], "model"); + assert_eq!( + effective.compat, + Some(json!({ + "supportsDeveloperRole": true, + "supportsStore": false, + "openRouterRouting": { + "only": ["model"], + "zdr": true, + "order": ["override"] + } + })) + ); + } + + #[test] + fn fractional_pi_numbers_survive_managed_round_trip() { + let mut fractional = model("fractional"); + fractional.context_window = Some(number(128000.5)); + fractional.max_tokens = Some(number(16384.25)); + let config = provider(vec![fractional]); + let encoded = serde_json::to_value(&config).expect("serialize"); + let decoded: PiManagedProviderConfig = + serde_json::from_value(encoded).expect("deserialize"); + let effective = effective_pi_model(&decoded, "fractional").expect("effective"); + assert_eq!(effective.context_window.get(), 128000.5); + assert_eq!(effective.max_tokens.get(), 16384.25); + } + + #[test] + fn managed_narrowing_rejects_duplicates_or_unknown_thinking_keys() { + let duplicate = provider(vec![model("same"), model("same")]); + assert_eq!( + validate_pi_managed_provider(&duplicate), + Err(PiConfigError::DuplicateModelId("same".into())) + ); + + let mut future_thinking = model("thinking"); + future_thinking + .thinking_level_map + .insert("future".into(), Some("opaque".into())); + assert_eq!( + validate_pi_managed_provider(&provider(vec![future_thinking])), + Err(PiConfigError::InvalidThinkingLevel { + model_id: "thinking".into(), + level: "future".into() + }) + ); + } + + #[test] + fn diagnostic_reason_serialization_is_structured() { + let reason = PiDiagnosticReason::new( + PiDiagnosticLayer::Gateway, + PiReasonCode::UnsupportedGatewayFamily, + Some("/models/0/api".into()), + ); + assert_eq!( + serde_json::to_value(reason).expect("serialize"), + json!({ + "layer": "gateway", + "code": "unsupported_gateway_family", + "jsonPointer": "/models/0/api" + }) + ); + } +}