mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Resolve local JSON Schema refs in Code Mode types (#38664)
## Why Code Mode rendered document-local `$ref` values as `unknown`, hiding referenced input and structured-output shapes from generated TypeScript declarations. ## What changed - Resolve fragment-only JSON Pointer references against the root schema, including escaped and percent-encoded pointer segments. - Preserve `$ref` siblings as intersections and parenthesize unions used in `allOf` intersections. - Bound recursive and repeated expansion, intermediate rendering work, and final output size, falling back to `unknown` when a limit is reached or a reference cannot be resolved safely. ## Testing Add unit coverage for recursive, escaped, nested-resource, dangling, and oversized references, plus integration coverage for generated Code Mode tool descriptions and MCP structured output types. GitOrigin-RevId: 95c6aa32e25325b5be9359dfac3b3328e5e7a499
This commit is contained in:
@@ -5,6 +5,7 @@ use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::PUBLIC_TOOL_NAME;
|
||||
use crate::json_schema_types::render_json_schema_to_typescript;
|
||||
|
||||
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
|
||||
const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
|
||||
@@ -332,8 +333,7 @@ pub fn build_exec_tool_description(
|
||||
}
|
||||
}
|
||||
|
||||
let nested_tool_reference = nested_tool_sections.join("\n\n");
|
||||
sections.push(nested_tool_reference);
|
||||
sections.push(nested_tool_sections.join("\n\n"));
|
||||
}
|
||||
|
||||
sections.join("\n\n")
|
||||
@@ -400,7 +400,7 @@ pub fn render_code_mode_sample(
|
||||
) -> String {
|
||||
let declaration = format!(
|
||||
"declare const tools: {{ {} }};",
|
||||
render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
|
||||
render_code_mode_tool_declaration(tool_name, input_name, &input_type, &output_type)
|
||||
);
|
||||
format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
|
||||
}
|
||||
@@ -446,8 +446,8 @@ fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String
|
||||
fn render_code_mode_tool_declaration(
|
||||
tool_name: &str,
|
||||
input_name: &str,
|
||||
input_type: String,
|
||||
output_type: String,
|
||||
input_type: &str,
|
||||
output_type: &str,
|
||||
) -> String {
|
||||
let tool_name = normalize_code_mode_identifier(tool_name);
|
||||
format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
|
||||
@@ -461,10 +461,6 @@ fn render_tool_heading(global_name: &str, raw_name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
|
||||
render_json_schema_to_typescript_inner(schema)
|
||||
}
|
||||
|
||||
fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> {
|
||||
let output_schema = output_schema?;
|
||||
let properties = output_schema
|
||||
@@ -506,226 +502,6 @@ fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&J
|
||||
)
|
||||
}
|
||||
|
||||
fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
|
||||
match schema {
|
||||
JsonValue::Bool(true) => "unknown".to_string(),
|
||||
JsonValue::Bool(false) => "never".to_string(),
|
||||
JsonValue::Object(map) => {
|
||||
if let Some(value) = map.get("const") {
|
||||
return render_json_schema_literal(value);
|
||||
}
|
||||
|
||||
if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
|
||||
let rendered = values
|
||||
.iter()
|
||||
.map(render_json_schema_literal)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["anyOf", "oneOf"] {
|
||||
if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
|
||||
let rendered = variants
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
|
||||
let rendered = variants
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" & ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = map.get("type") {
|
||||
if let Some(types) = schema_type.as_array() {
|
||||
let rendered = types
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.map(|schema_type| render_json_schema_type_keyword(map, schema_type))
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = schema_type.as_str() {
|
||||
return render_json_schema_type_keyword(map, schema_type);
|
||||
}
|
||||
}
|
||||
|
||||
if map.contains_key("properties")
|
||||
|| map.contains_key("additionalProperties")
|
||||
|| map.contains_key("required")
|
||||
{
|
||||
return render_json_schema_object(map);
|
||||
}
|
||||
|
||||
if map.contains_key("items") || map.contains_key("prefixItems") {
|
||||
return render_json_schema_array(map);
|
||||
}
|
||||
|
||||
"unknown".to_string()
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_type_keyword(
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
schema_type: &str,
|
||||
) -> String {
|
||||
match schema_type {
|
||||
"string" => "string".to_string(),
|
||||
"number" | "integer" => "number".to_string(),
|
||||
"boolean" => "boolean".to_string(),
|
||||
"null" => "null".to_string(),
|
||||
"array" => render_json_schema_array(map),
|
||||
"object" => render_json_schema_object(map),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
if let Some(items) = map.get("items") {
|
||||
let item_type = render_json_schema_to_typescript_inner(items);
|
||||
return format!("Array<{item_type}>");
|
||||
}
|
||||
|
||||
if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
|
||||
let item_types = items
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !item_types.is_empty() {
|
||||
return format!("[{}]", item_types.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
"unknown[]".to_string()
|
||||
}
|
||||
|
||||
fn append_additional_properties_line(
|
||||
lines: &mut Vec<String>,
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
properties: &serde_json::Map<String, JsonValue>,
|
||||
line_prefix: &str,
|
||||
) {
|
||||
if let Some(additional_properties) = map.get("additionalProperties") {
|
||||
let property_type = match additional_properties {
|
||||
JsonValue::Bool(true) => Some("unknown".to_string()),
|
||||
JsonValue::Bool(false) => None,
|
||||
value => Some(render_json_schema_to_typescript_inner(value)),
|
||||
};
|
||||
|
||||
if let Some(property_type) = property_type {
|
||||
lines.push(format!("{line_prefix}[key: string]: {property_type};"));
|
||||
}
|
||||
} else if properties.is_empty() {
|
||||
lines.push(format!("{line_prefix}[key: string]: unknown;"));
|
||||
}
|
||||
}
|
||||
|
||||
fn has_property_description(value: &JsonValue) -> bool {
|
||||
value
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|description| !description.is_empty())
|
||||
}
|
||||
|
||||
fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String {
|
||||
let optional = if required.iter().any(|required_name| required_name == &name) {
|
||||
""
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
let property_name = render_json_schema_property_name(name);
|
||||
let property_type = render_json_schema_to_typescript_inner(value);
|
||||
format!("{property_name}{optional}: {property_type};")
|
||||
}
|
||||
|
||||
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
let required = map
|
||||
.get("required")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let properties = map
|
||||
.get("properties")
|
||||
.and_then(JsonValue::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
|
||||
sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a);
|
||||
if sorted_properties
|
||||
.iter()
|
||||
.any(|(_, value)| has_property_description(value))
|
||||
{
|
||||
let mut lines = vec!["{".to_string()];
|
||||
for (name, value) in sorted_properties {
|
||||
if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
|
||||
for description_line in description
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
lines.push(format!(" // {description_line}"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!(
|
||||
" {}",
|
||||
render_json_schema_object_property(name, value, &required)
|
||||
));
|
||||
}
|
||||
|
||||
append_additional_properties_line(&mut lines, map, &properties, " ");
|
||||
lines.push("}".to_string());
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
let mut lines = sorted_properties
|
||||
.into_iter()
|
||||
.map(|(name, value)| render_json_schema_object_property(name, value, &required))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
append_additional_properties_line(&mut lines, map, &properties, "");
|
||||
|
||||
if lines.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
format!("{{ {} }}", lines.join(" "))
|
||||
}
|
||||
|
||||
fn render_json_schema_property_name(name: &str) -> String {
|
||||
if normalize_code_mode_identifier(name) == name {
|
||||
name.to_string()
|
||||
} else {
|
||||
serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_literal(value: &JsonValue) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CodeModeToolKind;
|
||||
@@ -875,6 +651,48 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_types_structured_content_result_refs() {
|
||||
let definition = ToolDefinition {
|
||||
name: "mcp__sample__search".to_string(),
|
||||
tool_name: ToolName::namespaced("mcp__sample__", "search"),
|
||||
description: "Search".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
})),
|
||||
output_schema: Some(mcp_call_tool_result_schema(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/Result~1item~0v1" }
|
||||
}
|
||||
},
|
||||
"required": ["results"],
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Result/item~v1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"score": { "type": "number" }
|
||||
},
|
||||
"required": ["id", "score"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}))),
|
||||
};
|
||||
|
||||
let description = augment_tool_definition(definition).description;
|
||||
assert!(description.contains(
|
||||
"mcp__sample__search(args: {}): Promise<CallToolResult<{ results: Array<{ id: string; score: number; }>; }>>;"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_description_includes_nested_tools() {
|
||||
let description = build_exec_tool_description(
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::description::normalize_code_mode_identifier;
|
||||
|
||||
// Expose one nested recursive shape, then fall back to `unknown` on the next
|
||||
// occurrence so generated tool declarations remain finite.
|
||||
const MAX_LOCAL_REF_EXPANSIONS_PER_PATH: usize = 2;
|
||||
// Bound repeated refs and DAG fan-out separately from cycle depth so one
|
||||
// compact schema cannot expand into an arbitrarily large model-visible item.
|
||||
const MAX_TOTAL_LOCAL_REF_EXPANSIONS: usize = 32;
|
||||
// Bound individual schema rendering before assembling the final declaration.
|
||||
const MAX_RENDERED_SCHEMA_BYTES: usize = 16_000;
|
||||
// Charge intermediate render strings as they are built so repeated local refs
|
||||
// cannot allocate unbounded expanded copies before the final schema cap runs.
|
||||
const MAX_RENDER_WORK_BYTES: usize = MAX_RENDERED_SCHEMA_BYTES * 4;
|
||||
|
||||
pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
|
||||
let rendered = JsonSchemaTypeRenderer::new(schema).render(schema);
|
||||
if rendered.len() > MAX_RENDERED_SCHEMA_BYTES {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
struct JsonSchemaTypeRenderer<'a> {
|
||||
root: &'a JsonValue,
|
||||
nested_schema_resource_depth: usize,
|
||||
active_local_ref_expansions: BTreeMap<String, usize>,
|
||||
remaining_local_ref_expansions: usize,
|
||||
remaining_render_work_bytes: usize,
|
||||
render_work_budget_exhausted: bool,
|
||||
}
|
||||
|
||||
impl<'a> JsonSchemaTypeRenderer<'a> {
|
||||
fn new(root: &'a JsonValue) -> Self {
|
||||
Self {
|
||||
root,
|
||||
nested_schema_resource_depth: 0,
|
||||
active_local_ref_expansions: BTreeMap::new(),
|
||||
remaining_local_ref_expansions: MAX_TOTAL_LOCAL_REF_EXPANSIONS,
|
||||
remaining_render_work_bytes: MAX_RENDER_WORK_BYTES,
|
||||
render_work_budget_exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, schema: &JsonValue) -> String {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
|
||||
// A nested `$id` starts a new schema resource. Fragment-only refs below
|
||||
// it are scoped to that resource, not to the outer document root.
|
||||
let enters_nested_schema_resource = !std::ptr::eq(schema, self.root)
|
||||
&& schema
|
||||
.as_object()
|
||||
.is_some_and(|map| map.contains_key("$id"));
|
||||
if enters_nested_schema_resource {
|
||||
self.nested_schema_resource_depth += 1;
|
||||
}
|
||||
|
||||
let rendered = match schema {
|
||||
JsonValue::Bool(true) => "unknown".to_string(),
|
||||
JsonValue::Bool(false) => "never".to_string(),
|
||||
JsonValue::Object(map) => self.render_map(map),
|
||||
_ => "unknown".to_string(),
|
||||
};
|
||||
if enters_nested_schema_resource {
|
||||
self.nested_schema_resource_depth -= 1;
|
||||
}
|
||||
self.finish_render(rendered)
|
||||
}
|
||||
|
||||
fn render_map(&mut self, map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
|
||||
if map.contains_key("$ref") {
|
||||
return self.render_ref(map);
|
||||
}
|
||||
|
||||
if let Some(value) = map.get("const") {
|
||||
return self.render_literal(value);
|
||||
}
|
||||
|
||||
if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
|
||||
let mut rendered = Vec::new();
|
||||
for value in values {
|
||||
let literal = self.render_literal(value);
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
if !self.consume_render_work(literal.len()) {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
rendered.push(literal);
|
||||
}
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["anyOf", "oneOf"] {
|
||||
if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
|
||||
let mut rendered = Vec::new();
|
||||
for variant in variants {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
rendered.push(self.render(variant));
|
||||
}
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
|
||||
let mut rendered = Vec::new();
|
||||
for variant in variants {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
rendered.push(parenthesize_union_for_intersection(self.render(variant)));
|
||||
}
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" & ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = map.get("type") {
|
||||
if let Some(types) = schema_type.as_array() {
|
||||
let mut rendered = Vec::new();
|
||||
for schema_type in types.iter().filter_map(JsonValue::as_str) {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
rendered.push(self.render_type_keyword(map, schema_type));
|
||||
}
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = schema_type.as_str() {
|
||||
return self.render_type_keyword(map, schema_type);
|
||||
}
|
||||
}
|
||||
|
||||
if map.contains_key("properties")
|
||||
|| map.contains_key("additionalProperties")
|
||||
|| map.contains_key("required")
|
||||
{
|
||||
return self.render_object(map);
|
||||
}
|
||||
|
||||
if map.contains_key("items") || map.contains_key("prefixItems") {
|
||||
return self.render_array(map);
|
||||
}
|
||||
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn render_ref(&mut self, map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
let referenced_type = if self.nested_schema_resource_depth > 0 {
|
||||
None
|
||||
} else {
|
||||
map.get("$ref")
|
||||
.and_then(JsonValue::as_str)
|
||||
.and_then(local_json_pointer)
|
||||
.and_then(|pointer| {
|
||||
let active_expansions = self
|
||||
.active_local_ref_expansions
|
||||
.get(&pointer)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
if active_expansions >= MAX_LOCAL_REF_EXPANSIONS_PER_PATH {
|
||||
return Some("unknown".to_string());
|
||||
}
|
||||
if self.remaining_local_ref_expansions == 0 {
|
||||
return Some("unknown".to_string());
|
||||
}
|
||||
|
||||
let root = self.root;
|
||||
let target = if pointer.is_empty() {
|
||||
Some(root)
|
||||
} else {
|
||||
root.pointer(&pointer)
|
||||
}?;
|
||||
self.remaining_local_ref_expansions -= 1;
|
||||
self.active_local_ref_expansions
|
||||
.insert(pointer.clone(), active_expansions + 1);
|
||||
|
||||
let rendered = self.render(target);
|
||||
if active_expansions == 0 {
|
||||
self.active_local_ref_expansions.remove(&pointer);
|
||||
} else {
|
||||
self.active_local_ref_expansions
|
||||
.insert(pointer, active_expansions);
|
||||
}
|
||||
Some(rendered)
|
||||
})
|
||||
}
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
|
||||
let siblings = map
|
||||
.iter()
|
||||
.filter(|(key, _)| !matches!(key.as_str(), "$ref" | "$defs" | "definitions"))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
if !has_renderable_schema_keywords(&siblings) {
|
||||
return referenced_type;
|
||||
}
|
||||
|
||||
let sibling_type = self.render_map(&siblings);
|
||||
match (referenced_type.as_str(), sibling_type.as_str()) {
|
||||
("unknown", _) => sibling_type,
|
||||
(_, "unknown") => referenced_type,
|
||||
_ => format!("({referenced_type}) & ({sibling_type})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_type_keyword(
|
||||
&mut self,
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
schema_type: &str,
|
||||
) -> String {
|
||||
match schema_type {
|
||||
"string" => "string".to_string(),
|
||||
"number" | "integer" => "number".to_string(),
|
||||
"boolean" => "boolean".to_string(),
|
||||
"null" => "null".to_string(),
|
||||
"array" => self.render_array(map),
|
||||
"object" => self.render_object(map),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_array(&mut self, map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
if let Some(items) = map.get("items") {
|
||||
let item_type = self.render(items);
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
return format!("Array<{item_type}>");
|
||||
}
|
||||
|
||||
if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
|
||||
let mut item_types = Vec::new();
|
||||
for item in items {
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
item_types.push(self.render(item));
|
||||
}
|
||||
if !item_types.is_empty() {
|
||||
return format!("[{}]", item_types.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
"unknown[]".to_string()
|
||||
}
|
||||
|
||||
fn append_additional_properties_line(
|
||||
&mut self,
|
||||
lines: &mut Vec<String>,
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
properties: &serde_json::Map<String, JsonValue>,
|
||||
line_prefix: &str,
|
||||
) -> bool {
|
||||
if let Some(additional_properties) = map.get("additionalProperties") {
|
||||
let property_type = match additional_properties {
|
||||
JsonValue::Bool(true) => Some("unknown".to_string()),
|
||||
JsonValue::Bool(false) => None,
|
||||
value => Some(self.render(value)),
|
||||
};
|
||||
|
||||
if let Some(property_type) = property_type {
|
||||
return self.push_render_line(
|
||||
lines,
|
||||
format!("{line_prefix}[key: string]: {property_type};"),
|
||||
);
|
||||
}
|
||||
} else if properties.is_empty() {
|
||||
return self.push_render_line(lines, format!("{line_prefix}[key: string]: unknown;"));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn render_object_property(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: &JsonValue,
|
||||
required: &[&str],
|
||||
) -> String {
|
||||
if name.len() > self.remaining_render_work_bytes {
|
||||
self.render_work_budget_exhausted = true;
|
||||
return "unknown".to_string();
|
||||
}
|
||||
let optional = if required.iter().any(|required_name| required_name == &name) {
|
||||
""
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
let property_name = render_json_schema_property_name(name);
|
||||
let property_type = self.render(value);
|
||||
if self.render_work_budget_exhausted {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
format!("{property_name}{optional}: {property_type};")
|
||||
}
|
||||
|
||||
fn render_object(&mut self, map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
let required = map
|
||||
.get("required")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let empty_properties = serde_json::Map::new();
|
||||
let properties = map
|
||||
.get("properties")
|
||||
.and_then(JsonValue::as_object)
|
||||
.unwrap_or(&empty_properties);
|
||||
|
||||
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
|
||||
sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a);
|
||||
if sorted_properties
|
||||
.iter()
|
||||
.any(|(_, value)| has_property_description(value))
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
if !self.push_render_line(&mut lines, "{".to_string()) {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
for (name, value) in sorted_properties {
|
||||
if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
|
||||
for description_line in description
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
if description_line.len().saturating_add(5)
|
||||
> self.remaining_render_work_bytes
|
||||
|| !self
|
||||
.push_render_line(&mut lines, format!(" // {description_line}"))
|
||||
{
|
||||
return "unknown".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let property = self.render_object_property(name, value, &required);
|
||||
if self.render_work_budget_exhausted
|
||||
|| !self.push_render_line(&mut lines, format!(" {property}"))
|
||||
{
|
||||
return "unknown".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if !self.append_additional_properties_line(&mut lines, map, properties, " ")
|
||||
|| !self.push_render_line(&mut lines, "}".to_string())
|
||||
{
|
||||
return "unknown".to_string();
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (name, value) in sorted_properties {
|
||||
let property = self.render_object_property(name, value, &required);
|
||||
if self.render_work_budget_exhausted || !self.push_render_line(&mut lines, property) {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if !self.append_additional_properties_line(&mut lines, map, properties, "") {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
format!("{{ {} }}", lines.join(" "))
|
||||
}
|
||||
|
||||
fn finish_render(&mut self, rendered: String) -> String {
|
||||
if self.consume_render_work(rendered.len()) {
|
||||
rendered
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_literal(&mut self, value: &JsonValue) -> String {
|
||||
if json_literal_serialization_upper_bound(value) > self.remaining_render_work_bytes {
|
||||
self.render_work_budget_exhausted = true;
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
render_json_schema_literal(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_render_work(&mut self, rendered_bytes: usize) -> bool {
|
||||
if rendered_bytes > self.remaining_render_work_bytes {
|
||||
self.render_work_budget_exhausted = true;
|
||||
false
|
||||
} else {
|
||||
self.remaining_render_work_bytes -= rendered_bytes;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn push_render_line(&mut self, lines: &mut Vec<String>, line: String) -> bool {
|
||||
if !self.consume_render_work(line.len()) {
|
||||
return false;
|
||||
}
|
||||
lines.push(line);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn parenthesize_union_for_intersection(rendered: String) -> String {
|
||||
if rendered.contains(" | ") {
|
||||
format!("({rendered})")
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
fn local_json_pointer(reference: &str) -> Option<String> {
|
||||
let fragment = reference.strip_prefix('#')?;
|
||||
let pointer = percent_decode_uri_fragment(fragment)?;
|
||||
if pointer.is_empty() || pointer.starts_with('/') {
|
||||
Some(pointer)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode_uri_fragment(fragment: &str) -> Option<String> {
|
||||
let bytes = fragment.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' {
|
||||
let high = decode_hex_digit(*bytes.get(index + 1)?)?;
|
||||
let low = decode_hex_digit(*bytes.get(index + 2)?)?;
|
||||
decoded.push((high << 4) | low);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
fn decode_hex_digit(digit: u8) -> Option<u8> {
|
||||
match digit {
|
||||
b'0'..=b'9' => Some(digit - b'0'),
|
||||
b'a'..=b'f' => Some(digit - b'a' + 10),
|
||||
b'A'..=b'F' => Some(digit - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_renderable_schema_keywords(map: &serde_json::Map<String, JsonValue>) -> bool {
|
||||
[
|
||||
"const",
|
||||
"enum",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"allOf",
|
||||
"type",
|
||||
"properties",
|
||||
"additionalProperties",
|
||||
"required",
|
||||
"items",
|
||||
"prefixItems",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| map.contains_key(*key))
|
||||
}
|
||||
|
||||
fn has_property_description(value: &JsonValue) -> bool {
|
||||
value
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|description| !description.is_empty())
|
||||
}
|
||||
|
||||
fn render_json_schema_property_name(name: &str) -> String {
|
||||
if normalize_code_mode_identifier(name) == name {
|
||||
name.to_string()
|
||||
} else {
|
||||
serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_literal(value: &JsonValue) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn json_literal_serialization_upper_bound(value: &JsonValue) -> usize {
|
||||
match value {
|
||||
JsonValue::Null => 4,
|
||||
JsonValue::Bool(false) => 5,
|
||||
JsonValue::Bool(true) => 4,
|
||||
JsonValue::Number(number) => number.to_string().len(),
|
||||
// JSON escaping can expand one UTF-8 byte to at most one six-byte
|
||||
// Unicode escape, so this bounds allocation before serialization.
|
||||
JsonValue::String(string) => string.len().saturating_mul(6).saturating_add(2),
|
||||
JsonValue::Array(values) => values.iter().fold(2, |size, value| {
|
||||
size.saturating_add(1)
|
||||
.saturating_add(json_literal_serialization_upper_bound(value))
|
||||
}),
|
||||
JsonValue::Object(map) => map.iter().fold(2, |size, (key, value)| {
|
||||
size.saturating_add(4)
|
||||
.saturating_add(key.len().saturating_mul(6))
|
||||
.saturating_add(json_literal_serialization_upper_bound(value))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "json_schema_types_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn renders_recursive_local_refs_with_escaped_pointer_segments() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clauses": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/Boolean~1Clause~0v1" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Boolean/Clause~v1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "$ref": "#/$defs/Query" }
|
||||
}
|
||||
},
|
||||
"Query": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clauses": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/Boolean~1Clause~0v1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let rendered = render_json_schema_to_typescript(&schema);
|
||||
assert!(rendered.contains("clauses?: Array<{ query?: string | { clauses?: Array<{"));
|
||||
assert!(rendered.contains("query?: string | { clauses?: Array<unknown>; };"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_ref_siblings_uri_fragments_and_all_of_precedence() {
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&json!({
|
||||
"$ref": "#/$defs/Label",
|
||||
"enum": ["A"],
|
||||
"$defs": { "Label": { "type": "string" } }
|
||||
})),
|
||||
r#"(string) & ("A")"#
|
||||
);
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&json!({
|
||||
"$ref": "#/$defs/Foo%20Bar",
|
||||
"$defs": { "Foo Bar": { "type": "string" } }
|
||||
})),
|
||||
"string"
|
||||
);
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&json!({
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/Choice" },
|
||||
{ "type": "object", "properties": { "value": { "type": "string" } } }
|
||||
],
|
||||
"$defs": {
|
||||
"Choice": { "oneOf": [{ "type": "string" }, { "type": "number" }] }
|
||||
}
|
||||
})),
|
||||
"(string | number) & { value?: string; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_local_refs_under_nested_schema_resources_unresolved() {
|
||||
let schema = json!({
|
||||
"$defs": {
|
||||
"Choice": { "type": "string" }
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nested": {
|
||||
"$id": "urn:nested",
|
||||
"$defs": {
|
||||
"Choice": { "type": "number" }
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": { "$ref": "#/$defs/Choice" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&schema),
|
||||
"{ nested?: { value?: unknown; }; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_expansions_without_charging_dangling_refs() {
|
||||
let mut properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS)
|
||||
.map(|index| {
|
||||
(
|
||||
format!("a_missing_{index}"),
|
||||
json!({ "$ref": format!("#/$defs/Missing{index}") }),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
properties.insert("z_valid".to_string(), json!({ "$ref": "#/$defs/Valid" }));
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"$defs": { "Valid": { "type": "string" } }
|
||||
});
|
||||
|
||||
let rendered = render_json_schema_to_typescript(&schema);
|
||||
assert!(rendered.contains("z_valid?: string;"));
|
||||
|
||||
let properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS + 2)
|
||||
.map(|index| {
|
||||
(
|
||||
format!("property_{index}"),
|
||||
json!({ "$ref": "#/$defs/Item" }),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let rendered = render_json_schema_to_typescript(&json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"$defs": { "Item": { "type": "string" } }
|
||||
}));
|
||||
assert_eq!(
|
||||
rendered.matches("string").count(),
|
||||
MAX_TOTAL_LOCAL_REF_EXPANSIONS
|
||||
);
|
||||
assert_eq!(rendered.matches("unknown").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_large_ref_expansions_exhaust_render_work_budget() {
|
||||
let properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS)
|
||||
.map(|index| {
|
||||
(
|
||||
format!("property_{index}"),
|
||||
json!({ "$ref": "#/$defs/Item" }),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"$defs": {
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "x".repeat(MAX_RENDERED_SCHEMA_BYTES / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut renderer = JsonSchemaTypeRenderer::new(&schema);
|
||||
assert_eq!(renderer.render(&schema), "unknown");
|
||||
assert!(renderer.render_work_budget_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_ref_literal_exhausts_render_work_budget() {
|
||||
let schema = json!({
|
||||
"$ref": "#/$defs/Value",
|
||||
"$defs": {
|
||||
"Value": {
|
||||
"const": "x".repeat(MAX_RENDER_WORK_BYTES)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut renderer = JsonSchemaTypeRenderer::new(&schema);
|
||||
assert_eq!(renderer.render(&schema), "unknown");
|
||||
assert!(renderer.render_work_budget_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_schema_has_a_hard_size_cap() {
|
||||
let description = "x".repeat(MAX_RENDERED_SCHEMA_BYTES);
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": { "type": "string", "description": description }
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(render_json_schema_to_typescript(&schema), "unknown");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod description;
|
||||
pub mod grpc;
|
||||
pub mod host;
|
||||
mod json_schema_types;
|
||||
mod response;
|
||||
mod runtime;
|
||||
mod session;
|
||||
@@ -19,7 +20,7 @@ pub use description::is_code_mode_nested_tool;
|
||||
pub use description::normalize_code_mode_identifier;
|
||||
pub use description::parse_exec_source;
|
||||
pub use description::render_code_mode_sample;
|
||||
pub use description::render_json_schema_to_typescript;
|
||||
pub use json_schema_types::render_json_schema_to_typescript;
|
||||
pub use response::DEFAULT_IMAGE_DETAIL;
|
||||
pub use response::FunctionCallOutputContentItem;
|
||||
pub use response::ImageDetail;
|
||||
|
||||
@@ -5417,6 +5417,89 @@ text(JSON.stringify({
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_renders_local_refs_in_outbound_exec_description() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeModeOnly)
|
||||
.expect("code mode only should be enabled");
|
||||
});
|
||||
let base_test = builder.build_with_auto_env(&server).await?;
|
||||
let new_thread = base_test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
dynamic_tools: vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
|
||||
name: "boolean_search".to_string(),
|
||||
description: "Search with nested Boolean clauses.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clauses": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/Clause" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Clause": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clauses": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/Clause" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
defer_loading: false,
|
||||
})],
|
||||
..StartThreadOptions::new(base_test.config.clone())
|
||||
})
|
||||
.await?;
|
||||
let mut test = base_test;
|
||||
test.codex = new_thread.thread;
|
||||
test.session_configured = new_thread.session_configured;
|
||||
|
||||
test.submit_turn("inspect the tool schema").await?;
|
||||
|
||||
let body = response.single_request().body_json();
|
||||
let exec_description = body["tools"]
|
||||
.as_array()
|
||||
.expect("request should contain tools")
|
||||
.iter()
|
||||
.find_map(|tool| {
|
||||
(tool["name"].as_str() == Some("exec"))
|
||||
.then(|| tool["description"].as_str())
|
||||
.flatten()
|
||||
})
|
||||
.expect("Code Mode exec should remain available");
|
||||
assert!(exec_description.contains("query?: string | { clauses?: Array<"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user