mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Compare commits
5 Commits
cleanup/tu
...
jif/feed-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbe3acdf4 | ||
|
|
98923654d0 | ||
|
|
57ba9fa100 | ||
|
|
acb8ed493f | ||
|
|
53a486f7ea |
@@ -181,12 +181,13 @@ mod tests {
|
||||
"display_name": "gpt-test",
|
||||
"description": "desc",
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": ["low", "medium", "high"],
|
||||
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"minimal_client_version": [0, 99, 0],
|
||||
"supported_in_api": true,
|
||||
"priority": 1
|
||||
"priority": 1,
|
||||
"upgrade": null,
|
||||
}))
|
||||
.unwrap(),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use http::HeaderMap;
|
||||
use http::Method;
|
||||
use wiremock::Mock;
|
||||
@@ -57,15 +58,25 @@ async fn models_client_hits_models_endpoint() {
|
||||
description: Some("desc".to_string()),
|
||||
default_reasoning_level: ReasoningEffort::Medium,
|
||||
supported_reasoning_levels: vec![
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Low,
|
||||
description: ReasoningEffort::Low.to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: ReasoningEffort::Medium.to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: ReasoningEffort::High.to_string(),
|
||||
},
|
||||
],
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility: ModelVisibility::List,
|
||||
minimal_client_version: ClientVersion(0, 1, 0),
|
||||
supported_in_api: true,
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -536,7 +536,7 @@ impl Session {
|
||||
|
||||
for (alias, feature) in config.features.legacy_feature_usages() {
|
||||
let canonical = feature.key();
|
||||
let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead.");
|
||||
let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead.");
|
||||
let details = if alias == canonical {
|
||||
None
|
||||
} else {
|
||||
@@ -1470,6 +1470,16 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
let mut previous_context: Option<Arc<TurnContext>> =
|
||||
Some(sess.new_turn(SessionSettingsUpdate::default()).await);
|
||||
|
||||
if config.features.enabled(Feature::RemoteModels)
|
||||
&& let Err(err) = sess
|
||||
.services
|
||||
.models_manager
|
||||
.refresh_available_models(&config.model_provider)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
|
||||
// To break out of this loop, send Op::Shutdown.
|
||||
while let Ok(sub) = rx_sub.recv().await {
|
||||
debug!(?sub, "Submission");
|
||||
|
||||
@@ -40,6 +40,8 @@ pub enum Feature {
|
||||
// Experimental
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Use the unified exec wrapper tool with named sessions.
|
||||
UnifiedExecWrapper,
|
||||
/// Enable experimental RMCP features such as OAuth login.
|
||||
RmcpClient,
|
||||
/// Include the freeform apply_patch tool.
|
||||
@@ -54,6 +56,8 @@ pub enum Feature {
|
||||
WindowsSandbox,
|
||||
/// Remote compaction enabled (only for ChatGPT auth)
|
||||
RemoteCompaction,
|
||||
/// Refresh remote models and emit AppReady once the list is available.
|
||||
RemoteModels,
|
||||
/// Allow model to call multiple tools in parallel (only for models supporting it).
|
||||
ParallelToolCalls,
|
||||
/// Experimental skills injection (CLI flag-driven).
|
||||
@@ -291,6 +295,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::UnifiedExecWrapper,
|
||||
key: "unified_exec_wrapper",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RmcpClient,
|
||||
key: "rmcp_client",
|
||||
@@ -333,6 +343,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RemoteModels,
|
||||
key: "remote_models",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ParallelToolCalls,
|
||||
key: "parallel",
|
||||
|
||||
@@ -291,6 +291,7 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::openai_models::ClientVersion;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
|
||||
fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo {
|
||||
ModelInfo {
|
||||
@@ -298,12 +299,16 @@ mod tests {
|
||||
display_name: slug.to_string(),
|
||||
description: Some(format!("{slug} desc")),
|
||||
default_reasoning_level: effort,
|
||||
supported_reasoning_levels: vec![effort],
|
||||
supported_reasoning_levels: vec![ReasoningEffortPreset {
|
||||
effort,
|
||||
description: effort.to_string(),
|
||||
}],
|
||||
shell_type: shell,
|
||||
visibility: ModelVisibility::List,
|
||||
minimal_client_version: ClientVersion(0, 1, 0),
|
||||
supported_in_api: true,
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ impl ModelsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// do not use this function yet. It's work in progress.
|
||||
pub async fn refresh_available_models(
|
||||
&self,
|
||||
provider: &ModelProviderInfo,
|
||||
@@ -47,16 +46,21 @@ impl ModelsManager {
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let client = ModelsClient::new(transport, api_provider, api_auth);
|
||||
|
||||
let mut client_version = env!("CARGO_PKG_VERSION");
|
||||
if client_version == "0.0.0" {
|
||||
client_version = "99.99.99";
|
||||
}
|
||||
let response = client
|
||||
.list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new())
|
||||
.list_models(client_version, HeaderMap::new())
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
|
||||
let models = response.models;
|
||||
*self.remote_models.write().await = models.clone();
|
||||
let available_models = self.build_available_models().await;
|
||||
{
|
||||
let mut available_models_guard = self.available_models.write().await;
|
||||
*available_models_guard = self.build_available_models().await;
|
||||
*available_models_guard = available_models;
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
@@ -75,8 +79,11 @@ impl ModelsManager {
|
||||
async fn build_available_models(&self) -> Vec<ModelPreset> {
|
||||
let mut available_models = self.remote_models.read().await.clone();
|
||||
available_models.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
let mut model_presets: Vec<ModelPreset> =
|
||||
available_models.into_iter().map(Into::into).collect();
|
||||
let mut model_presets: Vec<ModelPreset> = available_models
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.filter(|preset: &ModelPreset| preset.show_in_picker)
|
||||
.collect();
|
||||
if let Some(default) = model_presets.first_mut() {
|
||||
default.is_default = true;
|
||||
}
|
||||
@@ -103,12 +110,13 @@ mod tests {
|
||||
"display_name": display,
|
||||
"description": format!("{display} desc"),
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": ["low", "medium"],
|
||||
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"minimal_client_version": [0, 1, 0],
|
||||
"supported_in_api": true,
|
||||
"priority": priority
|
||||
"priority": priority,
|
||||
"upgrade": null,
|
||||
}))
|
||||
.expect("valid model")
|
||||
}
|
||||
|
||||
@@ -58,6 +58,28 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn login_command(&self) -> Vec<String> {
|
||||
self.command_with_login(true)
|
||||
}
|
||||
|
||||
pub fn non_login_command(&self) -> Vec<String> {
|
||||
self.command_with_login(false)
|
||||
}
|
||||
|
||||
fn command_with_login(&self, login_shell: bool) -> Vec<String> {
|
||||
let shell_path = self.shell_path.to_string_lossy().to_string();
|
||||
match self.shell_type {
|
||||
ShellType::PowerShell | ShellType::Cmd => vec![shell_path],
|
||||
ShellType::Zsh | ShellType::Bash | ShellType::Sh => {
|
||||
if login_shell {
|
||||
vec![shell_path, "-l".to_string()]
|
||||
} else {
|
||||
vec![shell_path]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -450,6 +472,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_login_variants() {
|
||||
let sh_shell = Shell {
|
||||
shell_type: ShellType::Sh,
|
||||
shell_path: PathBuf::from("/bin/sh"),
|
||||
};
|
||||
|
||||
assert_eq!(sh_shell.login_command(), vec!["/bin/sh", "-l"]);
|
||||
assert_eq!(sh_shell.non_login_command(), vec!["/bin/sh"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_current_shell_detects_zsh() {
|
||||
let shell = Command::new("sh")
|
||||
|
||||
@@ -58,6 +58,28 @@ struct WriteStdinArgs {
|
||||
max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewSessionArgs {
|
||||
session_name: String,
|
||||
#[serde(default)]
|
||||
workdir: Option<String>,
|
||||
#[serde(default = "default_write_stdin_yield_time_ms")]
|
||||
yield_time_ms: u64,
|
||||
#[serde(default)]
|
||||
max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeedCharsArgs {
|
||||
session_name: String,
|
||||
#[serde(default)]
|
||||
chars: String,
|
||||
#[serde(default = "default_write_stdin_yield_time_ms")]
|
||||
yield_time_ms: u64,
|
||||
#[serde(default)]
|
||||
max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
fn default_exec_yield_time_ms() -> u64 {
|
||||
10000
|
||||
}
|
||||
@@ -207,6 +229,87 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
FunctionCallError::RespondToModel(format!("exec_command failed: {err:?}"))
|
||||
})?
|
||||
}
|
||||
"new_session" => {
|
||||
let args: NewSessionArgs = serde_json::from_str(&arguments).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to parse new_session arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if args.session_name.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"session_name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
if let Err(err) = manager
|
||||
.register_session_name(&args.session_name, &process_id)
|
||||
.await
|
||||
{
|
||||
manager.release_process_id(&process_id).await;
|
||||
return Err(FunctionCallError::RespondToModel(err.to_string()));
|
||||
}
|
||||
|
||||
let workdir = args
|
||||
.workdir
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|dir| context.turn.resolve_path(Some(dir)));
|
||||
|
||||
let response = manager
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command: session.user_shell().login_command(),
|
||||
process_id,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
workdir,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("exec_command failed: {err:?}"))
|
||||
})?;
|
||||
|
||||
if response.process_id.is_none() {
|
||||
manager.clear_session_name(&args.session_name).await;
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
"feed_chars" => {
|
||||
let args: FeedCharsArgs = serde_json::from_str(&arguments).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to parse feed_chars arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let Some(process_id) = manager
|
||||
.process_id_for_session_name(&args.session_name)
|
||||
.await
|
||||
else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"Session '{}' not found",
|
||||
args.session_name
|
||||
)));
|
||||
};
|
||||
|
||||
manager
|
||||
.write_stdin(WriteStdinRequest {
|
||||
call_id: &call_id,
|
||||
process_id: &process_id,
|
||||
input: &args.chars,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}"))
|
||||
})?
|
||||
}
|
||||
"write_stdin" => {
|
||||
let args: WriteStdinArgs = serde_json::from_str(&arguments).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
|
||||
@@ -42,6 +42,8 @@ impl ToolsConfig {
|
||||
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::UnifiedExecWrapper) {
|
||||
ConfigShellToolType::UnifiedExecWrapper
|
||||
} else if features.enabled(Feature::UnifiedExec) {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
} else {
|
||||
@@ -251,6 +253,98 @@ fn create_write_stdin_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_new_session_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"session_name".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Unique name for the session".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional working directory for the session; defaults to the turn cwd.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) before returning the initial output."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "new_session".to_string(),
|
||||
description: "Open a new interactive exec session in a container. Normally used for launching an interactive shell. Multiple sessions may be running at a time.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_name".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_feed_chars_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"session_name".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Session to feed characters to".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"chars".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Characters to feed; may be empty".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before flushing STDOUT/STDERR."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "feed_chars".to_string(),
|
||||
description:
|
||||
"Feed characters to a session's STDIN, wait briefly, flush STDOUT/STDERR, and return the results."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_name".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_shell_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -804,10 +898,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api(
|
||||
}
|
||||
|
||||
if let Some(map) = tool.as_object_mut() {
|
||||
let name = map
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// Remove "type" field as it is not needed in chat completions.
|
||||
map.remove("type");
|
||||
Some(json!({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"function": map,
|
||||
}))
|
||||
} else {
|
||||
@@ -1007,6 +1107,12 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("exec_command", unified_exec_handler.clone());
|
||||
builder.register_handler("write_stdin", unified_exec_handler);
|
||||
}
|
||||
ConfigShellToolType::UnifiedExecWrapper => {
|
||||
builder.push_spec(create_new_session_tool());
|
||||
builder.push_spec(create_feed_chars_tool());
|
||||
builder.register_handler("new_session", unified_exec_handler.clone());
|
||||
builder.register_handler("feed_chars", unified_exec_handler);
|
||||
}
|
||||
ConfigShellToolType::Disabled => {
|
||||
// Do nothing.
|
||||
}
|
||||
@@ -1157,6 +1263,7 @@ mod tests {
|
||||
ConfigShellToolType::Default => Some("shell"),
|
||||
ConfigShellToolType::Local => Some("local_shell"),
|
||||
ConfigShellToolType::UnifiedExec => None,
|
||||
ConfigShellToolType::UnifiedExecWrapper => Some("unified_exec_wrapper"),
|
||||
ConfigShellToolType::Disabled => None,
|
||||
ConfigShellToolType::ShellCommand => Some("shell_command"),
|
||||
}
|
||||
@@ -2083,4 +2190,58 @@ Examples of valid command strings:
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tools_include_top_level_name() {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert("foo".to_string(), JsonSchema::String { description: None });
|
||||
let tools = vec![ToolSpec::Function(ResponsesApiTool {
|
||||
name: "demo".to_string(),
|
||||
description: "A demo tool".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
})];
|
||||
|
||||
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
|
||||
assert_eq!(
|
||||
responses_json,
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": "demo",
|
||||
"description": "A demo tool",
|
||||
"strict": false,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": { "type": "string" }
|
||||
},
|
||||
},
|
||||
})]
|
||||
);
|
||||
|
||||
let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
tools_json,
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": "demo",
|
||||
"function": {
|
||||
"name": "demo",
|
||||
"description": "A demo tool",
|
||||
"strict": false,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": { "type": "string" }
|
||||
},
|
||||
},
|
||||
}
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,17 +109,43 @@ pub(crate) struct UnifiedExecSessionManager {
|
||||
pub(crate) struct SessionStore {
|
||||
sessions: HashMap<String, SessionEntry>,
|
||||
reserved_sessions_id: HashSet<String>,
|
||||
session_names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
fn remove(&mut self, session_id: &str) -> Option<SessionEntry> {
|
||||
self.reserved_sessions_id.remove(session_id);
|
||||
self.session_names.retain(|_, id| id != session_id);
|
||||
self.sessions.remove(session_id)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.reserved_sessions_id.clear();
|
||||
self.sessions.clear();
|
||||
self.session_names.clear();
|
||||
}
|
||||
|
||||
fn process_id_for_name(&self, session_name: &str) -> Option<String> {
|
||||
self.session_names.get(session_name).cloned()
|
||||
}
|
||||
|
||||
fn insert_session_name(
|
||||
&mut self,
|
||||
session_name: &str,
|
||||
process_id: &str,
|
||||
) -> Result<(), UnifiedExecError> {
|
||||
if self.session_names.contains_key(session_name) {
|
||||
return Err(UnifiedExecError::create_session(format!(
|
||||
"Session '{session_name}' already in use"
|
||||
)));
|
||||
}
|
||||
self.session_names
|
||||
.insert(session_name.to_string(), process_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_session_name(&mut self, session_name: &str) {
|
||||
self.session_names.remove(session_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,4 +482,45 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_names_track_process_ids() {
|
||||
let (session, _turn) = test_session_and_turn();
|
||||
let process_id = session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.allocate_process_id()
|
||||
.await;
|
||||
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.register_session_name("default", &process_id)
|
||||
.await
|
||||
.expect("session name reserved");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.process_id_for_session_name("default")
|
||||
.await,
|
||||
Some(process_id.clone())
|
||||
);
|
||||
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.release_process_id(&process_id)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.process_id_for_session_name("default")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,25 @@ impl UnifiedExecSessionManager {
|
||||
store.remove(process_id);
|
||||
}
|
||||
|
||||
pub(crate) async fn register_session_name(
|
||||
&self,
|
||||
session_name: &str,
|
||||
process_id: &str,
|
||||
) -> Result<(), UnifiedExecError> {
|
||||
let mut store = self.session_store.lock().await;
|
||||
store.insert_session_name(session_name, process_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn process_id_for_session_name(&self, session_name: &str) -> Option<String> {
|
||||
let store = self.session_store.lock().await;
|
||||
store.process_id_for_name(session_name)
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_session_name(&self, session_name: &str) {
|
||||
let mut store = self.session_store.lock().await;
|
||||
store.clear_session_name(session_name);
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_command(
|
||||
&self,
|
||||
request: ExecCommandRequest,
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()
|
||||
let DeprecationNoticeEvent { summary, details } = notice;
|
||||
assert_eq!(
|
||||
summary,
|
||||
"`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead."
|
||||
"`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead."
|
||||
.to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -41,6 +41,7 @@ mod otel;
|
||||
mod prompt_caching;
|
||||
mod quota_exceeded;
|
||||
mod read_file;
|
||||
mod remote_models;
|
||||
mod resume;
|
||||
mod review;
|
||||
mod rmcp_client;
|
||||
|
||||
183
codex-rs/core/tests/suite/remote_models.rs
Normal file
183
codex-rs/core/tests/suite/remote_models.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
// unified exec is not supported on Windows OS
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::openai_models::models_manager::ModelsManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ClientVersion;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use serde_json::json;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
use wiremock::BodyPrintLimit;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const REMOTE_MODEL_SLUG: &str = "codex-test";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::builder()
|
||||
.body_print_limit(BodyPrintLimit::Limited(80_000))
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let remote_model = ModelInfo {
|
||||
slug: REMOTE_MODEL_SLUG.to_string(),
|
||||
display_name: "Remote Test".to_string(),
|
||||
description: Some("A remote model that requires the test shell".to_string()),
|
||||
default_reasoning_level: ReasoningEffort::Medium,
|
||||
supported_reasoning_levels: vec![ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: ReasoningEffort::Medium.to_string(),
|
||||
}],
|
||||
shell_type: ConfigShellToolType::UnifiedExec,
|
||||
visibility: ModelVisibility::List,
|
||||
minimal_client_version: ClientVersion(0, 1, 0),
|
||||
supported_in_api: true,
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
};
|
||||
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = "gpt-5.1".to_string();
|
||||
});
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
config,
|
||||
conversation_manager,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let models_manager = conversation_manager.get_models_manager();
|
||||
let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await;
|
||||
|
||||
assert_eq!(available_model.model, REMOTE_MODEL_SLUG);
|
||||
|
||||
let requests = models_mock.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"expected a single /models refresh request for the remote models feature"
|
||||
);
|
||||
assert_eq!(requests[0].url.path(), "/v1/models");
|
||||
|
||||
let family = models_manager
|
||||
.construct_model_family(REMOTE_MODEL_SLUG, &config)
|
||||
.await;
|
||||
assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec);
|
||||
|
||||
codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: Some(REMOTE_MODEL_SLUG.to_string()),
|
||||
effort: None,
|
||||
summary: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let call_id = "call";
|
||||
let args = json!({
|
||||
"cmd": "/bin/echo call",
|
||||
"yield_time_ms": 250,
|
||||
});
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run call".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REMOTE_MODEL_SLUG.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let begin_event = wait_for_event_match(&codex, |msg| match msg {
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup);
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_model_available(manager: &Arc<ModelsManager>, slug: &str) -> ModelPreset {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if let Some(model) = {
|
||||
let guard = manager.available_models.read().await;
|
||||
guard.iter().find(|model| model.model == slug).cloned()
|
||||
} {
|
||||
return model;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for the remote model {slug} to appear");
|
||||
}
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,7 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
|
||||
} else {
|
||||
config.features.disable(Feature::UnifiedExec);
|
||||
}
|
||||
config.features.disable(Feature::UnifiedExecWrapper);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
use ts_rs::TS;
|
||||
@@ -36,7 +37,7 @@ pub enum ReasoningEffort {
|
||||
}
|
||||
|
||||
/// A reasoning effort option that can be surfaced for a model.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
pub struct ReasoningEffortPreset {
|
||||
/// Effort level that the model supports.
|
||||
pub effort: ReasoningEffort,
|
||||
@@ -107,6 +108,7 @@ pub enum ConfigShellToolType {
|
||||
Default,
|
||||
Local,
|
||||
UnifiedExec,
|
||||
UnifiedExecWrapper,
|
||||
Disabled,
|
||||
ShellCommand,
|
||||
}
|
||||
@@ -123,7 +125,7 @@ pub struct ModelInfo {
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub default_reasoning_level: ReasoningEffort,
|
||||
pub supported_reasoning_levels: Vec<ReasoningEffort>,
|
||||
pub supported_reasoning_levels: Vec<ReasoningEffortPreset>,
|
||||
pub shell_type: ConfigShellToolType,
|
||||
#[serde(default = "default_visibility")]
|
||||
pub visibility: ModelVisibility,
|
||||
@@ -132,6 +134,8 @@ pub struct ModelInfo {
|
||||
pub supported_in_api: bool,
|
||||
#[serde(default)]
|
||||
pub priority: i32,
|
||||
#[serde(default)]
|
||||
pub upgrade: Option<String>,
|
||||
}
|
||||
|
||||
/// Response wrapper for `/models`.
|
||||
@@ -149,22 +153,57 @@ impl From<ModelInfo> for ModelPreset {
|
||||
fn from(info: ModelInfo) -> Self {
|
||||
ModelPreset {
|
||||
id: info.slug.clone(),
|
||||
model: info.slug,
|
||||
model: info.slug.clone(),
|
||||
display_name: info.display_name,
|
||||
description: info.description.unwrap_or_default(),
|
||||
default_reasoning_effort: info.default_reasoning_level,
|
||||
supported_reasoning_efforts: info
|
||||
.supported_reasoning_levels
|
||||
.into_iter()
|
||||
.map(|level| ReasoningEffortPreset {
|
||||
effort: level,
|
||||
// todo: add description for each reasoning effort
|
||||
description: level.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
|
||||
is_default: false, // default is the highest priority available model
|
||||
upgrade: None, // no upgrade available (todo: think about it)
|
||||
upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade {
|
||||
id: upgrade_slug.clone(),
|
||||
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
|
||||
&info.supported_reasoning_levels,
|
||||
),
|
||||
migration_config_key: info.slug.clone(),
|
||||
}),
|
||||
show_in_picker: info.visibility == ModelVisibility::List,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_effort_mapping_from_presets(
|
||||
presets: &[ReasoningEffortPreset],
|
||||
) -> Option<HashMap<ReasoningEffort, ReasoningEffort>> {
|
||||
if presets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Map every canonical effort to the closest supported effort for the new model.
|
||||
let supported: Vec<ReasoningEffort> = presets.iter().map(|p| p.effort).collect();
|
||||
let mut map = HashMap::new();
|
||||
for effort in ReasoningEffort::iter() {
|
||||
let nearest = nearest_effort(effort, &supported);
|
||||
map.insert(effort, nearest);
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
fn effort_rank(effort: ReasoningEffort) -> i32 {
|
||||
match effort {
|
||||
ReasoningEffort::None => 0,
|
||||
ReasoningEffort::Minimal => 1,
|
||||
ReasoningEffort::Low => 2,
|
||||
ReasoningEffort::Medium => 3,
|
||||
ReasoningEffort::High => 4,
|
||||
ReasoningEffort::XHigh => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort {
|
||||
let target_rank = effort_rank(target);
|
||||
supported
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
|
||||
.unwrap_or(target)
|
||||
}
|
||||
|
||||
@@ -1348,7 +1348,7 @@ pub struct ReviewLineRange {
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecCommandSource {
|
||||
Agent,
|
||||
|
||||
@@ -350,6 +350,8 @@ Though using this option may also be necessary if you try to use Codex in enviro
|
||||
|
||||
### tools.\*
|
||||
|
||||
These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)).
|
||||
|
||||
Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default:
|
||||
|
||||
```toml
|
||||
@@ -358,8 +360,6 @@ web_search = true # allow Codex to issue first-party web searches without prom
|
||||
view_image = false # disable image uploads (they're enabled by default)
|
||||
```
|
||||
|
||||
`web_search` is deprecated; use the `web_search_request` feature flag instead.
|
||||
|
||||
The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
|
||||
|
||||
### approval_presets
|
||||
@@ -615,12 +615,12 @@ Set `otel.exporter` to control where events go:
|
||||
endpoint, protocol, and headers your collector expects:
|
||||
|
||||
```toml
|
||||
[otel]
|
||||
exporter = { otlp-http = {
|
||||
endpoint = "https://otel.example.com/v1/logs",
|
||||
protocol = "binary",
|
||||
headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
|
||||
}}
|
||||
[otel.exporter."otlp-http"]
|
||||
endpoint = "https://otel.example.com/v1/logs"
|
||||
protocol = "binary"
|
||||
|
||||
[otel.exporter."otlp-http".headers]
|
||||
"x-otlp-api-key" = "${OTLP_TOKEN}"
|
||||
```
|
||||
|
||||
- `otlp-grpc` – streams OTLP log records over gRPC. Provide the endpoint and any
|
||||
@@ -628,27 +628,24 @@ Set `otel.exporter` to control where events go:
|
||||
|
||||
```toml
|
||||
[otel]
|
||||
exporter = { otlp-grpc = {
|
||||
endpoint = "https://otel.example.com:4317",
|
||||
headers = { "x-otlp-meta" = "abc123" }
|
||||
}}
|
||||
exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}}
|
||||
```
|
||||
|
||||
Both OTLP exporters accept an optional `tls` block so you can trust a custom CA
|
||||
or enable mutual TLS. Relative paths are resolved against `~/.codex/`:
|
||||
|
||||
```toml
|
||||
[otel]
|
||||
exporter = { otlp-http = {
|
||||
endpoint = "https://otel.example.com/v1/logs",
|
||||
protocol = "binary",
|
||||
headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" },
|
||||
tls = {
|
||||
ca-certificate = "certs/otel-ca.pem",
|
||||
client-certificate = "/etc/codex/certs/client.pem",
|
||||
client-private-key = "/etc/codex/certs/client-key.pem",
|
||||
}
|
||||
}}
|
||||
[otel.exporter."otlp-http"]
|
||||
endpoint = "https://otel.example.com/v1/logs"
|
||||
protocol = "binary"
|
||||
|
||||
[otel.exporter."otlp-http".headers]
|
||||
"x-otlp-api-key" = "${OTLP_TOKEN}"
|
||||
|
||||
[otel.exporter."otlp-http".tls]
|
||||
ca-certificate = "certs/otel-ca.pem"
|
||||
client-certificate = "/etc/codex/certs/client.pem"
|
||||
client-private-key = "/etc/codex/certs/client-key.pem"
|
||||
```
|
||||
|
||||
If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your
|
||||
|
||||
@@ -341,30 +341,28 @@ environment = "dev"
|
||||
exporter = "none"
|
||||
|
||||
# Example OTLP/HTTP exporter configuration
|
||||
# [otel]
|
||||
# exporter = { otlp-http = {
|
||||
# endpoint = "https://otel.example.com/v1/logs",
|
||||
# protocol = "binary", # "binary" | "json"
|
||||
# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
|
||||
# }}
|
||||
# [otel.exporter."otlp-http"]
|
||||
# endpoint = "https://otel.example.com/v1/logs"
|
||||
# protocol = "binary" # "binary" | "json"
|
||||
|
||||
# [otel.exporter."otlp-http".headers]
|
||||
# "x-otlp-api-key" = "${OTLP_TOKEN}"
|
||||
|
||||
# Example OTLP/gRPC exporter configuration
|
||||
# [otel]
|
||||
# exporter = { otlp-grpc = {
|
||||
# endpoint = "https://otel.example.com:4317",
|
||||
# headers = { "x-otlp-meta" = "abc123" }
|
||||
# }}
|
||||
# [otel.exporter."otlp-grpc"]
|
||||
# endpoint = "https://otel.example.com:4317",
|
||||
# headers = { "x-otlp-meta" = "abc123" }
|
||||
|
||||
# Example OTLP exporter with mutual TLS
|
||||
# [otel]
|
||||
# exporter = { otlp-http = {
|
||||
# endpoint = "https://otel.example.com/v1/logs",
|
||||
# protocol = "binary",
|
||||
# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" },
|
||||
# tls = {
|
||||
# ca-certificate = "certs/otel-ca.pem",
|
||||
# client-certificate = "/etc/codex/certs/client.pem",
|
||||
# client-private-key = "/etc/codex/certs/client-key.pem",
|
||||
# }
|
||||
# }}
|
||||
# [otel.exporter."otlp-http"]
|
||||
# endpoint = "https://otel.example.com/v1/logs"
|
||||
# protocol = "binary"
|
||||
|
||||
# [otel.exporter."otlp-http".headers]
|
||||
# "x-otlp-api-key" = "${OTLP_TOKEN}"
|
||||
|
||||
# [otel.exporter."otlp-http".tls]
|
||||
# ca-certificate = "certs/otel-ca.pem"
|
||||
# client-certificate = "/etc/codex/certs/client.pem"
|
||||
# client-private-key = "/etc/codex/certs/client-key.pem"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user