Compare commits

...

1 Commits

Author SHA1 Message Date
Liang-Ting Jiang
614c2ca47a Support Library uploads for Codex Apps 2026-05-29 16:50:01 -07:00
4 changed files with 205 additions and 24 deletions

View File

@@ -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");

View File

@@ -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");

View File

@@ -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(

View File

@@ -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);