Skip to content

JSON Schema构造

从外部 input_schema 追踪 Codex 的 JSON Schema 归一化、类型推断、引用裁剪与大规格压缩,并说明它如何进入函数工具参数。

基于rust-v0.150.0
CodexRustToolsJSON Schema

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 :: JsonSchema
  • codex-rs/tools/src/json_schema.rs :: JsonSchemaType
  • codex-rs/tools/src/json_schema.rs :: AdditionalProperties
rust
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::object
  • codex-rs/tools/src/json_schema.rs :: JsonSchema::array
  • codex-rs/tools/src/json_schema.rs :: JsonSchema::string_enum
rust
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_schema
  • codex-rs/tools/src/json_schema.rs :: prepare_tool_input_schema
rust
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_tool
  • codex-rs/tools/src/mcp_tool.rs :: parse_mcp_tool_with_description_limit
  • codex-rs/tools/src/responses_api.rs :: tool_definition_to_responses_api_tool
rust
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_schema
  • codex-rs/tools/src/json_schema.rs :: normalized_schema_types
rust
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

rust
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_schema
  • codex-rs/tools/src/json_schema.rs :: ensure_default_children_for_schema_types
rust
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_definitions
  • codex-rs/tools/src/json_schema.rs :: collect_reachable_definitions
rust
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

rust
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_schema
  • codex-rs/tools/src/json_schema.rs :: LARGE_SCHEMA_COMPACTION_PASSES
rust
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_descriptions
  • codex-rs/tools/src/json_schema.rs :: drop_schema_definitions
rust
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_objects
  • codex-rs/tools/src/json_schema.rs :: prune_schema_compositions
rust
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

rust
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_properties
  • codex-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_defs
  • codex-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_path
  • codex-rs/tools/src/json_schema_tests.rs :: parse_tool_input_schema_rejects_singleton_null_type

这些测试证明归一化算法的输入、断言和分支边界,不证明 provider 最终接受所有生成 schema,也不证明 handler 的 业务参数校验会与 schema 完全重合。

8. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
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

然后尝试回答:

  1. 输入 { "properties": { "q": { "description": "query" } } } 时,根 schema 和 q 分别变成什么?指出推断和 清空发生在哪两个代码分支。
  2. 一个 $defs 中有 A、B 两个定义,根只引用 A,而 A 又引用 B,最终哪些定义保留?沿 pending 集合复述过程。
  3. 为什么默认入口会删除 description,而 parse_tool_input_schema_without_compaction 不会?这两个入口分别适合什么 可信度/预算场景?
  4. 一个深层 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。