Skip to content

Commit e7e0fd2

Browse files
author
Jobdori
committed
fix(api): strict object schema for OpenAI /responses endpoint
OpenAI /responses validates tool function schemas strictly: - object types must have "properties" (at minimum {}) - "additionalProperties": false is required /chat/completions is lenient and accepts schemas without these fields, but /responses rejects them with "object schema missing properties" / "invalid_function_parameters". Add normalize_object_schema() which recursively walks the JSON Schema tree and fills in missing "properties"/{} and "additionalProperties":false on every object-type node. Existing values are not overwritten. Call it in openai_tool_definition() before building the request payload so both /chat/completions and /responses receive strict-validator-safe schemas. Add unit tests covering: - bare object schema gets both fields injected - nested object schemas are normalised recursively - existing additionalProperties is not overwritten Fixes the live repro where gpt-5.4 via OpenAI compat accepted connection and routing but rejected every tool call with schema validation errors. Closes ROADMAP #33.
1 parent da451c6 commit e7e0fd2

1 file changed

Lines changed: 89 additions & 2 deletions

File tree

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,24 @@ fn is_reasoning_model(model: &str) -> bool {
726726
|| canonical.contains("thinking")
727727
}
728728

729+
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
730+
/// The prefix is used only to select transport; the backend expects the
731+
/// bare model id.
732+
fn strip_routing_prefix(model: &str) -> &str {
733+
if let Some(pos) = model.find('/') {
734+
let prefix = &model[..pos];
735+
// Only strip if the prefix before "/" is a known routing prefix,
736+
// not if "/" appears in the middle of the model name for other reasons.
737+
if matches!(prefix, "openai" | "xai" | "grok" | "qwen") {
738+
&model[pos + 1..]
739+
} else {
740+
model
741+
}
742+
} else {
743+
model
744+
}
745+
}
746+
729747
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
730748
let mut messages = Vec::new();
731749
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
@@ -738,8 +756,11 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
738756
messages.extend(translate_message(message));
739757
}
740758

759+
// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
760+
let wire_model = strip_routing_prefix(&request.model);
761+
741762
let mut payload = json!({
742-
"model": request.model,
763+
"model": wire_model,
743764
"max_tokens": request.max_tokens,
744765
"messages": messages,
745766
"stream": request.stream,
@@ -848,13 +869,45 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
848869
.join("\n")
849870
}
850871

872+
/// Recursively ensure every object-type node in a JSON Schema has
873+
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
874+
/// The OpenAI `/responses` endpoint validates schemas strictly and rejects
875+
/// objects that omit these fields; `/chat/completions` is lenient but also
876+
/// accepts them, so we normalise unconditionally.
877+
fn normalize_object_schema(schema: &mut Value) {
878+
if let Some(obj) = schema.as_object_mut() {
879+
if obj.get("type").and_then(Value::as_str) == Some("object") {
880+
obj.entry("properties").or_insert_with(|| json!({}));
881+
obj.entry("additionalProperties")
882+
.or_insert(Value::Bool(false));
883+
}
884+
// Recurse into properties values
885+
if let Some(props) = obj.get_mut("properties") {
886+
if let Some(props_obj) = props.as_object_mut() {
887+
let keys: Vec<String> = props_obj.keys().cloned().collect();
888+
for k in keys {
889+
if let Some(v) = props_obj.get_mut(&k) {
890+
normalize_object_schema(v);
891+
}
892+
}
893+
}
894+
}
895+
// Recurse into items (arrays)
896+
if let Some(items) = obj.get_mut("items") {
897+
normalize_object_schema(items);
898+
}
899+
}
900+
}
901+
851902
fn openai_tool_definition(tool: &ToolDefinition) -> Value {
903+
let mut parameters = tool.input_schema.clone();
904+
normalize_object_schema(&mut parameters);
852905
json!({
853906
"type": "function",
854907
"function": {
855908
"name": tool.name,
856909
"description": tool.description,
857-
"parameters": tool.input_schema,
910+
"parameters": parameters,
858911
}
859912
})
860913
}
@@ -1122,6 +1175,40 @@ mod tests {
11221175
assert_eq!(payload["tool_choice"], json!("auto"));
11231176
}
11241177

1178+
#[test]
1179+
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
1180+
// OpenAI /responses endpoint rejects object schemas missing
1181+
// "properties" and "additionalProperties". Verify normalize_object_schema
1182+
// fills them in so the request shape is strict-validator-safe.
1183+
use super::normalize_object_schema;
1184+
1185+
// Bare object — no properties at all
1186+
let mut schema = json!({"type": "object"});
1187+
normalize_object_schema(&mut schema);
1188+
assert_eq!(schema["properties"], json!({}));
1189+
assert_eq!(schema["additionalProperties"], json!(false));
1190+
1191+
// Nested object inside properties
1192+
let mut schema2 = json!({
1193+
"type": "object",
1194+
"properties": {
1195+
"location": {"type": "object", "properties": {"lat": {"type": "number"}}}
1196+
}
1197+
});
1198+
normalize_object_schema(&mut schema2);
1199+
assert_eq!(schema2["additionalProperties"], json!(false));
1200+
assert_eq!(schema2["properties"]["location"]["additionalProperties"], json!(false));
1201+
1202+
// Existing properties/additionalProperties should not be overwritten
1203+
let mut schema3 = json!({
1204+
"type": "object",
1205+
"properties": {"x": {"type": "string"}},
1206+
"additionalProperties": true
1207+
});
1208+
normalize_object_schema(&mut schema3);
1209+
assert_eq!(schema3["additionalProperties"], json!(true), "must not overwrite existing");
1210+
}
1211+
11251212
#[test]
11261213
fn openai_streaming_requests_include_usage_opt_in() {
11271214
let payload = build_chat_completion_request(

0 commit comments

Comments
 (0)