mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
fix(pi): mirror pinned native runtime semantics
This commit is contained in:
@@ -71,6 +71,18 @@ pub(crate) struct PiComposerReason {
|
||||
pub json_pointer: String,
|
||||
}
|
||||
|
||||
/// One configured header together with the source pointer that Pi resolves.
|
||||
///
|
||||
/// `headers` remains the pinned composer's flattened observable result, while
|
||||
/// these entries retain the provider-vs-model boundary needed to reproduce
|
||||
/// the later `ModelRuntime` merge on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PiComposedHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub json_pointer: String,
|
||||
}
|
||||
|
||||
/// The lossless native result of pinned Pi composition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PiComposedNativeModel {
|
||||
@@ -86,6 +98,8 @@ pub(crate) struct PiComposedNativeModel {
|
||||
pub context_window: Value,
|
||||
pub max_tokens: Value,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub provider_headers: Vec<PiComposedHeader>,
|
||||
pub model_headers: Vec<PiComposedHeader>,
|
||||
pub compat: Option<Value>,
|
||||
pub api_key: Option<String>,
|
||||
pub oauth: Option<Value>,
|
||||
@@ -176,7 +190,11 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
);
|
||||
}
|
||||
let provider_compat = provider_object.get("compat").cloned();
|
||||
let provider_headers = string_map(provider_object.get("headers"));
|
||||
let provider_header_entries = header_entries(provider_object.get("headers"), "/headers");
|
||||
let provider_headers = provider_header_entries
|
||||
.iter()
|
||||
.map(|entry| (entry.name.clone(), entry.value.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let provider_extra = unknown_fields(provider_object, PROVIDER_FIELDS);
|
||||
let api_key = provider_object
|
||||
.get("apiKey")
|
||||
@@ -282,6 +300,8 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(16384)),
|
||||
headers: BTreeMap::new(),
|
||||
provider_headers: provider_header_entries.clone(),
|
||||
model_headers: Vec::new(),
|
||||
compat: merge_compat(provider_compat.clone(), definition.get("compat").cloned()),
|
||||
api_key: api_key.clone(),
|
||||
oauth: oauth.clone(),
|
||||
@@ -301,22 +321,50 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
// Pinned Pi's rawModelHeaders uses Array.find, so duplicate model
|
||||
// definitions obtain request headers from the first definition even
|
||||
// though the later definition replaces the composed model slot.
|
||||
let definition = definitions
|
||||
let (definition_index, definition) = definitions
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|definition| {
|
||||
definition.get("id").and_then(Value::as_str) == Some(model.id.as_str())
|
||||
.enumerate()
|
||||
.find_map(|(index, definition)| {
|
||||
definition
|
||||
.as_object()
|
||||
.filter(|definition| {
|
||||
definition.get("id").and_then(Value::as_str) == Some(model.id.as_str())
|
||||
})
|
||||
.map(|definition| (index, definition))
|
||||
})
|
||||
.expect("raw-valid composed model has a source definition");
|
||||
let model_override =
|
||||
overrides.and_then(|overrides| overrides.get(&model.id).and_then(Value::as_object));
|
||||
|
||||
let mut headers = provider_headers.clone();
|
||||
// rawModelHeaders constructs one case-sensitive JavaScript object from
|
||||
// override headers followed by the first matching model definition.
|
||||
// Exact-name replacement keeps its insertion slot; differently-cased
|
||||
// names remain distinct until ModelRuntime performs its later
|
||||
// case-insensitive HTTP merge.
|
||||
let mut model_headers = Vec::new();
|
||||
if let Some(model_override) = model_override {
|
||||
headers.extend(string_map(model_override.get("headers")));
|
||||
overlay_header_entries(
|
||||
&mut model_headers,
|
||||
header_entries(
|
||||
model_override.get("headers"),
|
||||
&format!("/modelOverrides/{}/headers", escape_json_pointer(&model.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
overlay_header_entries(
|
||||
&mut model_headers,
|
||||
header_entries(
|
||||
definition.get("headers"),
|
||||
&format!("/models/{definition_index}/headers"),
|
||||
),
|
||||
);
|
||||
|
||||
let mut headers = provider_headers.clone();
|
||||
for entry in &model_headers {
|
||||
headers.insert(entry.name.clone(), entry.value.clone());
|
||||
}
|
||||
headers.extend(string_map(definition.get("headers")));
|
||||
model.headers = headers;
|
||||
model.model_headers = model_headers;
|
||||
|
||||
if let Some(model_override) = model_override {
|
||||
if let Some(name) = model_override.get("name").and_then(Value::as_str) {
|
||||
@@ -393,9 +441,13 @@ fn default_cost() -> Value {
|
||||
}
|
||||
|
||||
fn merge_cost(base: &Value, overlay: &Map<String, Value>) -> Value {
|
||||
let mut merged = base.as_object().cloned().unwrap_or_default();
|
||||
let base = base.as_object();
|
||||
let mut merged = Map::new();
|
||||
for key in ["input", "output", "cacheRead", "cacheWrite", "tiers"] {
|
||||
if let Some(value) = overlay.get(key) {
|
||||
if let Some(value) = overlay
|
||||
.get(key)
|
||||
.or_else(|| base.and_then(|base| base.get(key)))
|
||||
{
|
||||
merged.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
@@ -435,15 +487,38 @@ fn merge_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||
Some(Value::Object(merged))
|
||||
}
|
||||
|
||||
fn string_map(value: Option<&Value>) -> BTreeMap<String, String> {
|
||||
fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec<PiComposedHeader> {
|
||||
value
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flat_map(|object| object.iter())
|
||||
.filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string())))
|
||||
.filter_map(|(name, value)| {
|
||||
value.as_str().map(|value| PiComposedHeader {
|
||||
name: name.clone(),
|
||||
value: value.to_string(),
|
||||
json_pointer: format!("{base_pointer}/{}", escape_json_pointer(name)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn overlay_header_entries(
|
||||
base: &mut Vec<PiComposedHeader>,
|
||||
overlay: impl IntoIterator<Item = PiComposedHeader>,
|
||||
) {
|
||||
for entry in overlay {
|
||||
if let Some(existing) = base.iter_mut().find(|existing| existing.name == entry.name) {
|
||||
*existing = entry;
|
||||
} else {
|
||||
base.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_json_pointer(segment: &str) -> String {
|
||||
segment.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
fn unknown_fields(object: &Map<String, Value>, recognized: &[&str]) -> BTreeMap<String, Value> {
|
||||
object
|
||||
.iter()
|
||||
@@ -704,6 +779,78 @@ mod tests {
|
||||
assert_eq!(composed.models[0].headers["x-tenant"], "${TENANT}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_cost_override_reconstructs_only_known_cost_members() {
|
||||
let value = json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://cost.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 2,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0.2,
|
||||
"futureRate": 9
|
||||
}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {"cost": {"output": 3}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"cost-shape",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
assert_eq!(
|
||||
composed.models[0].cost,
|
||||
json!({
|
||||
"input": 1,
|
||||
"output": 3,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0.2
|
||||
}),
|
||||
"pinned applyModelOverride drops unknown base cost keys when an override exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_layers_retain_runtime_precedence_and_source_pointers() {
|
||||
let value = json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://headers.example",
|
||||
"apiKey": "literal",
|
||||
"headers": {"authorization": "Bearer provider"},
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"headers": {"Authorization": "Bearer model"}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {"headers": {"x-layer": "override"}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"header-layers",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
let model = &composed.models[0];
|
||||
assert_eq!(
|
||||
model.provider_headers[0].json_pointer,
|
||||
"/headers/authorization"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.model_headers
|
||||
.iter()
|
||||
.map(|entry| (entry.name.as_str(), entry.value.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("x-layer", "override"), ("Authorization", "Bearer model")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_model_and_override_fields_are_retained_losslessly() {
|
||||
let value = json!({
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::composer::{PiComposedNativeModel, PiComposerStatus, PiNativeComposition};
|
||||
use super::composer::{
|
||||
PiComposedHeader, PiComposedNativeModel, PiComposerStatus, PiNativeComposition,
|
||||
};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use url::Url;
|
||||
|
||||
@@ -177,11 +179,19 @@ pub(super) struct CandidateHeaderPlan {
|
||||
endpoint: Url,
|
||||
credential: DeferredHeaderValue,
|
||||
auth_header: bool,
|
||||
custom_headers: Vec<(HeaderName, String, DeferredHeaderValue)>,
|
||||
protocol_headers: Vec<(HeaderName, String, DeferredHeaderValue)>,
|
||||
provider_headers: Vec<PlannedHeader>,
|
||||
model_headers: Vec<PlannedHeader>,
|
||||
protocol_identity_predictable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlannedHeader {
|
||||
name: HeaderName,
|
||||
json_pointer: String,
|
||||
value: DeferredHeaderValue,
|
||||
class: ConfiguredHeaderClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct MaterializedCandidate {
|
||||
pub endpoint: Url,
|
||||
@@ -239,48 +249,17 @@ impl CandidateHeaderPlan {
|
||||
});
|
||||
};
|
||||
|
||||
let mut custom_headers = Vec::new();
|
||||
let mut protocol_headers = Vec::new();
|
||||
let mut protocol_identity_predictable = true;
|
||||
for (name, value) in &model.headers {
|
||||
let pointer = format!(
|
||||
"/models/{model_index}/headers/{}",
|
||||
escape_json_pointer(name)
|
||||
);
|
||||
let Ok(parsed_name) = HeaderName::from_bytes(name.as_bytes()) else {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderName,
|
||||
json_pointer: pointer,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let class = configured_header_class(&parsed_name);
|
||||
if class == ConfiguredHeaderClass::GatewayOwned {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::ProtectedHeader,
|
||||
json_pointer: pointer,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if !is_deferred(value) && parse_transport_header_value(value).is_none() {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderValue,
|
||||
json_pointer: pointer,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let planned = (
|
||||
parsed_name,
|
||||
pointer,
|
||||
DeferredHeaderValue::new(value.clone()),
|
||||
);
|
||||
if class == ConfiguredHeaderClass::Protocol {
|
||||
protocol_identity_predictable &= !value.starts_with('!');
|
||||
protocol_headers.push(planned);
|
||||
} else {
|
||||
custom_headers.push(planned);
|
||||
}
|
||||
}
|
||||
let provider_headers = plan_configured_headers(
|
||||
&model.provider_headers,
|
||||
&mut reasons,
|
||||
&mut protocol_identity_predictable,
|
||||
);
|
||||
let model_headers = plan_configured_headers(
|
||||
&model.model_headers,
|
||||
&mut reasons,
|
||||
&mut protocol_identity_predictable,
|
||||
);
|
||||
if !reasons.is_empty() {
|
||||
return Err(reasons);
|
||||
}
|
||||
@@ -298,8 +277,8 @@ impl CandidateHeaderPlan {
|
||||
endpoint,
|
||||
credential: DeferredHeaderValue::new(credential),
|
||||
auth_header: model.auth_header,
|
||||
custom_headers,
|
||||
protocol_headers,
|
||||
provider_headers,
|
||||
model_headers,
|
||||
protocol_identity_predictable,
|
||||
})
|
||||
}
|
||||
@@ -337,20 +316,27 @@ impl CandidateHeaderPlan {
|
||||
}
|
||||
};
|
||||
headers.insert(auth_name, auth_value);
|
||||
// Pinned Anthropic/OpenAI SDKs merge explicit configured headers after
|
||||
// their synthesized family auth, so an explicit candidate-auth value
|
||||
// wins when authHeader is disabled.
|
||||
for (name, pointer, value) in &self.custom_headers {
|
||||
headers.insert(name.clone(), value.materialize(resolver, pointer)?);
|
||||
}
|
||||
for (name, pointer, value) in &self.protocol_headers {
|
||||
let value = value.materialize(resolver, pointer)?;
|
||||
|
||||
// The pinned Anthropic SDK contributes this protocol default before
|
||||
// configured headers. Identity must therefore compare the final value
|
||||
// even when the user omitted it.
|
||||
if self.family == PiGatewayApiFamily::AnthropicMessages {
|
||||
let name = HeaderName::from_static("anthropic-version");
|
||||
let value = HeaderValue::from_static("2023-06-01");
|
||||
protocol_headers.insert(name.clone(), value.clone());
|
||||
headers.insert(name.clone(), value);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
// Pi's provider composer applies authHeader after custom headers. It
|
||||
// overwrites only Authorization and leaves family auth (for example
|
||||
// x-api-key) at its already-materialized final value.
|
||||
|
||||
// Provider headers are part of provider auth resolution. Pinned SDKs
|
||||
// merge them after synthesized family auth.
|
||||
apply_planned_headers(
|
||||
&self.provider_headers,
|
||||
resolver,
|
||||
&mut headers,
|
||||
&mut protocol_headers,
|
||||
)?;
|
||||
|
||||
// Provider-level authHeader is applied after provider headers.
|
||||
if self.auth_header {
|
||||
let credential = bearer_credential.to_str().map_err(|_| PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderValue,
|
||||
@@ -364,6 +350,17 @@ impl CandidateHeaderPlan {
|
||||
})?;
|
||||
headers.insert(HeaderName::from_static("authorization"), bearer);
|
||||
}
|
||||
|
||||
// ModelRuntime then performs a case-insensitive model-header overlay.
|
||||
// Keeping this as a separate phase is essential: flattening the two
|
||||
// layers before authHeader can send a different credential than Pi.
|
||||
apply_planned_headers(
|
||||
&self.model_headers,
|
||||
resolver,
|
||||
&mut headers,
|
||||
&mut protocol_headers,
|
||||
)?;
|
||||
|
||||
let host = authority_header(&self.endpoint).ok_or_else(|| PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidEndpoint,
|
||||
json_pointer: "/baseUrl".to_string(),
|
||||
@@ -379,6 +376,64 @@ impl CandidateHeaderPlan {
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_configured_headers(
|
||||
entries: &[PiComposedHeader],
|
||||
reasons: &mut Vec<PiGatewayReason>,
|
||||
protocol_identity_predictable: &mut bool,
|
||||
) -> Vec<PlannedHeader> {
|
||||
let mut planned = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let Ok(name) = HeaderName::from_bytes(entry.name.as_bytes()) else {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderName,
|
||||
json_pointer: entry.json_pointer.clone(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let class = configured_header_class(&name);
|
||||
if class == ConfiguredHeaderClass::GatewayOwned {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::ProtectedHeader,
|
||||
json_pointer: entry.json_pointer.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if !is_deferred(&entry.value) && parse_transport_header_value(&entry.value).is_none() {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderValue,
|
||||
json_pointer: entry.json_pointer.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if class == ConfiguredHeaderClass::Protocol {
|
||||
*protocol_identity_predictable &= !entry.value.starts_with('!');
|
||||
}
|
||||
planned.push(PlannedHeader {
|
||||
name,
|
||||
json_pointer: entry.json_pointer.clone(),
|
||||
value: DeferredHeaderValue::new(entry.value.clone()),
|
||||
class,
|
||||
});
|
||||
}
|
||||
planned
|
||||
}
|
||||
|
||||
fn apply_planned_headers(
|
||||
planned: &[PlannedHeader],
|
||||
resolver: &impl DeferredValueResolver,
|
||||
headers: &mut HeaderMap,
|
||||
protocol_headers: &mut HeaderMap,
|
||||
) -> Result<(), PiGatewayReason> {
|
||||
for entry in planned {
|
||||
let value = entry.value.materialize(resolver, &entry.json_pointer)?;
|
||||
if entry.class == ConfiguredHeaderClass::Protocol {
|
||||
protocol_headers.insert(entry.name.clone(), value.clone());
|
||||
}
|
||||
headers.insert(entry.name.clone(), value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl MaterializedCandidate {
|
||||
pub(super) fn failover_protocol_identity(&self) -> Option<(PiGatewayApiFamily, &HeaderMap)> {
|
||||
// Auth, tenant and arbitrary custom headers are deliberately excluded.
|
||||
@@ -438,10 +493,6 @@ fn parse_transport_header_value(value: &str) -> Option<HeaderValue> {
|
||||
HeaderValue::from_str(value).ok()
|
||||
}
|
||||
|
||||
fn escape_json_pointer(value: &str) -> String {
|
||||
value.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -766,6 +817,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_headers_overlay_provider_auth_case_insensitively() {
|
||||
for (provider_name, model_name) in [
|
||||
("authorization", "Authorization"),
|
||||
("Authorization", "authorization"),
|
||||
] {
|
||||
let composition = composed(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://candidate.example/v1",
|
||||
"apiKey": "candidate-secret",
|
||||
"authHeader": true,
|
||||
"headers": {provider_name: "Bearer provider-token"},
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"headers": {model_name: "Bearer model-token"}
|
||||
}]
|
||||
}));
|
||||
let materialized = assess_composition(&composition)
|
||||
.plans
|
||||
.remove(0)
|
||||
.materialize(&|_expression: &str| None)
|
||||
.expect("layered header materialization");
|
||||
assert_eq!(
|
||||
materialized.headers[&HeaderName::from_static("authorization")],
|
||||
"Bearer model-token",
|
||||
"pinned ModelRuntime applies model headers after provider authHeader"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_protocol_identity_includes_the_sdk_default() {
|
||||
let omitted = composed(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://first.example/v1",
|
||||
"apiKey": "first",
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let explicit = composed(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://second.example/v1",
|
||||
"apiKey": "second",
|
||||
"headers": {"anthropic-version": "2023-06-01"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let omitted = assess_composition(&omitted)
|
||||
.plans
|
||||
.remove(0)
|
||||
.materialize(&|_expression: &str| None)
|
||||
.expect("omitted SDK default");
|
||||
let explicit = assess_composition(&explicit)
|
||||
.plans
|
||||
.remove(0)
|
||||
.materialize(&|_expression: &str| None)
|
||||
.expect("explicit SDK default");
|
||||
assert_eq!(
|
||||
omitted.headers[&HeaderName::from_static("anthropic-version")],
|
||||
"2023-06-01"
|
||||
);
|
||||
assert_eq!(
|
||||
omitted.failover_protocol_identity(),
|
||||
explicit.failover_protocol_identity(),
|
||||
"wire-equivalent omitted and explicit SDK defaults must remain failover-compatible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_families_materialize_their_own_auth_headers() {
|
||||
for (family, auth_name, expected_value) in [
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use indexmap::IndexMap;
|
||||
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>;
|
||||
pub(crate) type PiHeaderMap = IndexMap<String, String>;
|
||||
pub(crate) type PiThinkingLevelMap = BTreeMap<String, Value>;
|
||||
|
||||
/// Pi uses JavaScript/TypeBox `Number`, not `Integer`, for model limits.
|
||||
@@ -183,7 +184,7 @@ pub(crate) struct PiManagedModelOverride {
|
||||
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")]
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
@@ -213,7 +214,7 @@ pub(crate) struct PiManagedModel {
|
||||
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")]
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
@@ -234,7 +235,7 @@ pub(crate) struct PiManagedProviderConfig {
|
||||
/// 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")]
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub headers: PiHeaderMap,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_header: Option<bool>,
|
||||
@@ -266,6 +267,11 @@ pub(crate) struct PiEffectiveModel {
|
||||
pub context_window: PiNumber,
|
||||
pub max_tokens: PiNumber,
|
||||
pub headers: PiHeaderMap,
|
||||
/// Provider auth headers and the later model overlay are kept distinct so
|
||||
/// a gateway can reproduce pinned Pi's case-insensitive runtime merge
|
||||
/// without changing the serialized effective projection.
|
||||
#[serde(skip)]
|
||||
pub header_layers: PiEffectiveHeaderLayers,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
pub provider_extra: BTreeMap<String, Value>,
|
||||
@@ -273,6 +279,12 @@ pub(crate) struct PiEffectiveModel {
|
||||
pub override_extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct PiEffectiveHeaderLayers {
|
||||
pub provider: PiHeaderMap,
|
||||
pub model: PiHeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub(crate) enum PiConfigError {
|
||||
#[error("Pi provider must declare at least one managed model")]
|
||||
@@ -453,7 +465,7 @@ pub(crate) fn validate_pi_managed_provider(
|
||||
|
||||
let mut model_ids = HashSet::with_capacity(provider.models.len());
|
||||
for (index, model) in provider.models.iter().enumerate() {
|
||||
if model.id.trim().is_empty() {
|
||||
if model.id.is_empty() {
|
||||
return Err(PiConfigError::EmptyModelId);
|
||||
}
|
||||
if !model_ids.insert(model.id.as_str()) {
|
||||
@@ -519,11 +531,13 @@ fn effective_pi_model_unchecked(
|
||||
model_id: model.id.clone(),
|
||||
})?;
|
||||
let model_override = provider.model_overrides.get(&model.id);
|
||||
let mut headers = provider.headers.clone();
|
||||
let mut model_headers = PiHeaderMap::new();
|
||||
if let Some(model_override) = model_override {
|
||||
headers.extend(model_override.headers.clone());
|
||||
model_headers.extend(model_override.headers.clone());
|
||||
}
|
||||
headers.extend(model.headers.clone());
|
||||
model_headers.extend(model.headers.clone());
|
||||
let mut headers = provider.headers.clone();
|
||||
headers.extend(model_headers.clone());
|
||||
|
||||
let thinking_level_map = merge_thinking_level_maps(
|
||||
model.thinking_level_map.as_ref(),
|
||||
@@ -570,6 +584,10 @@ fn effective_pi_model_unchecked(
|
||||
.or(model.max_tokens)
|
||||
.unwrap_or(PiNumber::DEFAULT_MAX_TOKENS),
|
||||
headers,
|
||||
header_layers: PiEffectiveHeaderLayers {
|
||||
provider: provider.headers.clone(),
|
||||
model: model_headers,
|
||||
},
|
||||
compat,
|
||||
provider_extra: provider.extra.clone(),
|
||||
model_extra: model.extra.clone(),
|
||||
@@ -634,7 +652,7 @@ fn merge_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), PiConfigError> {
|
||||
if value.is_some_and(|value| value.trim().is_empty()) {
|
||||
if value.is_some_and(str::is_empty) {
|
||||
return Err(PiConfigError::EmptyOptionalField { field });
|
||||
}
|
||||
Ok(())
|
||||
@@ -744,7 +762,7 @@ mod tests {
|
||||
cost: None,
|
||||
context_window: None,
|
||||
max_tokens: None,
|
||||
headers: BTreeMap::new(),
|
||||
headers: PiHeaderMap::new(),
|
||||
compat: None,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
@@ -756,7 +774,7 @@ mod tests {
|
||||
base_url: Some("https://example.com/api".into()),
|
||||
api: Some(api("anthropic-messages")),
|
||||
api_key: Some("$PI_KEY".into()),
|
||||
headers: BTreeMap::new(),
|
||||
headers: PiHeaderMap::new(),
|
||||
auth_header: None,
|
||||
models,
|
||||
model_overrides: BTreeMap::new(),
|
||||
@@ -800,11 +818,31 @@ mod tests {
|
||||
assert_eq!(effective.base_url, "https://model.example/v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_valid_whitespace_strings_remain_manageable() {
|
||||
let config: PiManagedProviderConfig = serde_json::from_value(json!({
|
||||
"name": " ",
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://example.com",
|
||||
"apiKey": " ",
|
||||
"models": [{"id": " ", "name": " "}]
|
||||
}))
|
||||
.expect("pinned schema-valid whitespace fields");
|
||||
validate_pi_managed_provider(&config)
|
||||
.expect("managed validation must use pinned minLength semantics without trimming");
|
||||
assert_eq!(
|
||||
effective_pi_model(&config, " ")
|
||||
.expect("whitespace model id")
|
||||
.id,
|
||||
" "
|
||||
);
|
||||
}
|
||||
|
||||
#[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([
|
||||
base_model.headers = PiHeaderMap::from([
|
||||
("layer".into(), "model".into()),
|
||||
("model".into(), "yes".into()),
|
||||
]);
|
||||
@@ -813,7 +851,7 @@ mod tests {
|
||||
"openRouterRouting": {"only": ["model"], "zdr": true}
|
||||
}));
|
||||
let mut config = provider(vec![base_model]);
|
||||
config.headers = BTreeMap::from([
|
||||
config.headers = PiHeaderMap::from([
|
||||
("layer".into(), "provider".into()),
|
||||
("provider".into(), "yes".into()),
|
||||
]);
|
||||
@@ -822,7 +860,7 @@ mod tests {
|
||||
"m".into(),
|
||||
PiManagedModelOverride {
|
||||
reasoning: Some(true),
|
||||
headers: BTreeMap::from([
|
||||
headers: PiHeaderMap::from([
|
||||
("layer".into(), "override".into()),
|
||||
("override".into(), "yes".into()),
|
||||
]),
|
||||
@@ -837,6 +875,8 @@ mod tests {
|
||||
let effective = effective_pi_model(&config, "m").expect("effective");
|
||||
assert!(effective.reasoning);
|
||||
assert_eq!(effective.headers["layer"], "model");
|
||||
assert_eq!(effective.header_layers.provider["layer"], "provider");
|
||||
assert_eq!(effective.header_layers.model["layer"], "model");
|
||||
assert_eq!(
|
||||
effective.compat,
|
||||
Some(json!({
|
||||
|
||||
@@ -364,7 +364,7 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec<PiDiagnostic
|
||||
}
|
||||
for (index, model) in config.models.iter().enumerate() {
|
||||
let pointer = format!("/models/{index}");
|
||||
if model.id.trim().is_empty() {
|
||||
if model.id.is_empty() {
|
||||
add_reason(
|
||||
&mut reasons,
|
||||
diagnostic_reason(
|
||||
@@ -860,6 +860,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_inspection_accepts_schema_valid_whitespace_strings() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("models.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"providers":{"whitespace":{
|
||||
"name":" ",
|
||||
"api":"anthropic-messages",
|
||||
"baseUrl":"https://whitespace.example",
|
||||
"apiKey":" ",
|
||||
"models":[{"id":" ","name":" "}]
|
||||
}}}"#,
|
||||
)
|
||||
.expect("write");
|
||||
let inspection =
|
||||
PiNativeInspectionService::inspect_entry(&path, "whitespace", &BTreeMap::new())
|
||||
.expect("inspect")
|
||||
.expect("entry");
|
||||
assert_eq!(
|
||||
inspection.diagnostic.raw_validity,
|
||||
PiRawNativeValidity::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.diagnostic.managed_assessment,
|
||||
PiManagedAssessment::Manageable
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.diagnostic.management_status,
|
||||
PiManagementStatus::Importable
|
||||
);
|
||||
assert_eq!(
|
||||
inspection
|
||||
.managed_config
|
||||
.as_ref()
|
||||
.expect("managed config")
|
||||
.models[0]
|
||||
.id,
|
||||
" "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_entry_fingerprint_changes_only_when_that_raw_entry_changes() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in New Issue
Block a user