diff --git a/docs/pi-support-restructure-zh.md b/docs/pi-support-restructure-zh.md index aa6fc019f..6e464720e 100644 --- a/docs/pi-support-restructure-zh.md +++ b/docs/pi-support-restructure-zh.md @@ -50,6 +50,30 @@ > 文档考据说明:修正案 1/2(`pi-support-contracts-amendment-*.md`)的条款已按其自身要求**合并**进 `pi-support-contracts-zh.md` 与 `pi-support-review-contract-zh.md`,独立文件已随合并删除,这是预期状态而非丢失;本文引用的"修正案 2 §F/E1"以合并后的规范文档对应章节为准。 +## 4.7 协议放宽:契约纲领化(用户裁决,2026-08-02,优先于 §2、§4) + +自本条起,**所有后续契约以纲领为主**:只给目标与验收标准,实现方(编码 Codex) +获得相当大的自由度。§2"测试先行 + 冻结认证套件"的重仪式**不再默认适用**。 + +**保留的最小规则**(全部有实证代价支撑,不是仪式): + +1. **既有测试不得弱化**:测试总数只增不减,盲审核对; +2. **冻结物不动**:pinned 夹具、已重冻的 infra 三文件、restore 面; +3. **行为主张须有证据**:凡断言"pinned Pi 如此行为",须有 oracle 或 + request-capture 实证(`scripts/pi-transport-capture.mjs`)。此条是四轮 + 返工换来的——读源码推断先后写反过 Google header-only 契约与 authHeader 次序; +4. **不 push、不建 PR、不动 `stash@{0}`、不碰 PR #5598**。 + +**取消的**:精确红绿账本与"偏离即硬停止";每工程 2 轮 fresh 双审的固定配额; +实现路径、DTO 形状、测试组织方式的规定;裁决方逐条批准。实现方自行判断何时 +完成,自行组织验证与审查,完成后报终态 SHA。 + +**为什么放宽**:前置 A/B/C 的证据表明,重仪式的边际收益在下降而成本在上升—— +后期盲审 finding 多为"防作弊机器"的自我军备竞赛,且契约越长裁决方自身出错越多 +(表名、文件映射、虚假覆盖声称、与上游相反的契约,均为契约复杂化后的产物)。 +实现方在四个工程中零作弊记录(精确硬停、拒改认证文件、顶住 reviewer 的错误建议), +信任已由行为挣得。 + ## 4.6 前置 B 终止关账(用户裁决,2026-08-02,优先于 §4.5) §4.5 的缩围执行后,重认证 R1 仍在**存量产品面**出现 3 项 High(配置目录可变 diff --git a/scripts/pi-transport-capture.mjs b/scripts/pi-transport-capture.mjs new file mode 100644 index 000000000..aac80b77b --- /dev/null +++ b/scripts/pi-transport-capture.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * Pi transport request-capture harness. + * + * 现有的 native-oracle 只执行 pinned Pi 的 schema evaluator / composer / + * config-value resolver,**不执行** adapter 与厂商 SDK 的头合并,因此 + * "Pi 实际发出什么认证头" 一直只能靠读源码推断。本脚本补上这一层: + * 起一个本地 HTTP 抓包端点当 baseUrl,用 pinned Pi 的 adapter 真发一次 + * 请求,记录实际发出的 header。 + * + * 用法: + * PI_CHECKOUT=/path/to/pinned/pi node scripts/pi-transport-capture.mjs + * + * 不含任何密钥:测试用的 apiKey 是本地抓包用的假值;若要打真实端点, + * 通过环境变量传入(PI_CAPTURE_BASE_URL / PI_CAPTURE_API_KEY),不要写进文件。 + * + * 输出为 JSON,可作为 transport 断言的出处依据。若要升级为受冻结的 + * oracle 夹具,请比照 scripts/generate-pi-native-oracle.mjs 补 provenance + * (pinned commit、源码哈希、bundler 版本)。 + */ + +import { createServer } from "node:http"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const PI = process.env.PI_CHECKOUT; +const EXPECTED_PI_COMMIT = "ab366ebe94cacd419d986be454f12b1b9913aaca"; +if (!PI) { + console.error( + "PI_CHECKOUT must point at a pinned Pi checkout (with node_modules).", + ); + process.exit(2); +} +const piCommit = execFileSync("git", ["-C", PI, "rev-parse", "HEAD"], { + encoding: "utf8", +}).trim(); +if (piCommit !== EXPECTED_PI_COMMIT) { + throw new Error( + `Pi checkout pin mismatch: expected ${EXPECTED_PI_COMMIT}, got ${piCommit}`, + ); +} + +const requireFromPi = createRequire(join(PI, "package.json")); +const { buildSync, version: esbuildVersion } = requireFromPi("esbuild"); + +const ANTHROPIC_SSE = + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' + + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}\n\n' + + 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; +const OPENAI_SSE = + 'data: {"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' + + "data: [DONE]\n\n"; + +const captured = []; +const server = createServer((request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + captured.push({ url: request.url, headers: { ...request.headers } }); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(request.url.includes("messages") ? ANTHROPIC_SSE : OPENAI_SSE); + }); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const baseUrl = + process.env.PI_CAPTURE_BASE_URL ?? + `http://127.0.0.1:${server.address().port}`; + +const harnessDirectory = mkdtempSync(join(PI, ".cc-switch-transport-capture-")); +process.on("exit", () => + rmSync(harnessDirectory, { recursive: true, force: true }), +); +const aiShimPath = join(harnessDirectory, "pi-ai-shim.mjs"); +const compatShimPath = join(harnessDirectory, "pi-ai-compat-shim.mjs"); +writeFileSync( + aiShimPath, + 'export function lazyStream() { throw new Error("transport shim must not execute during compat capture"); }\n', +); +writeFileSync( + compatShimPath, + 'export function getApiProvider() { throw new Error("transport shim must not execute during compat capture"); }\n', +); +const entryPoint = join(harnessDirectory, "entry.mjs"); +writeFileSync( + entryPoint, + [ + `export { streamSimple as anthropicMessages } from "${PI}/packages/ai/src/api/anthropic-messages.ts";`, + `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";`, + ].join("\n"), +); +const bundlePath = join(harnessDirectory, "bundle.mjs"); +buildSync({ + entryPoints: [entryPoint], + bundle: true, + platform: "node", + format: "esm", + outfile: bundlePath, + external: ["node:*"], + packages: "external", + alias: { + "@earendil-works/pi-ai": aiShimPath, + "@earendil-works/pi-ai/compat": compatShimPath, + }, + logLevel: "silent", +}); +const adapters = await import(pathToFileURL(bundlePath).href); + +const API_BY_ADAPTER = { + anthropicMessages: "anthropic-messages", + openaiResponses: "openai-responses", + openaiCompletions: "openai-completions", +}; + +/** 每个用例只改变凭证与显式 header,其余保持最小合法模型。 */ +const CASES = [ + ["anthropicMessages", "plain-key", "sk-ant-api03-plain", {}], + ["anthropicMessages", "oauth-token", "sk-ant-oat01-token", {}], + [ + "anthropicMessages", + "explicit-x-api-key", + "synthesized-secret", + { "x-api-key": "explicit-secret" }, + ], + [ + "anthropicMessages", + "explicit-authorization", + "synthesized-secret", + { authorization: "Bearer configured" }, + ], + ["openaiResponses", "plain-key", "sk-plain", {}], + ["openaiResponses", "oauth-shaped-token", "sk-ant-oat01-not-anthropic", {}], + [ + "openaiResponses", + "explicit-authorization", + "synthesized-secret", + { authorization: "Bearer configured" }, + ], + [ + "openaiCompletions", + "explicit-authorization", + "synthesized-secret", + { authorization: "Bearer configured" }, + ], + [ + "openaiCompletions", + "explicit-x-api-key", + "synthesized-secret", + { "x-api-key": "explicit-secret" }, + ], +]; + +const results = []; +for (const [adapter, label, apiKey, headers] of CASES) { + const model = { + id: "m", + name: "m", + api: API_BY_ADAPTER[adapter], + provider: "candidate", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }; + const before = captured.length; + let error; + try { + const stream = adapters[adapter]( + model, + { messages: [{ role: "user", content: "hi" }] }, + { + apiKey: process.env.PI_CAPTURE_API_KEY ?? apiKey, + headers, + maxTokens: 16, + }, + ); + for await (const _event of stream) { + // drain + } + } catch (caught) { + error = String(caught); + } + const request = + captured.length > before ? captured[captured.length - 1] : undefined; + results.push({ + adapter: API_BY_ADAPTER[adapter], + case: label, + requestSent: Boolean(request), + error: request ? undefined : error, + authHeaders: request + ? Object.fromEntries( + Object.entries(request.headers).filter(([name]) => + [ + "authorization", + "x-api-key", + "x-goog-api-key", + "anthropic-beta", + "anthropic-version", + "openai-beta", + ].includes(name), + ), + ) + : undefined, + }); +} + +const compatInput = { + api: "openai-responses", + baseUrl: "https://compat.example/v1", + apiKey: "literal", + compat: { + openRouterRouting: ["first", "second"], + chatTemplateKwargs: "ab", + baseOnly: true, + }, + models: [{ id: "m", compat: { supportsStore: true } }], + modelOverrides: { + m: { + compat: { + openRouterRouting: null, + chatTemplateKwargs: { named: true }, + overlayOnly: true, + }, + }, + }, +}; +const compatProvider = adapters.composeModelProvider( + "compat-spread", + undefined, + { + getProvider(providerId) { + return providerId === "compat-spread" ? compatInput : undefined; + }, + }, + undefined, +); +const compatSpread = compatProvider.getModels()[0].compat; + +server.close(); +console.log( + JSON.stringify( + { + bundler: `esbuild@${esbuildVersion}`, + piCheckout: PI, + piCommit, + baseUrl, + results, + compatSpread, + }, + null, + 2, + ), +); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4e17bcf7..c89808a4a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ test-hooks = [] tauri-build = { version = "2.4.0", features = [] } [dependencies] -serde_json = { version = "1.0", features = ["preserve_order"] } +serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] } serde = { version = "1.0", features = ["derive"] } log = "0.4" diff --git a/src-tauri/src/architecture_tests.rs b/src-tauri/src/architecture_tests.rs index beef8776e..aad8e6788 100644 --- a/src-tauri/src/architecture_tests.rs +++ b/src-tauri/src/architecture_tests.rs @@ -148,22 +148,18 @@ struct ArchitectureVisitor<'a> { internal_edges: BTreeSet, } +fn pi_config_source_module(path: &str) -> Option<&'static str> { + ["raw_schema", "composer", "gateway", "native", "model"] + .into_iter() + .find(|module| { + let root = format!("pi_config/{module}"); + path.ends_with(&format!("{root}.rs")) || path.contains(&format!("{root}/")) + }) +} + impl ArchitectureVisitor<'_> { fn record_dependency(&mut self, segments: &[String]) { - let source_module = if self.path.ends_with("pi_config/raw_schema.rs") { - Some("raw_schema") - } else if self.path.ends_with("pi_config/composer.rs") { - Some("composer") - } else if self.path.ends_with("pi_config/gateway.rs") { - Some("gateway") - } else if self.path.ends_with("pi_config/native.rs") { - Some("native") - } else if self.path.ends_with("pi_config/model.rs") { - Some("model") - } else { - None - }; - let Some(source_module) = source_module else { + let Some(source_module) = pi_config_source_module(self.path) else { return; }; let qualified_internal_path = segments.len() > 1 @@ -398,6 +394,27 @@ fn rust_sources(root: &Path) -> Vec { output } +fn scan_production_tree( + manifest_dir: &Path, + source_root: &Path, +) -> (Vec, BTreeSet) { + 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); + } + (violations, edges) +} + fn type_leaf(ty: &Type) -> String { match ty { Type::Path(path) => path @@ -480,26 +497,7 @@ fn provider_write_api_snapshot(source: &str) -> serde_json::Value { fn architecture_scanner_accepts_production_tree_and_matches_machine_snapshots() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let source_root = manifest_dir.join("src"); - 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('\\', "/"); - if relative.ends_with("architecture_tests.rs") - || relative.ends_with("/tests.rs") - || relative.contains("/tests/") - { - continue; - } - 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 (violations, edges) = scan_production_tree(manifest_dir, &source_root); assert!( violations.is_empty(), "architecture violations:\n{}", @@ -648,4 +646,28 @@ fn architecture_scanner_negative_fixtures_prove_each_guard_fires() { violations.is_empty(), "a predicate requiring test must stay excluded: {violations:?}" ); + + let temp = tempfile::tempdir().expect("temp architecture tree"); + let nested = temp.path().join("src/pi_config/composer/tests/mod.rs"); + fs::create_dir_all(nested.parent().expect("nested parent")).expect("create nested tree"); + fs::write( + &nested, + r#" + use super::super::gateway::PiGatewayApiFamily; + fn production_escape_attempt() { + save_provider(); + } + "#, + ) + .expect("write production nested-module fixture"); + let (violations, _) = scan_production_tree(temp.path(), &temp.path().join("src")); + for expected_kind in ["cross_layer_import", "forbidden_provider_symbol"] { + assert!( + violations + .iter() + .any(|violation| violation.kind == expected_kind), + "production code under tests/ must inherit composer ownership and trigger \ + {expected_kind}: {violations:?}" + ); + } } diff --git a/src-tauri/src/pi_config/composer.rs b/src-tauri/src/pi_config/composer.rs index dfe40b275..b2c8fa3ce 100644 --- a/src-tauri/src/pi_config/composer.rs +++ b/src-tauri/src/pi_config/composer.rs @@ -6,7 +6,10 @@ #![allow(dead_code)] -use super::raw_schema::{PiRawApiId, PiRawValidProvider}; +use super::{ + merge_pi_compat, + raw_schema::{PiRawApiId, PiRawValidProvider}, +}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, HashSet}; @@ -302,7 +305,7 @@ pub(super) fn compose_explicit_custom_catalog( headers: BTreeMap::new(), provider_headers: provider_header_entries.clone(), model_headers: Vec::new(), - compat: merge_compat(provider_compat.clone(), definition.get("compat").cloned()), + compat: merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()), api_key: api_key.clone(), oauth: oauth.clone(), auth_header, @@ -398,7 +401,7 @@ pub(super) fn compose_explicit_custom_catalog( model.max_tokens = max_tokens.clone(); } model.compat = - merge_compat(model.compat.clone(), model_override.get("compat").cloned()); + merge_pi_compat(model.compat.clone(), model_override.get("compat").cloned()); model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS); } } @@ -454,39 +457,6 @@ fn merge_cost(base: &Value, overlay: &Map) -> Value { Value::Object(merged) } -fn merge_compat(base: Option, overlay: Option) -> Option { - let Some(overlay) = overlay else { - return base; - }; - let mut merged = base - .as_ref() - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let Some(overlay_object) = overlay.as_object() else { - return Some(overlay); - }; - for (key, value) in overlay_object { - if matches!( - key.as_str(), - "openRouterRouting" | "vercelGatewayRouting" | "chatTemplateKwargs" - ) { - let base_nested = merged.get(key).and_then(Value::as_object); - let override_nested = value.as_object(); - if base_nested.is_some() || override_nested.is_some() { - let mut nested = base_nested.cloned().unwrap_or_default(); - if let Some(override_nested) = override_nested { - nested.extend(override_nested.clone()); - } - merged.insert(key.clone(), Value::Object(nested)); - continue; - } - } - merged.insert(key.clone(), value.clone()); - } - Some(Value::Object(merged)) -} - fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec { value .and_then(Value::as_object) @@ -816,6 +786,50 @@ mod tests { ); } + #[test] + fn compat_spread_matches_pinned_composer_request_capture() { + let value = json!({ + "api": "openai-responses", + "baseUrl": "https://compat.example/v1", + "apiKey": "literal", + "compat": { + "openRouterRouting": ["first", "second"], + "chatTemplateKwargs": "ab", + "baseOnly": true + }, + "models": [{ + "id": "m", + "compat": {"supportsStore": true} + }], + "modelOverrides": { + "m": { + "compat": { + "openRouterRouting": null, + "chatTemplateKwargs": {"named": true}, + "overlayOnly": true + } + } + } + }); + let raw = evaluate_provider_value(&value); + let composed = compose_explicit_custom_catalog( + "compat-spread", + raw.valid_provider.as_ref().expect("raw-valid"), + ); + + assert_eq!( + composed.models[0].compat, + Some(json!({ + "openRouterRouting": {"0": "first", "1": "second"}, + "chatTemplateKwargs": {"0": "a", "1": "b", "named": true}, + "baseOnly": true, + "supportsStore": true, + "overlayOnly": true + })), + "captured by scripts/pi-transport-capture.mjs at the pinned Pi commit" + ); + } + #[test] fn header_layers_retain_runtime_precedence_and_source_pointers() { let value = json!({ diff --git a/src-tauri/src/pi_config/document.rs b/src-tauri/src/pi_config/document.rs index 01e89f71b..46f0aba1b 100644 --- a/src-tauri/src/pi_config/document.rs +++ b/src-tauri/src/pi_config/document.rs @@ -288,6 +288,29 @@ mod tests { assert!(entry.raw_source.contains("\"model//literal\"")); } + #[test] + fn parses_javascript_overflow_without_hiding_sibling_entries() { + let document = parse_models_source( + Path::new("models.json"), + r#"{ + "providers": { + "healthy": {"models": [{"id": "healthy"}]}, + "overflow": {"models": [{"id": "m", "contextWindow": 1e400}]} + } +}"#, + ) + .expect("JSON.parse accepts the numeric token before TypeBox validation"); + + assert!(document.providers().contains_key("healthy")); + let overflow = &document.providers()["overflow"].value["models"][0]["contextWindow"]; + assert!( + overflow + .as_number() + .is_some_and(|number| number.as_f64().is_none()), + "the raw evaluator must still be able to distinguish non-finite JavaScript Number" + ); + } + #[test] fn rejects_json_extensions_that_pinned_pi_rejects() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index 520a86ac0..a932177ef 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -122,6 +122,7 @@ pub(super) enum PiGatewayCapability { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum PiGatewayReasonCode { UnsupportedFamily, + UnsupportedCredentialKind, InvalidEndpoint, MissingCredential, InvalidHeaderName, @@ -242,11 +243,27 @@ impl CandidateHeaderPlan { } }; let credential = model.api_key.as_ref(); - if credential.is_none() { - reasons.push(PiGatewayReason { + match credential { + None => reasons.push(PiGatewayReason { code: PiGatewayReasonCode::MissingCredential, json_pointer: "/apiKey".to_string(), - }); + }), + Some(credential) if !is_deferred(credential) => { + if parse_transport_header_value(credential).is_none() { + reasons.push(PiGatewayReason { + code: PiGatewayReasonCode::InvalidHeaderValue, + json_pointer: "/apiKey".to_string(), + }); + } else if family == PiGatewayApiFamily::AnthropicMessages + && is_anthropic_oauth_credential(credential) + { + reasons.push(PiGatewayReason { + code: PiGatewayReasonCode::UnsupportedCredentialKind, + json_pointer: "/apiKey".to_string(), + }); + } + } + Some(_) => {} }; let mut protocol_identity_predictable = true; @@ -292,6 +309,14 @@ impl CandidateHeaderPlan { let mut headers = HeaderMap::new(); let mut protocol_headers = HeaderMap::new(); let credential = self.credential.materialize(resolver, "/apiKey")?; + if self.family == PiGatewayApiFamily::AnthropicMessages + && credential.to_str().is_ok_and(is_anthropic_oauth_credential) + { + return Err(PiGatewayReason { + code: PiGatewayReasonCode::UnsupportedCredentialKind, + json_pointer: "/apiKey".to_string(), + }); + } let bearer_credential = credential.clone(); let (auth_name, auth_value) = match self.family { PiGatewayApiFamily::AnthropicMessages => { @@ -486,6 +511,11 @@ fn is_deferred(value: &str) -> bool { value.starts_with('!') || value.contains('$') } +fn is_anthropic_oauth_credential(value: &str) -> bool { + // Pinned Pi's Anthropic adapter uses `includes`, not a prefix test. + value.contains("sk-ant-oat") +} + fn parse_transport_header_value(value: &str) -> Option { if !value.bytes().all(|byte| matches!(byte, 0x20..=0x7e)) { return None; diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs index 0d3500214..f062d9988 100644 --- a/src-tauri/src/pi_config/mod.rs +++ b/src-tauri/src/pi_config/mod.rs @@ -4,6 +4,8 @@ //! Pi's shared files and from the proxy data plane. Callers must use the //! typed model resolver rather than reimplementing provider/model inheritance. +use serde_json::{Map, Value}; + mod composer; mod document; mod gateway; @@ -12,3 +14,113 @@ pub(crate) mod native; #[cfg(test)] mod native_inspection_certification; mod raw_schema; + +const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [ + "openRouterRouting", + "vercelGatewayRouting", + "chatTemplateKwargs", +]; + +/// 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, overlay: Option) -> Option { + let Some(overlay) = overlay else { + return base; + }; + if !javascript_truthy(&overlay) { + return base; + } + + 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); + let overlay_value = javascript_property(Some(&overlay), key); + 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)); + merged.insert(key.to_string(), Value::Object(nested)); + } + } + Some(Value::Object(merged)) +} + +fn javascript_truthy(value: &Value) -> bool { + match value { + Value::Null | Value::Bool(false) => false, + Value::Number(value) => value.as_f64().is_none_or(|value| value != 0.0), + Value::String(value) => !value.is_empty(), + Value::Bool(true) | Value::Array(_) | Value::Object(_) => true, + } +} + +fn javascript_is_object(value: &Value) -> bool { + matches!(value, Value::Array(_) | Value::Object(_)) +} + +fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Value> { + value.and_then(Value::as_object)?.get(key) +} + +fn javascript_object_spread(value: Option<&Value>) -> Map { + 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::Null | Value::Bool(_) | Value::Number(_)) | None => Map::new(), + } +} + +#[cfg(test)] +mod compat_spread_tests { + use super::*; + use serde_json::json; + + #[test] + fn compat_nested_values_follow_javascript_object_spread() { + let merged = merge_pi_compat( + Some(json!({ + "openRouterRouting": ["first", "second"], + "chatTemplateKwargs": "ab", + "baseOnly": true + })), + Some(json!({ + "openRouterRouting": null, + "chatTemplateKwargs": {"named": true}, + "overlayOnly": true + })), + ) + .expect("truthy overlay produces an object"); + + assert_eq!( + merged, + json!({ + "openRouterRouting": {"0": "first", "1": "second"}, + "chatTemplateKwargs": {"0": "a", "1": "b", "named": true}, + "baseOnly": true, + "overlayOnly": true + }) + ); + } + + #[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); + } +} diff --git a/src-tauri/src/pi_config/model.rs b/src-tauri/src/pi_config/model.rs index 89b739b95..e1570c81b 100644 --- a/src-tauri/src/pi_config/model.rs +++ b/src-tauri/src/pi_config/model.rs @@ -7,6 +7,7 @@ #![allow(dead_code)] +use super::merge_pi_compat; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -144,8 +145,8 @@ pub(crate) struct PiModelCostTier { pub(crate) struct PiModelCost { #[serde(flatten)] pub rates: PiModelCostRates, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tiers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tiers: Option>, #[serde(flatten)] pub extra: BTreeMap, } @@ -404,6 +405,7 @@ pub(crate) enum PiReasonCode { InvalidThinkingLevel, CompositionFailed, GatewayCredentialUnavailable, + UnsupportedCredentialKind, UnsupportedGatewayFamily, InvalidHeaderName, InvalidHeaderValue, @@ -550,8 +552,8 @@ fn effective_pi_model_unchecked( .map(|entry| apply_cost_override(base_cost.clone(), entry)) .unwrap_or(base_cost); - let compat = merge_compat( - merge_compat(provider.compat.clone(), model.compat.clone()), + let compat = merge_pi_compat( + merge_pi_compat(provider.compat.clone(), model.compat.clone()), model_override.and_then(|entry| entry.compat.clone()), ); @@ -620,37 +622,12 @@ fn apply_cost_override(mut base: PiModelCost, model_override: &PiModelCostOverri cache_write: model_override.cache_write.unwrap_or(base.rates.cache_write), }; if let Some(tiers) = &model_override.tiers { - base.tiers = tiers.clone(); + base.tiers = Some(tiers.clone()); } base.extra.extend(model_override.extra.clone()); base } -fn merge_compat(base: Option, overlay: Option) -> Option { - match (base, overlay) { - (None, None) => None, - (value, None) | (None, value) => value, - (Some(Value::Object(mut base)), Some(Value::Object(overlay))) => { - for (key, value) in overlay { - if matches!( - key.as_str(), - "openRouterRouting" | "vercelGatewayRouting" | "chatTemplateKwargs" - ) { - if let Some(Value::Object(base_nested)) = base.get_mut(&key) { - if let Value::Object(overlay_nested) = &value { - base_nested.extend(overlay_nested.clone()); - continue; - } - } - } - base.insert(key, value); - } - Some(Value::Object(base)) - } - (Some(_), Some(overlay)) => Some(overlay), - } -} - fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), PiConfigError> { if value.is_some_and(str::is_empty) { return Err(PiConfigError::EmptyOptionalField { field }); @@ -891,6 +868,46 @@ mod tests { ); } + #[test] + fn effective_compat_uses_the_pinned_composer_spread_result() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "api": "openai-responses", + "baseUrl": "https://compat.example/v1", + "compat": { + "openRouterRouting": ["first", "second"], + "chatTemplateKwargs": "ab", + "baseOnly": true + }, + "models": [{ + "id": "m", + "compat": {"supportsStore": true} + }], + "modelOverrides": { + "m": { + "compat": { + "openRouterRouting": null, + "chatTemplateKwargs": {"named": true}, + "overlayOnly": true + } + } + } + })) + .expect("deserialize pinned compat vector"); + + assert_eq!( + effective_pi_model(&config, "m") + .expect("effective compat") + .compat, + Some(json!({ + "openRouterRouting": {"0": "first", "1": "second"}, + "chatTemplateKwargs": {"0": "a", "1": "b", "named": true}, + "baseOnly": true, + "supportsStore": true, + "overlayOnly": true + })) + ); + } + #[test] fn fractional_pi_numbers_survive_managed_round_trip() { let mut fractional = model("fractional"); @@ -1001,6 +1018,51 @@ mod tests { ); } + #[test] + fn empty_cost_tiers_remain_distinct_from_absent_at_managed_and_effective_boundaries() { + let config: PiManagedProviderConfig = serde_json::from_value(json!({ + "api": "anthropic-messages", + "baseUrl": "https://cost.example", + "models": [ + { + "id": "empty", + "cost": { + "input": 1.0, + "output": 2.0, + "cacheRead": 0.5, + "cacheWrite": 0.25, + "tiers": [] + } + }, + { + "id": "absent", + "cost": { + "input": 1.0, + "output": 2.0, + "cacheRead": 0.5, + "cacheWrite": 0.25 + } + } + ] + })) + .expect("deserialize explicit and absent tiers"); + + let managed = serde_json::to_value(&config).expect("serialize managed config"); + assert_eq!(managed.pointer("/models/0/cost/tiers"), Some(&json!([]))); + assert_eq!(managed.pointer("/models/1/cost/tiers"), None); + + let empty_effective = serde_json::to_value( + effective_pi_model(&config, "empty").expect("effective empty tiers"), + ) + .expect("serialize effective empty tiers"); + let absent_effective = serde_json::to_value( + effective_pi_model(&config, "absent").expect("effective absent tiers"), + ) + .expect("serialize effective absent tiers"); + assert_eq!(empty_effective.pointer("/cost/tiers"), Some(&json!([]))); + assert_eq!(absent_effective.pointer("/cost/tiers"), None); + } + #[test] fn diagnostic_reason_serialization_is_structured() { let reason = PiDiagnosticReason::new( @@ -1016,5 +1078,19 @@ mod tests { "jsonPointer": "/models/0/api" }) ); + + let credential_reason = PiDiagnosticReason::new( + PiDiagnosticLayer::Gateway, + PiReasonCode::UnsupportedCredentialKind, + Some("/apiKey".into()), + ); + assert_eq!( + serde_json::to_value(credential_reason).expect("serialize"), + json!({ + "layer": "gateway", + "code": "unsupported_credential_kind", + "jsonPointer": "/apiKey" + }) + ); } } diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index 489b70bd8..c6f3fb6d4 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -604,6 +604,9 @@ fn map_gateway_reasons(gateway: &PiGatewayAssessment) -> Vec PiGatewayReasonCode::UnsupportedFamily => { PiReasonCode::UnsupportedGatewayFamily } + PiGatewayReasonCode::UnsupportedCredentialKind => { + PiReasonCode::UnsupportedCredentialKind + } PiGatewayReasonCode::InvalidEndpoint => PiReasonCode::InvalidEndpoint, PiGatewayReasonCode::MissingCredential => { PiReasonCode::GatewayCredentialUnavailable diff --git a/src-tauri/src/pi_config/native_inspection_certification.rs b/src-tauri/src/pi_config/native_inspection_certification.rs index fd4d0f18f..fa919b5c3 100644 --- a/src-tauri/src/pi_config/native_inspection_certification.rs +++ b/src-tauri/src/pi_config/native_inspection_certification.rs @@ -11,12 +11,13 @@ //! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审 //! 核对为准,不设扫描器。 //! -//! ## 四条裁决及其上游证据 +//! ## 六条裁决及其上游证据 //! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键 //! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证 //! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与 //! **effective 边界**(`effective_pi_model` 是 projection/routing/failover -//! 的共同入口)都必须无损,`{}` 与缺席必须保持可区分。 +//! 的共同入口)都必须无损,**空容器与缺席必须保持可区分**(`{}` 之于 +//! thinkingLevelMap、`[]` 之于 cost.tiers 同理)。 //! 据此取代两个既有测试中把收窄固化为断言的部分: //! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与 //! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately` @@ -41,22 +42,64 @@ //! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始 //! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。 //! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性 -//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。字面量值仍按 -//! 原样校验。 +//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。**字面量值仍在 +//! 判定期校验,且该规则对 credential 与 header 一视同仁**——判定期说 +//! "可代理"而每次物化必然失败,是判定层与执行层自相矛盾。 +//! +//! C5【凭证种类,2026-08-02 新增,**已 request-capture 实证**】pinned +//! Anthropic 传输层以 `apiKey.includes("sk-ant-oat")`(子串,非前缀)判定 +//! OAuth,命中则以 `Authorization: Bearer` 发送、**不发 x-api-key**,并附 +//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`;**models.json +//! 里的字面量 apiKey 同样会走该分支**;该判定**只在 Anthropic 族**,同形 +//! token 在 OpenAI 族仍按普通 Bearer 发送。因此网关不得把这类凭证当普通 +//! x-api-key 代理:字面量命中即判定期 DirectOnly 并给结构化理由(不得是 +//! MissingCredential);deferred 凭证判定期不可知,则**物化期解析出命中值 +//! 时必须失败**,绝不发出错误的认证形态。 +//! **完整 OAuth 传输(Bearer + oauth beta 值)不在前置 C 范围**——按 +//! docs/pi-support-restructure-zh.md §1,gateway 数据面属主工程,且需要 +//! 先补 request-capture oracle。本工程只保证判定诚实、不发错凭证。 +//! C6【entry 隔离,2026-08-02 新增】pinned Pi 逐 entry 做 TypeBox 判定, +//! 单个 entry 的取值错误(如 `contextWindow: 1e400`)只令该 entry 非法; +//! 整文件解析失败会让合法的兄弟 entry 被连坐隐藏,违反四层判定"每个 +//! entry 独立"的核心设计。 +//! +//! ## 实现方义务(不在本文件断言,交盲审核查) +//! O1 `compat` 需复现 JavaScript object-spread 对嵌套值(尤其数组)的语义; +//! O2 架构扫描器:cfg 布尔语义(`cfg(not(test))` 的生产代码必须被扫描)、 +//! 不得按 `tests/` 路径整体跳过文件、嵌套模块须继承父层归属。 //! //! ## 预期红绿(2026-08-02 复审修订基线) -//! 首轮实现已使 C1/C2/C3 四项转绿。本次修订新增两项: -//! `certify_header_only_credentials_stay_direct_only` **应绿**——它把"无 -//! apiKey 即降级"钉为契约(上游证据见 C2,首轮盲审曾按缺陷提报,现裁定 -//! 实现正确、契约缺失);`certify_deferred_header_values_are_validated_after_resolution` -//! **应红**(C4:现实现对原始表达式做头校验)。 -//! 即:应红 1、应绿 8。偏离(非清单红、应红变绿、编译失败)即上报。 +//! 前两轮实现已使 C1–C4 全部转绿(9 项)。本轮据盲审 finding 新增四项, +//! 全部**应红**:`certify_oauth_credentials_are_never_proxied_as_api_key`(C5)、 +//! `certify_literal_credentials_are_validated_at_plan_time`(C4 扩展: +//! 判定期说可代理、物化必失败的自相矛盾)、 +//! `certify_empty_containers_stay_distinct_from_absent`(C1 扩展:cost.tiers +//! `[]` 被抹成缺席)、`certify_one_bad_entry_does_not_hide_its_siblings`(C6)。 +//! 即:应红 4、应绿 9。偏离(非清单红、应红变绿、编译失败)即上报。 +//! +//! ## 本轮裁决的两点澄清 +//! 1. 盲审以"C2 同族复发"停止,**裁定不成立**:C2 管的是认证头的分类与取值 +//! 次序,C5 是凭证种类识别,且完整 OAuth 传输按范围表属主工程。停止解除。 +//! 2. 盲审对"网关强制 apiKey"的上一轮 High 已于前轮裁定为实现正确(见 C2)。 +//! +//! ## 上游实证(request-capture,2026-08-02) +//! `scripts/pi-transport-capture.mjs` 以本地抓包端点作 baseUrl,用 pinned Pi +//! 的 adapter 真发请求,实测矩阵(据此 C2/C5 不再是"读源码推断"): +//! - anthropic 普通 key → `x-api-key: `; +//! - anthropic `sk-ant-oat...` → `authorization: Bearer ` + +//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`,**无 x-api-key**; +//! - anthropic apiKey + 显式 `x-api-key` → 发**显式值**(显式覆盖合成); +//! - anthropic apiKey + 显式 `authorization` → 两者**并存** +//! (`authorization` 取显式值,`x-api-key` 取合成值); +//! - openai responses/completions + 显式 `authorization` → 发**显式值**; +//! - openai + `sk-ant-oat` 形状 token → 仍是普通 `Bearer`,无 OAuth 特殊处理; +//! - openai completions + 显式 `x-api-key` → 与合成 `authorization` **并存**。 //! //! ## 残余 -//! 显式优先只对 Anthropic/OpenAI 两族有 SDK 证据(Google 两值并存的优先级、 -//! 大小写变体未断言);transport oracle 只执行 resolver,不执行 adapter/SDK -//! 头合并,主工程触碰数据面须先补 request-capture oracle;命令输出 trim 与 -//! 环境模板不 trim 的差异属数据面语义,本只读面不断言;其余按盲审 finding 处理。 +//! Google 族两值并存的优先级、头名大小写变体未实测;命令输出 trim 与环境模板 +//! 不 trim 的差异属数据面语义,本只读面不断言;完整 OAuth 传输实现按范围表 +//! 归主工程(harness 已就位,可直接扩为受冻结的 transport oracle); +//! 其余按盲审 finding 处理。 use super::composer::compose_explicit_custom_catalog; use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode}; @@ -64,7 +107,7 @@ use super::model::{ effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment, PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity, }; -use super::native::inspect_pi_native_entry; +use super::native::{inspect_pi_native_catalog, inspect_pi_native_entry}; use super::raw_schema::evaluate_provider_value; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -598,3 +641,213 @@ fn certify_deferred_header_values_are_validated_after_resolution() { "a literal header value outside visible ASCII must still be rejected" ); } + +// --------------------------------------------------------------------------- +// 应红(C5):OAuth 凭证绝不能按 x-api-key 代理 +// --------------------------------------------------------------------------- + +#[test] +fn certify_oauth_credentials_are_never_proxied_as_api_key() { + // 字面量命中:判定期即可知,必须 DirectOnly 并给出结构化理由—— + // 而不是宣称可代理再发出错误的认证形态。 + let literal = composed_catalog(json!({ + "api": "anthropic-messages", + "baseUrl": "https://anthropic.example", + "apiKey": "sk-ant-oat01-example-token", + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&literal); + assert_eq!( + gateway.capability, + PiGatewayCapability::DirectOnly, + "pinned Pi sends an sk-ant-oat credential as an OAuth Bearer with oauth beta \ + headers; proxying it as x-api-key would send the wrong auth form" + ); + assert!( + !gateway.reasons.is_empty(), + "the downgrade must carry a structured reason" + ); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential), + "the credential is present; MissingCredential would misreport the cause" + ); + + // deferred 凭证:判定期不可知,允许 Proxyable;但物化解析出命中值时必须 + // 失败,绝不发出错误的认证形态。 + let deferred = composed_catalog(json!({ + "api": "anthropic-messages", + "baseUrl": "https://anthropic.example", + "apiKey": "!load-token", + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&deferred); + assert_eq!( + gateway.capability, + PiGatewayCapability::Proxyable, + "a deferred credential's kind is unknowable at plan time" + ); + assert!( + gateway.plans[0] + .materialize(&|_: &str| Some("sk-ant-oat01-resolved".to_string())) + .is_err(), + "materialising a resolved OAuth credential must fail rather than send it as \ + a plain api key" + ); + + // 防过度收窄:普通 Anthropic key 不受影响;非 Anthropic 族不适用该判定 + // (pinned 的 includes 检查只在 Anthropic 传输层)。 + for (api, key) in [ + ("anthropic-messages", "sk-ant-api03-plain"), + ("openai-responses", "sk-ant-oat01-not-anthropic"), + ] { + let plain = composed_catalog(json!({ + "api": api, + "baseUrl": "https://plain.example/v1", + "apiKey": key, + "models": [{"id": "m"}] + })); + assert_eq!( + assess_composition(&plain).capability, + PiGatewayCapability::Proxyable, + "{api}: the OAuth rule must not over-reach" + ); + } +} + +// --------------------------------------------------------------------------- +// 应红(C4 扩展):字面量凭证必须在判定期校验 +// --------------------------------------------------------------------------- + +#[test] +fn certify_literal_credentials_are_validated_at_plan_time() { + // 判定期宣称"可代理"、而每次物化必然失败,是判定层与执行层自相矛盾。 + let illegal = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "café", + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&illegal); + assert!( + gateway.plans.is_empty() || gateway.plans[0].materialize(&|_: &str| None).is_err(), + "sanity: this literal credential can never materialise" + ); + assert_eq!( + gateway.capability, + PiGatewayCapability::DirectOnly, + "a literal credential that can never materialise must not be judged proxyable" + ); + assert!( + !gateway.reasons.is_empty(), + "the downgrade must carry a structured reason" + ); + + // 对称约束:deferred 凭证仍不得因原始表达式在判定期被拒(C4)。 + let deferred = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "!echo café", + "models": [{"id": "m"}] + })); + assert_eq!( + assess_composition(&deferred).capability, + PiGatewayCapability::Proxyable, + "a deferred credential must not be validated as a header value before it is \ + resolved" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C1 扩展):空容器与缺席必须可区分 +// --------------------------------------------------------------------------- + +#[test] +fn certify_empty_containers_stay_distinct_from_absent() { + let base_rates = json!({ + "input": 1.0, "output": 2.0, "cacheRead": 0.5, "cacheWrite": 0.25 + }); + let mut with_empty = base_rates.as_object().expect("rates").clone(); + with_empty.insert("tiers".into(), json!([])); + let catalog = json!({ + "providers": { + "tiers": { + "api": "anthropic-messages", + "baseUrl": "https://tiers.example", + "apiKey": "literal", + "models": [ + {"id": "empty-tiers", "cost": Value::Object(with_empty)}, + {"id": "absent-tiers", "cost": base_rates.clone()} + ] + } + } + }); + let (_temp, path) = write_catalog(&catalog); + let managed = inspect_pi_native_entry(&path, "tiers", &BTreeMap::new()) + .expect("inspect") + .expect("entry present") + .managed_config + .expect("managed config"); + let round_trip = serde_json::to_value(&managed).expect("serialize managed config"); + assert_eq!( + round_trip.pointer("/models/0/cost/tiers"), + Some(&json!([])), + "an explicitly empty tiers list must survive as an empty list" + ); + assert_eq!( + round_trip.pointer("/models/1/cost/tiers"), + None, + "an absent tiers list must stay absent" + ); +} + +// --------------------------------------------------------------------------- +// 应红(C6):单个 entry 的错误不得连坐兄弟 entry +// --------------------------------------------------------------------------- + +#[test] +fn certify_one_bad_entry_does_not_hide_its_siblings() { + // pinned Pi 逐 entry 判定:`contextWindow: 1e400` 只令该 entry 非法。 + // 整文件解析失败会让合法条目一并消失,破坏"每个 entry 独立"的判定设计。 + let source = r#"{ + "providers": { + "healthy": { + "api": "anthropic-messages", + "baseUrl": "https://healthy.example", + "apiKey": "literal", + "models": [{"id": "m"}] + }, + "overflow": { + "api": "anthropic-messages", + "baseUrl": "https://overflow.example", + "apiKey": "literal", + "models": [{"id": "m", "contextWindow": 1e400}] + } + } +}"#; + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write(&path, source).expect("write"); + + let diagnostics = inspect_pi_native_catalog(&path, &BTreeMap::new()) + .expect("one malformed entry must not fail the whole catalog"); + assert_eq!(diagnostics.len(), 2, "both entries must still be reported"); + let healthy = diagnostics + .iter() + .find(|diagnostic| diagnostic.provider_key == "healthy") + .expect("healthy entry present"); + assert_eq!( + healthy.raw_validity, + PiRawNativeValidity::Valid, + "a legal sibling must not be hidden by a malformed entry" + ); + assert_eq!(healthy.management_status, PiManagementStatus::Importable); + let overflow = diagnostics + .iter() + .find(|diagnostic| diagnostic.provider_key == "overflow") + .expect("overflow entry present"); + assert_ne!( + overflow.raw_validity, + PiRawNativeValidity::Valid, + "the out-of-range contextWindow entry itself must not be judged valid" + ); +} diff --git a/src-tauri/src/pi_config/raw_schema.rs b/src-tauri/src/pi_config/raw_schema.rs index eb6353ffe..cd70a7d23 100644 --- a/src-tauri/src/pi_config/raw_schema.rs +++ b/src-tauri/src/pi_config/raw_schema.rs @@ -662,7 +662,13 @@ fn evaluate_schema(schema: &Value, instance: &Value, pointer: &str) -> SchemaOut "object" => instance.is_object(), "array" => instance.is_array(), "string" => instance.is_string(), - "number" => instance.is_number(), + // TypeBox Number follows JavaScript Number semantics and rejects + // NaN/Infinity. With serde_json arbitrary precision, an overflow + // token remains a Number but has no finite f64 representation. + "number" => instance + .as_number() + .and_then(serde_json::Number::as_f64) + .is_some(), "boolean" => instance.is_boolean(), "null" => instance.is_null(), _ => return unsupported(pointer),