Skip to content

Commit 1bf8205

Browse files
authored
fix: introduce create_tools_json() and share it with chat_completions.rs (openai#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 openai#1167. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1177). * openai#1167 * __->__ openai#1177
1 parent e207f20 commit 1bf8205

File tree

4 files changed

+169
-116
lines changed

4 files changed

+169
-116
lines changed

codex-rs/core/src/chat_completions.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::flags::OPENAI_REQUEST_MAX_RETRIES;
2525
use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS;
2626
use crate::models::ContentItem;
2727
use crate::models::ResponseItem;
28+
use crate::openai_tools::create_tools_json_for_chat_completions_api;
2829
use crate::util::backoff;
2930

3031
/// Implementation for the classic Chat Completions API. This is intentionally
@@ -56,17 +57,22 @@ pub(crate) async fn stream_chat_completions(
5657
}
5758
}
5859

60+
let tools_json = create_tools_json_for_chat_completions_api(prompt, model)?;
5961
let payload = json!({
6062
"model": model,
6163
"messages": messages,
62-
"stream": true
64+
"stream": true,
65+
"tools": tools_json,
6366
});
6467

6568
let base_url = provider.base_url.trim_end_matches('/');
6669
let url = format!("{}/chat/completions", base_url);
6770

6871
debug!(url, "POST (chat)");
69-
trace!("request payload: {}", payload);
72+
trace!(
73+
"request payload: {}",
74+
serde_json::to_string_pretty(&payload).unwrap_or_default()
75+
);
7076

7177
let api_key = provider.api_key()?;
7278
let mut attempt = 0;

codex-rs/core/src/client.rs

Lines changed: 2 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
use std::collections::BTreeMap;
21
use std::io::BufRead;
32
use std::path::Path;
4-
use std::sync::LazyLock;
53
use std::time::Duration;
64

75
use bytes::Bytes;
@@ -11,7 +9,6 @@ use reqwest::StatusCode;
119
use serde::Deserialize;
1210
use serde::Serialize;
1311
use serde_json::Value;
14-
use serde_json::json;
1512
use tokio::sync::mpsc;
1613
use tokio::time::timeout;
1714
use tokio_util::io::ReaderStream;
@@ -36,71 +33,9 @@ use crate::flags::OPENAI_STREAM_IDLE_TIMEOUT_MS;
3633
use crate::model_provider_info::ModelProviderInfo;
3734
use crate::model_provider_info::WireApi;
3835
use crate::models::ResponseItem;
36+
use crate::openai_tools::create_tools_json_for_responses_api;
3937
use crate::util::backoff;
4038

41-
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
42-
/// Responses API.
43-
#[derive(Debug, Clone, Serialize)]
44-
#[serde(tag = "type")]
45-
enum OpenAiTool {
46-
#[serde(rename = "function")]
47-
Function(ResponsesApiTool),
48-
#[serde(rename = "local_shell")]
49-
LocalShell {},
50-
}
51-
52-
#[derive(Debug, Clone, Serialize)]
53-
struct ResponsesApiTool {
54-
name: &'static str,
55-
description: &'static str,
56-
strict: bool,
57-
parameters: JsonSchema,
58-
}
59-
60-
/// Generic JSON‑Schema subset needed for our tool definitions
61-
#[derive(Debug, Clone, Serialize)]
62-
#[serde(tag = "type", rename_all = "lowercase")]
63-
enum JsonSchema {
64-
String,
65-
Number,
66-
Array {
67-
items: Box<JsonSchema>,
68-
},
69-
Object {
70-
properties: BTreeMap<String, JsonSchema>,
71-
required: &'static [&'static str],
72-
#[serde(rename = "additionalProperties")]
73-
additional_properties: bool,
74-
},
75-
}
76-
77-
/// Tool usage specification
78-
static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
79-
let mut properties = BTreeMap::new();
80-
properties.insert(
81-
"command".to_string(),
82-
JsonSchema::Array {
83-
items: Box::new(JsonSchema::String),
84-
},
85-
);
86-
properties.insert("workdir".to_string(), JsonSchema::String);
87-
properties.insert("timeout".to_string(), JsonSchema::Number);
88-
89-
vec![OpenAiTool::Function(ResponsesApiTool {
90-
name: "shell",
91-
description: "Runs a shell command, and returns its output.",
92-
strict: false,
93-
parameters: JsonSchema::Object {
94-
properties,
95-
required: &["command"],
96-
additional_properties: false,
97-
},
98-
})]
99-
});
100-
101-
static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
102-
LazyLock::new(|| vec![OpenAiTool::LocalShell {}]);
103-
10439
#[derive(Clone)]
10540
pub struct ModelClient {
10641
model: String,
@@ -161,27 +96,8 @@ impl ModelClient {
16196
return stream_from_fixture(path).await;
16297
}
16398

164-
// Assemble tool list: built-in tools + any extra tools from the prompt.
165-
let default_tools = if self.model.starts_with("codex") {
166-
&DEFAULT_CODEX_MODEL_TOOLS
167-
} else {
168-
&DEFAULT_TOOLS
169-
};
170-
let mut tools_json = Vec::with_capacity(default_tools.len() + prompt.extra_tools.len());
171-
for t in default_tools.iter() {
172-
tools_json.push(serde_json::to_value(t)?);
173-
}
174-
tools_json.extend(
175-
prompt
176-
.extra_tools
177-
.clone()
178-
.into_iter()
179-
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
180-
);
181-
182-
debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
183-
18499
let full_instructions = prompt.get_full_instructions();
100+
let tools_json = create_tools_json_for_responses_api(prompt, &self.model)?;
185101
let payload = Payload {
186102
model: &self.model,
187103
instructions: &full_instructions,
@@ -276,34 +192,6 @@ impl ModelClient {
276192
}
277193
}
278194

279-
fn mcp_tool_to_openai_tool(
280-
fully_qualified_name: String,
281-
tool: mcp_types::Tool,
282-
) -> serde_json::Value {
283-
let mcp_types::Tool {
284-
description,
285-
mut input_schema,
286-
..
287-
} = tool;
288-
289-
// OpenAI models mandate the "properties" field in the schema. The Agents
290-
// SDK fixed this by inserting an empty object for "properties" if it is not
291-
// already present https://github.com/openai/openai-agents-python/issues/449
292-
// so here we do the same.
293-
if input_schema.properties.is_none() {
294-
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
295-
}
296-
297-
// TODO(mbolin): Change the contract of this function to return
298-
// ResponsesApiTool.
299-
json!({
300-
"name": fully_qualified_name,
301-
"description": description,
302-
"parameters": input_schema,
303-
"type": "function",
304-
})
305-
}
306-
307195
#[derive(Debug, Deserialize, Serialize)]
308196
struct SseEvent {
309197
#[serde(rename = "type")]

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ mod model_provider_info;
2727
pub use model_provider_info::ModelProviderInfo;
2828
pub use model_provider_info::WireApi;
2929
mod models;
30+
mod openai_tools;
3031
mod project_doc;
3132
pub mod protocol;
3233
mod rollout;

codex-rs/core/src/openai_tools.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use serde::Serialize;
2+
use serde_json::json;
3+
use std::collections::BTreeMap;
4+
use std::sync::LazyLock;
5+
6+
use crate::client_common::Prompt;
7+
8+
#[derive(Debug, Clone, Serialize)]
9+
pub(crate) struct ResponsesApiTool {
10+
name: &'static str,
11+
description: &'static str,
12+
strict: bool,
13+
parameters: JsonSchema,
14+
}
15+
16+
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
17+
/// Responses API.
18+
#[derive(Debug, Clone, Serialize)]
19+
#[serde(tag = "type")]
20+
pub(crate) enum OpenAiTool {
21+
#[serde(rename = "function")]
22+
Function(ResponsesApiTool),
23+
#[serde(rename = "local_shell")]
24+
LocalShell {},
25+
}
26+
27+
/// Generic JSON‑Schema subset needed for our tool definitions
28+
#[derive(Debug, Clone, Serialize)]
29+
#[serde(tag = "type", rename_all = "lowercase")]
30+
pub(crate) enum JsonSchema {
31+
String,
32+
Number,
33+
Array {
34+
items: Box<JsonSchema>,
35+
},
36+
Object {
37+
properties: BTreeMap<String, JsonSchema>,
38+
required: &'static [&'static str],
39+
#[serde(rename = "additionalProperties")]
40+
additional_properties: bool,
41+
},
42+
}
43+
44+
/// Tool usage specification
45+
static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
46+
let mut properties = BTreeMap::new();
47+
properties.insert(
48+
"command".to_string(),
49+
JsonSchema::Array {
50+
items: Box::new(JsonSchema::String),
51+
},
52+
);
53+
properties.insert("workdir".to_string(), JsonSchema::String);
54+
properties.insert("timeout".to_string(), JsonSchema::Number);
55+
56+
vec![OpenAiTool::Function(ResponsesApiTool {
57+
name: "shell",
58+
description: "Runs a shell command, and returns its output.",
59+
strict: false,
60+
parameters: JsonSchema::Object {
61+
properties,
62+
required: &["command"],
63+
additional_properties: false,
64+
},
65+
})]
66+
});
67+
68+
static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
69+
LazyLock::new(|| vec![OpenAiTool::LocalShell {}]);
70+
71+
/// Returns JSON values that are compatible with Function Calling in the
72+
/// Responses API:
73+
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
74+
pub(crate) fn create_tools_json_for_responses_api(
75+
prompt: &Prompt,
76+
model: &str,
77+
) -> crate::error::Result<Vec<serde_json::Value>> {
78+
// Assemble tool list: built-in tools + any extra tools from the prompt.
79+
let default_tools = if model.starts_with("codex") {
80+
&DEFAULT_CODEX_MODEL_TOOLS
81+
} else {
82+
&DEFAULT_TOOLS
83+
};
84+
let mut tools_json = Vec::with_capacity(default_tools.len() + prompt.extra_tools.len());
85+
for t in default_tools.iter() {
86+
tools_json.push(serde_json::to_value(t)?);
87+
}
88+
tools_json.extend(
89+
prompt
90+
.extra_tools
91+
.clone()
92+
.into_iter()
93+
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
94+
);
95+
96+
tracing::debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
97+
Ok(tools_json)
98+
}
99+
100+
/// Returns JSON values that are compatible with Function Calling in the
101+
/// Chat Completions API:
102+
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
103+
pub(crate) fn create_tools_json_for_chat_completions_api(
104+
prompt: &Prompt,
105+
model: &str,
106+
) -> crate::error::Result<Vec<serde_json::Value>> {
107+
// We start with the JSON for the Responses API and than rewrite it to match
108+
// the chat completions tool call format.
109+
let responses_api_tools_json = create_tools_json_for_responses_api(prompt, model)?;
110+
let tools_json = responses_api_tools_json
111+
.into_iter()
112+
.filter_map(|mut tool| {
113+
if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) {
114+
return None;
115+
}
116+
117+
if let Some(map) = tool.as_object_mut() {
118+
// Remove "type" field as it is not needed in chat completions.
119+
map.remove("type");
120+
Some(json!({
121+
"type": "function",
122+
"function": map,
123+
}))
124+
} else {
125+
None
126+
}
127+
})
128+
.collect::<Vec<serde_json::Value>>();
129+
Ok(tools_json)
130+
}
131+
132+
fn mcp_tool_to_openai_tool(
133+
fully_qualified_name: String,
134+
tool: mcp_types::Tool,
135+
) -> serde_json::Value {
136+
let mcp_types::Tool {
137+
description,
138+
mut input_schema,
139+
..
140+
} = tool;
141+
142+
// OpenAI models mandate the "properties" field in the schema. The Agents
143+
// SDK fixed this by inserting an empty object for "properties" if it is not
144+
// already present https://github.com/openai/openai-agents-python/issues/449
145+
// so here we do the same.
146+
if input_schema.properties.is_none() {
147+
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
148+
}
149+
150+
// TODO(mbolin): Change the contract of this function to return
151+
// ResponsesApiTool.
152+
json!({
153+
"name": fully_qualified_name,
154+
"description": description,
155+
"parameters": input_schema,
156+
"type": "function",
157+
})
158+
}

0 commit comments

Comments
 (0)