mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
go further
This commit is contained in:
@@ -13,6 +13,8 @@ doctest = false
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ Crates that define contributed tools should depend on this crate. It owns:
|
||||
|
||||
- the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`,
|
||||
and `ToolError`
|
||||
- the one model-visible spec an extension may contribute directly:
|
||||
`FunctionToolSpec`
|
||||
- the shared model-visible function-tool fields stored directly on
|
||||
`ToolBundle`
|
||||
- contributed namespace metadata through `ToolNamespace`
|
||||
|
||||
The contract is intentionally narrow: contributed tools receive a call id plus
|
||||
raw JSON arguments and return a JSON value. If a feature needs richer host
|
||||
@@ -23,7 +24,7 @@ tool-owning extension crate --> codex-tool-api <-- codex-core
|
||||
```
|
||||
|
||||
`codex-tools` has a different job. It remains the host-side owner of Responses
|
||||
API tool models, schema parsing, namespaces, discovery, MCP/dynamic conversion,
|
||||
code-mode shaping, and other aggregate host concerns. A crate that only wants
|
||||
to contribute one ordinary function tool through an extension should not need
|
||||
to depend on `codex-tools`.
|
||||
API tool models, namespace rendering and aggregation, discovery, MCP/dynamic
|
||||
conversion, code-mode shaping, and other aggregate host concerns. A crate that
|
||||
only wants to contribute one ordinary function tool through an extension should
|
||||
not need to depend on `codex-tools`.
|
||||
|
||||
+151
-18
@@ -4,9 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::FunctionToolSpec;
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolCall;
|
||||
use crate::ToolError;
|
||||
use crate::ToolName;
|
||||
use crate::ToolNamespace;
|
||||
|
||||
/// Future returned by one contributed function-tool invocation.
|
||||
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>> + Send + 'a>>;
|
||||
@@ -15,24 +17,98 @@ pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>>
|
||||
/// function tool.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolBundle {
|
||||
spec: FunctionToolSpec,
|
||||
tool_name: ToolName,
|
||||
namespace: Option<ToolNamespace>,
|
||||
description: String,
|
||||
input_schema: JsonSchema,
|
||||
strict: bool,
|
||||
output_schema: Option<Value>,
|
||||
defer_loading: bool,
|
||||
executor: Arc<dyn ToolExecutor>,
|
||||
}
|
||||
|
||||
impl ToolBundle {
|
||||
/// Creates one contributed function-tool bundle.
|
||||
pub fn new(spec: FunctionToolSpec, executor: Arc<dyn ToolExecutor>) -> Self {
|
||||
Self { spec, executor }
|
||||
pub fn new(
|
||||
tool_name: ToolName,
|
||||
description: String,
|
||||
input_schema: JsonSchema,
|
||||
executor: Arc<dyn ToolExecutor>,
|
||||
) -> Self {
|
||||
let namespace = tool_name
|
||||
.namespace
|
||||
.as_ref()
|
||||
.map(|namespace| ToolNamespace::new(namespace.clone()));
|
||||
Self {
|
||||
tool_name,
|
||||
namespace,
|
||||
description,
|
||||
input_schema,
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
defer_loading: false,
|
||||
executor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the contributed function-tool spec.
|
||||
pub fn spec(&self) -> &FunctionToolSpec {
|
||||
&self.spec
|
||||
/// Enables strict schema handling for this contributed tool.
|
||||
pub fn strict(mut self) -> Self {
|
||||
self.strict = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a model-visible output schema for this contributed tool.
|
||||
pub fn with_output_schema(mut self, output_schema: Value) -> Self {
|
||||
self.output_schema = Some(output_schema);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks this contributed tool as loadable on demand.
|
||||
pub fn deferred(mut self) -> Self {
|
||||
self.defer_loading = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Places this contributed tool in a model-visible namespace.
|
||||
pub fn in_namespace(mut self, namespace: ToolNamespace) -> Self {
|
||||
self.tool_name.namespace = Some(namespace.name().to_string());
|
||||
self.namespace = Some(namespace);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the contributed function-tool name.
|
||||
pub fn tool_name(&self) -> &str {
|
||||
self.spec.name.as_str()
|
||||
pub fn tool_name(&self) -> &ToolName {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
/// Returns the contributed tool namespace, if any.
|
||||
pub fn namespace(&self) -> Option<&ToolNamespace> {
|
||||
self.namespace.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the contributed function-tool description.
|
||||
pub fn description(&self) -> &str {
|
||||
self.description.as_str()
|
||||
}
|
||||
|
||||
/// Returns the contributed function-tool input schema.
|
||||
pub fn input_schema(&self) -> &JsonSchema {
|
||||
&self.input_schema
|
||||
}
|
||||
|
||||
/// Returns whether strict schema handling is enabled.
|
||||
pub fn is_strict(&self) -> bool {
|
||||
self.strict
|
||||
}
|
||||
|
||||
/// Returns the optional contributed function-tool output schema.
|
||||
pub fn output_schema(&self) -> Option<&Value> {
|
||||
self.output_schema.as_ref()
|
||||
}
|
||||
|
||||
/// Returns whether the contributed function tool should be loadable on demand.
|
||||
pub fn defer_loading(&self) -> bool {
|
||||
self.defer_loading
|
||||
}
|
||||
|
||||
/// Returns the executable implementation.
|
||||
@@ -59,8 +135,10 @@ mod tests {
|
||||
use super::ToolBundle;
|
||||
use super::ToolExecutor;
|
||||
use super::ToolFuture;
|
||||
use crate::FunctionToolSpec;
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolCall;
|
||||
use crate::ToolName;
|
||||
use crate::ToolNamespace;
|
||||
|
||||
struct StubExecutor;
|
||||
|
||||
@@ -71,17 +149,72 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_derives_name_from_function_spec() {
|
||||
fn bundle_preserves_plain_tool_name() {
|
||||
let bundle = ToolBundle::new(
|
||||
FunctionToolSpec {
|
||||
name: "echo".to_string(),
|
||||
description: "Echo arguments.".to_string(),
|
||||
strict: false,
|
||||
parameters: json!({ "type": "object" }),
|
||||
},
|
||||
ToolName::plain("echo"),
|
||||
"Echo arguments.".to_string(),
|
||||
JsonSchema::object(
|
||||
std::collections::BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
Some(true.into()),
|
||||
),
|
||||
Arc::new(StubExecutor),
|
||||
);
|
||||
|
||||
assert_eq!(bundle.tool_name(), "echo");
|
||||
assert_eq!(bundle.tool_name(), &ToolName::plain("echo"));
|
||||
assert_eq!(bundle.namespace(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_derives_namespace_from_namespaced_tool_name() {
|
||||
let bundle = ToolBundle::new(
|
||||
ToolName::namespaced("extension_tools/", "echo"),
|
||||
"Echo arguments.".to_string(),
|
||||
JsonSchema::object(
|
||||
std::collections::BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
Some(true.into()),
|
||||
),
|
||||
Arc::new(StubExecutor),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bundle.tool_name(),
|
||||
&ToolName::namespaced("extension_tools/", "echo")
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.namespace(),
|
||||
Some(&ToolNamespace::new("extension_tools/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_sets_tool_name_when_namespace_metadata_is_attached() {
|
||||
let bundle = ToolBundle::new(
|
||||
ToolName::plain("echo"),
|
||||
"Echo arguments.".to_string(),
|
||||
JsonSchema::object(
|
||||
std::collections::BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
Some(true.into()),
|
||||
),
|
||||
Arc::new(StubExecutor),
|
||||
)
|
||||
.in_namespace(
|
||||
ToolNamespace::new("extension_tools/")
|
||||
.with_description("Extension-owned function tools."),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bundle.tool_name(),
|
||||
&ToolName::namespaced("extension_tools/", "echo")
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.namespace(),
|
||||
Some(
|
||||
&ToolNamespace::new("extension_tools/")
|
||||
.with_description("Extension-owned function tools.")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Primitive JSON Schema type names we support in tool definitions.
|
||||
///
|
||||
/// This mirrors the OpenAI Structured Outputs subset for JSON Schema `type`:
|
||||
/// string, number, boolean, integer, object, array, and null.
|
||||
/// Keywords such as `enum`, `const`, and `anyOf` are modeled separately.
|
||||
/// See <https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas>.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JsonSchemaPrimitiveType {
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Integer,
|
||||
Object,
|
||||
Array,
|
||||
Null,
|
||||
}
|
||||
|
||||
/// JSON Schema `type` supports either a single type name or a union of names.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum JsonSchemaType {
|
||||
Single(JsonSchemaPrimitiveType),
|
||||
Multiple(Vec<JsonSchemaPrimitiveType>),
|
||||
}
|
||||
|
||||
/// Generic JSON-Schema subset needed for our tool definitions.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JsonSchema {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub schema_type: Option<JsonSchemaType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
|
||||
pub enum_values: Option<Vec<JsonValue>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<JsonSchema>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub properties: Option<BTreeMap<String, JsonSchema>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<Vec<String>>,
|
||||
#[serde(
|
||||
rename = "additionalProperties",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub additional_properties: Option<AdditionalProperties>,
|
||||
#[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
|
||||
pub any_of: Option<Vec<JsonSchema>>,
|
||||
}
|
||||
|
||||
impl JsonSchema {
|
||||
/// Construct a scalar/object/array schema with a single JSON Schema type.
|
||||
fn typed(schema_type: JsonSchemaPrimitiveType, description: Option<String>) -> Self {
|
||||
Self {
|
||||
schema_type: Some(JsonSchemaType::Single(schema_type)),
|
||||
description,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn any_of(variants: Vec<JsonSchema>, description: Option<String>) -> Self {
|
||||
Self {
|
||||
description,
|
||||
any_of: Some(variants),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn boolean(description: Option<String>) -> Self {
|
||||
Self::typed(JsonSchemaPrimitiveType::Boolean, description)
|
||||
}
|
||||
|
||||
pub fn string(description: Option<String>) -> Self {
|
||||
Self::typed(JsonSchemaPrimitiveType::String, description)
|
||||
}
|
||||
|
||||
pub fn number(description: Option<String>) -> Self {
|
||||
Self::typed(JsonSchemaPrimitiveType::Number, description)
|
||||
}
|
||||
|
||||
pub fn integer(description: Option<String>) -> Self {
|
||||
Self::typed(JsonSchemaPrimitiveType::Integer, description)
|
||||
}
|
||||
|
||||
pub fn null(description: Option<String>) -> Self {
|
||||
Self::typed(JsonSchemaPrimitiveType::Null, description)
|
||||
}
|
||||
|
||||
pub fn string_enum(values: Vec<JsonValue>, description: Option<String>) -> Self {
|
||||
Self {
|
||||
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::String)),
|
||||
description,
|
||||
enum_values: Some(values),
|
||||
..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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether additional properties are allowed, and if so, any required schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum AdditionalProperties {
|
||||
Boolean(bool),
|
||||
Schema(Box<JsonSchema>),
|
||||
}
|
||||
|
||||
impl From<bool> for AdditionalProperties {
|
||||
fn from(value: bool) -> Self {
|
||||
Self::Boolean(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonSchema> for AdditionalProperties {
|
||||
fn from(value: JsonSchema) -> Self {
|
||||
Self::Schema(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the tool `input_schema` or return an error for invalid schema.
|
||||
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
let mut input_schema = input_schema.clone();
|
||||
sanitize_json_schema(&mut input_schema);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
|
||||
/// schema representation. This function:
|
||||
/// - Ensures every typed schema object has a `"type"` when required.
|
||||
/// - Preserves explicit `anyOf`.
|
||||
/// - Collapses `const` into single-value `enum`.
|
||||
/// - Fills required child fields for object/array schema types, including
|
||||
/// nullable unions, with permissive defaults when absent.
|
||||
fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
match value {
|
||||
JsonValue::Bool(_) => {
|
||||
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
|
||||
*value = json!({ "type": "string" });
|
||||
}
|
||||
JsonValue::Array(values) => {
|
||||
for value in values {
|
||||
sanitize_json_schema(value);
|
||||
}
|
||||
}
|
||||
JsonValue::Object(map) => {
|
||||
if let Some(properties) = map.get_mut("properties")
|
||||
&& let Some(properties_map) = properties.as_object_mut()
|
||||
{
|
||||
for value in properties_map.values_mut() {
|
||||
sanitize_json_schema(value);
|
||||
}
|
||||
}
|
||||
if let Some(items) = map.get_mut("items") {
|
||||
sanitize_json_schema(items);
|
||||
}
|
||||
if let Some(additional_properties) = map.get_mut("additionalProperties")
|
||||
&& !matches!(additional_properties, JsonValue::Bool(_))
|
||||
{
|
||||
sanitize_json_schema(additional_properties);
|
||||
}
|
||||
if let Some(value) = map.get_mut("prefixItems") {
|
||||
sanitize_json_schema(value);
|
||||
}
|
||||
if let Some(value) = map.get_mut("anyOf") {
|
||||
sanitize_json_schema(value);
|
||||
}
|
||||
|
||||
if let Some(const_value) = map.remove("const") {
|
||||
map.insert("enum".to_string(), JsonValue::Array(vec![const_value]));
|
||||
}
|
||||
|
||||
let mut schema_types = normalized_schema_types(map);
|
||||
|
||||
if schema_types.is_empty() && map.contains_key("anyOf") {
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
schema_types.push(JsonSchemaPrimitiveType::String);
|
||||
}
|
||||
}
|
||||
|
||||
write_schema_types(map, &schema_types);
|
||||
ensure_default_children_for_schema_types(map, &schema_types);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_default_children_for_schema_types(
|
||||
map: &mut serde_json::Map<String, JsonValue>,
|
||||
schema_types: &[JsonSchemaPrimitiveType],
|
||||
) {
|
||||
if schema_types.contains(&JsonSchemaPrimitiveType::Object) && !map.contains_key("properties") {
|
||||
map.insert(
|
||||
"properties".to_string(),
|
||||
JsonValue::Object(serde_json::Map::new()),
|
||||
);
|
||||
}
|
||||
|
||||
if schema_types.contains(&JsonSchemaPrimitiveType::Array) && !map.contains_key("items") {
|
||||
map.insert("items".to_string(), json!({ "type": "string" }));
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_type_from_str(schema_type: &str) -> Option<JsonSchemaPrimitiveType> {
|
||||
match schema_type {
|
||||
"string" => Some(JsonSchemaPrimitiveType::String),
|
||||
"number" => Some(JsonSchemaPrimitiveType::Number),
|
||||
"boolean" => Some(JsonSchemaPrimitiveType::Boolean),
|
||||
"integer" => Some(JsonSchemaPrimitiveType::Integer),
|
||||
"object" => Some(JsonSchemaPrimitiveType::Object),
|
||||
"array" => Some(JsonSchemaPrimitiveType::Array),
|
||||
"null" => Some(JsonSchemaPrimitiveType::Null),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_type_name(schema_type: JsonSchemaPrimitiveType) -> &'static str {
|
||||
match schema_type {
|
||||
JsonSchemaPrimitiveType::String => "string",
|
||||
JsonSchemaPrimitiveType::Number => "number",
|
||||
JsonSchemaPrimitiveType::Boolean => "boolean",
|
||||
JsonSchemaPrimitiveType::Integer => "integer",
|
||||
JsonSchemaPrimitiveType::Object => "object",
|
||||
JsonSchemaPrimitiveType::Array => "array",
|
||||
JsonSchemaPrimitiveType::Null => "null",
|
||||
}
|
||||
}
|
||||
|
||||
fn singleton_null_schema_error() -> serde_json::Error {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"tool input schema must not be a singleton null type",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "json_schema_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,786 @@
|
||||
use super::AdditionalProperties;
|
||||
use super::JsonSchema;
|
||||
use super::JsonSchemaPrimitiveType;
|
||||
use super::JsonSchemaType;
|
||||
use super::parse_tool_input_schema;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Tests in this section exercise normalization transforms that mutate badly
|
||||
// formed JSON for consumption by the Responses API.
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_coerces_boolean_schemas() {
|
||||
// Example schema shape:
|
||||
// true
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - JSON Schema boolean forms are coerced to `{ "type": "string" }`
|
||||
// because the baseline enum model cannot represent boolean-schema
|
||||
// semantics directly.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!(true)).expect("parse schema");
|
||||
|
||||
assert_eq!(schema, JsonSchema::string(/*description*/ None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "properties": {
|
||||
// "query": { "description": "search query" }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `properties` implies an object schema when `type` is omitted.
|
||||
// - The child property keeps its description and defaults to a string type.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"properties": {
|
||||
"query": {"description": "search query"}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"query".to_string(),
|
||||
JsonSchema::string(Some("search query".to_string())),
|
||||
)]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_integer_and_defaults_array_items() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "page": { "type": "integer" },
|
||||
// "tags": { "type": "array" }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `"integer"` is preserved distinctly from `"number"`.
|
||||
// - Arrays missing `items` receive a permissive string `items` schema.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "integer"},
|
||||
"tags": {"type": "array"}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"page".to_string(),
|
||||
JsonSchema::integer(/*description*/ None),
|
||||
),
|
||||
(
|
||||
"tags".to_string(),
|
||||
JsonSchema::array(
|
||||
JsonSchema::string(/*description*/ None),
|
||||
/*description*/ None,
|
||||
)
|
||||
),
|
||||
]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "additionalProperties": {
|
||||
// "required": ["value"],
|
||||
// "properties": {
|
||||
// "value": {
|
||||
// "anyOf": [
|
||||
// { "type": "string" },
|
||||
// { "type": "number" }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `additionalProperties` schema objects are recursively sanitized.
|
||||
// - The nested schema is normalized into the current object/anyOf form.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
Some(AdditionalProperties::Schema(Box::new(JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"value".to_string(),
|
||||
JsonSchema::any_of(
|
||||
vec![
|
||||
JsonSchema::string(/*description*/ None),
|
||||
JsonSchema::number(/*description*/ None),
|
||||
],
|
||||
/*description*/ None,
|
||||
),
|
||||
)]),
|
||||
Some(vec!["value".to_string()]),
|
||||
/*additional_properties*/ None,
|
||||
))))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_object_shape_from_boolean_additional_properties_only() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "additionalProperties": false
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `additionalProperties` implies an object schema when `type` is omitted.
|
||||
// - The boolean `additionalProperties` setting is preserved.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"additionalProperties": false
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_number_from_numeric_keywords() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "minimum": 1
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - Numeric constraint keywords imply a number schema when `type` is
|
||||
// omitted.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"minimum": 1
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(schema, JsonSchema::number(/*description*/ None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_number_from_multiple_of() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "multipleOf": 5
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `multipleOf` follows the same numeric-keyword inference path as
|
||||
// `minimum` / `maximum`.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"multipleOf": 5
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(schema, JsonSchema::number(/*description*/ None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_string_from_enum_const_and_format_keywords() {
|
||||
// Example schema shapes:
|
||||
// { "enum": ["fast", "safe"] }
|
||||
// { "const": "file" }
|
||||
// { "format": "date-time" }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `enum` and `const` normalize into explicit string-enum schemas.
|
||||
// - `format` still falls back to a plain string schema.
|
||||
let enum_schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"enum": ["fast", "safe"]
|
||||
}))
|
||||
.expect("parse enum schema");
|
||||
let const_schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"const": "file"
|
||||
}))
|
||||
.expect("parse const schema");
|
||||
let format_schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"format": "date-time"
|
||||
}))
|
||||
.expect("parse format schema");
|
||||
|
||||
assert_eq!(
|
||||
enum_schema,
|
||||
JsonSchema::string_enum(
|
||||
vec![serde_json::json!("fast"), serde_json::json!("safe")],
|
||||
/*description*/ None,
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
const_schema,
|
||||
JsonSchema::string_enum(vec![serde_json::json!("file")], /*description*/ None)
|
||||
);
|
||||
assert_eq!(format_schema, JsonSchema::string(/*description*/ None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_defaults_empty_schema_to_string() {
|
||||
// Example schema shape:
|
||||
// {}
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - With no structural hints at all, the normalizer falls back to a
|
||||
// permissive string schema.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({})).expect("parse schema");
|
||||
|
||||
assert_eq!(schema, JsonSchema::string(/*description*/ None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_array_from_prefix_items() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "prefixItems": [
|
||||
// { "type": "string" }
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `prefixItems` implies an array schema when `type` is omitted.
|
||||
// - The normalized result is stored as a regular array schema with string
|
||||
// items.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"prefixItems": [
|
||||
{"type": "string"}
|
||||
]
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::array(
|
||||
JsonSchema::string(/*description*/ None),
|
||||
/*description*/ None,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_boolean_additional_properties_on_inferred_object() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "metadata": {
|
||||
// "additionalProperties": true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The nested `metadata` schema is inferred to be an object because it has
|
||||
// `additionalProperties`.
|
||||
// - `additionalProperties: true` is preserved rather than rewritten.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"metadata".to_string(),
|
||||
JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(true.into())),
|
||||
)]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_infers_object_shape_from_schema_additional_properties_only() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "additionalProperties": {
|
||||
// "type": "string"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - A schema-valued `additionalProperties` also implies an object schema
|
||||
// when `type` is omitted.
|
||||
// - The nested schema is preserved as the object's
|
||||
// `additionalProperties` definition.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
Some(JsonSchema::string(/*description*/ None).into())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_rewrites_const_to_single_value_enum() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "const": "tagged"
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - `const` is rewritten through the sanitizer's `map.remove("const")`
|
||||
// path into an equivalent single-value string enum schema.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"const": "tagged"
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::string_enum(vec![serde_json::json!("tagged")], /*description*/ None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_rejects_singleton_null_type() {
|
||||
let err = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "null"
|
||||
}))
|
||||
.expect_err("singleton null should be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("tool input schema must not be a singleton null type"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_fills_default_properties_for_nullable_object_union() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": ["object", "null"]
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The full union is preserved.
|
||||
// - Object members of the union still receive default `properties`.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": ["object", "null"]
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema {
|
||||
schema_type: Some(JsonSchemaType::Multiple(vec![
|
||||
JsonSchemaPrimitiveType::Object,
|
||||
JsonSchemaPrimitiveType::Null,
|
||||
])),
|
||||
properties: Some(BTreeMap::new()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_fills_default_items_for_nullable_array_union() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": ["array", "null"]
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The full union is preserved.
|
||||
// - Array members of the union still receive default `items`.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": ["array", "null"]
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema {
|
||||
schema_type: Some(JsonSchemaType::Multiple(vec![
|
||||
JsonSchemaPrimitiveType::Array,
|
||||
JsonSchemaPrimitiveType::Null,
|
||||
])),
|
||||
items: Some(Box::new(JsonSchema::string(/*description*/ None))),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Schemas that should be preserved for Responses API compatibility rather than
|
||||
// being rewritten into a different shape.
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_nested_nullable_any_of_shape() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "open": {
|
||||
// "anyOf": [
|
||||
// {
|
||||
// "type": "array",
|
||||
// "items": {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "ref_id": { "type": "string" },
|
||||
// "lineno": { "anyOf": [{ "type": "integer" }, { "type": "null" }] }
|
||||
// },
|
||||
// "required": ["ref_id"],
|
||||
// "additionalProperties": false
|
||||
// }
|
||||
// },
|
||||
// { "type": "null" }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - Nested nullable `anyOf` shapes are preserved all the way down.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"open": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref_id": {"type": "string"},
|
||||
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
},
|
||||
"required": ["ref_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"open".to_string(),
|
||||
JsonSchema::any_of(
|
||||
vec![
|
||||
JsonSchema::array(
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"lineno".to_string(),
|
||||
JsonSchema::any_of(
|
||||
vec![
|
||||
JsonSchema::integer(/*description*/ None),
|
||||
JsonSchema::null(/*description*/ None),
|
||||
],
|
||||
/*description*/ None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ref_id".to_string(),
|
||||
JsonSchema::string(/*description*/ None),
|
||||
),
|
||||
]),
|
||||
Some(vec!["ref_id".to_string()]),
|
||||
Some(false.into()),
|
||||
),
|
||||
/*description*/ None,
|
||||
),
|
||||
JsonSchema::null(/*description*/ None),
|
||||
],
|
||||
/*description*/ None,
|
||||
),
|
||||
),]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_nested_nullable_type_union() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "nickname": {
|
||||
// "type": ["string", "null"],
|
||||
// "description": "Optional nickname"
|
||||
// }
|
||||
// },
|
||||
// "required": ["nickname"],
|
||||
// "additionalProperties": false
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The nested property keeps the explicit `["string", "null"]` union.
|
||||
// - The object-level `required` and `additionalProperties: false` stay intact.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nickname": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional nickname"
|
||||
}
|
||||
},
|
||||
"required": ["nickname"],
|
||||
"additionalProperties": false
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"nickname".to_string(),
|
||||
JsonSchema {
|
||||
schema_type: Some(JsonSchemaType::Multiple(vec![
|
||||
JsonSchemaPrimitiveType::String,
|
||||
JsonSchemaPrimitiveType::Null,
|
||||
])),
|
||||
description: Some("Optional nickname".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
Some(vec!["nickname".to_string()]),
|
||||
Some(false.into()),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_nested_any_of_property() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "query": {
|
||||
// "anyOf": [
|
||||
// { "type": "string" },
|
||||
// { "type": "number" }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The nested `anyOf` is preserved rather than flattened into a single
|
||||
// fallback type.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "number" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"query".to_string(),
|
||||
JsonSchema::any_of(
|
||||
vec![
|
||||
JsonSchema::string(/*description*/ None),
|
||||
JsonSchema::number(/*description*/ None),
|
||||
],
|
||||
/*description*/ None,
|
||||
),
|
||||
)]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_type_unions_without_rewriting_to_any_of() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": ["string", "null"],
|
||||
// "description": "optional string"
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - Explicit type unions are preserved as unions rather than rewritten to
|
||||
// `anyOf`.
|
||||
let schema = parse_tool_input_schema(&serde_json::json!({
|
||||
"type": ["string", "null"],
|
||||
"description": "optional string"
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema {
|
||||
schema_type: Some(JsonSchemaType::Multiple(vec![
|
||||
JsonSchemaPrimitiveType::String,
|
||||
JsonSchemaPrimitiveType::Null,
|
||||
])),
|
||||
description: Some("optional string".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_explicit_enum_type_union() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": ["string", "null"],
|
||||
// "enum": ["short", "medium", "long"],
|
||||
// "description": "optional response length"
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - The explicit string/null union is preserved alongside the enum values.
|
||||
let schema = super::parse_tool_input_schema(&serde_json::json!({
|
||||
"type": ["string", "null"],
|
||||
"enum": ["short", "medium", "long"],
|
||||
"description": "optional response length"
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema {
|
||||
schema_type: Some(JsonSchemaType::Multiple(vec![
|
||||
JsonSchemaPrimitiveType::String,
|
||||
JsonSchemaPrimitiveType::Null,
|
||||
])),
|
||||
description: Some("optional response length".to_string()),
|
||||
enum_values: Some(vec![
|
||||
serde_json::json!("short"),
|
||||
serde_json::json!("medium"),
|
||||
serde_json::json!("long"),
|
||||
]),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_input_schema_preserves_string_enum_constraints() {
|
||||
// Example schema shape:
|
||||
// {
|
||||
// "type": "object",
|
||||
// "properties": {
|
||||
// "response_length": { "type": "enum", "enum": ["short", "medium", "long"] },
|
||||
// "kind": { "type": "const", "const": "tagged" },
|
||||
// "scope": { "type": "enum", "enum": ["one", "two"] }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected normalization behavior:
|
||||
// - Legacy `type: "enum"` and `type: "const"` inputs are normalized into
|
||||
// the current string-enum representation.
|
||||
let schema = super::parse_tool_input_schema(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response_length": {
|
||||
"type": "enum",
|
||||
"enum": ["short", "medium", "long"]
|
||||
},
|
||||
"kind": {
|
||||
"type": "const",
|
||||
"const": "tagged"
|
||||
},
|
||||
"scope": {
|
||||
"type": "enum",
|
||||
"enum": ["one", "two"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("parse schema");
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"kind".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![serde_json::json!("tagged")],
|
||||
/*description*/ None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"response_length".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![
|
||||
serde_json::json!("short"),
|
||||
serde_json::json!("medium"),
|
||||
serde_json::json!("long"),
|
||||
],
|
||||
/*description*/ None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![serde_json::json!("one"), serde_json::json!("two")],
|
||||
/*description*/ None,
|
||||
),
|
||||
),
|
||||
]),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,18 @@
|
||||
mod bundle;
|
||||
mod call;
|
||||
mod error;
|
||||
mod spec;
|
||||
mod json_schema;
|
||||
mod namespace;
|
||||
|
||||
pub use bundle::ToolBundle;
|
||||
pub use bundle::ToolExecutor;
|
||||
pub use bundle::ToolFuture;
|
||||
pub use call::ToolCall;
|
||||
pub use codex_protocol::ToolName;
|
||||
pub use error::ToolError;
|
||||
pub use spec::FunctionToolSpec;
|
||||
pub use json_schema::AdditionalProperties;
|
||||
pub use json_schema::JsonSchema;
|
||||
pub use json_schema::JsonSchemaPrimitiveType;
|
||||
pub use json_schema::JsonSchemaType;
|
||||
pub use json_schema::parse_tool_input_schema;
|
||||
pub use namespace::ToolNamespace;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/// Model-visible namespace metadata for a contributed tool bundle.
|
||||
///
|
||||
/// The namespace name participates in the callable tool identity, while the
|
||||
/// optional description is host-visible metadata used when rendering namespace
|
||||
/// specs for the model.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ToolNamespace {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolNamespace {
|
||||
/// Creates namespace metadata with no explicit description.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the model-visible namespace description.
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the callable namespace name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.name.as_str()
|
||||
}
|
||||
|
||||
/// Returns the optional model-visible namespace description.
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
/// Model-visible definition for one contributed function tool.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FunctionToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub strict: bool,
|
||||
pub parameters: Value,
|
||||
}
|
||||
Reference in New Issue
Block a user