Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 178 additions & 23 deletions codex-rs/app-server-protocol/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ macro_rules! for_each_schema_type {
$macro!(codex_protocol::protocol::FileChange);
$macro!(codex_protocol::parse_command::ParsedCommand);
$macro!(codex_protocol::protocol::SandboxPolicy);

// v2 protocol types (namespaced in JSON Schema under definitions.v2 and on disk under v2/)
$macro!(crate::protocol::v2::Account);
$macro!(crate::protocol::v2::LoginAccountParams);
$macro!(crate::protocol::v2::LoginAccountResponse);
$macro!(crate::protocol::v2::LogoutAccountResponse);
$macro!(crate::protocol::v2::GetAccountRateLimitsResponse);
$macro!(crate::protocol::v2::GetAccountResponse);
$macro!(crate::protocol::v2::ListModelsParams);
$macro!(crate::protocol::v2::ReasoningEffortOption);
$macro!(crate::protocol::v2::Model);
$macro!(crate::protocol::v2::ListModelsResponse);
$macro!(crate::protocol::v2::UploadFeedbackParams);
$macro!(crate::protocol::v2::UploadFeedbackResponse);
};
}

Expand All @@ -112,7 +126,9 @@ pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
}

pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
let v2_out_dir = out_dir.join("v2");
ensure_dir(out_dir)?;
ensure_dir(&v2_out_dir)?;

ClientRequest::export_all_to(out_dir)?;
export_client_responses(out_dir)?;
Expand All @@ -123,12 +139,15 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
ServerNotification::export_all_to(out_dir)?;

generate_index_ts(out_dir)?;
generate_index_ts(&v2_out_dir)?;

let ts_files = ts_files_in(out_dir)?;
// Ensure our header is present on all TS files (root + subdirs like v2/).
let ts_files = ts_files_in_recursive(out_dir)?;
for file in &ts_files {
prepend_header_if_missing(file)?;
}

// Optionally run Prettier on all generated TS files.
if let Some(prettier_bin) = prettier
&& !ts_files.is_empty()
{
Expand All @@ -147,13 +166,18 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {

pub fn generate_json(out_dir: &Path) -> Result<()> {
ensure_dir(out_dir)?;
let mut bundle: BTreeMap<String, RootSchema> = BTreeMap::new();
let mut bundle_root: BTreeMap<String, RootSchema> = BTreeMap::new();
let mut bundle_v2: BTreeMap<String, RootSchema> = BTreeMap::new();

macro_rules! add_schema {
($ty:path) => {{
let name = type_basename(stringify!($ty));
let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?;
bundle.insert(name, schema);
let (schema, is_v2) = write_json_schema_with_return::<$ty>(out_dir, &name)?;
if is_v2 {
bundle_v2.insert(name, schema);
} else {
bundle_root.insert(name, schema);
}
}};
}

Expand All @@ -162,7 +186,26 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
export_client_response_schemas(out_dir)?;
export_server_response_schemas(out_dir)?;

let mut definitions = Map::new();
let mut definitions_root = Map::new();
let mut definitions_v2 = Map::new();

// Collect the set of ALL v2 definition names so we can selectively rewrite
// $refs that point at these names (and only these names). This must include
// hoisted nested definitions that schemars emits under `definitions` for
// v2 types (e.g., AccountApiKey, enum variant structs, etc.), not just the
// top-level type names we bundle.
let mut v2_names: HashSet<String> = bundle_v2.keys().cloned().collect();
for (_name, schema) in &bundle_v2 {
// Serialize the schema to peek at its hoisted `definitions` keys.
if let Ok(Value::Object(obj)) = serde_json::to_value(schema) {
if let Some(Value::Object(defs)) = obj.get("definitions") {
v2_names.extend(defs.keys().cloned());
}
if let Some(Value::Object(defs)) = obj.get("$defs") {
v2_names.extend(defs.keys().cloned());
}
}
}

const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
Expand All @@ -176,24 +219,60 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
"ServerRequest",
];

for (name, schema) in bundle {
let mut schema_value = serde_json::to_value(schema)?;
annotate_schema(&mut schema_value, Some(name.as_str()));
// Helper to process a bundle map into a definitions object, optionally rewriting
// refs to v2 but only for known v2 type names. When `drop_v2_defs` is true, any
// hoisted definitions whose name is a v2 type will be skipped to avoid
// duplicating them at the root while still preserving references by rewriting.
fn append_bundle(
src: BTreeMap<String, RootSchema>,
out_defs: &mut Map<String, Value>,
v2_names: &HashSet<String>,
rewrite_v2_refs: bool,
drop_v2_defs: bool,
) -> Result<()> {
for (name, schema) in src {
let mut schema_value = serde_json::to_value(schema)?;
annotate_schema(&mut schema_value, Some(name.as_str()));
if rewrite_v2_refs {
rewrite_json_refs_to_v2_in_set(&mut schema_value, v2_names);
}

if let Value::Object(ref mut obj) = schema_value
&& let Some(defs) = obj.remove("definitions")
&& let Value::Object(defs_obj) = defs
{
for (def_name, mut def_schema) in defs_obj {
if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) {
annotate_schema(&mut def_schema, Some(def_name.as_str()));
definitions.insert(def_name, def_schema);
if let Value::Object(ref mut obj) = schema_value
&& let Some(defs) = obj.remove("definitions")
&& let Value::Object(defs_obj) = defs
{
for (def_name, mut def_schema) in defs_obj {
if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) {
annotate_schema(&mut def_schema, Some(def_name.as_str()));
if rewrite_v2_refs {
rewrite_json_refs_to_v2_in_set(&mut def_schema, v2_names);
}
Comment on lines 176 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite nested v2 schema refs as well

The new JSON schema generation only collects v2 type names from the top‑level bundle (let v2_names = bundle_v2.keys().cloned().collect()), and rewrite_json_refs_to_v2_in_set rewrites $refs only for names in that set. Schemars emits additional nested definitions for many of these v2 types (e.g. AccountApiKey, variant structs, etc.), and those definitions are now moved under definitions.v2 but are not present in v2_names. Any references to those nested definitions remain as #/definitions/<Name> while the actual definition lives under #/definitions/v2/<Name>, leaving invalid references in the aggregated codex_app_server_protocol.schemas.json. The rewrite set should include all definition names (including hoisted ones) to keep refs aligned with where the definitions end up.

Useful? React with 👍 / 👎.

if !(drop_v2_defs && v2_names.contains(&def_name)) {
out_defs.insert(def_name, def_schema);
}
}
}
}
out_defs.insert(name, schema_value);
}
definitions.insert(name, schema_value);
Ok(())
}

append_bundle(
bundle_root,
&mut definitions_root,
&v2_names,
true, // rewrite v2 type refs inside root bundle
true, // and drop v2 definitions from root to avoid duplication
)?;
append_bundle(
bundle_v2,
&mut definitions_v2,
&v2_names,
true, // rewrite v2 type refs inside v2 bundle as well (for intra-v2 refs)
false, // keep v2 definitions under definitions.v2
)?;

let mut root = Map::new();
root.insert(
"$schema".to_string(),
Expand All @@ -204,7 +283,11 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
Value::String("CodexAppServerProtocol".into()),
);
root.insert("type".to_string(), Value::String("object".into()));
root.insert("definitions".to_string(), Value::Object(definitions));
// Compose root definitions with a nested v2 namespace.
if !definitions_v2.is_empty() {
definitions_root.insert("v2".to_string(), Value::Object(definitions_v2));
}
root.insert("definitions".to_string(), Value::Object(definitions_root));

write_pretty_json(
out_dir.join("codex_app_server_protocol.schemas.json"),
Expand All @@ -214,18 +297,27 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
Ok(())
}

fn write_json_schema_with_return<T>(out_dir: &Path, name: &str) -> Result<RootSchema>
fn write_json_schema_with_return<T>(out_dir: &Path, name: &str) -> Result<(RootSchema, bool)>
where
T: JsonSchema,
{
let file_stem = name.trim();
let file_stem = type_basename(name);
let schema = schema_for!(T);
let mut schema_value = serde_json::to_value(schema)?;
annotate_schema(&mut schema_value, Some(file_stem));
write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema_value)
annotate_schema(&mut schema_value, Some(&file_stem));
// Decide output dir based on the Rust type path (v2 types under v2/).
let ty_name = std::any::type_name::<T>();
let is_v2 = ty_name.contains("::protocol::v2::");
let target_dir = if is_v2 {
out_dir.join("v2")
} else {
out_dir.to_path_buf()
};
ensure_dir(&target_dir)?;
write_pretty_json(target_dir.join(format!("{file_stem}.json")), &schema_value)
.with_context(|| format!("Failed to write JSON schema for {file_stem}"))?;
let annotated_schema = serde_json::from_value(schema_value)?;
Ok(annotated_schema)
Ok((annotated_schema, is_v2))
}

pub(crate) fn write_json_schema<T>(out_dir: &Path, name: &str) -> Result<()>
Expand Down Expand Up @@ -322,6 +414,40 @@ fn annotate_schema(value: &mut Value, base: Option<&str>) {
}
}

// Rewrite internal $ref targets of the form "#/definitions/<Name>" to
// "#/definitions/v2/<Name>". Skip special top-level definitions which are
// intentionally kept at the root.
// NOTE: legacy helper removed in favor of the selective variant below.

// Like `rewrite_json_refs_to_v2`, but only rewrites references whose target name
// is present in `v2_names`. This avoids accidentally rewriting references to
// root-only definitions such as RequestId.
fn rewrite_json_refs_to_v2_in_set(value: &mut Value, v2_names: &HashSet<String>) {
match value {
Value::Object(obj) => {
if let Some(Value::String(r)) = obj.get_mut("$ref") {
const PREFIX: &str = "#/definitions/";
if r.starts_with("#/definitions/v2/") {
// already namespaced
} else if let Some(rest) = r.strip_prefix(PREFIX)
&& v2_names.contains(rest)
{
*r = format!("#/definitions/v2/{rest}");
}
}
for v in obj.values_mut() {
rewrite_json_refs_to_v2_in_set(v, v2_names);
}
}
Value::Array(arr) => {
for v in arr {
rewrite_json_refs_to_v2_in_set(v, v2_names);
}
}
_ => {}
}
}

fn annotate_object(map: &mut Map<String, Value>, base: Option<&str>) {
let owner = map.get("title").and_then(Value::as_str).map(str::to_owned);
if let Some(owner) = owner.as_deref()
Expand Down Expand Up @@ -504,6 +630,26 @@ fn ts_files_in(dir: &Path) -> Result<Vec<PathBuf>> {
Ok(files)
}

fn ts_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in
fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() && path.extension() == Some(OsStr::new("ts")) {
files.push(path);
}
}
}
files.sort();
Ok(files)
}

fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<String> = Vec::new();
let mut stems: Vec<String> = ts_files_in(out_dir)?
Expand All @@ -520,6 +666,14 @@ fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
entries.push(format!("export type {{ {name} }} from \"./{name}\";\n"));
}

// If this is the root out_dir and a ./v2 folder exists with TS files,
// expose it as a namespace to avoid symbol collisions at the root.
let v2_dir = out_dir.join("v2");
let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false);
if has_v2_ts {
entries.push("export * as v2 from \"./v2\";\n".to_string());
}

let mut content =
String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::<usize>());
content.push_str(HEADER);
Expand All @@ -546,6 +700,7 @@ mod tests {

#[test]
fn generated_ts_has_no_optional_nullable_fields() -> Result<()> {
// Assert that there are no types of the form "?: T | null" in the generated TS files.
let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;

Expand Down
12 changes: 12 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum Account {
#[serde(rename = "apiKey", rename_all = "camelCase")]
#[ts(rename = "apiKey", rename_all = "camelCase")]
Expand All @@ -27,6 +28,7 @@ pub enum Account {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum LoginAccountParams {
#[serde(rename = "apiKey")]
#[ts(rename = "apiKey")]
Expand All @@ -42,6 +44,7 @@ pub enum LoginAccountParams {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct LoginAccountResponse {
/// Only set if the login method is ChatGPT.
#[schemars(with = "String")]
Expand All @@ -54,22 +57,26 @@ pub struct LoginAccountResponse {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct LogoutAccountResponse {}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountRateLimitsResponse {
pub rate_limits: RateLimitSnapshot,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountResponse {
pub account: Account,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListModelsParams {
/// Optional page size; defaults to a reasonable server-side value.
pub page_size: Option<usize>,
Expand All @@ -79,6 +86,7 @@ pub struct ListModelsParams {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct Model {
pub id: String,
pub model: String,
Expand All @@ -92,13 +100,15 @@ pub struct Model {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReasoningEffortOption {
pub reasoning_effort: ReasoningEffort,
pub description: String,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListModelsResponse {
pub items: Vec<Model>,
/// Opaque cursor to pass to the next call to continue after the last item.
Expand All @@ -108,6 +118,7 @@ pub struct ListModelsResponse {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UploadFeedbackParams {
pub classification: String,
pub reason: Option<String>,
Expand All @@ -117,6 +128,7 @@ pub struct UploadFeedbackParams {

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UploadFeedbackResponse {
pub thread_id: String,
}
Loading