mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): certify pinned native inspection semantics
This commit is contained in:
@@ -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"以合并后的规范文档对应章节为准。
|
> 文档考据说明:修正案 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.6 前置 B 终止关账(用户裁决,2026-08-02,优先于 §4.5)
|
||||||
|
|
||||||
§4.5 的缩围执行后,重认证 R1 仍在**存量产品面**出现 3 项 High(配置目录可变
|
§4.5 的缩围执行后,重认证 R1 仍在**存量产品面**出现 3 项 High(配置目录可变
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -23,7 +23,7 @@ test-hooks = []
|
|||||||
tauri-build = { version = "2.4.0", features = [] }
|
tauri-build = { version = "2.4.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|||||||
@@ -148,22 +148,18 @@ struct ArchitectureVisitor<'a> {
|
|||||||
internal_edges: BTreeSet<String>,
|
internal_edges: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<'_> {
|
impl ArchitectureVisitor<'_> {
|
||||||
fn record_dependency(&mut self, segments: &[String]) {
|
fn record_dependency(&mut self, segments: &[String]) {
|
||||||
let source_module = if self.path.ends_with("pi_config/raw_schema.rs") {
|
let Some(source_module) = pi_config_source_module(self.path) else {
|
||||||
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 {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let qualified_internal_path = segments.len() > 1
|
let qualified_internal_path = segments.len() > 1
|
||||||
@@ -398,6 +394,27 @@ fn rust_sources(root: &Path) -> Vec<PathBuf> {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
(violations, edges)
|
||||||
|
}
|
||||||
|
|
||||||
fn type_leaf(ty: &Type) -> String {
|
fn type_leaf(ty: &Type) -> String {
|
||||||
match ty {
|
match ty {
|
||||||
Type::Path(path) => path
|
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() {
|
fn architecture_scanner_accepts_production_tree_and_matches_machine_snapshots() {
|
||||||
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
let source_root = manifest_dir.join("src");
|
let source_root = manifest_dir.join("src");
|
||||||
let mut violations = Vec::new();
|
let (violations, edges) = scan_production_tree(manifest_dir, &source_root);
|
||||||
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);
|
|
||||||
}
|
|
||||||
assert!(
|
assert!(
|
||||||
violations.is_empty(),
|
violations.is_empty(),
|
||||||
"architecture violations:\n{}",
|
"architecture violations:\n{}",
|
||||||
@@ -648,4 +646,28 @@ fn architecture_scanner_negative_fixtures_prove_each_guard_fires() {
|
|||||||
violations.is_empty(),
|
violations.is_empty(),
|
||||||
"a predicate requiring test must stay excluded: {violations:?}"
|
"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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
#![allow(dead_code)]
|
#![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 serde_json::{json, Map, Value};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
|
||||||
@@ -302,7 +305,7 @@ pub(super) fn compose_explicit_custom_catalog(
|
|||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
provider_headers: provider_header_entries.clone(),
|
provider_headers: provider_header_entries.clone(),
|
||||||
model_headers: Vec::new(),
|
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(),
|
api_key: api_key.clone(),
|
||||||
oauth: oauth.clone(),
|
oauth: oauth.clone(),
|
||||||
auth_header,
|
auth_header,
|
||||||
@@ -398,7 +401,7 @@ pub(super) fn compose_explicit_custom_catalog(
|
|||||||
model.max_tokens = max_tokens.clone();
|
model.max_tokens = max_tokens.clone();
|
||||||
}
|
}
|
||||||
model.compat =
|
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);
|
model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,39 +457,6 @@ fn merge_cost(base: &Value, overlay: &Map<String, Value>) -> Value {
|
|||||||
Value::Object(merged)
|
Value::Object(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value> {
|
|
||||||
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<PiComposedHeader> {
|
fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec<PiComposedHeader> {
|
||||||
value
|
value
|
||||||
.and_then(Value::as_object)
|
.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]
|
#[test]
|
||||||
fn header_layers_retain_runtime_precedence_and_source_pointers() {
|
fn header_layers_retain_runtime_precedence_and_source_pointers() {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
|
|||||||
@@ -288,6 +288,29 @@ mod tests {
|
|||||||
assert!(entry.raw_source.contains("\"model//literal\""));
|
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]
|
#[test]
|
||||||
fn rejects_json_extensions_that_pinned_pi_rejects() {
|
fn rejects_json_extensions_that_pinned_pi_rejects() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ pub(super) enum PiGatewayCapability {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) enum PiGatewayReasonCode {
|
pub(super) enum PiGatewayReasonCode {
|
||||||
UnsupportedFamily,
|
UnsupportedFamily,
|
||||||
|
UnsupportedCredentialKind,
|
||||||
InvalidEndpoint,
|
InvalidEndpoint,
|
||||||
MissingCredential,
|
MissingCredential,
|
||||||
InvalidHeaderName,
|
InvalidHeaderName,
|
||||||
@@ -242,11 +243,27 @@ impl CandidateHeaderPlan {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let credential = model.api_key.as_ref();
|
let credential = model.api_key.as_ref();
|
||||||
if credential.is_none() {
|
match credential {
|
||||||
reasons.push(PiGatewayReason {
|
None => reasons.push(PiGatewayReason {
|
||||||
code: PiGatewayReasonCode::MissingCredential,
|
code: PiGatewayReasonCode::MissingCredential,
|
||||||
json_pointer: "/apiKey".to_string(),
|
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;
|
let mut protocol_identity_predictable = true;
|
||||||
@@ -292,6 +309,14 @@ impl CandidateHeaderPlan {
|
|||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
let mut protocol_headers = HeaderMap::new();
|
let mut protocol_headers = HeaderMap::new();
|
||||||
let credential = self.credential.materialize(resolver, "/apiKey")?;
|
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 bearer_credential = credential.clone();
|
||||||
let (auth_name, auth_value) = match self.family {
|
let (auth_name, auth_value) = match self.family {
|
||||||
PiGatewayApiFamily::AnthropicMessages => {
|
PiGatewayApiFamily::AnthropicMessages => {
|
||||||
@@ -486,6 +511,11 @@ fn is_deferred(value: &str) -> bool {
|
|||||||
value.starts_with('!') || value.contains('$')
|
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<HeaderValue> {
|
fn parse_transport_header_value(value: &str) -> Option<HeaderValue> {
|
||||||
if !value.bytes().all(|byte| matches!(byte, 0x20..=0x7e)) {
|
if !value.bytes().all(|byte| matches!(byte, 0x20..=0x7e)) {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
//! Pi's shared files and from the proxy data plane. Callers must use the
|
//! Pi's shared files and from the proxy data plane. Callers must use the
|
||||||
//! typed model resolver rather than reimplementing provider/model inheritance.
|
//! typed model resolver rather than reimplementing provider/model inheritance.
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
mod composer;
|
mod composer;
|
||||||
mod document;
|
mod document;
|
||||||
mod gateway;
|
mod gateway;
|
||||||
@@ -12,3 +14,113 @@ pub(crate) mod native;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod native_inspection_certification;
|
mod native_inspection_certification;
|
||||||
mod raw_schema;
|
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<Value>, overlay: Option<Value>) -> Option<Value> {
|
||||||
|
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<String, Value> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use super::merge_pi_compat;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -144,8 +145,8 @@ pub(crate) struct PiModelCostTier {
|
|||||||
pub(crate) struct PiModelCost {
|
pub(crate) struct PiModelCost {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub rates: PiModelCostRates,
|
pub rates: PiModelCostRates,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tiers: Vec<PiModelCostTier>,
|
pub tiers: Option<Vec<PiModelCostTier>>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: BTreeMap<String, Value>,
|
pub extra: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
@@ -404,6 +405,7 @@ pub(crate) enum PiReasonCode {
|
|||||||
InvalidThinkingLevel,
|
InvalidThinkingLevel,
|
||||||
CompositionFailed,
|
CompositionFailed,
|
||||||
GatewayCredentialUnavailable,
|
GatewayCredentialUnavailable,
|
||||||
|
UnsupportedCredentialKind,
|
||||||
UnsupportedGatewayFamily,
|
UnsupportedGatewayFamily,
|
||||||
InvalidHeaderName,
|
InvalidHeaderName,
|
||||||
InvalidHeaderValue,
|
InvalidHeaderValue,
|
||||||
@@ -550,8 +552,8 @@ fn effective_pi_model_unchecked(
|
|||||||
.map(|entry| apply_cost_override(base_cost.clone(), entry))
|
.map(|entry| apply_cost_override(base_cost.clone(), entry))
|
||||||
.unwrap_or(base_cost);
|
.unwrap_or(base_cost);
|
||||||
|
|
||||||
let compat = merge_compat(
|
let compat = merge_pi_compat(
|
||||||
merge_compat(provider.compat.clone(), model.compat.clone()),
|
merge_pi_compat(provider.compat.clone(), model.compat.clone()),
|
||||||
model_override.and_then(|entry| entry.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),
|
cache_write: model_override.cache_write.unwrap_or(base.rates.cache_write),
|
||||||
};
|
};
|
||||||
if let Some(tiers) = &model_override.tiers {
|
if let Some(tiers) = &model_override.tiers {
|
||||||
base.tiers = tiers.clone();
|
base.tiers = Some(tiers.clone());
|
||||||
}
|
}
|
||||||
base.extra.extend(model_override.extra.clone());
|
base.extra.extend(model_override.extra.clone());
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_compat(base: Option<Value>, overlay: Option<Value>) -> Option<Value> {
|
|
||||||
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> {
|
fn validate_optional_text(value: Option<&str>, field: &'static str) -> Result<(), PiConfigError> {
|
||||||
if value.is_some_and(str::is_empty) {
|
if value.is_some_and(str::is_empty) {
|
||||||
return Err(PiConfigError::EmptyOptionalField { field });
|
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]
|
#[test]
|
||||||
fn fractional_pi_numbers_survive_managed_round_trip() {
|
fn fractional_pi_numbers_survive_managed_round_trip() {
|
||||||
let mut fractional = model("fractional");
|
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]
|
#[test]
|
||||||
fn diagnostic_reason_serialization_is_structured() {
|
fn diagnostic_reason_serialization_is_structured() {
|
||||||
let reason = PiDiagnosticReason::new(
|
let reason = PiDiagnosticReason::new(
|
||||||
@@ -1016,5 +1078,19 @@ mod tests {
|
|||||||
"jsonPointer": "/models/0/api"
|
"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"
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,9 @@ fn map_gateway_reasons(gateway: &PiGatewayAssessment) -> Vec<PiDiagnosticReason>
|
|||||||
PiGatewayReasonCode::UnsupportedFamily => {
|
PiGatewayReasonCode::UnsupportedFamily => {
|
||||||
PiReasonCode::UnsupportedGatewayFamily
|
PiReasonCode::UnsupportedGatewayFamily
|
||||||
}
|
}
|
||||||
|
PiGatewayReasonCode::UnsupportedCredentialKind => {
|
||||||
|
PiReasonCode::UnsupportedCredentialKind
|
||||||
|
}
|
||||||
PiGatewayReasonCode::InvalidEndpoint => PiReasonCode::InvalidEndpoint,
|
PiGatewayReasonCode::InvalidEndpoint => PiReasonCode::InvalidEndpoint,
|
||||||
PiGatewayReasonCode::MissingCredential => {
|
PiGatewayReasonCode::MissingCredential => {
|
||||||
PiReasonCode::GatewayCredentialUnavailable
|
PiReasonCode::GatewayCredentialUnavailable
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
//! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审
|
//! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审
|
||||||
//! 核对为准,不设扫描器。
|
//! 核对为准,不设扫描器。
|
||||||
//!
|
//!
|
||||||
//! ## 四条裁决及其上游证据
|
//! ## 六条裁决及其上游证据
|
||||||
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
|
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
|
||||||
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
|
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
|
||||||
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
|
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
|
||||||
//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover
|
//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover
|
||||||
//! 的共同入口)都必须无损,`{}` 与缺席必须保持可区分。
|
//! 的共同入口)都必须无损,**空容器与缺席必须保持可区分**(`{}` 之于
|
||||||
|
//! thinkingLevelMap、`[]` 之于 cost.tiers 同理)。
|
||||||
//! 据此取代两个既有测试中把收窄固化为断言的部分:
|
//! 据此取代两个既有测试中把收窄固化为断言的部分:
|
||||||
//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与
|
//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与
|
||||||
//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately`
|
//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately`
|
||||||
@@ -41,22 +42,64 @@
|
|||||||
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
|
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
|
||||||
//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。
|
//! 表达式**(命令输出 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 复审修订基线)
|
//! ## 预期红绿(2026-08-02 复审修订基线)
|
||||||
//! 首轮实现已使 C1/C2/C3 四项转绿。本次修订新增两项:
|
//! 前两轮实现已使 C1–C4 全部转绿(9 项)。本轮据盲审 finding 新增四项,
|
||||||
//! `certify_header_only_credentials_stay_direct_only` **应绿**——它把"无
|
//! 全部**应红**:`certify_oauth_credentials_are_never_proxied_as_api_key`(C5)、
|
||||||
//! apiKey 即降级"钉为契约(上游证据见 C2,首轮盲审曾按缺陷提报,现裁定
|
//! `certify_literal_credentials_are_validated_at_plan_time`(C4 扩展:
|
||||||
//! 实现正确、契约缺失);`certify_deferred_header_values_are_validated_after_resolution`
|
//! 判定期说可代理、物化必失败的自相矛盾)、
|
||||||
//! **应红**(C4:现实现对原始表达式做头校验)。
|
//! `certify_empty_containers_stay_distinct_from_absent`(C1 扩展:cost.tiers
|
||||||
//! 即:应红 1、应绿 8。偏离(非清单红、应红变绿、编译失败)即上报。
|
//! `[]` 被抹成缺席)、`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: <key>`;
|
||||||
|
//! - anthropic `sk-ant-oat...` → `authorization: Bearer <token>` +
|
||||||
|
//! `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 两值并存的优先级、
|
//! Google 族两值并存的优先级、头名大小写变体未实测;命令输出 trim 与环境模板
|
||||||
//! 大小写变体未断言);transport oracle 只执行 resolver,不执行 adapter/SDK
|
//! 不 trim 的差异属数据面语义,本只读面不断言;完整 OAuth 传输实现按范围表
|
||||||
//! 头合并,主工程触碰数据面须先补 request-capture oracle;命令输出 trim 与
|
//! 归主工程(harness 已就位,可直接扩为受冻结的 transport oracle);
|
||||||
//! 环境模板不 trim 的差异属数据面语义,本只读面不断言;其余按盲审 finding 处理。
|
//! 其余按盲审 finding 处理。
|
||||||
|
|
||||||
use super::composer::compose_explicit_custom_catalog;
|
use super::composer::compose_explicit_custom_catalog;
|
||||||
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
|
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
|
||||||
@@ -64,7 +107,7 @@ use super::model::{
|
|||||||
effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment,
|
effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment,
|
||||||
PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity,
|
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 super::raw_schema::evaluate_provider_value;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sha2::{Digest, Sha256};
|
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"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -662,7 +662,13 @@ fn evaluate_schema(schema: &Value, instance: &Value, pointer: &str) -> SchemaOut
|
|||||||
"object" => instance.is_object(),
|
"object" => instance.is_object(),
|
||||||
"array" => instance.is_array(),
|
"array" => instance.is_array(),
|
||||||
"string" => instance.is_string(),
|
"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(),
|
"boolean" => instance.is_boolean(),
|
||||||
"null" => instance.is_null(),
|
"null" => instance.is_null(),
|
||||||
_ => return unsupported(pointer),
|
_ => return unsupported(pointer),
|
||||||
|
|||||||
Reference in New Issue
Block a user