mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Support Library uploads for Codex Apps
This commit is contained in:
@@ -98,6 +98,7 @@ pub async fn upload_local_file(
|
||||
base_url: &str,
|
||||
auth: &dyn AuthProvider,
|
||||
path: &Path,
|
||||
store_in_oai_library: bool,
|
||||
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
@@ -129,12 +130,17 @@ pub async fn upload_local_file(
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let create_url = format!("{}/files", base_url.trim_end_matches('/'));
|
||||
let mut create_body = serde_json::json!({
|
||||
"file_name": file_name,
|
||||
"file_size": metadata.len(),
|
||||
"use_case": OPENAI_FILE_USE_CASE,
|
||||
});
|
||||
if store_in_oai_library {
|
||||
create_body["store_in_library"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
|
||||
let create_response = authorized_request(auth, reqwest::Method::POST, &create_url)
|
||||
.json(&serde_json::json!({
|
||||
"file_name": file_name,
|
||||
"file_size": metadata.len(),
|
||||
"use_case": OPENAI_FILE_USE_CASE,
|
||||
}))
|
||||
.json(&create_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
@@ -363,9 +369,14 @@ mod tests {
|
||||
let path = dir.path().join("hello.txt");
|
||||
tokio::fs::write(&path, b"hello").await.expect("write file");
|
||||
|
||||
let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path)
|
||||
.await
|
||||
.expect("upload succeeds");
|
||||
let uploaded = upload_local_file(
|
||||
&base_url,
|
||||
&chatgpt_auth(),
|
||||
&path,
|
||||
/*store_in_oai_library*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("upload succeeds");
|
||||
|
||||
assert_eq!(uploaded.file_id, "file_123");
|
||||
assert_eq!(uploaded.uri, "sediment://file_123");
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! Strategy:
|
||||
//! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are
|
||||
//! file inputs.
|
||||
//! - Inspect the validated visible tool schema for the reserved
|
||||
//! `save_to_openai_library: true` argument that opts uploaded local files into
|
||||
//! Library storage.
|
||||
//! - At tool execution time, upload those local files to OpenAI file storage
|
||||
//! and rewrite only the declared arguments into the provided-file payload
|
||||
//! shape expected by the downstream Apps tool.
|
||||
@@ -16,11 +19,17 @@ use codex_api::upload_local_file;
|
||||
use codex_login::CodexAuth;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OpenAIFileInputParams {
|
||||
pub(crate) names: Vec<String>,
|
||||
pub(crate) store_in_oai_library: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
arguments_value: Option<JsonValue>,
|
||||
openai_file_input_params: Option<&[String]>,
|
||||
openai_file_input_params: Option<&OpenAIFileInputParams>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
let Some(openai_file_input_params) = openai_file_input_params else {
|
||||
return Ok(arguments_value);
|
||||
@@ -34,14 +43,21 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let mut rewritten_arguments = arguments.clone();
|
||||
let store_in_oai_library =
|
||||
store_in_oai_library_for_openai_file_upload(arguments, openai_file_input_params);
|
||||
|
||||
for field_name in openai_file_input_params {
|
||||
for field_name in &openai_file_input_params.names {
|
||||
let Some(value) = arguments.get(field_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(uploaded_value) =
|
||||
rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value)
|
||||
.await?
|
||||
let Some(uploaded_value) = rewrite_argument_value_for_openai_files(
|
||||
turn_context,
|
||||
auth.as_ref(),
|
||||
field_name,
|
||||
value,
|
||||
store_in_oai_library,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -55,11 +71,23 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
Ok(Some(JsonValue::Object(rewritten_arguments)))
|
||||
}
|
||||
|
||||
fn store_in_oai_library_for_openai_file_upload(
|
||||
arguments: &serde_json::Map<String, JsonValue>,
|
||||
openai_file_input_params: &OpenAIFileInputParams,
|
||||
) -> bool {
|
||||
openai_file_input_params.store_in_oai_library
|
||||
&& arguments
|
||||
.get("save_to_openai_library")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn rewrite_argument_value_for_openai_files(
|
||||
turn_context: &TurnContext,
|
||||
auth: Option<&CodexAuth>,
|
||||
field_name: &str,
|
||||
value: &JsonValue,
|
||||
store_in_oai_library: bool,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
match value {
|
||||
JsonValue::String(path_or_file_ref) => {
|
||||
@@ -69,6 +97,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
/*index*/ None,
|
||||
path_or_file_ref,
|
||||
store_in_oai_library,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(rewritten))
|
||||
@@ -85,6 +114,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
Some(index),
|
||||
path_or_file_ref,
|
||||
store_in_oai_library,
|
||||
)
|
||||
.await?;
|
||||
rewritten_values.push(rewritten);
|
||||
@@ -101,6 +131,7 @@ async fn build_uploaded_local_argument_value(
|
||||
field_name: &str,
|
||||
index: Option<usize>,
|
||||
file_path: &str,
|
||||
store_in_oai_library: bool,
|
||||
) -> Result<JsonValue, String> {
|
||||
#[allow(deprecated)]
|
||||
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
|
||||
@@ -119,6 +150,7 @@ async fn build_uploaded_local_argument_value(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
upload_auth.as_ref(),
|
||||
&resolved_path,
|
||||
store_in_oai_library,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match index {
|
||||
@@ -166,7 +198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_uploaded_local_argument_value_uploads_local_file_path() {
|
||||
async fn build_uploaded_local_argument_value_honors_upload_options() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -183,6 +215,7 @@ mod tests {
|
||||
"file_name": "file_report.csv",
|
||||
"file_size": 5,
|
||||
"use_case": "codex",
|
||||
"store_in_library": true,
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
@@ -232,6 +265,7 @@ mod tests {
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"file_report.csv",
|
||||
/*store_in_oai_library*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should upload the local file");
|
||||
@@ -314,6 +348,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"file",
|
||||
&serde_json::json!("file_report.csv"),
|
||||
/*store_in_oai_library*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -431,6 +466,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"files",
|
||||
&serde_json::json!(["one.csv", "two.csv"]),
|
||||
/*store_in_oai_library*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -470,7 +506,10 @@ mod tests {
|
||||
Some(serde_json::json!({
|
||||
"file": "/definitely/missing/file.csv",
|
||||
})),
|
||||
Some(&["file".to_string()]),
|
||||
Some(&OpenAIFileInputParams {
|
||||
names: vec!["file".to_string()],
|
||||
store_in_oai_library: false,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing file should fail");
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::guardian::new_guardian_review_id;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::hook_runtime::run_permission_request_hooks;
|
||||
use crate::mcp_openai_file::OpenAIFileInputParams;
|
||||
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
||||
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
||||
@@ -328,7 +329,7 @@ async fn handle_approved_mcp_tool_call(
|
||||
sess,
|
||||
turn_context,
|
||||
arguments_value.clone(),
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_ref()),
|
||||
)
|
||||
.await;
|
||||
let tool_input = match &rewrite {
|
||||
@@ -976,13 +977,14 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
tool_description: Option<String>,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
openai_file_input_params: Option<Vec<String>>,
|
||||
openai_file_input_params: Option<OpenAIFileInputParams>,
|
||||
}
|
||||
|
||||
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
|
||||
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
||||
const MCP_TOOL_OPENAI_FILE_UPLOAD_SAVE_TO_OPENAI_LIBRARY_KEY: &str = "save_to_openai_library";
|
||||
|
||||
async fn custom_mcp_tool_approval_mode(
|
||||
sess: &Session,
|
||||
@@ -1464,6 +1466,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
openai_file_input_params: openai_file_input_params_for_server(
|
||||
server,
|
||||
tool_info.tool.meta.as_deref(),
|
||||
tool_info.tool.input_schema.as_ref(),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -1471,10 +1474,58 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
fn openai_file_input_params_for_server(
|
||||
server: &str,
|
||||
meta: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<Vec<String>> {
|
||||
(server == CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then_some(declared_openai_file_input_param_names(meta))
|
||||
.filter(|params| !params.is_empty())
|
||||
input_schema: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<OpenAIFileInputParams> {
|
||||
if server != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
}
|
||||
|
||||
let names = declared_openai_file_input_param_names(meta);
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(OpenAIFileInputParams {
|
||||
names,
|
||||
store_in_oai_library: visible_required_true_input_schema_arg(
|
||||
input_schema,
|
||||
MCP_TOOL_OPENAI_FILE_UPLOAD_SAVE_TO_OPENAI_LIBRARY_KEY,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn visible_required_true_input_schema_arg(
|
||||
input_schema: &serde_json::Map<String, serde_json::Value>,
|
||||
argument_name: &str,
|
||||
) -> bool {
|
||||
let is_required = input_schema
|
||||
.get("required")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|required| {
|
||||
required
|
||||
.iter()
|
||||
.any(|required_name| required_name.as_str() == Some(argument_name))
|
||||
});
|
||||
if !is_required {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(argument_schema) = input_schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|properties| properties.get(argument_name))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
argument_schema.get("const") == Some(&serde_json::Value::Bool(true))
|
||||
|| argument_schema
|
||||
.get("enum")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|values| {
|
||||
values.len() == 1 && values.first() == Some(&serde_json::Value::Bool(true))
|
||||
})
|
||||
}
|
||||
|
||||
fn get_mcp_app_resource_uri(
|
||||
|
||||
@@ -344,17 +344,97 @@ fn openai_file_params_are_only_honored_for_codex_apps() {
|
||||
"openai/fileParams": ["file"],
|
||||
});
|
||||
let meta = meta.as_object();
|
||||
let input_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
});
|
||||
let input_schema = input_schema.as_object().expect("input schema object");
|
||||
|
||||
assert_eq!(
|
||||
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta),
|
||||
Some(vec!["file".to_string()])
|
||||
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta, input_schema),
|
||||
Some(OpenAIFileInputParams {
|
||||
names: vec!["file".to_string()],
|
||||
store_in_oai_library: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
openai_file_input_params_for_server("minimaltest", meta),
|
||||
openai_file_input_params_for_server("minimaltest", meta, input_schema),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_file_upload_requires_visible_required_true_arg() {
|
||||
let meta = serde_json::json!({
|
||||
"openai/fileParams": ["file"],
|
||||
});
|
||||
let meta = meta.as_object();
|
||||
let valid_input_schema_value = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"save_to_openai_library": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
},
|
||||
},
|
||||
"required": ["save_to_openai_library"],
|
||||
});
|
||||
let valid_input_schema = valid_input_schema_value
|
||||
.as_object()
|
||||
.expect("input schema object");
|
||||
|
||||
assert_eq!(
|
||||
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta, valid_input_schema,),
|
||||
Some(OpenAIFileInputParams {
|
||||
names: vec!["file".to_string()],
|
||||
store_in_oai_library: true,
|
||||
})
|
||||
);
|
||||
|
||||
for input_schema in [
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirm": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
},
|
||||
},
|
||||
"required": ["confirm"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"save_to_openai_library": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"save_to_openai_library": {
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"required": ["save_to_openai_library"],
|
||||
}),
|
||||
] {
|
||||
assert_eq!(
|
||||
openai_file_input_params_for_server(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
meta,
|
||||
input_schema.as_object().expect("input schema object"),
|
||||
),
|
||||
Some(OpenAIFileInputParams {
|
||||
names: vec!["file".to_string()],
|
||||
store_in_oai_library: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);
|
||||
|
||||
Reference in New Issue
Block a user