mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
1130 lines
39 KiB
Rust
1130 lines
39 KiB
Rust
//! Immutable Pi gateway catalog and native projection planning.
|
|
//!
|
|
//! The database remains the managed provider authority. A snapshot is built
|
|
//! from complete provider aggregates, exact-key ownership claims, and a stable
|
|
//! device token; only after the matching `models.json` patch succeeds is that
|
|
//! snapshot published for request admission.
|
|
|
|
use crate::database::Database;
|
|
use crate::error::AppError;
|
|
use crate::pi_config::composer::PiComposedNativeModel;
|
|
use crate::pi_config::gateway::{
|
|
assess_composition_for_runtime, CandidateHeaderPlan, MaterializedCandidate, PiGatewayApiFamily,
|
|
PiGatewayCapability, PiGatewayReason,
|
|
};
|
|
use crate::pi_config::model::PiManagedProviderConfig;
|
|
use crate::pi_config::native::compose_managed_pi_provider;
|
|
use crate::provider::ProviderAggregate;
|
|
use crate::proxy::types::AppProxyConfig;
|
|
use crate::settings::GatewayToken;
|
|
use indexmap::IndexMap;
|
|
use serde_json::{Map, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::io::Read;
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::{
|
|
atomic::{AtomicU64, Ordering},
|
|
mpsc, Arc, RwLock,
|
|
};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::{OwnedRwLockReadGuard, RwLock as AsyncRwLock};
|
|
use url::Url;
|
|
|
|
const PI_APP: &str = "pi";
|
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const COMMAND_OUTPUT_LIMIT: u64 = 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PiRuntimeModel {
|
|
provider_id: String,
|
|
provider_name: String,
|
|
family: PiGatewayApiFamily,
|
|
wire_profile: Vec<u8>,
|
|
plan: CandidateHeaderPlan,
|
|
endpoints: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PiRuntimeProvider {
|
|
models: HashMap<String, PiRuntimeModel>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PiRouteBinding {
|
|
provider_id: String,
|
|
}
|
|
|
|
/// One immutable catalog matching a successfully published native projection.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PiRuntimeSnapshot {
|
|
pub(crate) server_generation: u64,
|
|
pub(crate) catalog_epoch: u64,
|
|
gateway_token: GatewayToken,
|
|
app_config: AppProxyConfig,
|
|
providers: HashMap<String, PiRuntimeProvider>,
|
|
failover_ids: Vec<String>,
|
|
routes: HashMap<String, PiRouteBinding>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PiRequestCandidate {
|
|
pub(crate) provider_id: String,
|
|
pub(crate) provider_name: String,
|
|
pub(crate) family: PiGatewayApiFamily,
|
|
pub(crate) plan: CandidateHeaderPlan,
|
|
pub(crate) is_failover: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PiRequestRoute {
|
|
pub(crate) catalog_epoch: u64,
|
|
pub(crate) app_config: AppProxyConfig,
|
|
pub(crate) candidates: Vec<PiRequestCandidate>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct PiMaterializedAttempt {
|
|
pub(crate) provider_id: String,
|
|
pub(crate) provider_name: String,
|
|
pub(crate) is_failover: bool,
|
|
pub(crate) transport: MaterializedCandidate,
|
|
pub(crate) url: Url,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct PiRuntimeBuild {
|
|
pub(crate) snapshot: Arc<PiRuntimeSnapshot>,
|
|
/// Exact keys only. A direct-only provider deliberately keeps its original
|
|
/// database projection while proxyable siblings point at the gateway.
|
|
pub(crate) projection_patch: IndexMap<String, Option<Value>>,
|
|
pub(crate) direct_only_provider_ids: Vec<String>,
|
|
}
|
|
|
|
impl PiRuntimeSnapshot {
|
|
pub(crate) fn token_matches(&self, candidate: &str) -> bool {
|
|
self.gateway_token.constant_time_eq(candidate)
|
|
}
|
|
|
|
pub(crate) fn route(
|
|
&self,
|
|
route_token: &str,
|
|
family: PiGatewayApiFamily,
|
|
model_id: &str,
|
|
) -> Result<PiRequestRoute, AppError> {
|
|
let binding = self
|
|
.routes
|
|
.get(route_token)
|
|
.ok_or_else(|| AppError::NotFound("unknown Pi gateway provider route".to_string()))?;
|
|
let primary = self
|
|
.providers
|
|
.get(&binding.provider_id)
|
|
.and_then(|provider| provider.models.get(model_id))
|
|
.filter(|model| model.family == family)
|
|
.ok_or_else(|| {
|
|
AppError::InvalidInput(format!(
|
|
"Pi provider '{}' does not expose model '{model_id}' for {}",
|
|
binding.provider_id,
|
|
family.as_str()
|
|
))
|
|
})?;
|
|
|
|
let mut candidates = expand_model_attempts(primary, false)?;
|
|
if self.app_config.auto_failover_enabled {
|
|
for provider_id in &self.failover_ids {
|
|
if provider_id == &binding.provider_id {
|
|
continue;
|
|
}
|
|
let Some(candidate) = self
|
|
.providers
|
|
.get(provider_id)
|
|
.and_then(|provider| provider.models.get(model_id))
|
|
else {
|
|
continue;
|
|
};
|
|
if candidate.family != family
|
|
|| candidate.wire_profile != primary.wire_profile
|
|
|| !candidate.plan.protocol_identity_is_predictable()
|
|
{
|
|
continue;
|
|
}
|
|
candidates.extend(expand_model_attempts(candidate, true)?);
|
|
}
|
|
}
|
|
|
|
Ok(PiRequestRoute {
|
|
catalog_epoch: self.catalog_epoch,
|
|
app_config: self.app_config.clone(),
|
|
candidates,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn expand_model_attempts(
|
|
model: &PiRuntimeModel,
|
|
is_failover: bool,
|
|
) -> Result<Vec<PiRequestCandidate>, AppError> {
|
|
let mut plans = Vec::with_capacity(model.endpoints.len().saturating_add(1));
|
|
plans.push(model.plan.clone());
|
|
for endpoint in &model.endpoints {
|
|
let plan = model.plan.with_endpoint(endpoint).map_err(gateway_reason)?;
|
|
if !plans
|
|
.iter()
|
|
.any(|existing| existing.endpoint() == plan.endpoint())
|
|
{
|
|
plans.push(plan);
|
|
}
|
|
}
|
|
Ok(plans
|
|
.into_iter()
|
|
.map(|plan| PiRequestCandidate {
|
|
provider_id: model.provider_id.clone(),
|
|
provider_name: model.provider_name.clone(),
|
|
family: model.family,
|
|
plan,
|
|
is_failover,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
impl PiRequestCandidate {
|
|
pub(crate) fn materialize(
|
|
self,
|
|
forwarded_path_and_query: &str,
|
|
) -> Result<PiMaterializedAttempt, AppError> {
|
|
let resolver_failure = std::cell::Cell::new(false);
|
|
let transport = self
|
|
.plan
|
|
.materialize_for_runtime(&|expression: &str| {
|
|
let resolved = resolve_pi_config_value(expression);
|
|
resolver_failure.set(resolver_failure.get() || resolved.is_err());
|
|
resolved.ok()
|
|
})
|
|
.map_err(|reason| {
|
|
if resolver_failure.get() {
|
|
AppError::Config(
|
|
"failed to resolve a deferred Pi gateway credential or header".to_string(),
|
|
)
|
|
} else {
|
|
gateway_reason(reason)
|
|
}
|
|
})?;
|
|
let url = build_family_url(self.family, &transport.endpoint, forwarded_path_and_query)?;
|
|
Ok(PiMaterializedAttempt {
|
|
provider_id: self.provider_id,
|
|
provider_name: self.provider_name,
|
|
is_failover: self.is_failover,
|
|
transport,
|
|
url,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn protocol_identity_is_predictable(&self) -> bool {
|
|
self.plan.protocol_identity_is_predictable()
|
|
}
|
|
|
|
pub(crate) fn planned_protocol_identity(
|
|
&self,
|
|
) -> Result<Option<(String, http::HeaderMap)>, AppError> {
|
|
let resolver_failure = std::cell::Cell::new(false);
|
|
let identity = self
|
|
.plan
|
|
.materialize_protocol_identity(&|expression: &str| {
|
|
// Protocol !commands are marked unpredictable before this
|
|
// method is called. An auth command must not be run merely to
|
|
// decide whether a circuit-skipped primary permits failover.
|
|
if expression.starts_with('!') {
|
|
resolver_failure.set(true);
|
|
return None;
|
|
}
|
|
let resolved = resolve_pi_config_value(expression);
|
|
resolver_failure.set(resolver_failure.get() || resolved.is_err());
|
|
resolved.ok()
|
|
})
|
|
.map_err(|reason| {
|
|
if resolver_failure.get() {
|
|
AppError::Config(
|
|
"failed to pre-resolve Pi primary protocol identity".to_string(),
|
|
)
|
|
} else {
|
|
gateway_reason(reason)
|
|
}
|
|
})?;
|
|
Ok(identity.map(|(family, headers)| (family.as_str().to_string(), headers)))
|
|
}
|
|
}
|
|
|
|
fn gateway_reason(reason: PiGatewayReason) -> AppError {
|
|
AppError::Config(format!(
|
|
"Pi gateway candidate rejected at {}: {:?}",
|
|
reason.json_pointer, reason.code
|
|
))
|
|
}
|
|
|
|
/// Build the immutable runtime and its exact native projection in one pass.
|
|
pub(crate) fn build_pi_runtime(
|
|
db: &Database,
|
|
server_generation: u64,
|
|
catalog_epoch: u64,
|
|
gateway_origin: &Url,
|
|
gateway_token: GatewayToken,
|
|
app_config: AppProxyConfig,
|
|
) -> Result<PiRuntimeBuild, AppError> {
|
|
if catalog_epoch % 2 != 0 {
|
|
return Err(AppError::Config(
|
|
"Pi runtime publication requires an even catalog epoch".to_string(),
|
|
));
|
|
}
|
|
let aggregates = db.get_all_provider_aggregates(PI_APP)?;
|
|
let manifest = db.get_pi_projection_manifest()?;
|
|
if aggregates.len() != manifest.len()
|
|
|| aggregates
|
|
.keys()
|
|
.any(|provider_id| !manifest.contains_key(provider_id))
|
|
{
|
|
return Err(AppError::Conflict(
|
|
"Pi provider aggregates and exact-key ownership claims diverged".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut providers = HashMap::new();
|
|
let mut routes = HashMap::new();
|
|
let mut projection_patch = IndexMap::new();
|
|
let mut direct_only_provider_ids = Vec::new();
|
|
for (provider_id, aggregate) in aggregates {
|
|
let projection = manifest.get(&provider_id).ok_or_else(|| {
|
|
AppError::Conflict(format!(
|
|
"Pi provider '{provider_id}' has no exact-key claim"
|
|
))
|
|
})?;
|
|
let config = decode_managed_config(&aggregate)?;
|
|
let composition = compose_managed_pi_provider(&projection.provider_key, &config)?;
|
|
let assessment = assess_composition_for_runtime(&composition);
|
|
if assessment.capability != PiGatewayCapability::Proxyable
|
|
|| assessment.plans.len() != composition.models.len()
|
|
{
|
|
projection_patch.insert(
|
|
projection.provider_key.clone(),
|
|
Some(serde_json::to_value(&config).map_err(|source| {
|
|
AppError::Config(format!(
|
|
"failed to serialize direct-only Pi provider: {source}"
|
|
))
|
|
})?),
|
|
);
|
|
direct_only_provider_ids.push(provider_id);
|
|
continue;
|
|
}
|
|
|
|
let token = pi_route_token(&provider_id, &projection.provider_key);
|
|
let local_base = gateway_origin
|
|
.join(&format!("pi/{token}"))
|
|
.map_err(|error| AppError::Config(format!("invalid Pi gateway origin: {error}")))?;
|
|
let endpoints = aggregate.endpoints.keys().cloned().collect::<Vec<_>>();
|
|
let mut models = HashMap::new();
|
|
for ((model, plan), expected) in composition
|
|
.models
|
|
.iter()
|
|
.zip(assessment.plans)
|
|
.zip(config.models.iter())
|
|
{
|
|
if model.id != expected.id {
|
|
return Err(AppError::Config(format!(
|
|
"Pi composer changed managed model order for '{provider_id}'"
|
|
)));
|
|
}
|
|
let family = plan.family();
|
|
let runtime_model = runtime_model(&aggregate, model, family, plan, endpoints.clone())?;
|
|
if models.insert(model.id.clone(), runtime_model).is_some() {
|
|
return Err(AppError::Conflict(format!(
|
|
"duplicate Pi model '{}' in provider '{provider_id}'",
|
|
model.id
|
|
)));
|
|
}
|
|
}
|
|
if routes
|
|
.insert(
|
|
token,
|
|
PiRouteBinding {
|
|
provider_id: provider_id.clone(),
|
|
},
|
|
)
|
|
.is_some()
|
|
{
|
|
return Err(AppError::Conflict(
|
|
"Pi gateway route digest collision".to_string(),
|
|
));
|
|
}
|
|
let projected = project_config_for_gateway(&config, &local_base, &gateway_token)?;
|
|
projection_patch.insert(projection.provider_key.clone(), Some(projected));
|
|
providers.insert(provider_id, PiRuntimeProvider { models });
|
|
}
|
|
|
|
let failover_ids = db
|
|
.get_failover_queue(PI_APP)?
|
|
.into_iter()
|
|
.map(|item| item.provider_id)
|
|
.collect();
|
|
Ok(PiRuntimeBuild {
|
|
snapshot: Arc::new(PiRuntimeSnapshot {
|
|
server_generation,
|
|
catalog_epoch,
|
|
gateway_token,
|
|
app_config,
|
|
providers,
|
|
failover_ids,
|
|
routes,
|
|
}),
|
|
projection_patch,
|
|
direct_only_provider_ids,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn direct_pi_projection_patch(
|
|
db: &Database,
|
|
) -> Result<IndexMap<String, Option<Value>>, AppError> {
|
|
let aggregates = db.get_all_provider_aggregates(PI_APP)?;
|
|
let manifest = db.get_pi_projection_manifest()?;
|
|
if aggregates.len() != manifest.len() {
|
|
return Err(AppError::Conflict(
|
|
"Pi provider aggregates and exact-key claims diverged".to_string(),
|
|
));
|
|
}
|
|
let mut patch = IndexMap::new();
|
|
for (provider_id, aggregate) in aggregates {
|
|
let projection = manifest.get(&provider_id).ok_or_else(|| {
|
|
AppError::Conflict(format!(
|
|
"Pi provider '{provider_id}' has no exact-key claim"
|
|
))
|
|
})?;
|
|
let config = decode_managed_config(&aggregate)?;
|
|
patch.insert(
|
|
projection.provider_key.clone(),
|
|
Some(serde_json::to_value(config).map_err(|source| {
|
|
AppError::Config(format!("failed to serialize Pi provider: {source}"))
|
|
})?),
|
|
);
|
|
}
|
|
Ok(patch)
|
|
}
|
|
|
|
/// Render one managed provider for an in-progress catalog mutation. This is
|
|
/// the same planning boundary used by the full runtime build, so the
|
|
/// coordinator never carries a second notion of "proxyable".
|
|
pub(crate) fn project_managed_pi_config(
|
|
provider_id: &str,
|
|
provider_key: &str,
|
|
config: &PiManagedProviderConfig,
|
|
gateway_origin: &Url,
|
|
gateway_token: &GatewayToken,
|
|
) -> Result<Value, AppError> {
|
|
let composition = compose_managed_pi_provider(provider_key, config)?;
|
|
let assessment = assess_composition_for_runtime(&composition);
|
|
if assessment.capability != PiGatewayCapability::Proxyable
|
|
|| assessment.plans.len() != composition.models.len()
|
|
{
|
|
return serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source });
|
|
}
|
|
let token = pi_route_token(provider_id, provider_key);
|
|
let local_base = gateway_origin
|
|
.join(&format!("pi/{token}"))
|
|
.map_err(|error| AppError::Config(format!("invalid Pi gateway origin: {error}")))?;
|
|
project_config_for_gateway(config, &local_base, gateway_token)
|
|
}
|
|
|
|
fn decode_managed_config(
|
|
aggregate: &ProviderAggregate,
|
|
) -> Result<PiManagedProviderConfig, AppError> {
|
|
serde_json::from_value(aggregate.provider.settings_config.clone()).map_err(|error| {
|
|
AppError::Config(format!(
|
|
"managed Pi provider '{}' is invalid: {error}",
|
|
aggregate.provider.id
|
|
))
|
|
})
|
|
}
|
|
|
|
fn runtime_model(
|
|
aggregate: &ProviderAggregate,
|
|
model: &PiComposedNativeModel,
|
|
family: PiGatewayApiFamily,
|
|
plan: CandidateHeaderPlan,
|
|
endpoints: Vec<String>,
|
|
) -> Result<PiRuntimeModel, AppError> {
|
|
Ok(PiRuntimeModel {
|
|
provider_id: aggregate.provider.id.clone(),
|
|
provider_name: aggregate.provider.name.clone(),
|
|
family,
|
|
wire_profile: canonical_wire_profile(model)?,
|
|
plan,
|
|
endpoints,
|
|
})
|
|
}
|
|
|
|
fn canonical_wire_profile(model: &PiComposedNativeModel) -> Result<Vec<u8>, AppError> {
|
|
let mut profile = Map::new();
|
|
profile.insert("reasoning".to_string(), Value::Bool(model.reasoning));
|
|
profile.insert(
|
|
"thinkingLevelMap".to_string(),
|
|
model.thinking_level_map.clone().unwrap_or(Value::Null),
|
|
);
|
|
profile.insert("input".to_string(), model.input.clone());
|
|
profile.insert("contextWindow".to_string(), model.context_window.clone());
|
|
profile.insert("maxTokens".to_string(), model.max_tokens.clone());
|
|
profile.insert(
|
|
"compat".to_string(),
|
|
model.compat.clone().unwrap_or(Value::Null),
|
|
);
|
|
profile.insert(
|
|
"providerExtra".to_string(),
|
|
serde_json::to_value(&model.provider_extra)
|
|
.map_err(|source| AppError::JsonSerialize { source })?,
|
|
);
|
|
profile.insert(
|
|
"modelExtra".to_string(),
|
|
serde_json::to_value(&model.model_extra)
|
|
.map_err(|source| AppError::JsonSerialize { source })?,
|
|
);
|
|
profile.insert(
|
|
"overrideExtra".to_string(),
|
|
serde_json::to_value(&model.override_extra)
|
|
.map_err(|source| AppError::JsonSerialize { source })?,
|
|
);
|
|
serde_json::to_vec(&canonical_json(&Value::Object(profile)))
|
|
.map_err(|source| AppError::JsonSerialize { source })
|
|
}
|
|
|
|
fn canonical_json(value: &Value) -> Value {
|
|
match value {
|
|
Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
|
|
Value::Object(values) => {
|
|
let sorted = values
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), canonical_json(value)))
|
|
.collect::<BTreeMap<_, _>>();
|
|
Value::Object(sorted.into_iter().collect())
|
|
}
|
|
scalar => scalar.clone(),
|
|
}
|
|
}
|
|
|
|
fn project_config_for_gateway(
|
|
config: &PiManagedProviderConfig,
|
|
local_base: &Url,
|
|
gateway_token: &GatewayToken,
|
|
) -> Result<Value, AppError> {
|
|
let mut value =
|
|
serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?;
|
|
let root = value
|
|
.as_object_mut()
|
|
.ok_or_else(|| AppError::Config("Pi provider projection is not an object".to_string()))?;
|
|
root.insert(
|
|
"apiKey".to_string(),
|
|
Value::String(gateway_token.expose().to_string()),
|
|
);
|
|
root.remove("headers");
|
|
root.remove("authHeader");
|
|
root.remove("oauth");
|
|
let provider_has_base = root.contains_key("baseUrl");
|
|
if provider_has_base {
|
|
root.insert(
|
|
"baseUrl".to_string(),
|
|
Value::String(local_base.as_str().trim_end_matches('/').to_string()),
|
|
);
|
|
}
|
|
let models = root
|
|
.get_mut("models")
|
|
.and_then(Value::as_array_mut)
|
|
.ok_or_else(|| AppError::Config("Pi provider projection has no models".to_string()))?;
|
|
for model in models {
|
|
let object = model
|
|
.as_object_mut()
|
|
.ok_or_else(|| AppError::Config("Pi model projection is not an object".to_string()))?;
|
|
object.remove("headers");
|
|
if object.contains_key("baseUrl") || !provider_has_base {
|
|
object.insert(
|
|
"baseUrl".to_string(),
|
|
Value::String(local_base.as_str().trim_end_matches('/').to_string()),
|
|
);
|
|
}
|
|
}
|
|
if let Some(overrides) = root
|
|
.get_mut("modelOverrides")
|
|
.and_then(Value::as_object_mut)
|
|
{
|
|
for model_override in overrides.values_mut() {
|
|
if let Some(object) = model_override.as_object_mut() {
|
|
object.remove("headers");
|
|
}
|
|
}
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn pi_route_token(provider_id: &str, provider_key: &str) -> String {
|
|
let mut digest = Sha256::new();
|
|
digest.update(b"cc-switch:pi-route:v2\0");
|
|
digest.update(provider_id.as_bytes());
|
|
digest.update([0]);
|
|
digest.update(provider_key.as_bytes());
|
|
digest
|
|
.finalize()
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect()
|
|
}
|
|
|
|
/// Process-local publication point. Odd epochs close admission; an even
|
|
/// snapshot is leased by `Arc`, so requests already admitted keep a coherent
|
|
/// catalog while a replacement is prepared.
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct PiRuntimeStore {
|
|
current: RwLock<Option<Arc<PiRuntimeSnapshot>>>,
|
|
catalog_epoch: AtomicU64,
|
|
epoch_gate: Arc<AsyncRwLock<()>>,
|
|
}
|
|
|
|
impl PiRuntimeStore {
|
|
pub(crate) async fn begin_mutation(&self) -> u64 {
|
|
let _guard = self.epoch_gate.write().await;
|
|
let current = self.catalog_epoch.load(Ordering::Acquire);
|
|
let odd = if current % 2 == 0 {
|
|
current.saturating_add(1)
|
|
} else {
|
|
current
|
|
};
|
|
self.catalog_epoch.store(odd, Ordering::Release);
|
|
odd.saturating_add(1)
|
|
}
|
|
|
|
pub(crate) fn next_even_epoch(&self) -> Result<u64, AppError> {
|
|
let current = self.catalog_epoch.load(Ordering::Acquire);
|
|
if current % 2 != 0 {
|
|
return Err(AppError::Conflict(
|
|
"cannot publish a sorted Pi runtime while catalog admission is fenced".to_string(),
|
|
));
|
|
}
|
|
let next = current.saturating_add(2);
|
|
if next % 2 != 0 {
|
|
return Err(AppError::Config(
|
|
"Pi catalog epoch overflowed its even publication sequence".to_string(),
|
|
));
|
|
}
|
|
Ok(next)
|
|
}
|
|
|
|
pub(crate) async fn publish(&self, snapshot: Arc<PiRuntimeSnapshot>) -> Result<(), AppError> {
|
|
if snapshot.catalog_epoch % 2 != 0 {
|
|
return Err(AppError::Config(
|
|
"cannot publish an odd Pi catalog epoch".to_string(),
|
|
));
|
|
}
|
|
let _guard = self.epoch_gate.write().await;
|
|
let epoch = snapshot.catalog_epoch;
|
|
*self
|
|
.current
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(snapshot);
|
|
self.catalog_epoch.store(epoch, Ordering::Release);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn close(&self, even_epoch: u64) -> Result<(), AppError> {
|
|
if even_epoch % 2 != 0 {
|
|
return Err(AppError::Config(
|
|
"Pi admission close requires an even terminal epoch".to_string(),
|
|
));
|
|
}
|
|
let _guard = self.epoch_gate.write().await;
|
|
*self
|
|
.current
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
|
self.catalog_epoch.store(even_epoch, Ordering::Release);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn republish_current(&self, even_epoch: u64) -> Result<bool, AppError> {
|
|
let current = self
|
|
.current
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.as_ref()
|
|
.cloned();
|
|
let Some(current) = current else {
|
|
self.close(even_epoch).await?;
|
|
return Ok(false);
|
|
};
|
|
let mut next = (*current).clone();
|
|
next.catalog_epoch = even_epoch;
|
|
self.publish(Arc::new(next)).await?;
|
|
Ok(true)
|
|
}
|
|
|
|
pub(crate) fn lease(&self, server_generation: u64) -> Option<Arc<PiRuntimeSnapshot>> {
|
|
let epoch = self.catalog_epoch.load(Ordering::Acquire);
|
|
if epoch % 2 != 0 {
|
|
return None;
|
|
}
|
|
let snapshot = self
|
|
.current
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.as_ref()
|
|
.filter(|snapshot| {
|
|
snapshot.server_generation == server_generation && snapshot.catalog_epoch == epoch
|
|
})
|
|
.cloned()?;
|
|
(self.catalog_epoch.load(Ordering::Acquire) == epoch).then_some(snapshot)
|
|
}
|
|
|
|
pub(crate) fn is_admitting(&self, server_generation: u64) -> bool {
|
|
self.lease(server_generation).is_some()
|
|
}
|
|
|
|
pub(crate) async fn admission_guard(
|
|
self: &Arc<Self>,
|
|
server_generation: u64,
|
|
snapshot: &Arc<PiRuntimeSnapshot>,
|
|
) -> Option<OwnedRwLockReadGuard<()>> {
|
|
let guard = self.epoch_gate.clone().read_owned().await;
|
|
let current = self
|
|
.current
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.as_ref()
|
|
.is_some_and(|current| {
|
|
snapshot.catalog_epoch % 2 == 0
|
|
&& self.catalog_epoch.load(Ordering::Acquire) == snapshot.catalog_epoch
|
|
&& current.server_generation == server_generation
|
|
&& Arc::ptr_eq(current, snapshot)
|
|
});
|
|
current.then_some(guard)
|
|
}
|
|
|
|
pub(crate) async fn writeback_guard(
|
|
self: &Arc<Self>,
|
|
expected_epoch: u64,
|
|
) -> Option<OwnedRwLockReadGuard<()>> {
|
|
let guard = self.epoch_gate.clone().read_owned().await;
|
|
(expected_epoch % 2 == 0 && self.catalog_epoch.load(Ordering::Acquire) == expected_epoch)
|
|
.then_some(guard)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn infer_family(path: &str) -> Option<PiGatewayApiFamily> {
|
|
if path == "/v1/messages" {
|
|
Some(PiGatewayApiFamily::AnthropicMessages)
|
|
} else if path == "/chat/completions" {
|
|
Some(PiGatewayApiFamily::OpenAiCompletions)
|
|
} else if matches!(path, "/responses" | "/responses/compact") {
|
|
Some(PiGatewayApiFamily::OpenAiResponses)
|
|
} else if path.starts_with("/models/") || path == "/models" {
|
|
Some(PiGatewayApiFamily::GoogleGenerativeAi)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn build_family_url(
|
|
family: PiGatewayApiFamily,
|
|
base: &Url,
|
|
path_and_query: &str,
|
|
) -> Result<Url, AppError> {
|
|
let (path, query) = path_and_query
|
|
.split_once('?')
|
|
.map_or((path_and_query, None), |(path, query)| (path, Some(query)));
|
|
if infer_family(path) != Some(family) {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Pi gateway path '{path}' does not match {}",
|
|
family.as_str()
|
|
)));
|
|
}
|
|
let mut url = base.clone();
|
|
let base_path = base.path().trim_end_matches('/');
|
|
let suffix = path.trim_start_matches('/');
|
|
let combined = if base_path.is_empty() || base_path == "/" {
|
|
format!("/{suffix}")
|
|
} else {
|
|
format!("{base_path}/{suffix}")
|
|
};
|
|
url.set_path(&combined);
|
|
url.set_query(query);
|
|
url.set_fragment(None);
|
|
Ok(url)
|
|
}
|
|
|
|
fn resolve_pi_config_value(expression: &str) -> Result<String, String> {
|
|
if let Some(command) = expression.strip_prefix('!') {
|
|
return execute_config_command(command);
|
|
}
|
|
expand_environment(expression)
|
|
}
|
|
|
|
fn expand_environment(input: &str) -> Result<String, String> {
|
|
const ESCAPED_DOLLAR: char = '\u{e000}';
|
|
const ESCAPED_BANG: char = '\u{e001}';
|
|
let chars = input.chars().collect::<Vec<_>>();
|
|
let mut output = String::new();
|
|
let mut index = 0;
|
|
while index < chars.len() {
|
|
if chars[index] != '$' {
|
|
output.push(chars[index]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if chars.get(index + 1) == Some(&'$') {
|
|
output.push(ESCAPED_DOLLAR);
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if chars.get(index + 1) == Some(&'!') {
|
|
output.push(ESCAPED_BANG);
|
|
index += 2;
|
|
continue;
|
|
}
|
|
let (name, next) = if chars.get(index + 1) == Some(&'{') {
|
|
let Some(end) = chars[index + 2..].iter().position(|value| *value == '}') else {
|
|
return Err("unterminated Pi environment expression".to_string());
|
|
};
|
|
let end = index + 2 + end;
|
|
(chars[index + 2..end].iter().collect::<String>(), end + 1)
|
|
} else {
|
|
let mut end = index + 1;
|
|
while end < chars.len() && (chars[end] == '_' || chars[end].is_ascii_alphanumeric()) {
|
|
end += 1;
|
|
}
|
|
if end == index + 1 {
|
|
output.push('$');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
(chars[index + 1..end].iter().collect::<String>(), end)
|
|
};
|
|
if name.is_empty()
|
|
|| !name
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|value| value == '_' || value.is_ascii_alphabetic())
|
|
{
|
|
return Err("invalid Pi environment variable name".to_string());
|
|
}
|
|
let value = std::env::var(&name)
|
|
.map_err(|_| format!("Pi environment variable '{name}' is unavailable"))?;
|
|
output.push_str(&value);
|
|
index = next;
|
|
}
|
|
Ok(output
|
|
.replace(ESCAPED_DOLLAR, "$")
|
|
.replace(ESCAPED_BANG, "!"))
|
|
}
|
|
|
|
fn execute_config_command(script: &str) -> Result<String, String> {
|
|
if script.trim().is_empty() {
|
|
return Err("empty Pi config command".to_string());
|
|
}
|
|
let mut command = if cfg!(windows) {
|
|
let mut command = Command::new("cmd");
|
|
command.args(["/D", "/S", "/C", script]);
|
|
command
|
|
} else {
|
|
let mut command = Command::new("/bin/sh");
|
|
command.args(["-c", script]);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
command.process_group(0);
|
|
}
|
|
command
|
|
};
|
|
let mut child = command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|error| format!("failed to start Pi config command: {error}"))?;
|
|
let command_tree = CommandTree::attach(&mut child)?;
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| "failed to capture Pi config command stdout".to_string())?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| "failed to capture Pi config command stderr".to_string())?;
|
|
let (stdout_sender, stdout_reader) = mpsc::sync_channel(1);
|
|
let (stderr_sender, stderr_reader) = mpsc::sync_channel(1);
|
|
std::thread::spawn(move || {
|
|
let _ = stdout_sender.send(read_bounded(stdout));
|
|
});
|
|
std::thread::spawn(move || {
|
|
let _ = stderr_sender.send(read_bounded(stderr));
|
|
});
|
|
let started = Instant::now();
|
|
let status = loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => break status,
|
|
Ok(None) if started.elapsed() < COMMAND_TIMEOUT => {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Ok(None) => {
|
|
command_tree.terminate(&mut child);
|
|
let _ = child.wait();
|
|
return Err("Pi config command timed out".to_string());
|
|
}
|
|
Err(error) => {
|
|
command_tree.terminate(&mut child);
|
|
let _ = child.wait();
|
|
return Err(format!("failed to wait for Pi config command: {error}"));
|
|
}
|
|
}
|
|
};
|
|
// A successful shell may leave descendants holding inherited pipe handles.
|
|
// Terminate the whole tree before draining, and keep the original deadline
|
|
// over both process wait and output collection.
|
|
command_tree.terminate(&mut child);
|
|
let deadline = started + COMMAND_TIMEOUT;
|
|
let stdout = receive_command_output(
|
|
&stdout_reader,
|
|
deadline,
|
|
"Pi config command stdout did not close before timeout",
|
|
)??;
|
|
let stderr = receive_command_output(
|
|
&stderr_reader,
|
|
deadline,
|
|
"Pi config command stderr did not close before timeout",
|
|
)??;
|
|
if !status.success() {
|
|
return Err(format!(
|
|
"Pi config command exited unsuccessfully: {}",
|
|
String::from_utf8_lossy(&stderr).trim()
|
|
));
|
|
}
|
|
String::from_utf8(stdout)
|
|
.map(|value| value.trim().to_string())
|
|
.map_err(|_| "Pi config command output is not UTF-8".to_string())
|
|
}
|
|
|
|
fn read_bounded(reader: impl Read) -> Result<Vec<u8>, String> {
|
|
let mut output = Vec::new();
|
|
reader
|
|
.take(COMMAND_OUTPUT_LIMIT + 1)
|
|
.read_to_end(&mut output)
|
|
.map_err(|error| format!("failed to read Pi config command output: {error}"))?;
|
|
if output.len() as u64 > COMMAND_OUTPUT_LIMIT {
|
|
return Err("Pi config command output exceeded 1 MiB".to_string());
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
fn receive_command_output(
|
|
receiver: &mpsc::Receiver<Result<Vec<u8>, String>>,
|
|
deadline: Instant,
|
|
timeout_message: &str,
|
|
) -> Result<Result<Vec<u8>, String>, String> {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
receiver
|
|
.recv_timeout(remaining)
|
|
.map_err(|error| match error {
|
|
mpsc::RecvTimeoutError::Timeout => timeout_message.to_string(),
|
|
mpsc::RecvTimeoutError::Disconnected => {
|
|
"Pi config command output reader stopped unexpectedly".to_string()
|
|
}
|
|
})
|
|
}
|
|
|
|
struct CommandTree {
|
|
#[cfg(windows)]
|
|
job: windows_sys::Win32::Foundation::HANDLE,
|
|
}
|
|
|
|
impl CommandTree {
|
|
fn attach(child: &mut std::process::Child) -> Result<Self, String> {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::mem::size_of;
|
|
use std::os::windows::io::AsRawHandle;
|
|
use windows_sys::Win32::Foundation::CloseHandle;
|
|
use windows_sys::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
};
|
|
|
|
// SAFETY: all pointers are either null or point to initialized
|
|
// values for the duration of their synchronous Win32 calls.
|
|
unsafe {
|
|
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
|
if job.is_null() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!(
|
|
"failed to create Pi config command job: {}",
|
|
std::io::Error::last_os_error()
|
|
));
|
|
}
|
|
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
if SetInformationJobObject(
|
|
job,
|
|
JobObjectExtendedLimitInformation,
|
|
(&raw const limits).cast(),
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
) == 0
|
|
{
|
|
let error = std::io::Error::last_os_error();
|
|
CloseHandle(job);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!(
|
|
"failed to configure Pi config command job: {error}"
|
|
));
|
|
}
|
|
if AssignProcessToJobObject(job, child.as_raw_handle() as _) == 0 {
|
|
let error = std::io::Error::last_os_error();
|
|
CloseHandle(job);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!(
|
|
"failed to assign Pi config command to its job: {error}"
|
|
));
|
|
}
|
|
return Ok(Self { job });
|
|
}
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
let _ = child;
|
|
Ok(Self {})
|
|
}
|
|
}
|
|
|
|
fn terminate(&self, child: &mut std::process::Child) {
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
let _ = libc::kill(-(child.id() as i32), libc::SIGKILL);
|
|
}
|
|
#[cfg(windows)]
|
|
unsafe {
|
|
let _ = windows_sys::Win32::System::JobObjects::TerminateJobObject(self.job, 1);
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl Drop for CommandTree {
|
|
fn drop(&mut self) {
|
|
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is the final safety net if an
|
|
// early return occurs before explicit termination.
|
|
unsafe {
|
|
let _ = windows_sys::Win32::Foundation::CloseHandle(self.job);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn environment_resolution_matches_vendored_transport_oracle() {
|
|
std::env::set_var("PI_RUNTIME_TEST_VALUE", "environment-secret");
|
|
assert_eq!(
|
|
resolve_pi_config_value("prefix-${PI_RUNTIME_TEST_VALUE}-suffix").unwrap(),
|
|
"prefix-environment-secret-suffix"
|
|
);
|
|
assert_eq!(
|
|
resolve_pi_config_value("$$literal-$!bang").unwrap(),
|
|
"$literal-!bang"
|
|
);
|
|
std::env::remove_var("PI_RUNTIME_TEST_VALUE");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn command_deadline_covers_descendants_holding_output_pipes() {
|
|
let started = Instant::now();
|
|
let output =
|
|
execute_config_command("sleep 30 & printf inherited-pipe").expect("command output");
|
|
assert_eq!(output, "inherited-pipe");
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(2),
|
|
"background descendants must be terminated before output drain"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_resolution_matches_vendored_transport_oracle() {
|
|
#[cfg(unix)]
|
|
assert_eq!(
|
|
resolve_pi_config_value("!printf pi-command-value").unwrap(),
|
|
"pi-command-value"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn family_url_builders_preserve_candidate_origin_and_base_path() {
|
|
let base = Url::parse("https://candidate.example:8443/root/v1").unwrap();
|
|
let url = build_family_url(
|
|
PiGatewayApiFamily::OpenAiResponses,
|
|
&base,
|
|
"/responses?stream=true",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
url.as_str(),
|
|
"https://candidate.example:8443/root/v1/responses?stream=true"
|
|
);
|
|
assert!(
|
|
build_family_url(PiGatewayApiFamily::AnthropicMessages, &base, "/responses").is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wire_profile_is_key_order_insensitive_but_array_and_unknown_sensitive() {
|
|
fn model(extra: Value, input: Value) -> PiComposedNativeModel {
|
|
PiComposedNativeModel {
|
|
id: "m".to_string(),
|
|
name: "M".to_string(),
|
|
api: crate::pi_config::raw_schema::PiRawApiId::new("openai-responses".to_string())
|
|
.unwrap(),
|
|
provider: "p".to_string(),
|
|
base_url: "https://example.test/v1".to_string(),
|
|
reasoning: false,
|
|
thinking_level_map: None,
|
|
input,
|
|
cost: json!({"input": 1}),
|
|
context_window: json!(1000),
|
|
max_tokens: json!(100),
|
|
headers: BTreeMap::new(),
|
|
provider_headers: Vec::new(),
|
|
model_headers: Vec::new(),
|
|
compat: None,
|
|
api_key: Some("secret".to_string()),
|
|
oauth: None,
|
|
auth_header: false,
|
|
provider_extra: serde_json::from_value(extra).unwrap(),
|
|
model_extra: BTreeMap::new(),
|
|
override_extra: BTreeMap::new(),
|
|
}
|
|
}
|
|
let first = model(json!({"z": 1, "a": {"b": 2, "a": 1}}), json!(["text"]));
|
|
let reordered = model(json!({"a": {"a": 1, "b": 2}, "z": 1}), json!(["text"]));
|
|
assert_eq!(
|
|
canonical_wire_profile(&first).unwrap(),
|
|
canonical_wire_profile(&reordered).unwrap()
|
|
);
|
|
let changed = model(
|
|
json!({"z": 1, "a": {"b": 2, "a": 1}}),
|
|
json!(["image", "text"]),
|
|
);
|
|
assert_ne!(
|
|
canonical_wire_profile(&first).unwrap(),
|
|
canonical_wire_profile(&changed).unwrap()
|
|
);
|
|
}
|
|
}
|