Compare commits

...

1 Commits

Author SHA1 Message Date
charlesdu
a71195253b Add exec client metadata flag 2026-05-27 15:12:30 -07:00
6 changed files with 172 additions and 1 deletions

View File

@@ -53,6 +53,15 @@ pub struct Cli {
#[arg(long = "output-schema", value_name = "FILE", global = true)]
pub output_schema: Option<PathBuf>,
/// Internal automation metadata to include in Responses API client metadata.
#[arg(
long = "responsesapi-client-metadata",
value_name = "KEY=VALUE",
hide = true,
global = true
)]
pub responsesapi_client_metadata: Vec<String>,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,

View File

@@ -74,6 +74,26 @@ fn parses_config_isolation_flags() {
assert!(cli.ignore_rules);
}
#[test]
fn parses_responsesapi_client_metadata_flags() {
let cli = Cli::parse_from([
"codex-exec",
"--responsesapi-client-metadata",
"usage_source=chronicle",
"--responsesapi-client-metadata",
"feature=memory_summary",
"summarize",
]);
assert_eq!(
cli.responsesapi_client_metadata,
vec![
"usage_source=chronicle".to_string(),
"feature=memory_summary".to_string()
]
);
}
#[test]
fn removed_full_auto_flag_reports_migration_path() {
let cli = Cli::parse_from(["codex-exec", "--full-auto", "summarize"]);

View File

@@ -209,6 +209,7 @@ struct ExecRunArgs {
oss: bool,
output_schema_path: Option<PathBuf>,
prompt: Option<String>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
skip_git_repo_check: bool,
stderr_with_ansi: bool,
}
@@ -230,6 +231,36 @@ fn exec_stderr_env_filter() -> EnvFilter {
.unwrap_or_else(|_| EnvFilter::new("error"))
}
fn parse_responsesapi_client_metadata(
raw_entries: Vec<String>,
) -> anyhow::Result<Option<HashMap<String, String>>> {
if raw_entries.is_empty() {
return Ok(None);
}
let mut metadata = HashMap::new();
for entry in raw_entries {
let Some((key, value)) = entry.split_once('=') else {
anyhow::bail!(
"Invalid --responsesapi-client-metadata value `{entry}`: expected KEY=VALUE"
);
};
if key.is_empty() {
anyhow::bail!(
"Invalid --responsesapi-client-metadata value `{entry}`: key must not be empty"
);
}
if metadata.contains_key(key) {
anyhow::bail!(
"Invalid --responsesapi-client-metadata value `{entry}`: duplicate key `{key}`"
);
}
metadata.insert(key.to_string(), value.to_string());
}
Ok(Some(metadata))
}
pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
#[allow(clippy::print_stderr)]
if let Some(message) = cli.removed_full_auto_warning() {
@@ -254,8 +285,11 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
json: json_mode,
prompt,
output_schema: output_schema_path,
responsesapi_client_metadata,
config_overrides,
} = cli;
let responsesapi_client_metadata =
parse_responsesapi_client_metadata(responsesapi_client_metadata)?;
let shared = shared.into_inner();
let SharedCliOptions {
images,
@@ -561,6 +595,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
oss,
output_schema_path,
prompt,
responsesapi_client_metadata,
skip_git_repo_check,
stderr_with_ansi,
})
@@ -583,6 +618,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
oss,
output_schema_path,
prompt,
responsesapi_client_metadata,
skip_git_repo_check,
stderr_with_ansi,
} = args;
@@ -675,6 +711,11 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
)
}
};
if matches!(initial_operation, InitialOperation::Review { .. })
&& responsesapi_client_metadata.is_some()
{
anyhow::bail!("--responsesapi-client-metadata is only supported for exec user turns");
}
// When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check
// since the user is explicitly running in an externally sandboxed environment.
@@ -787,7 +828,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
params: TurnStartParams {
thread_id: primary_thread_id_for_span.clone(),
input: items.into_iter().map(Into::into).collect(),
responsesapi_client_metadata: None,
responsesapi_client_metadata: responsesapi_client_metadata.clone(),
environments: None,
cwd: Some(default_cwd),
runtime_workspace_roots: None,

View File

@@ -102,6 +102,46 @@ fn exec_root_span_can_be_parented_from_trace_context() {
);
}
#[test]
fn parse_responsesapi_client_metadata_accepts_key_value_pairs() {
let metadata = parse_responsesapi_client_metadata(vec![
"usage_source=chronicle".to_string(),
"feature=memory_summary".to_string(),
"empty_value=".to_string(),
])
.expect("metadata should parse")
.expect("metadata should be present");
assert_eq!(metadata.get("usage_source"), Some(&"chronicle".to_string()));
assert_eq!(metadata.get("feature"), Some(&"memory_summary".to_string()));
assert_eq!(metadata.get("empty_value"), Some(&"".to_string()));
}
#[test]
fn parse_responsesapi_client_metadata_rejects_invalid_entries() {
let error = parse_responsesapi_client_metadata(vec!["usage_source".to_string()])
.expect_err("metadata without equals should fail");
assert!(
error.to_string().contains("expected KEY=VALUE"),
"unexpected error: {error}"
);
}
#[test]
fn parse_responsesapi_client_metadata_rejects_duplicate_keys() {
let error = parse_responsesapi_client_metadata(vec![
"usage_source=chronicle".to_string(),
"usage_source=other".to_string(),
])
.expect_err("duplicate metadata keys should fail");
assert!(
error.to_string().contains("duplicate key `usage_source`"),
"unexpected error: {error}"
);
}
#[test]
fn builds_uncommitted_review_request() {
let args = ReviewArgs {

View File

@@ -0,0 +1,60 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::expect_used, clippy::unwrap_used)]
use core_test_support::responses;
use core_test_support::test_codex_exec::test_codex_exec;
use predicates::str::contains;
use serde_json::Value;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_passes_responsesapi_client_metadata_to_turn_header() -> anyhow::Result<()> {
let test = test_codex_exec();
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp1"),
responses::ev_assistant_message("m1", "fixture hello"),
responses::ev_completed("resp1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("--responsesapi-client-metadata")
.arg("usage_source=chronicle")
.arg("--responsesapi-client-metadata")
.arg("feature=memory_summary")
.arg("tell me something")
.assert()
.success();
let request = response_mock.single_request();
let header = request
.header("x-codex-turn-metadata")
.expect("request missing x-codex-turn-metadata header");
let metadata: Value = serde_json::from_str(&header)?;
assert_eq!(metadata["usage_source"].as_str(), Some("chronicle"));
assert_eq!(metadata["feature"].as_str(), Some("memory_summary"));
assert!(metadata.get("turn_id").is_some());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn review_rejects_responsesapi_client_metadata() -> anyhow::Result<()> {
let test = test_codex_exec();
test.cmd()
.arg("review")
.arg("--uncommitted")
.arg("--responsesapi-client-metadata")
.arg("usage_source=chronicle")
.assert()
.failure()
.stderr(contains(
"--responsesapi-client-metadata is only supported for exec user turns",
));
Ok(())
}

View File

@@ -2,6 +2,7 @@
mod add_dir;
mod apply_patch;
mod auth_env;
mod client_metadata;
mod ephemeral;
mod mcp_required_exit;
mod originator;