diff --git a/crates/forge_app/src/utils.rs b/crates/forge_app/src/utils.rs index 02a21e26e7..0bda391758 100644 --- a/crates/forge_app/src/utils.rs +++ b/crates/forge_app/src/utils.rs @@ -253,6 +253,38 @@ fn normalize_schema_keywords( } } +fn is_supported_openai_string_format(format: &str) -> bool { + matches!( + format, + "date-time" + | "time" + | "date" + | "duration" + | "email" + | "hostname" + | "ipv4" + | "ipv6" + | "uuid" + ) +} + +fn normalize_string_format_keyword( + map: &mut serde_json::Map, + strict_mode: bool, +) { + if !strict_mode { + return; + } + + let Some(format) = map.get("format").and_then(|value| value.as_str()) else { + return; + }; + + if !is_supported_openai_string_format(format) { + map.remove("format"); + } +} + fn is_object_schema(map: &serde_json::Map) -> bool { map.get("type") .and_then(|value| value.as_str()) @@ -325,6 +357,8 @@ pub fn enforce_strict_schema(schema: &mut serde_json::Value, strict_mode: bool) map.remove("propertyNames"); } + normalize_string_format_keyword(map, strict_mode); + let is_object = is_object_schema(map); // If this looks like an object schema but has no explicit type, add it @@ -1202,6 +1236,63 @@ mod tests { ); } + #[test] + fn test_unsupported_format_is_removed_in_strict_mode() { + let mut fixture = json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + }); + + enforce_strict_schema(&mut fixture, true); + + let expected = json!({ + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["url"] + }); + + assert_eq!(fixture, expected); + } + + #[test] + fn test_supported_format_is_preserved_in_strict_mode() { + let mut fixture = json!({ + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }); + + enforce_strict_schema(&mut fixture, true); + + let expected = json!({ + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false, + "required": ["timestamp"] + }); + + assert_eq!(fixture, expected); + } + /// Integration test that simulates the full Notion MCP workflow: /// 1. Schema arrives from MCP server (with propertyNames) /// 2. Gets normalized for OpenAI/Codex (propertyNames removed) diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index 2ada99c894..96a4a13e6d 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -379,8 +379,11 @@ mod tests { Context as ChatContext, ContextMessage, ModelId, ToolCallId, ToolChoice, }; use forge_app::utils::enforce_strict_schema; + use pretty_assertions::assert_eq; + use serde_json::json; use crate::provider::FromDomain; + use crate::provider::openai_responses::request::codex_tool_parameters; #[test] fn test_reasoning_config_conversion_with_effort() -> anyhow::Result<()> { @@ -579,6 +582,37 @@ mod tests { .arguments(forge_app::domain::ToolCallArguments::from_json(args)) } + #[test] + fn test_codex_tool_parameters_removes_unsupported_uri_format() -> anyhow::Result<()> { + let fixture = schemars::Schema::try_from(json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + })) + .unwrap(); + + let actual = codex_tool_parameters(&fixture)?; + + let expected = json!({ + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["url"] + }); + + assert_eq!(actual, expected); + + Ok(()) + } + #[test] fn test_codex_request_tools_snapshot() -> anyhow::Result<()> { // Build a schema that exercises OpenAI strict-mode normalization: diff --git a/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap b/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap index dfa87b6e10..4762a25a82 100644 --- a/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap +++ b/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap @@ -12,7 +12,6 @@ expression: actual.tools "end_line": { "anyOf": [ { - "format": "int32", "type": "integer" }, { @@ -33,7 +32,6 @@ expression: actual.tools "start_line": { "anyOf": [ { - "format": "int32", "type": "integer" }, { @@ -94,7 +92,6 @@ expression: actual.tools "-A": { "anyOf": [ { - "format": "uint32", "minimum": 0, "type": "integer" }, @@ -107,7 +104,6 @@ expression: actual.tools "-B": { "anyOf": [ { - "format": "uint32", "minimum": 0, "type": "integer" }, @@ -120,7 +116,6 @@ expression: actual.tools "-C": { "anyOf": [ { - "format": "uint32", "minimum": 0, "type": "integer" }, @@ -166,7 +161,6 @@ expression: actual.tools "head_limit": { "anyOf": [ { - "format": "uint32", "minimum": 0, "type": "integer" }, @@ -190,7 +184,6 @@ expression: actual.tools "offset": { "anyOf": [ { - "format": "uint32", "minimum": 0, "type": "integer" },