fix: introduce create_tools_json() and share it with chat_completions.rs (#1177)
The main motivator behind this PR is that `stream_chat_completions()` was not adding the `"tools"` entry to the payload posted to the `/chat/completions` endpoint. This (1) refactors the existing logic to build up the `"tools"` JSON from `client.rs` into `openai_tools.rs`, and (2) updates the use of responses API (`client.rs`) and chat completions API (`chat_completions.rs`) to both use it. Note this PR alone is not sufficient to get tool calling from chat completions working: that is done in https://github.com/openai/codex/pull/1167. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1177). * #1167 * __->__ #1177
This commit is contained in:
@@ -25,6 +25,7 @@ use crate::flags::OPENAI_REQUEST_MAX_RETRIES;
|
|||||||
use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS;
|
use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS;
|
||||||
use crate::models::ContentItem;
|
use crate::models::ContentItem;
|
||||||
use crate::models::ResponseItem;
|
use crate::models::ResponseItem;
|
||||||
|
use crate::openai_tools::create_tools_json_for_chat_completions_api;
|
||||||
use crate::util::backoff;
|
use crate::util::backoff;
|
||||||
|
|
||||||
/// Implementation for the classic Chat Completions API. This is intentionally
|
/// Implementation for the classic Chat Completions API. This is intentionally
|
||||||
@@ -56,17 +57,22 @@ pub(crate) async fn stream_chat_completions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tools_json = create_tools_json_for_chat_completions_api(prompt, model)?;
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": true
|
"stream": true,
|
||||||
|
"tools": tools_json,
|
||||||
});
|
});
|
||||||
|
|
||||||
let base_url = provider.base_url.trim_end_matches('/');
|
let base_url = provider.base_url.trim_end_matches('/');
|
||||||
let url = format!("{}/chat/completions", base_url);
|
let url = format!("{}/chat/completions", base_url);
|
||||||
|
|
||||||
debug!(url, "POST (chat)");
|
debug!(url, "POST (chat)");
|
||||||
trace!("request payload: {}", payload);
|
trace!(
|
||||||
|
"request payload: {}",
|
||||||
|
serde_json::to_string_pretty(&payload).unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
let api_key = provider.api_key()?;
|
let api_key = provider.api_key()?;
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -11,7 +9,6 @@ use reqwest::StatusCode;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::json;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
@@ -36,71 +33,9 @@ use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS;
|
|||||||
use crate::model_provider_info::ModelProviderInfo;
|
use crate::model_provider_info::ModelProviderInfo;
|
||||||
use crate::model_provider_info::WireApi;
|
use crate::model_provider_info::WireApi;
|
||||||
use crate::models::ResponseItem;
|
use crate::models::ResponseItem;
|
||||||
|
use crate::openai_tools::create_tools_json_for_responses_api;
|
||||||
use crate::util::backoff;
|
use crate::util::backoff;
|
||||||
|
|
||||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
|
||||||
/// Responses API.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(tag = "type")]
|
|
||||||
enum OpenAiTool {
|
|
||||||
#[serde(rename = "function")]
|
|
||||||
Function(ResponsesApiTool),
|
|
||||||
#[serde(rename = "local_shell")]
|
|
||||||
LocalShell {},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
struct ResponsesApiTool {
|
|
||||||
name: &'static str,
|
|
||||||
description: &'static str,
|
|
||||||
strict: bool,
|
|
||||||
parameters: JsonSchema,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generic JSON‑Schema subset needed for our tool definitions
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
|
||||||
enum JsonSchema {
|
|
||||||
String,
|
|
||||||
Number,
|
|
||||||
Array {
|
|
||||||
items: Box<JsonSchema>,
|
|
||||||
},
|
|
||||||
Object {
|
|
||||||
properties: BTreeMap<String, JsonSchema>,
|
|
||||||
required: &'static [&'static str],
|
|
||||||
#[serde(rename = "additionalProperties")]
|
|
||||||
additional_properties: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tool usage specification
|
|
||||||
static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
|
|
||||||
let mut properties = BTreeMap::new();
|
|
||||||
properties.insert(
|
|
||||||
"command".to_string(),
|
|
||||||
JsonSchema::Array {
|
|
||||||
items: Box::new(JsonSchema::String),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
properties.insert("workdir".to_string(), JsonSchema::String);
|
|
||||||
properties.insert("timeout".to_string(), JsonSchema::Number);
|
|
||||||
|
|
||||||
vec![OpenAiTool::Function(ResponsesApiTool {
|
|
||||||
name: "shell",
|
|
||||||
description: "Runs a shell command, and returns its output.",
|
|
||||||
strict: false,
|
|
||||||
parameters: JsonSchema::Object {
|
|
||||||
properties,
|
|
||||||
required: &["command"],
|
|
||||||
additional_properties: false,
|
|
||||||
},
|
|
||||||
})]
|
|
||||||
});
|
|
||||||
|
|
||||||
static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
|
|
||||||
LazyLock::new(|| vec![OpenAiTool::LocalShell {}]);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ModelClient {
|
pub struct ModelClient {
|
||||||
model: String,
|
model: String,
|
||||||
@@ -161,27 +96,8 @@ impl ModelClient {
|
|||||||
return stream_from_fixture(path).await;
|
return stream_from_fixture(path).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble tool list: built-in tools + any extra tools from the prompt.
|
|
||||||
let default_tools = if self.model.starts_with("codex") {
|
|
||||||
&DEFAULT_CODEX_MODEL_TOOLS
|
|
||||||
} else {
|
|
||||||
&DEFAULT_TOOLS
|
|
||||||
};
|
|
||||||
let mut tools_json = Vec::with_capacity(default_tools.len() + prompt.extra_tools.len());
|
|
||||||
for t in default_tools.iter() {
|
|
||||||
tools_json.push(serde_json::to_value(t)?);
|
|
||||||
}
|
|
||||||
tools_json.extend(
|
|
||||||
prompt
|
|
||||||
.extra_tools
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
|
|
||||||
);
|
|
||||||
|
|
||||||
debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
|
|
||||||
|
|
||||||
let full_instructions = prompt.get_full_instructions();
|
let full_instructions = prompt.get_full_instructions();
|
||||||
|
let tools_json = create_tools_json_for_responses_api(prompt, &self.model)?;
|
||||||
let payload = Payload {
|
let payload = Payload {
|
||||||
model: &self.model,
|
model: &self.model,
|
||||||
instructions: &full_instructions,
|
instructions: &full_instructions,
|
||||||
@@ -276,34 +192,6 @@ impl ModelClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mcp_tool_to_openai_tool(
|
|
||||||
fully_qualified_name: String,
|
|
||||||
tool: mcp_types::Tool,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
let mcp_types::Tool {
|
|
||||||
description,
|
|
||||||
mut input_schema,
|
|
||||||
..
|
|
||||||
} = tool;
|
|
||||||
|
|
||||||
// OpenAI models mandate the "properties" field in the schema. The Agents
|
|
||||||
// SDK fixed this by inserting an empty object for "properties" if it is not
|
|
||||||
// already present https://github.com/openai/openai-agents-python/issues/449
|
|
||||||
// so here we do the same.
|
|
||||||
if input_schema.properties.is_none() {
|
|
||||||
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(mbolin): Change the contract of this function to return
|
|
||||||
// ResponsesApiTool.
|
|
||||||
json!({
|
|
||||||
"name": fully_qualified_name,
|
|
||||||
"description": description,
|
|
||||||
"parameters": input_schema,
|
|
||||||
"type": "function",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
struct SseEvent {
|
struct SseEvent {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod model_provider_info;
|
|||||||
pub use model_provider_info::ModelProviderInfo;
|
pub use model_provider_info::ModelProviderInfo;
|
||||||
pub use model_provider_info::WireApi;
|
pub use model_provider_info::WireApi;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod openai_tools;
|
||||||
mod project_doc;
|
mod project_doc;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
mod rollout;
|
mod rollout;
|
||||||
|
|||||||
158
codex-rs/core/src/openai_tools.rs
Normal file
158
codex-rs/core/src/openai_tools.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use crate::client_common::Prompt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub(crate) struct ResponsesApiTool {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
strict: bool,
|
||||||
|
parameters: JsonSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||||
|
/// Responses API.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub(crate) enum OpenAiTool {
|
||||||
|
#[serde(rename = "function")]
|
||||||
|
Function(ResponsesApiTool),
|
||||||
|
#[serde(rename = "local_shell")]
|
||||||
|
LocalShell {},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic JSON‑Schema subset needed for our tool definitions
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub(crate) enum JsonSchema {
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Array {
|
||||||
|
items: Box<JsonSchema>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
properties: BTreeMap<String, JsonSchema>,
|
||||||
|
required: &'static [&'static str],
|
||||||
|
#[serde(rename = "additionalProperties")]
|
||||||
|
additional_properties: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool usage specification
|
||||||
|
static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
|
||||||
|
let mut properties = BTreeMap::new();
|
||||||
|
properties.insert(
|
||||||
|
"command".to_string(),
|
||||||
|
JsonSchema::Array {
|
||||||
|
items: Box::new(JsonSchema::String),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
properties.insert("workdir".to_string(), JsonSchema::String);
|
||||||
|
properties.insert("timeout".to_string(), JsonSchema::Number);
|
||||||
|
|
||||||
|
vec![OpenAiTool::Function(ResponsesApiTool {
|
||||||
|
name: "shell",
|
||||||
|
description: "Runs a shell command, and returns its output.",
|
||||||
|
strict: false,
|
||||||
|
parameters: JsonSchema::Object {
|
||||||
|
properties,
|
||||||
|
required: &["command"],
|
||||||
|
additional_properties: false,
|
||||||
|
},
|
||||||
|
})]
|
||||||
|
});
|
||||||
|
|
||||||
|
static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
|
||||||
|
LazyLock::new(|| vec![OpenAiTool::LocalShell {}]);
|
||||||
|
|
||||||
|
/// Returns JSON values that are compatible with Function Calling in the
|
||||||
|
/// Responses API:
|
||||||
|
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||||
|
pub(crate) fn create_tools_json_for_responses_api(
|
||||||
|
prompt: &Prompt,
|
||||||
|
model: &str,
|
||||||
|
) -> crate::error::Result<Vec<serde_json::Value>> {
|
||||||
|
// Assemble tool list: built-in tools + any extra tools from the prompt.
|
||||||
|
let default_tools = if model.starts_with("codex") {
|
||||||
|
&DEFAULT_CODEX_MODEL_TOOLS
|
||||||
|
} else {
|
||||||
|
&DEFAULT_TOOLS
|
||||||
|
};
|
||||||
|
let mut tools_json = Vec::with_capacity(default_tools.len() + prompt.extra_tools.len());
|
||||||
|
for t in default_tools.iter() {
|
||||||
|
tools_json.push(serde_json::to_value(t)?);
|
||||||
|
}
|
||||||
|
tools_json.extend(
|
||||||
|
prompt
|
||||||
|
.extra_tools
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
|
||||||
|
Ok(tools_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns JSON values that are compatible with Function Calling in the
|
||||||
|
/// Chat Completions API:
|
||||||
|
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
|
||||||
|
pub(crate) fn create_tools_json_for_chat_completions_api(
|
||||||
|
prompt: &Prompt,
|
||||||
|
model: &str,
|
||||||
|
) -> crate::error::Result<Vec<serde_json::Value>> {
|
||||||
|
// We start with the JSON for the Responses API and than rewrite it to match
|
||||||
|
// the chat completions tool call format.
|
||||||
|
let responses_api_tools_json = create_tools_json_for_responses_api(prompt, model)?;
|
||||||
|
let tools_json = responses_api_tools_json
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|mut tool| {
|
||||||
|
if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(map) = tool.as_object_mut() {
|
||||||
|
// Remove "type" field as it is not needed in chat completions.
|
||||||
|
map.remove("type");
|
||||||
|
Some(json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": map,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<serde_json::Value>>();
|
||||||
|
Ok(tools_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_tool_to_openai_tool(
|
||||||
|
fully_qualified_name: String,
|
||||||
|
tool: mcp_types::Tool,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mcp_types::Tool {
|
||||||
|
description,
|
||||||
|
mut input_schema,
|
||||||
|
..
|
||||||
|
} = tool;
|
||||||
|
|
||||||
|
// OpenAI models mandate the "properties" field in the schema. The Agents
|
||||||
|
// SDK fixed this by inserting an empty object for "properties" if it is not
|
||||||
|
// already present https://github.com/openai/openai-agents-python/issues/449
|
||||||
|
// so here we do the same.
|
||||||
|
if input_schema.properties.is_none() {
|
||||||
|
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(mbolin): Change the contract of this function to return
|
||||||
|
// ResponsesApiTool.
|
||||||
|
json!({
|
||||||
|
"name": fully_qualified_name,
|
||||||
|
"description": description,
|
||||||
|
"parameters": input_schema,
|
||||||
|
"type": "function",
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user