mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): fail closed on unrepresentable compat spread
This commit is contained in:
@@ -91,6 +91,7 @@ writeFileSync(
|
||||
`export { streamSimple as openaiResponses } from "${PI}/packages/ai/src/api/openai-responses.ts";`,
|
||||
`export { streamSimple as openaiCompletions } from "${PI}/packages/ai/src/api/openai-completions.ts";`,
|
||||
`export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`,
|
||||
`export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`,
|
||||
].join("\n"),
|
||||
);
|
||||
const bundlePath = join(harnessDirectory, "bundle.mjs");
|
||||
@@ -242,6 +243,115 @@ const compatProvider = adapters.composeModelProvider(
|
||||
);
|
||||
const compatSpread = compatProvider.getModels()[0].compat;
|
||||
|
||||
function jsonSafeJavaScriptValue(value) {
|
||||
if (typeof value === "string") {
|
||||
const codeUnits = Array.from({ length: value.length }, (_, index) =>
|
||||
value.charCodeAt(index),
|
||||
);
|
||||
const hasLoneSurrogate = codeUnits.some((unit, index) => {
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
return !(
|
||||
index + 1 < codeUnits.length &&
|
||||
codeUnits[index + 1] >= 0xdc00 &&
|
||||
codeUnits[index + 1] <= 0xdfff
|
||||
);
|
||||
}
|
||||
if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||
return !(
|
||||
index > 0 &&
|
||||
codeUnits[index - 1] >= 0xd800 &&
|
||||
codeUnits[index - 1] <= 0xdbff
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return hasLoneSurrogate
|
||||
? {
|
||||
$javascriptStringUtf16: codeUnits.map((unit) =>
|
||||
unit.toString(16).padStart(4, "0"),
|
||||
),
|
||||
}
|
||||
: value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(jsonSafeJavaScriptValue);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
jsonSafeJavaScriptValue(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function captureCompatSpread(label, baseValue, overlayValue) {
|
||||
const providerId = `compat-${label}`;
|
||||
const provider = adapters.composeModelProvider(
|
||||
providerId,
|
||||
undefined,
|
||||
{
|
||||
getProvider(candidate) {
|
||||
if (candidate !== providerId) return undefined;
|
||||
return {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://compat.example/v1",
|
||||
apiKey: "literal",
|
||||
compat: { chatTemplateKwargs: baseValue },
|
||||
models: [{ id: "m" }],
|
||||
modelOverrides: {
|
||||
m: { compat: { chatTemplateKwargs: overlayValue } },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
label,
|
||||
baseValue,
|
||||
overlayValue,
|
||||
result: jsonSafeJavaScriptValue(
|
||||
provider.getModels()[0].compat.chatTemplateKwargs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const compatEdgeCases = [
|
||||
captureCompatSpread("ascii-string-to-string", "ab", "cd"),
|
||||
captureCompatSpread("astral-string-to-object", "😀", { named: true }),
|
||||
captureCompatSpread("string-to-array", "ab", ["first", "second"]),
|
||||
];
|
||||
|
||||
const resolverInputs = [
|
||||
"literal-secret",
|
||||
"cash$money",
|
||||
"café$literal",
|
||||
"$$literal-$!bang",
|
||||
"prefix-${PI_CAPTURE_MISSING}-suffix",
|
||||
];
|
||||
const resolverCases = resolverInputs.map((input) => {
|
||||
try {
|
||||
return {
|
||||
input,
|
||||
status: "success",
|
||||
result: adapters.resolveConfigValueOrThrow(
|
||||
input,
|
||||
"transport capture",
|
||||
{},
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
input,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.close();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -252,6 +362,8 @@ console.log(
|
||||
baseUrl,
|
||||
results,
|
||||
compatSpread,
|
||||
compatEdgeCases,
|
||||
resolverCases,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -144,6 +144,7 @@ fn path_segments(path: &SynPath) -> Vec<String> {
|
||||
|
||||
struct ArchitectureVisitor<'a> {
|
||||
path: &'a str,
|
||||
source_module: Option<&'static str>,
|
||||
violations: Vec<Violation>,
|
||||
internal_edges: BTreeSet<String>,
|
||||
}
|
||||
@@ -159,7 +160,7 @@ fn pi_config_source_module(path: &str) -> Option<&'static str> {
|
||||
|
||||
impl ArchitectureVisitor<'_> {
|
||||
fn record_dependency(&mut self, segments: &[String]) {
|
||||
let Some(source_module) = pi_config_source_module(self.path) else {
|
||||
let Some(source_module) = self.source_module else {
|
||||
return;
|
||||
};
|
||||
let qualified_internal_path = segments.len() > 1
|
||||
@@ -346,6 +347,14 @@ fn provider_dml_path_allowed(path: &str) -> bool {
|
||||
}
|
||||
|
||||
fn scan_source(path: &str, source: &str) -> (Vec<Violation>, BTreeSet<String>) {
|
||||
scan_source_as_module(path, source, pi_config_source_module(path))
|
||||
}
|
||||
|
||||
fn scan_source_as_module(
|
||||
path: &str,
|
||||
source: &str,
|
||||
source_module: Option<&'static str>,
|
||||
) -> (Vec<Violation>, BTreeSet<String>) {
|
||||
let syntax = match syn::parse_file(source) {
|
||||
Ok(syntax) => syntax,
|
||||
Err(error) => {
|
||||
@@ -366,6 +375,7 @@ fn scan_source(path: &str, source: &str) -> (Vec<Violation>, BTreeSet<String>) {
|
||||
}
|
||||
let mut visitor = ArchitectureVisitor {
|
||||
path,
|
||||
source_module,
|
||||
violations: Vec::new(),
|
||||
internal_edges: BTreeSet::new(),
|
||||
};
|
||||
@@ -394,23 +404,142 @@ fn rust_sources(root: &Path) -> Vec<PathBuf> {
|
||||
output
|
||||
}
|
||||
|
||||
fn module_path_attribute(attrs: &[Attribute]) -> Option<PathBuf> {
|
||||
attrs.iter().find_map(|attribute| {
|
||||
if !attribute.path().is_ident("path") {
|
||||
return None;
|
||||
}
|
||||
let Meta::NameValue(value) = &attribute.meta else {
|
||||
return None;
|
||||
};
|
||||
let syn::Expr::Lit(expression) = &value.value else {
|
||||
return None;
|
||||
};
|
||||
let Lit::Str(path) = &expression.lit else {
|
||||
return None;
|
||||
};
|
||||
Some(PathBuf::from(path.value()))
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_path_module_targets(items: &[Item], source_path: &Path, output: &mut Vec<PathBuf>) {
|
||||
for item in items {
|
||||
let Item::Mod(module) = item else {
|
||||
continue;
|
||||
};
|
||||
if is_cfg_test(&module.attrs) {
|
||||
continue;
|
||||
}
|
||||
if let Some(relative) = module_path_attribute(&module.attrs) {
|
||||
output.push(
|
||||
source_path
|
||||
.parent()
|
||||
.expect("Rust source has a parent directory")
|
||||
.join(relative),
|
||||
);
|
||||
}
|
||||
if let Some((_, nested)) = &module.content {
|
||||
collect_path_module_targets(nested, source_path, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inherited_module_owners(
|
||||
sources: &[(PathBuf, String, String)],
|
||||
) -> BTreeMap<PathBuf, BTreeSet<&'static str>> {
|
||||
let mut owners = BTreeMap::<PathBuf, BTreeSet<&'static str>>::new();
|
||||
for (path, relative, _) in sources {
|
||||
if let Some(owner) = pi_config_source_module(relative) {
|
||||
owners
|
||||
.entry(fs::canonicalize(path).expect("canonicalize Rust source"))
|
||||
.or_default()
|
||||
.insert(owner);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for (path, _, source) in sources {
|
||||
let canonical = fs::canonicalize(path).expect("canonicalize Rust source");
|
||||
let source_owners = owners.get(&canonical).cloned().unwrap_or_default();
|
||||
if source_owners.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(syntax) = syn::parse_file(source) else {
|
||||
continue;
|
||||
};
|
||||
if is_cfg_test(&syntax.attrs) {
|
||||
continue;
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
collect_path_module_targets(&syntax.items, path, &mut targets);
|
||||
for target in targets {
|
||||
let Ok(target) = fs::canonicalize(target) else {
|
||||
continue;
|
||||
};
|
||||
let target_owners = owners.entry(target).or_default();
|
||||
let before = target_owners.len();
|
||||
target_owners.extend(source_owners.iter().copied());
|
||||
changed |= target_owners.len() != before;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return owners;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_production_tree(
|
||||
manifest_dir: &Path,
|
||||
source_root: &Path,
|
||||
) -> (Vec<Violation>, BTreeSet<String>) {
|
||||
let mut violations = Vec::new();
|
||||
let mut edges = BTreeSet::new();
|
||||
for path in rust_sources(source_root) {
|
||||
let relative = path
|
||||
.strip_prefix(manifest_dir)
|
||||
.expect("source is under manifest")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let source = fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
|
||||
let (mut source_violations, source_edges) = scan_source(&relative, &source);
|
||||
violations.append(&mut source_violations);
|
||||
edges.extend(source_edges);
|
||||
let sources = rust_sources(source_root)
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let relative = path
|
||||
.strip_prefix(manifest_dir)
|
||||
.expect("source is under manifest")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let source = fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
|
||||
(path, relative, source)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let owners = inherited_module_owners(&sources);
|
||||
let scanned_paths = sources
|
||||
.iter()
|
||||
.map(|(path, _, _)| fs::canonicalize(path).expect("canonicalize Rust source"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
for (target, target_owners) in &owners {
|
||||
if !scanned_paths.contains(target) {
|
||||
violations.push(Violation {
|
||||
kind: "module_path_outside_scan",
|
||||
path: target.display().to_string(),
|
||||
detail: format!(
|
||||
"a #[path] module owned by {} escapes the scanned source tree",
|
||||
target_owners.iter().copied().collect::<Vec<_>>().join(",")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (path, relative, source) in sources {
|
||||
let canonical = fs::canonicalize(&path).expect("canonicalize Rust source");
|
||||
let inherited = owners.get(&canonical).cloned().unwrap_or_default();
|
||||
if inherited.is_empty() {
|
||||
let (mut source_violations, source_edges) = scan_source(&relative, &source);
|
||||
violations.append(&mut source_violations);
|
||||
edges.extend(source_edges);
|
||||
} else {
|
||||
for owner in inherited {
|
||||
let (mut source_violations, source_edges) =
|
||||
scan_source_as_module(&relative, &source, Some(owner));
|
||||
violations.append(&mut source_violations);
|
||||
edges.extend(source_edges);
|
||||
}
|
||||
}
|
||||
}
|
||||
(violations, edges)
|
||||
}
|
||||
@@ -670,4 +799,41 @@ fn architecture_scanner_negative_fixtures_prove_each_guard_fires() {
|
||||
{expected_kind}: {violations:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let custom_path_root = tempfile::tempdir().expect("temp custom-path architecture tree");
|
||||
let composer = custom_path_root.path().join("src/pi_config/composer.rs");
|
||||
let shared = custom_path_root.path().join("src/shared/helper.rs");
|
||||
fs::create_dir_all(composer.parent().expect("composer parent")).expect("create pi_config");
|
||||
fs::create_dir_all(shared.parent().expect("shared parent")).expect("create shared");
|
||||
fs::write(
|
||||
&composer,
|
||||
r#"
|
||||
#[path = "../shared/helper.rs"]
|
||||
mod helper;
|
||||
"#,
|
||||
)
|
||||
.expect("write custom-path parent");
|
||||
fs::write(
|
||||
&shared,
|
||||
r#"
|
||||
use crate::pi_config::gateway::PiGatewayApiFamily;
|
||||
fn production_escape_attempt() {
|
||||
save_provider();
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("write custom-path child");
|
||||
let (violations, _) = scan_production_tree(
|
||||
custom_path_root.path(),
|
||||
&custom_path_root.path().join("src"),
|
||||
);
|
||||
for expected_kind in ["cross_layer_import", "forbidden_provider_symbol"] {
|
||||
assert!(
|
||||
violations.iter().any(|violation| {
|
||||
violation.kind == expected_kind && violation.path.ends_with("src/shared/helper.rs")
|
||||
}),
|
||||
"#[path] modules must inherit composer ownership and trigger \
|
||||
{expected_kind}: {violations:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(crate) enum PiComposerReasonCode {
|
||||
MissingEffectiveApi,
|
||||
MissingEffectiveEndpoint,
|
||||
NonPositiveModelLimit,
|
||||
UnrepresentableCompat,
|
||||
CompositionFailed,
|
||||
}
|
||||
|
||||
@@ -165,6 +166,21 @@ impl PiNativeComposition {
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: PiComposerStatus::Unknown,
|
||||
provider_id: None,
|
||||
provider_name: None,
|
||||
provider_base_url: None,
|
||||
models: Vec::new(),
|
||||
ignored_override_keys: Vec::new(),
|
||||
reasons: vec![PiComposerReason {
|
||||
code,
|
||||
json_pointer: pointer.into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn compose_explicit_custom_catalog(
|
||||
@@ -274,6 +290,16 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
}
|
||||
}
|
||||
|
||||
let compat =
|
||||
match merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()) {
|
||||
Ok(compat) => compat,
|
||||
Err(_) => {
|
||||
return PiNativeComposition::unknown(
|
||||
PiComposerReasonCode::UnrepresentableCompat,
|
||||
format!("/models/{index}/compat"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let model = PiComposedNativeModel {
|
||||
id: id.to_string(),
|
||||
name: definition
|
||||
@@ -305,7 +331,7 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
headers: BTreeMap::new(),
|
||||
provider_headers: provider_header_entries.clone(),
|
||||
model_headers: Vec::new(),
|
||||
compat: merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()),
|
||||
compat,
|
||||
api_key: api_key.clone(),
|
||||
oauth: oauth.clone(),
|
||||
auth_header,
|
||||
@@ -400,8 +426,18 @@ pub(super) fn compose_explicit_custom_catalog(
|
||||
if let Some(max_tokens) = model_override.get("maxTokens") {
|
||||
model.max_tokens = max_tokens.clone();
|
||||
}
|
||||
model.compat =
|
||||
merge_pi_compat(model.compat.clone(), model_override.get("compat").cloned());
|
||||
model.compat = match merge_pi_compat(
|
||||
model.compat.clone(),
|
||||
model_override.get("compat").cloned(),
|
||||
) {
|
||||
Ok(compat) => compat,
|
||||
Err(_) => {
|
||||
return PiNativeComposition::unknown(
|
||||
PiComposerReasonCode::UnrepresentableCompat,
|
||||
format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)),
|
||||
)
|
||||
}
|
||||
};
|
||||
model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS);
|
||||
}
|
||||
}
|
||||
@@ -830,6 +866,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_fails_closed_when_pinned_output_requires_lone_surrogates() {
|
||||
let value = json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://compat.example/v1",
|
||||
"apiKey": "literal",
|
||||
"compat": {"chatTemplateKwargs": "😀"},
|
||||
"models": [{"id": "m"}],
|
||||
"modelOverrides": {
|
||||
"m": {"compat": {"chatTemplateKwargs": {"named": true}}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composition = compose_explicit_custom_catalog(
|
||||
"compat-surrogate",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
|
||||
assert_eq!(composition.status, PiComposerStatus::Unknown);
|
||||
assert_eq!(
|
||||
composition.reasons,
|
||||
vec![PiComposerReason {
|
||||
code: PiComposerReasonCode::UnrepresentableCompat,
|
||||
json_pointer: "/modelOverrides/m/compat".to_string(),
|
||||
}],
|
||||
"capture records UTF-16 d83d/de00 as two lone-surrogate values, which \
|
||||
serde_json::Value cannot represent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_layers_retain_runtime_precedence_and_source_pointers() {
|
||||
let value = json!({
|
||||
|
||||
@@ -21,22 +21,34 @@ const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [
|
||||
"chatTemplateKwargs",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PiCompatMergeError;
|
||||
|
||||
/// Mirror pinned Pi's `mergeCompat` JavaScript object-spread semantics.
|
||||
///
|
||||
/// Arrays expose numeric enumerable properties, strings expose character
|
||||
/// properties, objects expose their own fields, and the remaining JSON
|
||||
/// primitives expose none. Existing key positions are retained when an
|
||||
/// overlay replaces their values, matching object spread.
|
||||
fn merge_pi_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||
///
|
||||
/// A JavaScript string is indexed by UTF-16 code unit. Spreading an astral
|
||||
/// character therefore creates lone-surrogate string values, which cannot be
|
||||
/// represented by Rust `String` or `serde_json::Value`. That shape is rejected
|
||||
/// explicitly so callers can fail closed instead of emitting a different
|
||||
/// composed model.
|
||||
fn merge_pi_compat(
|
||||
base: Option<Value>,
|
||||
overlay: Option<Value>,
|
||||
) -> Result<Option<Value>, PiCompatMergeError> {
|
||||
let Some(overlay) = overlay else {
|
||||
return base;
|
||||
return Ok(base);
|
||||
};
|
||||
if !javascript_truthy(&overlay) {
|
||||
return base;
|
||||
return Ok(base);
|
||||
}
|
||||
|
||||
let mut merged = javascript_object_spread(base.as_ref());
|
||||
merged.extend(javascript_object_spread(Some(&overlay)));
|
||||
let mut merged = javascript_object_spread(base.as_ref())?;
|
||||
merged.extend(javascript_object_spread(Some(&overlay))?);
|
||||
|
||||
for key in PI_COMPAT_NESTED_SPREAD_KEYS {
|
||||
let base_value = javascript_property(base.as_ref(), key);
|
||||
@@ -44,12 +56,12 @@ fn merge_pi_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value>
|
||||
if base_value.is_some_and(javascript_is_object)
|
||||
|| overlay_value.is_some_and(javascript_is_object)
|
||||
{
|
||||
let mut nested = javascript_object_spread(base_value);
|
||||
nested.extend(javascript_object_spread(overlay_value));
|
||||
let mut nested = javascript_object_spread(base_value)?;
|
||||
nested.extend(javascript_object_spread(overlay_value)?);
|
||||
merged.insert(key.to_string(), Value::Object(nested));
|
||||
}
|
||||
}
|
||||
Some(Value::Object(merged))
|
||||
Ok(Some(Value::Object(merged)))
|
||||
}
|
||||
|
||||
fn javascript_truthy(value: &Value) -> bool {
|
||||
@@ -69,21 +81,29 @@ fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Va
|
||||
value.and_then(Value::as_object)?.get(key)
|
||||
}
|
||||
|
||||
fn javascript_object_spread(value: Option<&Value>) -> Map<String, Value> {
|
||||
match value {
|
||||
fn javascript_object_spread(
|
||||
value: Option<&Value>,
|
||||
) -> Result<Map<String, Value>, PiCompatMergeError> {
|
||||
let spread = match value {
|
||||
Some(Value::Object(object)) => object.clone(),
|
||||
Some(Value::Array(values)) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (index.to_string(), value.clone()))
|
||||
.collect(),
|
||||
Some(Value::String(value)) => value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (index.to_string(), Value::String(value.to_string())))
|
||||
.collect(),
|
||||
Some(Value::String(value)) => {
|
||||
if value.chars().any(|character| character.len_utf16() != 1) {
|
||||
return Err(PiCompatMergeError);
|
||||
}
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (index.to_string(), Value::String(value.to_string())))
|
||||
.collect()
|
||||
}
|
||||
Some(Value::Null | Value::Bool(_) | Value::Number(_)) | None => Map::new(),
|
||||
}
|
||||
};
|
||||
Ok(spread)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -105,6 +125,7 @@ mod compat_spread_tests {
|
||||
"overlayOnly": true
|
||||
})),
|
||||
)
|
||||
.expect("representable compat spread")
|
||||
.expect("truthy overlay produces an object");
|
||||
|
||||
assert_eq!(
|
||||
@@ -121,6 +142,17 @@ mod compat_spread_tests {
|
||||
#[test]
|
||||
fn compat_falsy_overlay_returns_base_without_spreading() {
|
||||
let base = Some(json!({"openRouterRouting": ["kept"]}));
|
||||
assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), base);
|
||||
assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), Ok(base));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_rejects_unrepresentable_javascript_surrogates() {
|
||||
assert_eq!(
|
||||
merge_pi_compat(
|
||||
Some(json!({"chatTemplateKwargs": "😀"})),
|
||||
Some(json!({"chatTemplateKwargs": {"named": true}})),
|
||||
),
|
||||
Err(PiCompatMergeError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,10 @@ pub(crate) enum PiConfigError {
|
||||
UnknownModelOverride(String),
|
||||
#[error("Pi compat at '{json_pointer}' must be an object")]
|
||||
InvalidCompat { json_pointer: String },
|
||||
#[error(
|
||||
"Pi compat at '{json_pointer}' requires JavaScript UTF-16 values that cannot be represented"
|
||||
)]
|
||||
UnrepresentableCompat { 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")]
|
||||
@@ -401,6 +405,7 @@ pub(crate) enum PiReasonCode {
|
||||
MissingEffectiveEndpoint,
|
||||
InvalidEndpoint,
|
||||
InvalidCompat,
|
||||
UnrepresentableCompat,
|
||||
NonPositiveModelLimit,
|
||||
InvalidThinkingLevel,
|
||||
CompositionFailed,
|
||||
@@ -552,10 +557,18 @@ fn effective_pi_model_unchecked(
|
||||
.map(|entry| apply_cost_override(base_cost.clone(), entry))
|
||||
.unwrap_or(base_cost);
|
||||
|
||||
let compat = merge_pi_compat(provider.compat.clone(), model.compat.clone()).map_err(|_| {
|
||||
PiConfigError::UnrepresentableCompat {
|
||||
json_pointer: "/compat".to_string(),
|
||||
}
|
||||
})?;
|
||||
let compat = merge_pi_compat(
|
||||
merge_pi_compat(provider.compat.clone(), model.compat.clone()),
|
||||
compat,
|
||||
model_override.and_then(|entry| entry.compat.clone()),
|
||||
);
|
||||
)
|
||||
.map_err(|_| PiConfigError::UnrepresentableCompat {
|
||||
json_pointer: format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)),
|
||||
})?;
|
||||
|
||||
Ok(PiEffectiveModel {
|
||||
id: model.id.clone(),
|
||||
@@ -908,6 +921,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_compat_rejects_unrepresentable_javascript_surrogate_spread() {
|
||||
let config: PiManagedProviderConfig = serde_json::from_value(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://compat.example/v1",
|
||||
"compat": {"chatTemplateKwargs": "😀"},
|
||||
"models": [{"id": "m"}],
|
||||
"modelOverrides": {
|
||||
"m": {"compat": {"chatTemplateKwargs": {"named": true}}}
|
||||
}
|
||||
}))
|
||||
.expect("deserialize pinned compat vector");
|
||||
|
||||
assert_eq!(
|
||||
effective_pi_model(&config, "m"),
|
||||
Err(PiConfigError::UnrepresentableCompat {
|
||||
json_pointer: "/modelOverrides/m/compat".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_pi_numbers_survive_managed_round_trip() {
|
||||
let mut fractional = model("fractional");
|
||||
@@ -1092,5 +1126,19 @@ mod tests {
|
||||
"jsonPointer": "/apiKey"
|
||||
})
|
||||
);
|
||||
|
||||
let compat_reason = PiDiagnosticReason::new(
|
||||
PiDiagnosticLayer::Composition,
|
||||
PiReasonCode::UnrepresentableCompat,
|
||||
Some("/modelOverrides/m/compat".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(compat_reason).expect("serialize"),
|
||||
json!({
|
||||
"layer": "composition",
|
||||
"code": "unrepresentable_compat",
|
||||
"jsonPointer": "/modelOverrides/m/compat"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +466,13 @@ fn collect_managed_reasons(config: &PiManagedProviderConfig) -> Vec<PiDiagnostic
|
||||
}
|
||||
|
||||
fn managed_validation_reason(error: PiConfigError) -> PiDiagnosticReason {
|
||||
if let PiConfigError::UnrepresentableCompat { json_pointer } = &error {
|
||||
return diagnostic_reason(
|
||||
PiDiagnosticLayer::Managed,
|
||||
PiReasonCode::UnrepresentableCompat,
|
||||
json_pointer,
|
||||
);
|
||||
}
|
||||
let (code, pointer) = match error {
|
||||
PiConfigError::ProviderHasNoModels => (PiReasonCode::MissingExplicitModels, "/models"),
|
||||
PiConfigError::EmptyApiId => (PiReasonCode::ManagedTypeConversionFailed, "/api"),
|
||||
@@ -481,6 +488,9 @@ fn managed_validation_reason(error: PiConfigError) -> PiDiagnosticReason {
|
||||
(PiReasonCode::UnknownModelOverride, "/modelOverrides")
|
||||
}
|
||||
PiConfigError::InvalidCompat { .. } => (PiReasonCode::InvalidCompat, "/compat"),
|
||||
PiConfigError::UnrepresentableCompat { .. } => {
|
||||
unreachable!("handled before the exhaustive mapping")
|
||||
}
|
||||
PiConfigError::EmptyOptionalField { .. } => (PiReasonCode::EmptyOptionalField, ""),
|
||||
PiConfigError::NonPositiveModelLimit { .. } => {
|
||||
(PiReasonCode::NonPositiveModelLimit, "/models")
|
||||
@@ -585,6 +595,9 @@ fn map_composer_reasons(composition: &PiNativeComposition) -> Vec<PiDiagnosticRe
|
||||
PiComposerReasonCode::NonPositiveModelLimit => {
|
||||
PiReasonCode::NonPositiveModelLimit
|
||||
}
|
||||
PiComposerReasonCode::UnrepresentableCompat => {
|
||||
PiReasonCode::UnrepresentableCompat
|
||||
}
|
||||
PiComposerReasonCode::CompositionFailed => PiReasonCode::CompositionFailed,
|
||||
},
|
||||
&reason.json_pointer,
|
||||
@@ -760,6 +773,46 @@ mod tests {
|
||||
assert_eq!(malformed.gateway_status, PiGatewayStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_inspection_fails_closed_for_unrepresentable_compat_spread() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("models.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"providers":{"surrogate":{
|
||||
"api":"openai-responses",
|
||||
"baseUrl":"https://compat.example/v1",
|
||||
"apiKey":"literal",
|
||||
"compat":{"chatTemplateKwargs":"😀"},
|
||||
"models":[{"id":"m"}],
|
||||
"modelOverrides":{"m":{"compat":{"chatTemplateKwargs":{"named":true}}}}
|
||||
}}}"#,
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let diagnostic =
|
||||
&PiNativeInspectionService::inspect_catalog(&path, &BTreeMap::new()).unwrap()[0];
|
||||
assert_eq!(diagnostic.raw_validity, PiRawNativeValidity::Valid);
|
||||
assert_eq!(
|
||||
diagnostic.managed_assessment,
|
||||
PiManagedAssessment::Unsupported
|
||||
);
|
||||
assert_eq!(diagnostic.composition_status, PiCompositionStatus::Unknown);
|
||||
assert_eq!(diagnostic.gateway_status, PiGatewayStatus::Unknown);
|
||||
assert!(has_reason(
|
||||
diagnostic,
|
||||
PiDiagnosticLayer::Managed,
|
||||
PiReasonCode::UnrepresentableCompat,
|
||||
"/modelOverrides/m/compat"
|
||||
));
|
||||
assert!(has_reason(
|
||||
diagnostic,
|
||||
PiDiagnosticLayer::Composition,
|
||||
PiReasonCode::UnrepresentableCompat,
|
||||
"/modelOverrides/m/compat"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_rejection_does_not_control_raw_composition() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in New Issue
Block a user