feat: experimental flags (#10231)

## Problem being solved
- We need a single, reliable way to mark app-server API surface as
experimental so that:
  1. the runtime can reject experimental usage unless the client opts in
2. generated TS/JSON schemas can exclude experimental methods/fields for
stable clients.

Right now that’s easy to drift or miss when done ad-hoc.

## How to declare experimental methods and fields
- **Experimental method**: add `#[experimental("method/name")]` to the
`ClientRequest` variant in `client_request_definitions!`.
- **Experimental field**: on the params struct, derive `ExperimentalApi`
and annotate the field with `#[experimental("method/name.field")]` + set
`inspect_params: true` for the method variant so
`ClientRequest::experimental_reason()` inspects params for experimental
fields.

## How the macro solves it
- The new derive macro lives in
`codex-rs/codex-experimental-api-macros/src/lib.rs` and is used via
`#[derive(ExperimentalApi)]` plus `#[experimental("reason")]`
attributes.
- **Structs**:
- Generates `ExperimentalApi::experimental_reason(&self)` that checks
only annotated fields.
  - The “presence” check is type-aware:
    - `Option<T>`: `is_some_and(...)` recursively checks inner.
    - `Vec`/`HashMap`/`BTreeMap`: must be non-empty.
    - `bool`: must be `true`.
    - Other types: considered present (returns `true`).
- Registers each experimental field in an `inventory` with `(type_name,
serialized field name, reason)` and exposes `EXPERIMENTAL_FIELDS` for
that type. Field names are converted from `snake_case` to `camelCase`
for schema/TS filtering.
- **Enums**:
- Generates an exhaustive `match` returning `Some(reason)` for annotated
variants and `None` otherwise (no wildcard arm).
- **Wiring**:
- Runtime gating uses `ExperimentalApi::experimental_reason()` in
`codex-rs/app-server/src/message_processor.rs` to reject requests unless
`InitializeParams.capabilities.experimental_api == true`.
- Schema/TS export filters use the inventory list and
`EXPERIMENTAL_CLIENT_METHODS` from `client_request_definitions!` to
strip experimental methods/fields when `experimental_api` is false.
This commit is contained in:
jif-oai
2026-02-02 12:06:50 +01:00
committed by GitHub
parent 9513f18bfe
commit 3cc9122ee2
37 changed files with 2682 additions and 1076 deletions

View File

@@ -41,6 +41,42 @@ pub enum AuthMode {
ChatgptAuthTokens,
}
macro_rules! experimental_reason_expr {
// If a request variant is explicitly marked experimental, that reason wins.
(#[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => {
Some($reason)
};
// `inspect_params: true` is used when a method is mostly stable but needs
// field-level gating from its params type (for example, ThreadStart).
($params:ident, true) => {
crate::experimental_api::ExperimentalApi::experimental_reason($params)
};
($params:ident $(, $inspect_params:tt)?) => {
None
};
}
macro_rules! experimental_method_entry {
(#[experimental($reason:expr)] => $wire:literal) => {
$wire
};
(#[experimental($reason:expr)]) => {
$reason
};
($($tt:tt)*) => {
""
};
}
macro_rules! experimental_type_entry {
(#[experimental($reason:expr)] $ty:ty) => {
stringify!($ty)
};
($ty:ty) => {
""
};
}
/// Generates an `enum ClientRequest` where each variant is a request that the
/// client can send to the server. Each variant has associated `params` and
/// `response` types. Also generates a `export_client_responses()` function to
@@ -48,9 +84,11 @@ pub enum AuthMode {
macro_rules! client_request_definitions {
(
$(
$(#[$variant_meta:meta])*
$(#[experimental($reason:expr)])?
$(#[doc = $variant_doc:literal])*
$variant:ident $(=> $wire:literal)? {
params: $(#[$params_meta:meta])* $params:ty,
$(inspect_params: $inspect_params:tt,)?
response: $response:ty,
}
),* $(,)?
@@ -60,7 +98,7 @@ macro_rules! client_request_definitions {
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientRequest {
$(
$(#[$variant_meta])*
$(#[doc = $variant_doc])*
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
$variant {
#[serde(rename = "id")]
@@ -71,6 +109,38 @@ macro_rules! client_request_definitions {
)*
}
impl crate::experimental_api::ExperimentalApi for ClientRequest {
fn experimental_reason(&self) -> Option<&'static str> {
match self {
$(
Self::$variant { params: _params, .. } => {
experimental_reason_expr!(
$(#[experimental($reason)])?
_params
$(, $inspect_params)?
)
}
)*
}
}
}
pub(crate) const EXPERIMENTAL_CLIENT_METHODS: &[&str] = &[
$(
experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?),
)*
];
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES: &[&str] = &[
$(
experimental_type_entry!($(#[experimental($reason)])? $params),
)*
];
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES: &[&str] = &[
$(
experimental_type_entry!($(#[experimental($reason)])? $response),
)*
];
pub fn export_client_responses(
out_dir: &::std::path::Path,
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
@@ -112,8 +182,10 @@ client_request_definitions! {
/// NEW APIs
// Thread lifecycle
// Uses `inspect_params` because only some fields are experimental.
ThreadStart => "thread/start" {
params: v2::ThreadStartParams,
inspect_params: true,
response: v2::ThreadStartResponse,
},
ThreadResume => "thread/resume" {
@@ -181,11 +253,18 @@ client_request_definitions! {
params: v2::ModelListParams,
response: v2::ModelListResponse,
},
/// EXPERIMENTAL - list collaboration mode presets.
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
params: v2::CollaborationModeListParams,
response: v2::CollaborationModeListResponse,
},
#[experimental("mock/experimentalMethod")]
/// Test-only method used to validate experimental gating.
MockExperimentalMethod => "mock/experimentalMethod" {
params: v2::MockExperimentalMethodParams,
response: v2::MockExperimentalMethodResponse,
},
McpServerOauthLogin => "mcpServer/oauth/login" {
params: v2::McpServerOauthLoginParams,
@@ -995,4 +1074,27 @@ mod tests {
);
Ok(())
}
#[test]
fn mock_experimental_method_is_marked_experimental() {
let request = ClientRequest::MockExperimentalMethod {
request_id: RequestId::Integer(1),
params: v2::MockExperimentalMethodParams::default(),
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("mock/experimentalMethod"));
}
#[test]
fn thread_start_mock_field_is_marked_experimental() {
let request = ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: v2::ThreadStartParams {
mock_experimental_field: Some("mock".to_string()),
..Default::default()
},
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/start.mockExperimentalField"));
}
}

View File

@@ -33,6 +33,8 @@ use crate::protocol::common::GitSha;
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub client_info: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<InitializeCapabilities>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
@@ -43,6 +45,15 @@ pub struct ClientInfo {
pub version: String,
}
/// Client-declared capabilities negotiated during initialize.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InitializeCapabilities {
/// Opt into receiving experimental API methods and fields.
#[serde(default)]
pub experimental_api: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::protocol::common::AuthMode;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::config_types::CollaborationMode;
@@ -1165,7 +1166,9 @@ pub struct CommandExecResponse {
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[derive(
Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadStartParams {
@@ -1179,7 +1182,12 @@ pub struct ThreadStartParams {
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
#[experimental("thread/start.dynamicTools")]
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
/// Test-only experimental field used to validate experimental gating and
/// schema filtering behavior in a stable way.
#[experimental("thread/start.mockExperimentalField")]
pub mock_experimental_field: Option<String>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
@@ -1188,6 +1196,22 @@ pub struct ThreadStartParams {
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MockExperimentalMethodParams {
/// Test-only payload field.
pub value: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MockExperimentalMethodResponse {
/// Echoes the input `value`.
pub echoed: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]