diff --git a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs index 5e8a87a3d..6d5e5119d 100644 --- a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs +++ b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs @@ -36,25 +36,6 @@ pub(crate) fn is_codex_chatgpt_endpoint(request_url: &str) -> bool { request_url.contains("chatgpt.com/backend-api/codex") } -/// Convert a `ToolDefinition` list to the *flat* Responses tool schema that -/// Codex backend (and OpenAI's public Responses API) expects: -/// `{ "type": "function", "name": ..., "description": ..., "parameters": ..., "strict": false }`. -fn convert_tools_flat(tools: Option>) -> Option> { - tools.map(|defs| { - defs.into_iter() - .map(|tool| { - json!({ - "type": "function", - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "strict": false, - }) - }) - .collect() - }) -} - fn attach_tools(request_body: &mut Value, tools: Option>) { if let Some(tools) = tools { let names: Vec = tools @@ -173,7 +154,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let tools_flat = convert_tools_flat(tools); + let tools_flat = common::convert_tools_flat(tools); let request_body = build_request_body(client, instructions, response_input, tools_flat, extra_body); let idle_timeout = client.stream_options.idle_timeout; diff --git a/src/crates/ai-adapters/src/providers/openai/common.rs b/src/crates/ai-adapters/src/providers/openai/common.rs index 8b4674ca4..6301bf57b 100644 --- a/src/crates/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/ai-adapters/src/providers/openai/common.rs @@ -2,7 +2,7 @@ use crate::client::quirks::apply_openai_compatible_reasoning_fields; use crate::client::utils::{dedupe_remote_models, normalize_base_url_for_discovery}; use crate::client::AIClient; use crate::providers::shared; -use crate::types::RemoteModelInfo; +use crate::types::{RemoteModelInfo, ToolDefinition}; use anyhow::Result; use log::warn; use reqwest::RequestBuilder; @@ -335,6 +335,7 @@ pub(crate) fn extract_tool_name(tool: &serde_json::Value) -> String { tool.get("function") .and_then(|function| function.get("name")) .and_then(|name| name.as_str()) + .or_else(|| tool.get("name").and_then(|name| name.as_str())) .unwrap_or("unknown") .to_string() } @@ -358,3 +359,21 @@ pub(crate) fn attach_tools( } } } + +pub(crate) fn convert_tools_flat( + tools: Option>, +) -> Option> { + tools.map(|defs| { + defs.into_iter() + .map(|tool| { + serde_json::json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "strict": false, + }) + }) + .collect() + }) +} diff --git a/src/crates/ai-adapters/src/providers/openai/responses.rs b/src/crates/ai-adapters/src/providers/openai/responses.rs index 0b3941434..56073b4ec 100644 --- a/src/crates/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/ai-adapters/src/providers/openai/responses.rs @@ -111,7 +111,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let openai_tools = OpenAIMessageConverter::convert_tools(tools); + let openai_tools = common::convert_tools_flat(tools); let request_body = build_request_body( client, instructions, @@ -133,3 +133,64 @@ pub(crate) async fn send_stream( ) .await } + +#[cfg(test)] +mod tests { + use super::build_request_body; + use crate::types::{ReasoningMode, ToolDefinition}; + use crate::{client::AIClient, types::AIConfig}; + use serde_json::json; + + fn test_client() -> AIClient { + AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + request_url: "https://api.openai.com/v1/responses".to_string(), + api_key: "test-key".to_string(), + model: "gpt-5.4".to_string(), + format: "responses".to_string(), + context_window: 128_000, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + }) + } + + #[test] + fn attaches_flat_tool_schema_for_responses_api() { + let client = test_client(); + let request_body = build_request_body( + &client, + None, + vec![json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + })], + crate::providers::openai::common::convert_tools_flat(Some(vec![ToolDefinition { + name: "get_weather".to_string(), + description: "Get weather".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + } + }), + }])), + None, + ); + + assert_eq!(request_body["tools"][0]["name"], json!("get_weather")); + assert_eq!(request_body["tools"][0]["type"], json!("function")); + assert!(request_body["tools"][0].get("function").is_none()); + } +}