diff --git a/codex-rs/tools/src/file_ref.rs b/codex-rs/tools/src/file_ref.rs new file mode 100644 index 0000000000..26bf4b30d6 --- /dev/null +++ b/codex-rs/tools/src/file_ref.rs @@ -0,0 +1,134 @@ +use std::fmt; + +/// Fully qualified reference to a file-like asset known to Code Mode. +/// +/// The scheme tells the file broker which provider owns the asset. The broker +/// should keep provider-specific credentials and bytes out of model-visible +/// arguments while still letting Code Mode pass stable refs between tools. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileRef { + raw: String, + scheme: FileScheme, + body: String, +} + +impl FileRef { + pub fn parse(raw: impl Into) -> Result { + let raw = raw.into(); + let Some((scheme, body)) = raw.split_once("://") else { + return Err(FileRefParseError::MissingScheme); + }; + if body.is_empty() { + return Err(FileRefParseError::MissingBody); + } + let scheme = FileScheme::parse(scheme)?; + let body = body.to_string(); + Ok(Self { raw, scheme, body }) + } + + pub fn raw(&self) -> &str { + &self.raw + } + + pub fn scheme(&self) -> FileScheme { + self.scheme + } + + pub fn body(&self) -> &str { + &self.body + } +} + +/// Provider family that owns a file ref. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileScheme { + Env, + Library, + Connector, + Other, +} + +impl FileScheme { + fn parse(scheme: &str) -> Result { + if scheme.is_empty() { + return Err(FileRefParseError::MissingScheme); + } + if !scheme + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') + { + return Err(FileRefParseError::InvalidScheme(scheme.to_string())); + } + Ok(match scheme { + "env" => Self::Env, + "oai_library" => Self::Library, + "connector" => Self::Connector, + _ => Self::Other, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FileRefParseError { + MissingScheme, + InvalidScheme(String), + MissingBody, +} + +impl fmt::Display for FileRefParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingScheme => write!(f, "file ref must start with a provider scheme"), + Self::InvalidScheme(scheme) => write!(f, "invalid file ref scheme `{scheme}`"), + Self::MissingBody => write!(f, "file ref must include a provider-owned path or id"), + } + } +} + +impl std::error::Error for FileRefParseError {} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_env_file_ref() { + assert_eq!( + FileRef::parse("env://current/work/report.pdf"), + Ok(FileRef { + raw: "env://current/work/report.pdf".to_string(), + scheme: FileScheme::Env, + body: "current/work/report.pdf".to_string(), + }) + ); + } + + #[test] + fn classifies_known_provider_schemes() { + assert_eq!( + FileRef::parse("oai_library://file_123") + .expect("library ref should parse") + .scheme(), + FileScheme::Library + ); + assert_eq!( + FileRef::parse("connector://google_drive/file_123") + .expect("connector ref should parse") + .scheme(), + FileScheme::Connector + ); + } + + #[test] + fn rejects_ambiguous_refs() { + assert_eq!( + FileRef::parse("report.pdf"), + Err(FileRefParseError::MissingScheme) + ); + assert_eq!( + FileRef::parse("env://"), + Err(FileRefParseError::MissingBody) + ); + } +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2a87ba35d7..3441d6192e 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -3,6 +3,7 @@ mod code_mode; mod dynamic_tool; +mod file_ref; mod function_call_error; mod image_detail; mod json_schema; @@ -25,6 +26,9 @@ pub use code_mode::collect_code_mode_tool_definitions; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use codex_protocol::ToolName; pub use dynamic_tool::parse_dynamic_tool; +pub use file_ref::FileRef; +pub use file_ref::FileRefParseError; +pub use file_ref::FileScheme; pub use function_call_error::FunctionCallError; pub use image_detail::can_request_original_image_detail; pub use image_detail::normalize_output_image_detail;