From 15c735f040ffddb5e9cf99f018647054d115615d Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 13:56:18 +0000 Subject: [PATCH] fix(pi): mirror pinned native runtime semantics --- src-tauri/src/pi_config/composer.rs | 171 ++++++++++++++++++-- src-tauri/src/pi_config/gateway.rs | 241 +++++++++++++++++++++------- src-tauri/src/pi_config/model.rs | 68 ++++++-- src-tauri/src/pi_config/native.rs | 44 ++++- 4 files changed, 435 insertions(+), 89 deletions(-) diff --git a/src-tauri/src/pi_config/composer.rs b/src-tauri/src/pi_config/composer.rs index 29decaed9..dfe40b275 100644 --- a/src-tauri/src/pi_config/composer.rs +++ b/src-tauri/src/pi_config/composer.rs @@ -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, + pub provider_headers: Vec, + pub model_headers: Vec, pub compat: Option, pub api_key: Option, pub oauth: Option, @@ -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::>(); 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) -> 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, overlay: Option) -> Option { Some(Value::Object(merged)) } -fn string_map(value: Option<&Value>) -> BTreeMap { +fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec { 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, + overlay: impl IntoIterator, +) { + 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, recognized: &[&str]) -> BTreeMap { 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![("x-layer", "override"), ("Authorization", "Bearer model")] + ); + } + #[test] fn unknown_provider_model_and_override_fields_are_retained_losslessly() { let value = json!({ diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index 7f9ad65ff..520a86ac0 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -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, + model_headers: Vec, 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, + protocol_identity_predictable: &mut bool, +) -> Vec { + 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::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 [ diff --git a/src-tauri/src/pi_config/model.rs b/src-tauri/src/pi_config/model.rs index c269a85af..89b739b95 100644 --- a/src-tauri/src/pi_config/model.rs +++ b/src-tauri/src/pi_config/model.rs @@ -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; +pub(crate) type PiHeaderMap = IndexMap; pub(crate) type PiThinkingLevelMap = BTreeMap; /// Pi uses JavaScript/TypeBox `Number`, not `Integer`, for model limits. @@ -183,7 +184,7 @@ pub(crate) struct PiManagedModelOverride { pub context_window: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, - #[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, @@ -213,7 +214,7 @@ pub(crate) struct PiManagedModel { pub context_window: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, - #[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, @@ -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, - #[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, @@ -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, pub provider_extra: BTreeMap, @@ -273,6 +279,12 @@ pub(crate) struct PiEffectiveModel { pub override_extra: BTreeMap, } +#[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, overlay: Option) -> Option { } 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!({ diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index 09237c2f6..489b70bd8 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -364,7 +364,7 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec