mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
domain(pi): add managed model and capability contracts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<String, String>;
|
||||
|
||||
/// 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<Self, PiNumberError> {
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = f64::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<f64> for PiNumber {
|
||||
type Error = PiNumberError;
|
||||
|
||||
fn try_from(value: f64) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PiNumber> 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<String>) -> Result<Self, PiConfigError> {
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<PiModelCostTier>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tiers: Option<Vec<PiModelCostTier>>,
|
||||
}
|
||||
|
||||
#[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<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 input: Option<Vec<PiModelInput>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<PiModelCostOverride>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window: Option<PiNumber>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<PiNumber>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 input: Option<Vec<PiModelInput>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<PiModelCost>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window: Option<PiNumber>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<PiNumber>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<PiManagedApiId>,
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_header: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<PiManagedModel>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub model_overrides: BTreeMap<String, PiManagedModelOverride>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub auth_header: bool,
|
||||
pub reasoning: bool,
|
||||
pub thinking_level_map: BTreeMap<String, Option<String>>,
|
||||
pub input: Vec<PiModelInput>,
|
||||
pub cost: PiModelCost,
|
||||
pub context_window: PiNumber,
|
||||
pub max_tokens: PiNumber,
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
pub provider_extra: BTreeMap<String, Value>,
|
||||
pub model_extra: BTreeMap<String, Value>,
|
||||
pub override_extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
impl PiDiagnosticReason {
|
||||
pub(crate) fn new(
|
||||
layer: PiDiagnosticLayer,
|
||||
code: PiReasonCode,
|
||||
json_pointer: Option<String>,
|
||||
) -> 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<String>,
|
||||
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<PiDiagnosticReason>,
|
||||
}
|
||||
|
||||
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<PiEffectiveModel, PiConfigError> {
|
||||
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<PiEffectiveModel, PiConfigError> {
|
||||
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<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||
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<PiNumber>,
|
||||
field: &'static str,
|
||||
) -> Result<(), PiConfigError> {
|
||||
validate_optional_limit(&model.id, value, field)
|
||||
}
|
||||
|
||||
fn validate_optional_limit(
|
||||
model_id: &str,
|
||||
value: Option<PiNumber>,
|
||||
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<Item = &'a String>,
|
||||
) -> 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<PiManagedModel>) -> 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"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user