mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge d06ae316d6 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -1777,7 +1777,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -9503,7 +9503,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -6317,7 +6317,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown";
|
||||
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
|
||||
|
||||
@@ -474,7 +474,13 @@ impl Client {
|
||||
crate::types::PlanType::Plus => AccountPlanType::Plus,
|
||||
crate::types::PlanType::Pro => AccountPlanType::Pro,
|
||||
crate::types::PlanType::Team => AccountPlanType::Team,
|
||||
crate::types::PlanType::SelfServeBusinessUsageBased => {
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
}
|
||||
crate::types::PlanType::Business => AccountPlanType::Business,
|
||||
crate::types::PlanType::EnterpriseCbpUsageBased => {
|
||||
AccountPlanType::EnterpriseCbpUsageBased
|
||||
}
|
||||
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
|
||||
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
|
||||
crate::types::PlanType::Guest
|
||||
@@ -499,6 +505,18 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn map_plan_type_supports_usage_based_business_variants() {
|
||||
assert_eq!(
|
||||
Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased),
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
);
|
||||
assert_eq!(
|
||||
Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased),
|
||||
AccountPlanType::EnterpriseCbpUsageBased
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_primary_and_additional_rate_limits() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
|
||||
@@ -327,11 +327,11 @@ impl CloudRequirementsService {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
)
|
||||
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -547,11 +547,11 @@ impl CloudRequirementsService {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return false;
|
||||
};
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return false;
|
||||
};
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
)
|
||||
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1125,6 +1125,20 @@ mod tests {
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("self_serve_business_usage_based"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
assert_eq!(service.fetch().await, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_business_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -1153,6 +1167,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("enterprise_cbp_usage_based"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
assert_eq!(
|
||||
service.fetch().await,
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
guardian_developer_instructions: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
|
||||
@@ -69,8 +69,12 @@ pub enum PlanType {
|
||||
FreeWorkspace,
|
||||
#[serde(rename = "team")]
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
#[serde(rename = "business")]
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
#[serde(rename = "education")]
|
||||
Education,
|
||||
#[serde(rename = "quorum")]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::config::types::Personality;
|
||||
use crate::error::Result;
|
||||
pub use codex_api::common::ResponseEvent;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -157,158 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
|
||||
}
|
||||
|
||||
pub(crate) mod tools {
|
||||
use crate::tools::spec::JsonSchema;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ToolSpec {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "tool_search")]
|
||||
ToolSearch {
|
||||
execution: String,
|
||||
description: String,
|
||||
parameters: JsonSchema,
|
||||
},
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "image_generation")]
|
||||
ImageGeneration { output_format: String },
|
||||
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
|
||||
// The `external_web_access` field determines whether the web search is over cached or live content.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
|
||||
#[serde(rename = "web_search")]
|
||||
WebSearch {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_web_access: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filters: Option<ResponsesApiWebSearchFilters>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_location: Option<ResponsesApiWebSearchUserLocation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_context_size: Option<WebSearchContextSize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_content_types: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "custom")]
|
||||
Freeform(FreeformTool),
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
match self {
|
||||
ToolSpec::Function(tool) => tool.name.as_str(),
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(tool) => tool.name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub(crate) struct ResponsesApiWebSearchFilters {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) allowed_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
|
||||
fn from(filters: ConfigWebSearchFilters) -> Self {
|
||||
Self {
|
||||
allowed_domains: filters.allowed_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub(crate) struct ResponsesApiWebSearchUserLocation {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) r#type: WebSearchUserLocationType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
|
||||
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
|
||||
Self {
|
||||
r#type: user_location.r#type,
|
||||
country: user_location.country,
|
||||
region: user_location.region,
|
||||
city: user_location.city,
|
||||
timezone: user_location.timezone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformTool {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) format: FreeformToolFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformToolFormat {
|
||||
pub(crate) r#type: String,
|
||||
pub(crate) syntax: String,
|
||||
pub(crate) definition: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiTool {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
/// TODO: Validation. When strict is set to true, the JSON schema,
|
||||
/// `required` and `additional_properties` must be present. All fields in
|
||||
/// `properties` must be present in `required`.
|
||||
pub(crate) strict: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) defer_loading: Option<bool>,
|
||||
pub(crate) parameters: JsonSchema,
|
||||
#[serde(skip)]
|
||||
pub(crate) output_schema: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ToolSearchOutputTool {
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "namespace")]
|
||||
Namespace(ResponsesApiNamespace),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub(crate) struct ResponsesApiNamespace {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) tools: Vec<ResponsesApiNamespaceTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ResponsesApiNamespaceTool {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
}
|
||||
pub(crate) use codex_tools::FreeformTool;
|
||||
pub(crate) use codex_tools::FreeformToolFormat;
|
||||
pub(crate) use codex_tools::ResponsesApiTool;
|
||||
pub(crate) use codex_tools::ToolSearchOutputTool;
|
||||
pub(crate) use codex_tools::ToolSpec;
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
|
||||
@@ -197,49 +197,3 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
|
||||
let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__calendar".to_string(),
|
||||
description: "Plan events".to_string(),
|
||||
tools: vec![tools::ResponsesApiNamespaceTool::Function(
|
||||
tools::ResponsesApiTool {
|
||||
name: "create_event".to_string(),
|
||||
description: "Create a calendar event.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
},
|
||||
)],
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(namespace).expect("serialize namespace");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({
|
||||
"type": "namespace",
|
||||
"name": "mcp__codex_apps__calendar",
|
||||
"description": "Plan events",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,12 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
|
||||
Some(PlanType::Known(
|
||||
KnownPlan::Team
|
||||
| KnownPlan::SelfServeBusinessUsageBased
|
||||
| KnownPlan::Business
|
||||
| KnownPlan::EnterpriseCbpUsageBased,
|
||||
)) => {
|
||||
format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
@@ -460,21 +465,6 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Unknown(plan))
|
||||
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
|
||||
{
|
||||
match self
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.credits.as_ref())
|
||||
.map(|credits| credits.has_credits)
|
||||
{
|
||||
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
|
||||
.to_string(),
|
||||
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
Some(PlanType::Unknown(_)) | None => format!(
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
|
||||
@@ -243,6 +243,34 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_default_for_other_plans() {
|
||||
let err = UsageLimitReachedError {
|
||||
|
||||
@@ -33,6 +33,32 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
||||
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
|
||||
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
||||
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
||||
/// name we expose to the model by replacing any disallowed character with `_`.
|
||||
pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
|
||||
let mut sanitized = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
sanitized.push(c);
|
||||
} else {
|
||||
sanitized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"_".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
|
||||
sanitize_responses_api_tool_name(&format!(
|
||||
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
||||
|
||||
@@ -52,6 +52,14 @@ fn split_qualified_tool_name_returns_server_and_tool() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
|
||||
assert_eq!(
|
||||
qualified_mcp_tool_name_prefix("Some-Server"),
|
||||
"mcp__Some_Server__".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_qualified_tool_name_rejects_invalid_names() {
|
||||
assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None);
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::time::Instant;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
use crate::mcp::sanitize_responses_api_tool_name;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -104,26 +105,6 @@ const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
|
||||
const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms";
|
||||
const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms";
|
||||
|
||||
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
||||
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
||||
/// name we expose to the model by replacing any disallowed character with `_`.
|
||||
fn sanitize_responses_api_tool_name(name: &str) -> String {
|
||||
let mut sanitized = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
sanitized.push(c);
|
||||
} else {
|
||||
sanitized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"_".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn sha1_hex(s: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(s.as_bytes());
|
||||
|
||||
@@ -7,8 +7,11 @@ use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC;
|
||||
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_METRIC;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tempfile::TempDir;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
use zip::ZipArchive;
|
||||
@@ -27,6 +30,8 @@ const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
|
||||
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
|
||||
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// Keep this comfortably above a normal sync attempt so we do not race another Codex process.
|
||||
const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60);
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -63,14 +68,23 @@ fn sync_openai_plugins_repo_with_transport_overrides(
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
|
||||
Ok(remote_sha) => Ok(remote_sha),
|
||||
Ok(remote_sha) => {
|
||||
emit_curated_plugins_startup_sync_metric("git", "success");
|
||||
emit_curated_plugins_startup_sync_final_metric("git", "success");
|
||||
Ok(remote_sha)
|
||||
}
|
||||
Err(err) => {
|
||||
emit_curated_plugins_startup_sync_metric("git", "failure");
|
||||
warn!(
|
||||
error = %err,
|
||||
git_binary,
|
||||
"git sync failed for curated plugin sync; falling back to GitHub HTTP"
|
||||
);
|
||||
sync_openai_plugins_repo_via_http(codex_home, api_base_url)
|
||||
let result = sync_openai_plugins_repo_via_http(codex_home, api_base_url);
|
||||
let status = if result.is_ok() { "success" } else { "failure" };
|
||||
emit_curated_plugins_startup_sync_metric("http", status);
|
||||
emit_curated_plugins_startup_sync_final_metric("http", status);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +99,7 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let clone_output = run_git_command_with_timeout(
|
||||
Command::new(git_binary)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
@@ -93,21 +107,21 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.arg("https://github.com/openai/plugins.git")
|
||||
.arg(&cloned_repo_path),
|
||||
.arg(staged_repo_dir.path()),
|
||||
"git clone curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
|
||||
|
||||
let cloned_sha = git_head_sha(&cloned_repo_path, git_binary)?;
|
||||
let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?;
|
||||
if cloned_sha != remote_sha {
|
||||
return Err(format!(
|
||||
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
|
||||
));
|
||||
}
|
||||
|
||||
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
|
||||
activate_curated_repo(&repo_path, &cloned_repo_path)?;
|
||||
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
|
||||
activate_curated_repo(&repo_path, staged_repo_dir)?;
|
||||
write_curated_plugins_sha(&sha_path, &remote_sha)?;
|
||||
Ok(remote_sha)
|
||||
}
|
||||
@@ -129,11 +143,11 @@ fn sync_openai_plugins_repo_via_http(
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
|
||||
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
|
||||
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
|
||||
activate_curated_repo(&repo_path, &cloned_repo_path)?;
|
||||
extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?;
|
||||
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
|
||||
activate_curated_repo(&repo_path, staged_repo_dir)?;
|
||||
write_curated_plugins_sha(&sha_path, &remote_sha)?;
|
||||
Ok(remote_sha)
|
||||
}
|
||||
@@ -227,7 +241,7 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
|
||||
tokio::fs::write(marker_path, b"ok\n").await
|
||||
}
|
||||
|
||||
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf, String> {
|
||||
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<TempDir, String> {
|
||||
let Some(parent) = repo_path.parent() else {
|
||||
return Err(format!(
|
||||
"failed to determine curated plugins parent directory for {}",
|
||||
@@ -240,6 +254,7 @@ fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf,
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
remove_stale_curated_repo_temp_dirs(parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
|
||||
|
||||
let clone_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-clone-")
|
||||
@@ -250,7 +265,120 @@ fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf,
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
Ok(clone_dir.keep())
|
||||
Ok(clone_dir)
|
||||
}
|
||||
|
||||
fn remove_stale_curated_repo_temp_dirs(parent: &Path, max_age: Duration) {
|
||||
let entries = match std::fs::read_dir(parent) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
parent = %parent.display(),
|
||||
"failed to list curated plugins temp directory parent for stale cleanup"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
path = %entry.path().display(),
|
||||
"failed to inspect curated plugins temp directory entry"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
let is_plugins_clone_dir = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("plugins-clone-"));
|
||||
if !is_plugins_clone_dir {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
path = %path.display(),
|
||||
"failed to read curated plugins temp directory metadata"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let modified = match metadata.modified() {
|
||||
Ok(modified) => modified,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
path = %path.display(),
|
||||
"failed to read curated plugins temp directory modification time"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let age = match modified.elapsed() {
|
||||
Ok(age) => age,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
path = %path.display(),
|
||||
"failed to compute curated plugins temp directory age"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if age < max_age {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||
warn!(
|
||||
error = %err,
|
||||
path = %path.display(),
|
||||
"failed to remove stale curated plugins temp directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) {
|
||||
emit_curated_plugins_startup_sync_counter(
|
||||
CURATED_PLUGINS_STARTUP_SYNC_METRIC,
|
||||
transport,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) {
|
||||
emit_curated_plugins_startup_sync_counter(
|
||||
CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC,
|
||||
transport,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_curated_plugins_startup_sync_counter(
|
||||
metric_name: &str,
|
||||
transport: &'static str,
|
||||
status: &'static str,
|
||||
) {
|
||||
let Some(metrics) = codex_otel::metrics::global() else {
|
||||
return;
|
||||
};
|
||||
let tags = [("transport", transport), ("status", status)];
|
||||
let _ = metrics.counter(metric_name, /*inc*/ 1, &tags);
|
||||
}
|
||||
|
||||
fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
|
||||
@@ -263,7 +391,8 @@ fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> {
|
||||
fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> {
|
||||
let staged_repo_path = staged_repo_dir.path();
|
||||
if repo_path.exists() {
|
||||
let parent = repo_path.parent().ok_or_else(|| {
|
||||
format!(
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -17,6 +18,21 @@ use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn has_plugins_clone_dirs(codex_home: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
entries.flatten().any(|entry| {
|
||||
let path = entry.path();
|
||||
path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("plugins-clone-"))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -38,6 +54,49 @@ fn read_curated_plugins_sha_reads_trimmed_sha_file() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||
let modified_at = now.saturating_sub(age);
|
||||
let tv_sec = i64::try_from(modified_at.as_secs())?;
|
||||
let ts = libc::timespec { tv_sec, tv_nsec: 0 };
|
||||
let times = [ts, ts];
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
|
||||
let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let parent = tmp.path().join(".tmp");
|
||||
let stale_clone_dir = parent.join("plugins-clone-stale");
|
||||
let fresh_clone_dir = parent.join("plugins-clone-fresh");
|
||||
let unrelated_dir = parent.join("plugins-cache");
|
||||
|
||||
std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir");
|
||||
std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir");
|
||||
std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir");
|
||||
set_dir_mtime(
|
||||
&stale_clone_dir,
|
||||
CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60),
|
||||
)
|
||||
.expect("age stale clone dir");
|
||||
set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir");
|
||||
|
||||
remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE);
|
||||
|
||||
assert!(!stale_clone_dir.exists());
|
||||
assert!(fresh_clone_dir.is_dir());
|
||||
assert!(unrelated_dir.is_dir());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_openai_plugins_repo_prefers_git_when_available() {
|
||||
@@ -229,6 +288,94 @@ exit 1
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let bin_dir = tempfile::Builder::new()
|
||||
.prefix("fake-git-partial-fail-")
|
||||
.tempdir()
|
||||
.expect("tempdir");
|
||||
let git_path = bin_dir.path().join("git");
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
std::fs::write(
|
||||
&git_path,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "ls-remote" ]; then
|
||||
printf '%s\tHEAD\n' "{sha}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "clone" ]; then
|
||||
dest="$5"
|
||||
mkdir -p "$dest/.git"
|
||||
echo "fatal: early EOF" >&2
|
||||
exit 128
|
||||
fi
|
||||
echo "unexpected git invocation: $@" >&2
|
||||
exit 1
|
||||
"#
|
||||
),
|
||||
)
|
||||
.expect("write fake git");
|
||||
let mut permissions = std::fs::metadata(&git_path)
|
||||
.expect("metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_path, permissions).expect("chmod");
|
||||
|
||||
let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path"))
|
||||
.expect_err("git sync should fail");
|
||||
|
||||
assert!(err.contains("fatal: early EOF"));
|
||||
assert!(!has_plugins_clone_dirs(tmp.path()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let server = MockServer::start().await;
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/zip")
|
||||
.set_body_bytes(b"not a zip archive".to_vec()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
let err = tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_via_http(tmp_path.as_path(), &server_uri)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect_err("http sync should fail");
|
||||
|
||||
assert!(err.contains("failed to open curated plugins zip archive"));
|
||||
assert!(!has_plugins_clone_dirs(tmp.path()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use crate::client_common::tools::ResponsesApiNamespace;
|
||||
use crate::client_common::tools::ResponsesApiNamespaceTool;
|
||||
use crate::client_common::tools::ToolSearchOutputTool;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
@@ -8,17 +5,17 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::spec::mcp_tool_to_deferred_openai_tool;
|
||||
use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolSearchOutputTool;
|
||||
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
|
||||
pub struct ToolSearchHandler {
|
||||
tools: HashMap<String, ToolInfo>,
|
||||
}
|
||||
@@ -129,7 +126,7 @@ fn serialize_tool_search_output_tools(
|
||||
let tools = tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone())
|
||||
mcp_tool_to_deferred_responses_api_tool(tool.tool_name.clone(), &tool.tool)
|
||||
.map(ResponsesApiNamespaceTool::Function)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use crate::client_common::tools::FreeformTool;
|
||||
use crate::client_common::tools::FreeformToolFormat;
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
@@ -46,9 +43,11 @@ use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tools::ToolDefinition;
|
||||
use codex_tools::parse_dynamic_tool;
|
||||
use codex_tools::parse_mcp_tool;
|
||||
use codex_tools::FreeformTool;
|
||||
use codex_tools::FreeformToolFormat;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::dynamic_tool_to_responses_api_tool;
|
||||
use codex_tools::mcp_tool_to_responses_api_tool;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_template::Template;
|
||||
use serde::Deserialize;
|
||||
@@ -2382,41 +2381,6 @@ fn push_tool_spec(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_tool_to_openai_tool(
|
||||
fully_qualified_name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_openai_tool(
|
||||
parse_mcp_tool(&tool)?.renamed(fully_qualified_name),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_tool_to_deferred_openai_tool(
|
||||
name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_openai_tool(
|
||||
parse_mcp_tool(&tool)?.renamed(name).into_deferred(),
|
||||
))
|
||||
}
|
||||
|
||||
fn dynamic_tool_to_openai_tool(
|
||||
tool: &DynamicToolSpec,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_openai_tool(parse_dynamic_tool(tool)?))
|
||||
}
|
||||
|
||||
fn tool_definition_to_openai_tool(tool_definition: ToolDefinition) -> ResponsesApiTool {
|
||||
ResponsesApiTool {
|
||||
name: tool_definition.name,
|
||||
description: tool_definition.description,
|
||||
strict: false,
|
||||
defer_loading: tool_definition.defer_loading.then_some(true),
|
||||
parameters: tool_definition.input_schema,
|
||||
output_schema: tool_definition.output_schema,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tool registry builder while collecting tool specs for later serialization.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_specs(
|
||||
@@ -2911,7 +2875,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
for (name, tool) in entries.into_iter() {
|
||||
match mcp_tool_to_openai_tool(name.clone(), tool.clone()) {
|
||||
match mcp_tool_to_responses_api_tool(name.clone(), &tool) {
|
||||
Ok(converted_tool) => {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
@@ -2930,7 +2894,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
|
||||
if !dynamic_tools.is_empty() {
|
||||
for tool in dynamic_tools {
|
||||
match dynamic_tool_to_openai_tool(tool) {
|
||||
match dynamic_tool_to_responses_api_tool(tool) {
|
||||
Ok(converted_tool) => {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::client_common::tools::FreeformTool;
|
||||
use crate::config::test_config;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
@@ -8,11 +7,14 @@ use crate::tools::ToolRouter;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::FreeformTool;
|
||||
use codex_tools::ResponsesApiWebSearchFilters;
|
||||
use codex_tools::ResponsesApiWebSearchUserLocation;
|
||||
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
@@ -64,28 +66,6 @@ fn search_capable_model_info() -> ModelInfo {
|
||||
model_info
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_deferred_tools_always_set_defer_loading_true() {
|
||||
let tool = mcp_tool(
|
||||
"lookup_order",
|
||||
"Look up an order",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
);
|
||||
|
||||
let openai_tool =
|
||||
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
||||
.expect("convert deferred tool");
|
||||
|
||||
assert_eq!(openai_tool.defer_loading, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
let tool = mcp_tool(
|
||||
@@ -102,7 +82,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(ToolSpec::Function(
|
||||
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
||||
mcp_tool_to_deferred_responses_api_tool("mcp__codex_apps__lookup_order".to_string(), &tool)
|
||||
.expect("convert deferred tool"),
|
||||
))
|
||||
.expect("serialize deferred tool");
|
||||
@@ -127,53 +107,8 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_preserves_defer_loading() {
|
||||
let tool = DynamicToolSpec {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
|
||||
let openai_tool = dynamic_tool_to_openai_tool(&tool).expect("convert dynamic tool");
|
||||
|
||||
assert_eq!(
|
||||
openai_tool,
|
||||
ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_name(tool: &ToolSpec) -> &str {
|
||||
match tool {
|
||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
|
||||
}
|
||||
tool.name()
|
||||
}
|
||||
|
||||
// Avoid order-based assertions; compare via set containment instead.
|
||||
@@ -1137,10 +1072,10 @@ fn web_search_config_is_forwarded_to_tool_spec() {
|
||||
external_web_access: Some(true),
|
||||
filters: web_search_config
|
||||
.filters
|
||||
.map(crate::client_common::tools::ResponsesApiWebSearchFilters::from),
|
||||
.map(ResponsesApiWebSearchFilters::from),
|
||||
user_location: web_search_config
|
||||
.user_location
|
||||
.map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from),
|
||||
.map(ResponsesApiWebSearchUserLocation::from),
|
||||
search_context_size: web_search_config.search_context_size,
|
||||
search_content_types: None,
|
||||
}
|
||||
|
||||
@@ -458,6 +458,52 @@ fn plan_type_maps_known_plan() {
|
||||
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_self_serve_business_usage_based_plan() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let _jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()),
|
||||
chatgpt_account_id: None,
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.account_plan_type(),
|
||||
Some(AccountPlanType::SelfServeBusinessUsageBased)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_enterprise_cbp_usage_based_plan() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let _jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()),
|
||||
chatgpt_account_id: None,
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.account_plan_type(),
|
||||
Some(AccountPlanType::EnterpriseCbpUsageBased)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_unknown_to_unknown() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
|
||||
@@ -266,7 +266,11 @@ impl CodexAuth {
|
||||
InternalKnownPlan::Plus => AccountPlanType::Plus,
|
||||
InternalKnownPlan::Pro => AccountPlanType::Pro,
|
||||
InternalKnownPlan::Team => AccountPlanType::Team,
|
||||
InternalKnownPlan::SelfServeBusinessUsageBased => {
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
}
|
||||
InternalKnownPlan::Business => AccountPlanType::Business,
|
||||
InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased,
|
||||
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
|
||||
InternalKnownPlan::Edu => AccountPlanType::Edu,
|
||||
};
|
||||
|
||||
@@ -41,7 +41,14 @@ pub struct IdTokenInfo {
|
||||
impl IdTokenInfo {
|
||||
pub fn get_chatgpt_plan_type(&self) -> Option<String> {
|
||||
self.chatgpt_plan_type.as_ref().map(|t| match t {
|
||||
PlanType::Known(plan) => format!("{plan:?}"),
|
||||
PlanType::Known(plan) => plan.display_name().to_string(),
|
||||
PlanType::Unknown(s) => s.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_chatgpt_plan_type_raw(&self) -> Option<String> {
|
||||
self.chatgpt_plan_type.as_ref().map(|t| match t {
|
||||
PlanType::Known(plan) => plan.raw_value().to_string(),
|
||||
PlanType::Unknown(s) => s.clone(),
|
||||
})
|
||||
}
|
||||
@@ -49,9 +56,7 @@ impl IdTokenInfo {
|
||||
pub fn is_workspace_account(&self) -> bool {
|
||||
matches!(
|
||||
self.chatgpt_plan_type,
|
||||
Some(PlanType::Known(
|
||||
KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu
|
||||
))
|
||||
Some(PlanType::Known(plan)) if plan.is_workspace_account()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +76,11 @@ impl PlanType {
|
||||
"plus" => Self::Known(KnownPlan::Plus),
|
||||
"pro" => Self::Known(KnownPlan::Pro),
|
||||
"team" => Self::Known(KnownPlan::Team),
|
||||
"self_serve_business_usage_based" => {
|
||||
Self::Known(KnownPlan::SelfServeBusinessUsageBased)
|
||||
}
|
||||
"business" => Self::Known(KnownPlan::Business),
|
||||
"enterprise_cbp_usage_based" => Self::Known(KnownPlan::EnterpriseCbpUsageBased),
|
||||
"enterprise" | "hc" => Self::Known(KnownPlan::Enterprise),
|
||||
"education" | "edu" => Self::Known(KnownPlan::Edu),
|
||||
_ => Self::Unknown(raw.to_string()),
|
||||
@@ -79,7 +88,7 @@ impl PlanType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KnownPlan {
|
||||
Free,
|
||||
@@ -87,12 +96,60 @@ pub enum KnownPlan {
|
||||
Plus,
|
||||
Pro,
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
#[serde(alias = "hc")]
|
||||
Enterprise,
|
||||
Edu,
|
||||
}
|
||||
|
||||
impl KnownPlan {
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Free => "Free",
|
||||
Self::Go => "Go",
|
||||
Self::Plus => "Plus",
|
||||
Self::Pro => "Pro",
|
||||
Self::Team => "Team",
|
||||
Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based",
|
||||
Self::Business => "Business",
|
||||
Self::EnterpriseCbpUsageBased => "Enterprise CBP Usage Based",
|
||||
Self::Enterprise => "Enterprise",
|
||||
Self::Edu => "Edu",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raw_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Free => "free",
|
||||
Self::Go => "go",
|
||||
Self::Plus => "plus",
|
||||
Self::Pro => "pro",
|
||||
Self::Team => "team",
|
||||
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
|
||||
Self::Business => "business",
|
||||
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
|
||||
Self::Enterprise => "enterprise",
|
||||
Self::Edu => "edu",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_workspace_account(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Team
|
||||
| Self::SelfServeBusinessUsageBased
|
||||
| Self::Business
|
||||
| Self::EnterpriseCbpUsageBased
|
||||
| Self::Enterprise
|
||||
| Self::Edu
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -68,6 +68,44 @@ fn id_token_info_parses_hc_plan_as_enterprise() {
|
||||
assert_eq!(info.is_workspace_account(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_parses_usage_based_business_plans() {
|
||||
let self_serve_business_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "self_serve_business_usage_based"
|
||||
}
|
||||
}));
|
||||
let self_serve_business =
|
||||
parse_chatgpt_jwt_claims(&self_serve_business_jwt).expect("should parse");
|
||||
assert_eq!(
|
||||
self_serve_business.get_chatgpt_plan_type().as_deref(),
|
||||
Some("Self Serve Business Usage Based")
|
||||
);
|
||||
assert_eq!(
|
||||
self_serve_business.get_chatgpt_plan_type_raw().as_deref(),
|
||||
Some("self_serve_business_usage_based")
|
||||
);
|
||||
assert_eq!(self_serve_business.is_workspace_account(), true);
|
||||
|
||||
let enterprise_cbp_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "enterprise_cbp_usage_based"
|
||||
}
|
||||
}));
|
||||
let enterprise_cbp = parse_chatgpt_jwt_claims(&enterprise_cbp_jwt).expect("should parse");
|
||||
assert_eq!(
|
||||
enterprise_cbp.get_chatgpt_plan_type().as_deref(),
|
||||
Some("Enterprise CBP Usage Based")
|
||||
);
|
||||
assert_eq!(
|
||||
enterprise_cbp.get_chatgpt_plan_type_raw().as_deref(),
|
||||
Some("enterprise_cbp_usage_based")
|
||||
);
|
||||
assert_eq!(enterprise_cbp.is_workspace_account(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_handles_missing_fields() {
|
||||
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
|
||||
|
||||
@@ -27,6 +27,8 @@ pub const TURN_NETWORK_PROXY_METRIC: &str = "codex.turn.network_proxy";
|
||||
pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call";
|
||||
pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage";
|
||||
pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage";
|
||||
pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync";
|
||||
pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final";
|
||||
/// Total runtime of a startup prewarm attempt until it completes, tagged by final status.
|
||||
pub const STARTUP_PREWARM_DURATION_METRIC: &str = "codex.startup_prewarm.duration_ms";
|
||||
/// Age of the startup prewarm attempt when the first real turn resolves it, tagged by outcome.
|
||||
|
||||
@@ -13,9 +13,66 @@ pub enum PlanType {
|
||||
Plus,
|
||||
Pro,
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
#[ts(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
#[ts(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
Enterprise,
|
||||
Edu,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PlanType {
|
||||
pub fn is_team_like(self) -> bool {
|
||||
matches!(self, Self::Team | Self::SelfServeBusinessUsageBased)
|
||||
}
|
||||
|
||||
pub fn is_business_like(self) -> bool {
|
||||
matches!(self, Self::Business | Self::EnterpriseCbpUsageBased)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn usage_based_plan_types_use_expected_wire_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PlanType::SelfServeBusinessUsageBased)
|
||||
.expect("self-serve business usage based should serialize"),
|
||||
"\"self_serve_business_usage_based\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PlanType::EnterpriseCbpUsageBased)
|
||||
.expect("enterprise cbp usage based should serialize"),
|
||||
"\"enterprise_cbp_usage_based\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PlanType>("\"self_serve_business_usage_based\"")
|
||||
.expect("self-serve business usage based should deserialize"),
|
||||
PlanType::SelfServeBusinessUsageBased
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PlanType>("\"enterprise_cbp_usage_based\"")
|
||||
.expect("enterprise cbp usage based should deserialize"),
|
||||
PlanType::EnterpriseCbpUsageBased
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_family_helpers_group_usage_based_variants_with_existing_plans() {
|
||||
assert_eq!(PlanType::Team.is_team_like(), true);
|
||||
assert_eq!(PlanType::SelfServeBusinessUsageBased.is_team_like(), true);
|
||||
assert_eq!(PlanType::Business.is_team_like(), false);
|
||||
|
||||
assert_eq!(PlanType::Business.is_business_like(), true);
|
||||
assert_eq!(PlanType::EnterpriseCbpUsageBased.is_business_like(), true);
|
||||
assert_eq!(PlanType::Team.is_business_like(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,29 @@ shared across multiple crates and does not need to stay coupled to
|
||||
`codex-core`.
|
||||
|
||||
Today this crate is intentionally small. It currently owns the shared tool
|
||||
schema primitives that no longer need to live in `core/src/tools/spec.rs`:
|
||||
schema and Responses API tool primitives that no longer need to live in
|
||||
`core/src/tools/spec.rs` or `core/src/client_common.rs`:
|
||||
|
||||
- `JsonSchema`
|
||||
- `AdditionalProperties`
|
||||
- `ToolDefinition`
|
||||
- `ToolSpec`
|
||||
- `ResponsesApiTool`
|
||||
- `FreeformTool`
|
||||
- `FreeformToolFormat`
|
||||
- `ToolSearchOutputTool`
|
||||
- `ResponsesApiWebSearchFilters`
|
||||
- `ResponsesApiWebSearchUserLocation`
|
||||
- `ResponsesApiNamespace`
|
||||
- `ResponsesApiNamespaceTool`
|
||||
- `parse_tool_input_schema()`
|
||||
- `parse_dynamic_tool()`
|
||||
- `parse_mcp_tool()`
|
||||
- `mcp_call_tool_result_output_schema()`
|
||||
- `tool_definition_to_responses_api_tool()`
|
||||
- `dynamic_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_deferred_responses_api_tool()`
|
||||
|
||||
That extraction is the first step in a longer migration. The goal is not to
|
||||
move all of `core/src/tools` into this crate in one shot. Instead, the plan is
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Shared tool-schema parsing primitives that can live outside `codex-core`.
|
||||
//! Shared tool definitions and Responses API tool primitives that can live
|
||||
//! outside `codex-core`.
|
||||
|
||||
mod dynamic_tool;
|
||||
mod json_schema;
|
||||
mod mcp_tool;
|
||||
mod responses_api;
|
||||
mod tool_definition;
|
||||
mod tool_spec;
|
||||
|
||||
pub use dynamic_tool::parse_dynamic_tool;
|
||||
pub use json_schema::AdditionalProperties;
|
||||
@@ -11,4 +14,17 @@ pub use json_schema::JsonSchema;
|
||||
pub use json_schema::parse_tool_input_schema;
|
||||
pub use mcp_tool::mcp_call_tool_result_output_schema;
|
||||
pub use mcp_tool::parse_mcp_tool;
|
||||
pub use responses_api::FreeformTool;
|
||||
pub use responses_api::FreeformToolFormat;
|
||||
pub use responses_api::ResponsesApiNamespace;
|
||||
pub use responses_api::ResponsesApiNamespaceTool;
|
||||
pub use responses_api::ResponsesApiTool;
|
||||
pub use responses_api::ToolSearchOutputTool;
|
||||
pub use responses_api::dynamic_tool_to_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_responses_api_tool;
|
||||
pub use responses_api::tool_definition_to_responses_api_tool;
|
||||
pub use tool_definition::ToolDefinition;
|
||||
pub use tool_spec::ResponsesApiWebSearchFilters;
|
||||
pub use tool_spec::ResponsesApiWebSearchUserLocation;
|
||||
pub use tool_spec::ToolSpec;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolDefinition;
|
||||
use crate::parse_dynamic_tool;
|
||||
use crate::parse_mcp_tool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub format: FreeformToolFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformToolFormat {
|
||||
pub r#type: String,
|
||||
pub syntax: String,
|
||||
pub definition: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// TODO: Validation. When strict is set to true, the JSON schema,
|
||||
/// `required` and `additional_properties` must be present. All fields in
|
||||
/// `properties` must be present in `required`.
|
||||
pub strict: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defer_loading: Option<bool>,
|
||||
pub parameters: JsonSchema,
|
||||
#[serde(skip)]
|
||||
pub output_schema: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolSearchOutputTool {
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "namespace")]
|
||||
Namespace(ResponsesApiNamespace),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiNamespace {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub tools: Vec<ResponsesApiNamespaceTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ResponsesApiNamespaceTool {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
}
|
||||
|
||||
pub fn dynamic_tool_to_responses_api_tool(
|
||||
tool: &DynamicToolSpec,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool(
|
||||
tool,
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn mcp_tool_to_responses_api_tool(
|
||||
name: String,
|
||||
tool: &rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_responses_api_tool(
|
||||
parse_mcp_tool(tool)?.renamed(name),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mcp_tool_to_deferred_responses_api_tool(
|
||||
name: String,
|
||||
tool: &rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_responses_api_tool(
|
||||
parse_mcp_tool(tool)?.renamed(name).into_deferred(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn tool_definition_to_responses_api_tool(tool_definition: ToolDefinition) -> ResponsesApiTool {
|
||||
ResponsesApiTool {
|
||||
name: tool_definition.name,
|
||||
description: tool_definition.description,
|
||||
strict: false,
|
||||
defer_loading: tool_definition.defer_loading.then_some(true),
|
||||
parameters: tool_definition.input_schema,
|
||||
output_schema: tool_definition.output_schema,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "responses_api_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,173 @@
|
||||
use super::ResponsesApiNamespace;
|
||||
use super::ResponsesApiNamespaceTool;
|
||||
use super::ResponsesApiTool;
|
||||
use super::ToolSearchOutputTool;
|
||||
use super::dynamic_tool_to_responses_api_tool;
|
||||
use super::mcp_tool_to_deferred_responses_api_tool;
|
||||
use super::tool_definition_to_responses_api_tool;
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolDefinition;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
|
||||
assert_eq!(
|
||||
tool_definition_to_responses_api_tool(ToolDefinition {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
input_schema: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(json!({"type": "object"})),
|
||||
defer_loading: false,
|
||||
}),
|
||||
ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(json!({"type": "object"})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
|
||||
let tool = DynamicToolSpec {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
dynamic_tool_to_responses_api_tool(&tool).expect("convert dynamic tool"),
|
||||
ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
|
||||
let tool = rmcp::model::Tool {
|
||||
name: "lookup_order".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Look up an order".to_string().into()),
|
||||
input_schema: std::sync::Arc::new(rmcp::model::object(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}))),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_to_deferred_responses_api_tool(
|
||||
"mcp__codex_apps__lookup_order".to_string(),
|
||||
&tool,
|
||||
)
|
||||
.expect("convert deferred tool"),
|
||||
ResponsesApiTool {
|
||||
name: "mcp__codex_apps__lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
|
||||
let namespace = ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__calendar".to_string(),
|
||||
description: "Plan events".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "create_event".to_string(),
|
||||
description: "Create a calendar event.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})],
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(namespace).expect("serialize namespace");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"type": "namespace",
|
||||
"name": "mcp__codex_apps__calendar",
|
||||
"description": "Plan events",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use crate::FreeformTool;
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use serde::Serialize;
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolSpec {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "tool_search")]
|
||||
ToolSearch {
|
||||
execution: String,
|
||||
description: String,
|
||||
parameters: JsonSchema,
|
||||
},
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "image_generation")]
|
||||
ImageGeneration { output_format: String },
|
||||
// TODO: Understand why we get an error on web_search although the API docs
|
||||
// say it's supported.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
|
||||
// The `external_web_access` field determines whether the web search is over
|
||||
// cached or live content.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
|
||||
#[serde(rename = "web_search")]
|
||||
WebSearch {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_web_access: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filters: Option<ResponsesApiWebSearchFilters>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_location: Option<ResponsesApiWebSearchUserLocation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_context_size: Option<WebSearchContextSize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_content_types: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "custom")]
|
||||
Freeform(FreeformTool),
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
ToolSpec::Function(tool) => tool.name.as_str(),
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(tool) => tool.name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiWebSearchFilters {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
|
||||
fn from(filters: ConfigWebSearchFilters) -> Self {
|
||||
Self {
|
||||
allowed_domains: filters.allowed_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiWebSearchUserLocation {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: WebSearchUserLocationType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
|
||||
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
|
||||
Self {
|
||||
r#type: user_location.r#type,
|
||||
country: user_location.country,
|
||||
region: user_location.region,
|
||||
city: user_location.city,
|
||||
timezone: user_location.timezone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tool_spec_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,183 @@
|
||||
use super::ResponsesApiWebSearchFilters;
|
||||
use super::ResponsesApiWebSearchUserLocation;
|
||||
use super::ToolSpec;
|
||||
use crate::AdditionalProperties;
|
||||
use crate::FreeformTool;
|
||||
use crate::FreeformToolFormat;
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn tool_spec_name_covers_all_variants() {
|
||||
assert_eq!(
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
.name(),
|
||||
"lookup_order"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::ToolSearch {
|
||||
execution: "sync".to_string(),
|
||||
description: "Search for tools".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
}
|
||||
.name(),
|
||||
"tool_search"
|
||||
);
|
||||
assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell");
|
||||
assert_eq!(
|
||||
ToolSpec::ImageGeneration {
|
||||
output_format: "png".to_string(),
|
||||
}
|
||||
.name(),
|
||||
"image_generation"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
filters: None,
|
||||
user_location: None,
|
||||
search_context_size: None,
|
||||
search_content_types: None,
|
||||
}
|
||||
.name(),
|
||||
"web_search"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "exec".to_string(),
|
||||
description: "Run a command".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: \"exec\"".to_string(),
|
||||
},
|
||||
})
|
||||
.name(),
|
||||
"exec"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_config_converts_to_responses_api_types() {
|
||||
assert_eq!(
|
||||
ResponsesApiWebSearchFilters::from(ConfigWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}),
|
||||
ResponsesApiWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ResponsesApiWebSearchUserLocation::from(ConfigWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}),
|
||||
ResponsesApiWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_tool_spec_serializes_expected_wire_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
filters: Some(ResponsesApiWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}),
|
||||
user_location: Some(ResponsesApiWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}),
|
||||
search_context_size: Some(WebSearchContextSize::High),
|
||||
search_content_types: Some(vec!["text".to_string(), "image".to_string()]),
|
||||
})
|
||||
.expect("serialize web_search"),
|
||||
json!({
|
||||
"type": "web_search",
|
||||
"external_web_access": true,
|
||||
"filters": {
|
||||
"allowed_domains": ["example.com"],
|
||||
},
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"region": "California",
|
||||
"city": "San Francisco",
|
||||
"timezone": "America/Los_Angeles",
|
||||
},
|
||||
"search_context_size": "high",
|
||||
"search_content_types": ["text", "image"],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_tool_spec_serializes_expected_wire_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ToolSpec::ToolSearch {
|
||||
execution: "sync".to_string(),
|
||||
description: "Search app tools".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"query".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Tool search query".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: Some(vec!["query".to_string()]),
|
||||
additional_properties: Some(AdditionalProperties::Boolean(false)),
|
||||
},
|
||||
})
|
||||
.expect("serialize tool_search"),
|
||||
json!({
|
||||
"type": "tool_search",
|
||||
"execution": "sync",
|
||||
"description": "Search app tools",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Tool search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ use base64::Engine;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::mcp::McpManager;
|
||||
use codex_core::mcp::qualified_mcp_tool_name_prefix;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core::web_search::web_search_detail;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
@@ -1824,7 +1825,7 @@ pub(crate) fn new_mcp_tools_output(
|
||||
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (server, cfg) in servers {
|
||||
let prefix = format!("mcp__{server}__");
|
||||
let prefix = qualified_mcp_tool_name_prefix(server);
|
||||
let mut names: Vec<String> = tools
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(&prefix))
|
||||
@@ -2544,7 +2545,6 @@ mod tests {
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_otel::RuntimeMetricTotals;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -2582,6 +2582,88 @@ mod tests {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
fn stdio_server_config(
|
||||
command: &str,
|
||||
args: Vec<&str>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env_vars: Vec<&str>,
|
||||
) -> McpServerConfig {
|
||||
let mut table = toml::Table::new();
|
||||
table.insert(
|
||||
"command".to_string(),
|
||||
toml::Value::String(command.to_string()),
|
||||
);
|
||||
if !args.is_empty() {
|
||||
table.insert(
|
||||
"args".to_string(),
|
||||
toml::Value::Array(
|
||||
args.into_iter()
|
||||
.map(|arg| toml::Value::String(arg.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(env) = env {
|
||||
table.insert("env".to_string(), string_map_to_toml_value(env));
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
table.insert(
|
||||
"env_vars".to_string(),
|
||||
toml::Value::Array(
|
||||
env_vars
|
||||
.into_iter()
|
||||
.map(|name| toml::Value::String(name.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
toml::Value::Table(table)
|
||||
.try_into()
|
||||
.expect("test stdio MCP config should deserialize")
|
||||
}
|
||||
|
||||
fn streamable_http_server_config(
|
||||
url: &str,
|
||||
bearer_token_env_var: Option<&str>,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
) -> McpServerConfig {
|
||||
let mut table = toml::Table::new();
|
||||
table.insert("url".to_string(), toml::Value::String(url.to_string()));
|
||||
if let Some(bearer_token_env_var) = bearer_token_env_var {
|
||||
table.insert(
|
||||
"bearer_token_env_var".to_string(),
|
||||
toml::Value::String(bearer_token_env_var.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(http_headers) = http_headers {
|
||||
table.insert(
|
||||
"http_headers".to_string(),
|
||||
string_map_to_toml_value(http_headers),
|
||||
);
|
||||
}
|
||||
if let Some(env_http_headers) = env_http_headers {
|
||||
table.insert(
|
||||
"env_http_headers".to_string(),
|
||||
string_map_to_toml_value(env_http_headers),
|
||||
);
|
||||
}
|
||||
|
||||
toml::Value::Table(table)
|
||||
.try_into()
|
||||
.expect("test streamable_http MCP config should deserialize")
|
||||
}
|
||||
|
||||
fn string_map_to_toml_value(entries: HashMap<String, String>) -> toml::Value {
|
||||
toml::Value::Table(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, toml::Value::String(value)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
@@ -2897,25 +2979,7 @@ mod tests {
|
||||
let mut config = test_config().await;
|
||||
let mut env = HashMap::new();
|
||||
env.insert("TOKEN".to_string(), "secret".to_string());
|
||||
let stdio_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: vec![],
|
||||
env: Some(env),
|
||||
env_vars: vec!["APP_TOKEN".to_string()],
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
let stdio_config = stdio_server_config("docs-server", vec![], Some(env), vec!["APP_TOKEN"]);
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert("docs".to_string(), stdio_config);
|
||||
|
||||
@@ -2923,24 +2987,12 @@ mod tests {
|
||||
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
|
||||
let mut env_headers = HashMap::new();
|
||||
env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
|
||||
let http_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
|
||||
http_headers: Some(headers),
|
||||
env_http_headers: Some(env_headers),
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
let http_config = streamable_http_server_config(
|
||||
"https://example.com/mcp",
|
||||
Some("MCP_TOKEN"),
|
||||
Some(headers),
|
||||
Some(env_headers),
|
||||
);
|
||||
servers.insert("http".to_string(), http_config);
|
||||
config
|
||||
.mcp_servers
|
||||
@@ -2988,6 +3040,46 @@ mod tests {
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() {
|
||||
let mut config = test_config().await;
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
"some-server".to_string(),
|
||||
stdio_server_config("docs-server", vec!["--stdio"], None, vec![]),
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
|
||||
let tools = HashMap::from([(
|
||||
"mcp__some_server__lookup".to_string(),
|
||||
Tool {
|
||||
description: None,
|
||||
name: "lookup".to_string(),
|
||||
title: None,
|
||||
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
let auth_statuses: HashMap<String, McpAuthStatus> = HashMap::new();
|
||||
let cell = new_mcp_tools_output(
|
||||
&config,
|
||||
tools,
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&auth_statuses,
|
||||
);
|
||||
let rendered = render_lines(&cell.display_lines(120)).join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_agent_message_cell_transcript() {
|
||||
let cell = AgentMessageCell::new(vec![Line::default()], false);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 3080
|
||||
expression: rendered
|
||||
---
|
||||
/mcp
|
||||
|
||||
🔌 MCP Tools
|
||||
|
||||
• some-server
|
||||
• Status: enabled
|
||||
• Auth: Unsupported
|
||||
• Command: docs-server --stdio
|
||||
• Tools: lookup
|
||||
• Resources: (none)
|
||||
• Resource templates: (none)
|
||||
@@ -94,14 +94,23 @@ pub(crate) fn compose_account_display(
|
||||
CoreAuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
|
||||
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
|
||||
let email = auth.get_account_email();
|
||||
let plan = plan
|
||||
.map(|plan_type| title_case(format!("{plan_type:?}").as_str()))
|
||||
.or_else(|| Some("Unknown".to_string()));
|
||||
let plan = plan.map(plan_type_display_name);
|
||||
let plan = plan.or_else(|| Some("Unknown".to_string()));
|
||||
Some(StatusAccountDisplay::ChatGpt { email, plan })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
|
||||
if plan_type.is_team_like() {
|
||||
"Business".to_string()
|
||||
} else if plan_type.is_business_like() {
|
||||
"Enterprise".to_string()
|
||||
} else {
|
||||
title_case(format!("{plan_type:?}").as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_tokens_compact(value: i64) -> String {
|
||||
let value = value.max(0);
|
||||
if value == 0 {
|
||||
@@ -187,3 +196,49 @@ pub(crate) fn title_case(s: &str) -> String {
|
||||
let rest: String = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::auth::CodexAuth;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plan_type_display_name_remaps_display_labels() {
|
||||
let cases = [
|
||||
(PlanType::Free, "Free"),
|
||||
(PlanType::Go, "Go"),
|
||||
(PlanType::Plus, "Plus"),
|
||||
(PlanType::Pro, "Pro"),
|
||||
(PlanType::Team, "Business"),
|
||||
(PlanType::SelfServeBusinessUsageBased, "Business"),
|
||||
(PlanType::Business, "Enterprise"),
|
||||
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
|
||||
(PlanType::Enterprise, "Enterprise"),
|
||||
(PlanType::Edu, "Edu"),
|
||||
(PlanType::Unknown, "Unknown"),
|
||||
];
|
||||
|
||||
for (plan_type, expected) in cases {
|
||||
assert_eq!(plan_type_display_name(plan_type), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_account_display_uses_remapped_plan_label() {
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
|
||||
let display = compose_account_display(
|
||||
auth_manager.as_ref(),
|
||||
Some(PlanType::SelfServeBusinessUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
display,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Business"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
|
||||
// Leave small chance for a random tooltip to be shown.
|
||||
if rng.random_ratio(8, 10) {
|
||||
match plan {
|
||||
Some(PlanType::Plus)
|
||||
| Some(PlanType::Business)
|
||||
| Some(PlanType::Team)
|
||||
| Some(PlanType::Enterprise)
|
||||
| Some(PlanType::Pro) => {
|
||||
Some(plan_type)
|
||||
if matches!(
|
||||
plan_type,
|
||||
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
|
||||
) || plan_type.is_team_like()
|
||||
|| plan_type.is_business_like() =>
|
||||
{
|
||||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::bottom_pane::FeedbackAudience;
|
||||
use crate::status::StatusAccountDisplay;
|
||||
use crate::status::plan_type_display_name;
|
||||
use codex_app_server_client::AppServerClient;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
@@ -228,7 +229,7 @@ impl AppServerSession {
|
||||
Some(TelemetryAuthMode::Chatgpt),
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: Some(email),
|
||||
plan: Some(title_case(format!("{plan_type:?}").as_str())),
|
||||
plan: Some(plan_type_display_name(plan_type)),
|
||||
}),
|
||||
Some(plan_type),
|
||||
feedback_audience,
|
||||
@@ -733,19 +734,6 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut chars = s.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let rest = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
pub(crate) fn status_account_display_from_auth_mode(
|
||||
auth_mode: Option<AuthMode>,
|
||||
plan_type: Option<codex_protocol::account::PlanType>,
|
||||
@@ -755,7 +743,7 @@ pub(crate) fn status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => {
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())),
|
||||
plan: plan_type.map(plan_type_display_name),
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -1264,4 +1252,31 @@ mod tests {
|
||||
assert_ne!(session.history_log_id, 0);
|
||||
assert_eq!(session.history_entry_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_account_display_from_auth_mode_uses_remapped_plan_labels() {
|
||||
let business = status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt),
|
||||
Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
business,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Enterprise"
|
||||
));
|
||||
|
||||
let team = status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt),
|
||||
Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
team,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Business"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ use codex_core::config::types::McpServerTransportConfig;
|
||||
#[cfg(test)]
|
||||
use codex_core::mcp::McpManager;
|
||||
#[cfg(test)]
|
||||
use codex_core::mcp::qualified_mcp_tool_name_prefix;
|
||||
#[cfg(test)]
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core::web_search::web_search_detail;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
@@ -1831,7 +1833,7 @@ pub(crate) fn new_mcp_tools_output(
|
||||
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (server, cfg) in servers {
|
||||
let prefix = format!("mcp__{server}__");
|
||||
let prefix = qualified_mcp_tool_name_prefix(server);
|
||||
let mut names: Vec<String> = tools
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(&prefix))
|
||||
@@ -2773,7 +2775,6 @@ mod tests {
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerDisabledReason;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_otel::RuntimeMetricTotals;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -2811,6 +2812,88 @@ mod tests {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
fn stdio_server_config(
|
||||
command: &str,
|
||||
args: Vec<&str>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env_vars: Vec<&str>,
|
||||
) -> McpServerConfig {
|
||||
let mut table = toml::Table::new();
|
||||
table.insert(
|
||||
"command".to_string(),
|
||||
toml::Value::String(command.to_string()),
|
||||
);
|
||||
if !args.is_empty() {
|
||||
table.insert(
|
||||
"args".to_string(),
|
||||
toml::Value::Array(
|
||||
args.into_iter()
|
||||
.map(|arg| toml::Value::String(arg.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(env) = env {
|
||||
table.insert("env".to_string(), string_map_to_toml_value(env));
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
table.insert(
|
||||
"env_vars".to_string(),
|
||||
toml::Value::Array(
|
||||
env_vars
|
||||
.into_iter()
|
||||
.map(|name| toml::Value::String(name.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
toml::Value::Table(table)
|
||||
.try_into()
|
||||
.expect("test stdio MCP config should deserialize")
|
||||
}
|
||||
|
||||
fn streamable_http_server_config(
|
||||
url: &str,
|
||||
bearer_token_env_var: Option<&str>,
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
) -> McpServerConfig {
|
||||
let mut table = toml::Table::new();
|
||||
table.insert("url".to_string(), toml::Value::String(url.to_string()));
|
||||
if let Some(bearer_token_env_var) = bearer_token_env_var {
|
||||
table.insert(
|
||||
"bearer_token_env_var".to_string(),
|
||||
toml::Value::String(bearer_token_env_var.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(http_headers) = http_headers {
|
||||
table.insert(
|
||||
"http_headers".to_string(),
|
||||
string_map_to_toml_value(http_headers),
|
||||
);
|
||||
}
|
||||
if let Some(env_http_headers) = env_http_headers {
|
||||
table.insert(
|
||||
"env_http_headers".to_string(),
|
||||
string_map_to_toml_value(env_http_headers),
|
||||
);
|
||||
}
|
||||
|
||||
toml::Value::Table(table)
|
||||
.try_into()
|
||||
.expect("test streamable_http MCP config should deserialize")
|
||||
}
|
||||
|
||||
fn string_map_to_toml_value(entries: HashMap<String, String>) -> toml::Value {
|
||||
toml::Value::Table(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, toml::Value::String(value)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
@@ -3126,25 +3209,7 @@ mod tests {
|
||||
let mut config = test_config().await;
|
||||
let mut env = HashMap::new();
|
||||
env.insert("TOKEN".to_string(), "secret".to_string());
|
||||
let stdio_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: vec![],
|
||||
env: Some(env),
|
||||
env_vars: vec!["APP_TOKEN".to_string()],
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
let stdio_config = stdio_server_config("docs-server", vec![], Some(env), vec!["APP_TOKEN"]);
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert("docs".to_string(), stdio_config);
|
||||
|
||||
@@ -3152,24 +3217,12 @@ mod tests {
|
||||
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
|
||||
let mut env_headers = HashMap::new();
|
||||
env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
|
||||
let http_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
|
||||
http_headers: Some(headers),
|
||||
env_http_headers: Some(env_headers),
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
let http_config = streamable_http_server_config(
|
||||
"https://example.com/mcp",
|
||||
Some("MCP_TOKEN"),
|
||||
Some(headers),
|
||||
Some(env_headers),
|
||||
);
|
||||
servers.insert("http".to_string(), http_config);
|
||||
config
|
||||
.mcp_servers
|
||||
@@ -3218,30 +3271,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tools_output_from_statuses_renders_status_only_servers() {
|
||||
async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() {
|
||||
let mut config = test_config().await;
|
||||
let servers = HashMap::from([(
|
||||
"plugin_docs".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: vec!["--stdio".to_string()],
|
||||
env: None,
|
||||
env_vars: vec![],
|
||||
cwd: None,
|
||||
},
|
||||
enabled: false,
|
||||
required: false,
|
||||
disabled_reason: Some(McpServerDisabledReason::Unknown),
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
servers.insert(
|
||||
"some-server".to_string(),
|
||||
stdio_server_config("docs-server", vec!["--stdio"], None, vec![]),
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
|
||||
let tools = HashMap::from([(
|
||||
"mcp__some_server__lookup".to_string(),
|
||||
Tool {
|
||||
description: None,
|
||||
name: "lookup".to_string(),
|
||||
title: None,
|
||||
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
let auth_statuses: HashMap<String, McpAuthStatus> = HashMap::new();
|
||||
let cell = new_mcp_tools_output(
|
||||
&config,
|
||||
tools,
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&auth_statuses,
|
||||
);
|
||||
let rendered = render_lines(&cell.display_lines(120)).join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tools_output_from_statuses_renders_status_only_servers() {
|
||||
let mut config = test_config().await;
|
||||
let mut plugin_docs = stdio_server_config("docs-server", vec!["--stdio"], None, vec![]);
|
||||
plugin_docs.enabled = false;
|
||||
plugin_docs.disabled_reason = Some(McpServerDisabledReason::Unknown);
|
||||
let servers = HashMap::from([("plugin_docs".to_string(), plugin_docs)]);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(servers)
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) fn load_local_chatgpt_auth(
|
||||
|
||||
let chatgpt_plan_type = tokens
|
||||
.id_token
|
||||
.get_chatgpt_plan_type()
|
||||
.get_chatgpt_plan_type_raw()
|
||||
.map(|plan_type| plan_type.to_ascii_lowercase());
|
||||
|
||||
Ok(LocalChatgptAuth {
|
||||
@@ -92,9 +92,9 @@ mod tests {
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn write_chatgpt_auth(codex_home: &Path) {
|
||||
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
fn write_chatgpt_auth(codex_home: &Path, plan_type: &str) {
|
||||
let id_token = fake_jwt("user@example.com", "workspace-1", plan_type);
|
||||
let access_token = fake_jwt("user@example.com", "workspace-1", plan_type);
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
@@ -114,7 +114,7 @@ mod tests {
|
||||
#[test]
|
||||
fn loads_local_chatgpt_auth_from_managed_auth() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
write_chatgpt_auth(codex_home.path(), "business");
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
#[test]
|
||||
fn prefers_managed_auth_over_external_ephemeral_tokens() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
write_chatgpt_auth(codex_home.path(), "business");
|
||||
login_with_chatgpt_auth_tokens(
|
||||
codex_home.path(),
|
||||
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
|
||||
@@ -181,4 +181,22 @@ mod tests {
|
||||
assert_eq!(auth.chatgpt_account_id, "workspace-1");
|
||||
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_usage_based_plan_type_wire_name() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based");
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
Some("workspace-1"),
|
||||
)
|
||||
.expect("chatgpt auth should load");
|
||||
|
||||
assert_eq!(
|
||||
auth.chatgpt_plan_type.as_deref(),
|
||||
Some("self_serve_business_usage_based")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui_app_server/src/history_cell.rs
|
||||
assertion_line: 3310
|
||||
expression: rendered
|
||||
---
|
||||
/mcp
|
||||
|
||||
🔌 MCP Tools
|
||||
|
||||
• some-server
|
||||
• Status: enabled
|
||||
• Auth: Unsupported
|
||||
• Command: docs-server --stdio
|
||||
• Tools: lookup
|
||||
• Resources: (none)
|
||||
• Resource templates: (none)
|
||||
@@ -5,6 +5,7 @@ use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::project_doc::discover_project_doc_paths;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -86,6 +87,16 @@ pub(crate) fn compose_account_display(
|
||||
account_display.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
|
||||
if plan_type.is_team_like() {
|
||||
"Business".to_string()
|
||||
} else if plan_type.is_business_like() {
|
||||
"Enterprise".to_string()
|
||||
} else {
|
||||
title_case(format!("{plan_type:?}").as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_tokens_compact(value: i64) -> String {
|
||||
let value = value.max(0);
|
||||
if value == 0 {
|
||||
@@ -158,3 +169,42 @@ pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<
|
||||
format!("{time} on {}", dt.format("%-d %b"))
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut chars = s.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let rest = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plan_type_display_name_remaps_display_labels() {
|
||||
let cases = [
|
||||
(PlanType::Free, "Free"),
|
||||
(PlanType::Go, "Go"),
|
||||
(PlanType::Plus, "Plus"),
|
||||
(PlanType::Pro, "Pro"),
|
||||
(PlanType::Team, "Business"),
|
||||
(PlanType::SelfServeBusinessUsageBased, "Business"),
|
||||
(PlanType::Business, "Enterprise"),
|
||||
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
|
||||
(PlanType::Enterprise, "Enterprise"),
|
||||
(PlanType::Edu, "Edu"),
|
||||
(PlanType::Unknown, "Unknown"),
|
||||
];
|
||||
|
||||
for (plan_type, expected) in cases {
|
||||
assert_eq!(plan_type_display_name(plan_type), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(crate) use card::new_status_output;
|
||||
pub(crate) use card::new_status_output_with_rate_limits;
|
||||
pub(crate) use helpers::format_directory_display;
|
||||
pub(crate) use helpers::format_tokens_compact;
|
||||
pub(crate) use helpers::plan_type_display_name;
|
||||
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
|
||||
pub(crate) use rate_limits::RateLimitWindowDisplay;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
|
||||
// Leave small chance for a random tooltip to be shown.
|
||||
if rng.random_ratio(8, 10) {
|
||||
match plan {
|
||||
Some(PlanType::Plus)
|
||||
| Some(PlanType::Business)
|
||||
| Some(PlanType::Team)
|
||||
| Some(PlanType::Enterprise)
|
||||
| Some(PlanType::Pro) => {
|
||||
Some(plan_type)
|
||||
if matches!(
|
||||
plan_type,
|
||||
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
|
||||
) || plan_type.is_team_like()
|
||||
|| plan_type.is_business_like() =>
|
||||
{
|
||||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ from .v2_all import ContextCompactedNotification
|
||||
from .v2_all import DeprecationNoticeNotification
|
||||
from .v2_all import ErrorNotification
|
||||
from .v2_all import FileChangeOutputDeltaNotification
|
||||
from .v2_all import FsChangedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionCompletedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
|
||||
from .v2_all import HookCompletedNotification
|
||||
@@ -26,6 +27,7 @@ from .v2_all import ItemGuardianApprovalReviewCompletedNotification
|
||||
from .v2_all import ItemGuardianApprovalReviewStartedNotification
|
||||
from .v2_all import ItemStartedNotification
|
||||
from .v2_all import McpServerOauthLoginCompletedNotification
|
||||
from .v2_all import McpServerStatusUpdatedNotification
|
||||
from .v2_all import McpToolCallProgressNotification
|
||||
from .v2_all import ModelReroutedNotification
|
||||
from .v2_all import PlanDeltaNotification
|
||||
@@ -43,6 +45,7 @@ from .v2_all import ThreadRealtimeErrorNotification
|
||||
from .v2_all import ThreadRealtimeItemAddedNotification
|
||||
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
|
||||
from .v2_all import ThreadRealtimeStartedNotification
|
||||
from .v2_all import ThreadRealtimeTranscriptUpdatedNotification
|
||||
from .v2_all import ThreadStartedNotification
|
||||
from .v2_all import ThreadStatusChangedNotification
|
||||
from .v2_all import ThreadTokenUsageUpdatedNotification
|
||||
@@ -63,6 +66,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"configWarning": ConfigWarningNotification,
|
||||
"deprecationNotice": DeprecationNoticeNotification,
|
||||
"error": ErrorNotification,
|
||||
"fs/changed": FsChangedNotification,
|
||||
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
|
||||
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
|
||||
"hook/completed": HookCompletedNotification,
|
||||
@@ -81,6 +85,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
|
||||
"item/started": ItemStartedNotification,
|
||||
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
|
||||
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
|
||||
"model/rerouted": ModelReroutedNotification,
|
||||
"serverRequest/resolved": ServerRequestResolvedNotification,
|
||||
"skills/changed": SkillsChangedNotification,
|
||||
@@ -93,6 +98,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
|
||||
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
|
||||
"thread/realtime/started": ThreadRealtimeStartedNotification,
|
||||
"thread/realtime/transcriptUpdated": ThreadRealtimeTranscriptUpdatedNotification,
|
||||
"thread/started": ThreadStartedNotification,
|
||||
"thread/status/changed": ThreadStatusChangedNotification,
|
||||
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user