Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions crates/forge_app/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Value>,
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<String, serde_json::Value>) -> bool {
map.get("type")
.and_then(|value| value.as_str())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions crates/forge_repo/src/provider/openai_responses/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ expression: actual.tools
"end_line": {
"anyOf": [
{
"format": "int32",
"type": "integer"
},
{
Expand All @@ -33,7 +32,6 @@ expression: actual.tools
"start_line": {
"anyOf": [
{
"format": "int32",
"type": "integer"
},
{
Expand Down Expand Up @@ -94,7 +92,6 @@ expression: actual.tools
"-A": {
"anyOf": [
{
"format": "uint32",
"minimum": 0,
"type": "integer"
},
Expand All @@ -107,7 +104,6 @@ expression: actual.tools
"-B": {
"anyOf": [
{
"format": "uint32",
"minimum": 0,
"type": "integer"
},
Expand All @@ -120,7 +116,6 @@ expression: actual.tools
"-C": {
"anyOf": [
{
"format": "uint32",
"minimum": 0,
"type": "integer"
},
Expand Down Expand Up @@ -166,7 +161,6 @@ expression: actual.tools
"head_limit": {
"anyOf": [
{
"format": "uint32",
"minimum": 0,
"type": "integer"
},
Expand All @@ -190,7 +184,6 @@ expression: actual.tools
"offset": {
"anyOf": [
{
"format": "uint32",
"minimum": 0,
"type": "integer"
},
Expand Down
Loading