JSON Schema构造
外部工具带来的 input_schema 并不一定正好符合 Codex 内部的 JsonSchema 结构。当前实现先复制输入,再递归 补齐类型、清洗组合节点、保留可达 $ref,最后按预算逐级压缩,才反序列化为可以放进 ResponsesApiTool.parameters 的对象。理解这条路径,比记住字段表更重要:它解释了为什么缺少 type 的 schema 有时会被推断成 object,有时会变成空 schema,也解释了为什么超大 schema 会丢描述但不会立刻拒绝注册。
本文面向已经读过ToolSpec与函数规格的读者。前文解释 parameters 如何进入 Responses wire shape;本文继续向前追踪 schema 构造,不展开完整的 Responses provider 兼容性,也不讲某个 MCP 工具的业务调用。读完后,读者应能从一个 JSON 输入判断当前实现会保留什么、补什么、裁掉什么,以及应该运行 哪些测试来验证结论。
1. 两层对象
1.1 JSON模型
JsonSchema 是 Codex 自己的可序列化子集,不是通用 JSON Schema validator。它保留 $ref、type、描述、 枚举、数组、对象、required、additionalProperties、anyOf/oneOf/allOf 和两种 definition table,供工具 规格和搜索文本共同消费。
相关源码:
codex-rs/tools/src/json_schema.rs :: JsonSchemacodex-rs/tools/src/json_schema.rs :: JsonSchemaTypecodex-rs/tools/src/json_schema.rs :: AdditionalProperties
pub enum JsonSchemaType {
Single(JsonSchemaPrimitiveType),
Multiple(Vec<JsonSchemaPrimitiveType>),
}
pub struct JsonSchema {
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub schema_ref: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<JsonSchemaType>,
pub description: Option<String>,
pub encrypted: Option<bool>,
pub enum_values: Option<Vec<JsonValue>>,
pub items: Option<Box<JsonSchema>>,
pub properties: Option<BTreeMap<String, JsonSchema>>,
pub required: Option<Vec<String>>,
pub additional_properties: Option<AdditionalProperties>,
pub any_of: Option<Vec<JsonSchema>>,
pub one_of: Option<Vec<JsonSchema>>,
pub all_of: Option<Vec<JsonSchema>>,
pub defs: Option<BTreeMap<String, JsonSchema>>,
pub definitions: Option<BTreeMap<String, JsonSchema>>,
}JsonSchemaPrimitiveType 只包含 string、number、boolean、integer、object、array、null 七种类型;像 minimum、 format 和 const 这些输入关键词不会作为独立字段保存,而会在 sanitize 阶段影响类型或枚举结果。因此, JsonSchema 是“工具协议需要的表示”,不是“输入 JSON 的无损 AST”。
1.2 构造器
内置 handler 通常使用 JsonSchema::object、array、string_enum 等构造器直接生成稳定 schema。构造器只负责 建立 Rust 对象,不负责外部 schema 的宽松兼容、definition 裁剪或预算压缩。
相关源码:
codex-rs/tools/src/json_schema.rs :: JsonSchema::objectcodex-rs/tools/src/json_schema.rs :: JsonSchema::arraycodex-rs/tools/src/json_schema.rs :: JsonSchema::string_enum
pub fn object(
properties: BTreeMap<String, JsonSchema>,
required: Option<Vec<String>>,
additional_properties: Option<AdditionalProperties>,
) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(properties),
required,
additional_properties,
..Default::default()
}
}
pub fn array(items: JsonSchema, description: Option<String>) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Array)),
description,
items: Some(Box::new(items)),
..Default::default()
}
}2. 入口流水线
2.1 默认入口
parse_tool_input_schema 接受任意 serde_json::Value,但不在原对象上修改。它先调用 prepare_tool_input_schema,再执行大 schema compaction,最后反序列化;可信 schema 可以调用 parse_tool_input_schema_without_compaction 跳过预算压缩,但仍会执行 sanitize 和 definition pruning。
相关源码:
codex-rs/tools/src/json_schema.rs :: parse_tool_input_schemacodex-rs/tools/src/json_schema.rs :: prepare_tool_input_schema
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = prepare_tool_input_schema(input_schema);
compact_large_tool_schema(&mut input_schema);
deserialize_tool_input_schema(input_schema)
}
pub fn parse_tool_input_schema_without_compaction(
input_schema: &JsonValue,
) -> Result<JsonSchema, serde_json::Error> {
deserialize_tool_input_schema(prepare_tool_input_schema(input_schema))
}
fn prepare_tool_input_schema(input_schema: &JsonValue) -> JsonValue {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
prune_unreachable_definitions(&mut input_schema);
input_schema
}复制输入是所有权边界:外部调用方持有的 JSON 不会被 sanitize 的递归修改污染。两条入口的差异只在 compaction, 不能把 without_compaction 理解成“完全不清洗”。
2.2 消费者
dynamic tool 和 MCP tool 都把解析结果继续交给 ToolDefinition,再转成 ResponsesApiTool.parameters。 因此 sanitize 的结果会同时影响模型看到的参数 schema 和 tool search 的字段搜索文本;它不会直接决定 handler 最终是否接受业务参数。
相关源码:
codex-rs/tools/src/dynamic_tool.rs :: parse_dynamic_toolcodex-rs/tools/src/mcp_tool.rs :: parse_mcp_tool_with_description_limitcodex-rs/tools/src/responses_api.rs :: tool_definition_to_responses_api_tool
pub fn parse_dynamic_tool(
tool: &DynamicToolFunctionSpec,
) -> Result<ToolDefinition, serde_json::Error> {
Ok(ToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: parse_tool_input_schema(&tool.input_schema)?,
output_schema: None,
defer_loading: tool.defer_loading,
})
}
pub fn tool_definition_to_responses_api_tool(
tool_definition: ToolDefinition,
) -> ResponsesApiTool {
ResponsesApiTool {
name: tool_definition.name,
description: tool_definition.description,
strict: false,
defer_loading: tool_definition.defer_loading.then_some(true),
parameters: tool_definition.input_schema,
output_schema: tool_definition.output_schema,
}
}解析成功只表示 schema 能进入当前内部表示。MCP/dynamic handler 仍会在执行时解析 arguments,并可能返回业务 错误;schema 层不替代运行时验证。
3. 类型归一化
3.1 显式类型
sanitize_json_schema 首先递归处理 properties、items、additionalProperties、prefixItems、组合关键词和 definition table。已有合法 type 会被转换为内部枚举;未知或非字符串 type 不会直接成为内部类型。
相关源码:
codex-rs/tools/src/json_schema.rs :: sanitize_json_schemacodex-rs/tools/src/json_schema.rs :: normalized_schema_types
fn normalized_schema_types(
map: &serde_json::Map<String, JsonValue>,
) -> Vec<JsonSchemaPrimitiveType> {
let Some(schema_type) = map.get("type") else {
return Vec::new();
};
match schema_type {
JsonValue::String(schema_type) => {
schema_type_from_str(schema_type).into_iter().collect()
}
JsonValue::Array(schema_types) => schema_types
.iter()
.filter_map(JsonValue::as_str)
.filter_map(schema_type_from_str)
.collect(),
_ => Vec::new(),
}
}
fn write_schema_types(
map: &mut serde_json::Map<String, JsonValue>,
schema_types: &[JsonSchemaPrimitiveType],
) {
match schema_types {
[] => {
map.remove("type");
}
[schema_type] => {
map.insert(
"type".to_string(),
JsonValue::String(schema_type_name(*schema_type).to_string()),
);
}
_ => {
map.insert(
"type".to_string(),
JsonValue::Array(
schema_types
.iter()
.map(|schema_type| JsonValue::String(schema_type_name(*schema_type).to_string()))
.collect(),
),
);
}
}
}联合类型不会被改写成 anyOf,而是保留为 JsonSchemaType::Multiple。这点对 nullable object/array 很重要: 后续 ensure_default_children_for_schema_types 仍会为 union 中的 object 补 properties,为 array 补 items。
3.2 缺失类型
当 type 缺失时,代码根据可识别关键词推断类型:properties/required/additionalProperties 指向 object, items/prefixItems 指向 array,enum/format 指向 string,数值约束指向 number。没有任何已知提示的对象会被清空为 空 schema;空 schema 本身是合法的宽松表示,不是解析失败。
源码位置:codex-rs/tools/src/json_schema.rs :: sanitize_json_schema
if schema_types.is_empty() {
if map.contains_key("properties")
|| map.contains_key("required")
|| map.contains_key("additionalProperties")
{
schema_types.push(JsonSchemaPrimitiveType::Object);
} else if map.contains_key("items") || map.contains_key("prefixItems") {
schema_types.push(JsonSchemaPrimitiveType::Array);
} else if map.contains_key("enum") || map.contains_key("format") {
schema_types.push(JsonSchemaPrimitiveType::String);
} else if map.contains_key("minimum")
|| map.contains_key("maximum")
|| map.contains_key("exclusiveMinimum")
|| map.contains_key("exclusiveMaximum")
|| map.contains_key("multipleOf")
{
schema_types.push(JsonSchemaPrimitiveType::Number);
} else {
map.clear();
return;
}
}
write_schema_types(map, &schema_types);
ensure_default_children_for_schema_types(map, &schema_types);3.3 关键字降级
const 被改写成单值 enum,boolean schema 被改写成 string schema,object/array 缺少必需子字段时填入宽松 默认值。它们是面向当前内部表示的兼容降级,不是声称原始 JSON Schema 语义完全等价。
相关源码:
codex-rs/tools/src/json_schema.rs :: sanitize_json_schemacodex-rs/tools/src/json_schema.rs :: ensure_default_children_for_schema_types
match value {
JsonValue::Bool(_) => {
*value = json!({ "type": "string" });
}
JsonValue::Object(map) => {
if let Some(const_value) = map.remove("const") {
map.insert("enum".to_string(), JsonValue::Array(vec![const_value]));
}
// …递归处理子 schema 后再推断和补默认字段
}
_ => {}
}单独的 { "type": "null" } 在 deserialize 阶段明确拒绝;但 type: ["object", "null"] 仍可保留为 union。 这是输入工具参数与通用 JSON Schema nullable 语义之间的一个明确边界。
4. 引用裁剪
4.1 可达定义
schema 可以在 $defs 或旧式 definitions 中声明多个定义,但模型请求不应携带从未被 $ref 引用的条目。 prune_unreachable_definitions 先从 definitions 之外收集本地引用,再沿被引用 definition 继续收集嵌套引用,形成 可达集合。
相关源码:
codex-rs/tools/src/json_schema.rs :: prune_unreachable_definitionscodex-rs/tools/src/json_schema.rs :: collect_reachable_definitions
fn collect_reachable_definitions(value: &JsonValue) -> BTreeSet<DefinitionPointer> {
let mut reachable = BTreeSet::new();
let mut pending = Vec::new();
collect_refs_outside_definitions(value, &mut pending);
while let Some(pointer) = pending.pop() {
if !reachable.insert(pointer.clone()) {
continue;
}
if let Some(definition) = definition_for_pointer(value, &pointer) {
collect_refs(definition, &mut pending);
}
}
reachable
}这里不是简单扫描字符串:collect_refs_outside_definitions 会遍历 properties、items、anyOf、oneOf、allOf 和 schema-valued additionalProperties;定义表本身要等到父定义被确认可达后才继续遍历。这样可以保留嵌套引用, 同时删除无关定义。
4.2 引用解析
parse_local_definition_ref 只识别以 # 开头、指向 $defs 或 definitions 的本地 JSON Pointer。它支持 URL 编码,并只保留父 definition 名称,因此 #/$defs/User/properties/name 会让 User 保持可达。
源码位置:codex-rs/tools/src/json_schema.rs :: parse_local_definition_ref
fn parse_local_definition_ref(schema_ref: &str) -> Option<DefinitionPointer> {
let fragment = schema_ref.strip_prefix('#')?;
let pointer = urlencoding::decode(fragment).ok()?;
let pointer = jsonptr::Pointer::parse(pointer.as_ref()).ok()?;
let (table_token, pointer) = pointer.split_front()?;
let table = table_token.decoded();
let table = DEFINITION_TABLE_KEYS
.into_iter()
.find(|candidate| table.as_ref() == *candidate)?;
let (name, _) = pointer.split_front()?;
Some(DefinitionPointer {
table,
name: name.decoded().into_owned(),
})
}外部 ref、无法解析的 pointer 和不存在的本地 definition 不会被强行展开;当前代码保留可表示的 $ref 字符串, 但不把外部引用当作可达本地 definition。测试因此区分“保留 unresolved ref”和“裁掉 unreachable defs”。
5. 大规格压缩
5.1 预算与顺序
默认解析会以 5,000 个规范化 JSON bytes 作为本地预算代理。压缩不是硬拒绝:只有仍超预算时才依次执行四个 pass,且每个 pass 后重新测量。顺序从信息损失较小的描述移除开始,最后才裁剪组合 schema。
相关源码:
codex-rs/tools/src/json_schema.rs :: compact_large_tool_schemacodex-rs/tools/src/json_schema.rs :: LARGE_SCHEMA_COMPACTION_PASSES
const MAX_COMPACT_TOOL_SCHEMA_BYTES: usize = 5_000;
const MAX_COMPACT_TOOL_SCHEMA_DEPTH: usize = 3;
const LARGE_SCHEMA_COMPACTION_PASSES: &[LargeSchemaCompactionPass] = &[
strip_schema_descriptions,
drop_schema_definitions,
collapse_deep_schema_objects_from_root,
prune_schema_compositions,
];
fn compact_large_tool_schema(value: &mut JsonValue) {
for pass in LARGE_SCHEMA_COMPACTION_PASSES {
if compact_schema_fits_budget(value) {
break;
}
pass(value);
}
}“5,000 bytes”是本地 proxy,不是 provider 宣称的 token 上限;实现注释明确它只是近似预算。压缩完成后仍要经过 deserialize,因此某些无法表示的输入仍会返回错误。
5.2 描述与定义
第一 pass 递归删除 description,第二 pass 删除 definitions table,并把本地 definition refs 改写成空 schema, 避免留下指向已经删除定义的悬空引用。只有在这两步仍不够时,才进入深度和组合裁剪。
相关源码:
codex-rs/tools/src/json_schema.rs :: strip_schema_descriptionscodex-rs/tools/src/json_schema.rs :: drop_schema_definitions
fn drop_schema_definitions(value: &mut JsonValue) {
rewrite_definition_refs_to_empty_schemas(value);
let JsonValue::Object(map) = value else {
return;
};
for key in DEFINITION_TABLE_KEYS {
map.remove(key);
}
}
fn rewrite_definition_refs_to_empty_schemas(value: &mut JsonValue) {
match value {
JsonValue::Array(values) => {
for value in values {
rewrite_definition_refs_to_empty_schemas(value);
}
}
JsonValue::Object(map) => {
if map
.get("$ref")
.and_then(JsonValue::as_str)
.and_then(parse_local_definition_ref)
.is_some()
{
*value = json!({});
return;
}
for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| {
rewrite_definition_refs_to_empty_schemas(value);
});
}
_ => {}
}
}5.3 深度与组合
深度 pass 从 root depth 0 开始,达到 depth 3 且仍是复杂 object 时替换为空对象;最后的 composition pass 会把 包含 anyOf/oneOf/allOf 的节点替换为空对象。它们是最后的兼容降级,目的是保留顶层参数表,让工具仍可被模型调用, 而不是保证复杂嵌套约束完整。
相关源码:
codex-rs/tools/src/json_schema.rs :: collapse_deep_schema_objectscodex-rs/tools/src/json_schema.rs :: prune_schema_compositions
fn collapse_deep_schema_objects(value: &mut JsonValue, depth: usize) {
match value {
JsonValue::Array(values) => {
for value in values {
collapse_deep_schema_objects(value, depth);
}
}
JsonValue::Object(map) => {
if depth >= MAX_COMPACT_TOOL_SCHEMA_DEPTH && is_complex_schema_object(map) {
*value = json!({});
return;
}
for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| {
collapse_deep_schema_objects(value, depth + 1);
});
}
_ => {}
}
}
fn prune_schema_compositions(value: &mut JsonValue) {
match value {
JsonValue::Object(map) if has_composition_keyword(map) => {
*value = json!({});
}
JsonValue::Object(map) => {
for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| {
prune_schema_compositions(value);
});
}
JsonValue::Array(values) => {
for value in values {
prune_schema_compositions(value);
}
}
_ => {}
}
}6. 反序列化边界
deserialize_tool_input_schema 把归一化后的 JSON 转成 JsonSchema,之后只额外拒绝 singleton null。它不会验证 required 中的字段是否都存在 properties,也不会执行完整 JSON Schema 语义验证;这些限制属于当前工具协议和 具体 handler 的后续边界。
源码位置:codex-rs/tools/src/json_schema.rs :: deserialize_tool_input_schema
fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result<JsonSchema, serde_json::Error> {
let schema: JsonSchema = serde_json::from_value(input_schema)?;
if matches!(
schema.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Null))
) {
return Err(singleton_null_schema_error());
}
Ok(schema)
}这解释了三个看似矛盾的结果:空 schema 可以通过,未知对象提示会被转为空 schema,singleton null 却会失败。 它们分别对应“宽松合法输入”“兼容降级”和“工具参数不接受单独 null”三种设计选择。
7. 测试路径
7.1 归一化断言
json_schema_tests::parse_tool_input_schema_infers_object_shape_and_defaults_properties 输入只有 properties 没有 type,断言根节点变成 object,未知 child 变成空 schema。parse_tool_input_schema_preserves_integer_and_defaults_array_items 同时证明 integer 不会降成 number,缺少 items 的 array 会补入 string item schema。
相关测试:
codex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_infers_object_shape_and_defaults_propertiescodex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_preserves_integer_and_defaults_array_items
7.2 引用断言
parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defs 构造一个被引用 definition 和一个无引用 definition, 断言前者保留、后者被删除。parse_tool_input_schema_preserves_nested_defs_ref_parent 进一步验证嵌套 pointer 只要 指向 $defs/User,父 definition 就保持可达。
相关测试:
codex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defscodex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_preserves_nested_defs_ref_parent
7.3 压缩与失败
parse_large_tool_input_schema_compacts_descriptions_only_on_default_path 对比默认入口和 parse_tool_input_schema_without_compaction:默认入口会移除描述以满足预算,trusted no-compaction 路径保留它。 parse_tool_input_schema_rejects_singleton_null_type 则断言单独 null 返回明确错误。
相关测试:
codex-rs/tools/src/json_schema_tests.rs :: parse_large_tool_input_schema_compacts_descriptions_only_on_default_pathcodex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_rejects_singleton_null_type
这些测试证明归一化算法的输入、断言和分支边界,不证明 provider 最终接受所有生成 schema,也不证明 handler 的 业务参数校验会与 schema 完全重合。
8. 阅读练习
在 Codex 源码 workspace 中运行:
cargo test -p codex-tools parse_tool_input_schema_infers_object_shape_and_defaults_properties
cargo test -p codex-tools parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defs
cargo test -p codex-tools parse_large_tool_input_schema_compacts_descriptions_only_on_default_path
cargo test -p codex-tools parse_tool_input_schema_rejects_singleton_null_type然后尝试回答:
- 输入
{ "properties": { "q": { "description": "query" } } }时,根 schema 和q分别变成什么?指出推断和 清空发生在哪两个代码分支。 - 一个
$defs中有 A、B 两个定义,根只引用 A,而 A 又引用 B,最终哪些定义保留?沿 pending 集合复述过程。 - 为什么默认入口会删除 description,而
parse_tool_input_schema_without_compaction不会?这两个入口分别适合什么 可信度/预算场景? - 一个深层 anyOf schema 在预算压缩最后仍超限时会发生什么?说明这会改变哪些嵌套约束,哪些顶层字段仍可能保留。
9. 边界
当前 JSON Schema 层的职责是把外部输入转换成可发送、可搜索、可继续注册的内部表示,不是完整 validator:
- 类型推断和默认子字段是兼容归一化,不保证与原始 schema 逻辑完全等价;
- definition pruning 只处理可识别的本地
$defs/definitions引用,不展开外部 ref; - compaction 是 best-effort 预算降级,可能删除描述、definitions、深层对象或组合约束;
- singleton null 被拒绝,但 union 中的 null 可以保留;
- schema 通过解析不代表 handler 参数、approval、sandbox 或业务状态检查通过。
排查工具“模型看到了奇怪参数约束”时,应依次比较原始 input_schema、prepare_tool_input_schema 后的 JSON、 compaction 是否触发,以及最终 ResponsesApiTool.parameters,不要只检查最初的外部 schema。
