Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"aimux-providers",
"aimux-stream",
"tools/aimux-cli",
"tools/aimux-codegen",
"tools/aimux-replay",
"tools/aimux-web",
]
Expand Down
21 changes: 21 additions & 0 deletions tools/aimux-codegen/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "aimux-codegen"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Generator PoC (phase 1 unit 1.8): one type spec -> TypeScript + Go"
publish = false

[[bin]]
name = "aimux-codegen"
path = "src/main.rs"

[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }

[lints]
workspace = true

[dev-dependencies]
aimux-core = { workspace = true }
27 changes: 27 additions & 0 deletions tools/aimux-codegen/generated/go/tool_call.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions tools/aimux-codegen/generated/ts/ToolCall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// This file was generated by aimux-codegen (tools/aimux-codegen). Do not edit this file manually.
import type { AiMuxError } from "./AiMuxError";
import type { JsonValue } from "./serde_json/JsonValue";

/**
* A tool call requested by the model.
*/
export type ToolCall = {
/**
* Provider-assigned call id.
*/
toolCallId: string,
/**
* Tool name.
*/
toolName: string,
/**
* Parsed arguments, or the original string when `invalid` is true and the
* provider input was not valid JSON.
*/
input: JsonValue,
/**
* Whether the tool call will be executed by the provider.
*/
providerExecuted?: boolean,
/**
* Whether the tool is dynamic (defined at runtime, e.g. MCP tools).
*/
dynamic?: boolean,
/**
* Additional provider-specific metadata associated with this call.
*/
providerMetadata?: { [key in string]: { [key in string]: JsonValue } },
/**
* Set when lookup, JSON parsing, or schema validation still failed after
* the optional repair attempt.
*/
invalid?: boolean,
/**
* Typed failure associated with an invalid tool call.
*/
error?: AiMuxError, };
14 changes: 14 additions & 0 deletions tools/aimux-codegen/spec/ToolCall.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "ToolCall",
"doc": ["A tool call requested by the model."],
"fields": [
{ "name": "toolCallId", "type": "string", "optional": false, "doc": ["Provider-assigned call id."] },
{ "name": "toolName", "type": "string", "optional": false, "doc": ["Tool name."] },
{ "name": "input", "type": "json", "optional": false, "doc": ["Parsed arguments, or the original string when `invalid` is true and the", "provider input was not valid JSON."] },
{ "name": "providerExecuted", "type": "bool", "optional": true, "doc": ["Whether the tool call will be executed by the provider."] },
{ "name": "dynamic", "type": "bool", "optional": true, "doc": ["Whether the tool is dynamic (defined at runtime, e.g. MCP tools)."] },
{ "name": "providerMetadata", "type": "provider_metadata", "optional": true, "doc": ["Additional provider-specific metadata associated with this call."] },
{ "name": "invalid", "type": "bool", "optional": true, "doc": ["Set when lookup, JSON parsing, or schema validation still failed after", "the optional repair attempt."] },
{ "name": "error", "type": { "ref": "AiMuxError" }, "optional": true, "doc": ["Typed failure associated with an invalid tool call."] }
]
}
267 changes: 267 additions & 0 deletions tools/aimux-codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
//! Generator PoC (phase 1 unit 1.8; #200:1509, RFC-0042 T5).
//!
//! One data-only type spec (`spec/<Type>.json`) produces a TypeScript type
//! and a Go struct with the same wire shape. Outputs are committed under
//! `generated/`; `--check` regenerates in memory and fails on any
//! difference, so a hand edit (tamper) is caught. No `build.rs`, no second
//! source of truth (#200 §6.5).

use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use serde::Deserialize;

/// A type spec.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TypeSpec {
pub name: String,
pub doc: Vec<String>,
pub fields: Vec<FieldSpec>,
}

/// One field: wire name, type, optionality, doc lines.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FieldSpec {
pub name: String,
#[serde(rename = "type")]
pub ty: FieldType,
pub optional: bool,
pub doc: Vec<String>,
}

/// The field types this PoC supports.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
String,
Bool,
/// Any JSON value.
Json,
/// `{ [provider]: { [key]: JsonValue } }`.
ProviderMetadata,
/// Another generated/declared type, by name.
#[serde(untagged)]
Ref {
r#ref: String,
},
}

/// One generated file: path relative to the output root, and its content.
#[derive(Debug, PartialEq, Eq)]
pub struct Output {
pub path: PathBuf,
pub content: String,
}

/// Header line written at the top of every generated TypeScript file.
pub const TS_HEADER: &str = "// This file was generated by aimux-codegen (tools/aimux-codegen). Do not edit this file manually.";

/// Header line written at the top of every generated Go file.
pub const GO_HEADER: &str =
"// Code generated by aimux-codegen (tools/aimux-codegen). DO NOT EDIT.";

/// Parse a spec.
///
/// # Errors
///
/// Returns the JSON error for a malformed or unknown-field spec.
pub fn parse_spec(text: &str) -> Result<TypeSpec, serde_json::Error> {
serde_json::from_str(text)
}

/// TypeScript in the layout of the current ts-rs output
/// (`bindings/node/src/types/<Type>.ts`), so the two can be compared line by
/// line below the header.
#[must_use]
pub fn render_ts(spec: &TypeSpec) -> String {
let mut out = String::new();
let _ = writeln!(out, "{TS_HEADER}");
let mut refs: Vec<&str> = spec
.fields
.iter()
.filter_map(|f| match &f.ty {
FieldType::Ref { r#ref } => Some(r#ref.as_str()),
_ => None,
})
.collect();
refs.sort_unstable();
refs.dedup();
for r in refs {
let _ = writeln!(out, "import type {{ {r} }} from \"./{r}\";");
}
if spec
.fields
.iter()
.any(|f| matches!(f.ty, FieldType::Json | FieldType::ProviderMetadata))
{
let _ = writeln!(
out,
"import type {{ JsonValue }} from \"./serde_json/JsonValue\";"
);
}
out.push('\n');
write_ts_doc(&mut out, &spec.doc);
let _ = write!(out, "export type {} = {{ ", spec.name);
for field in &spec.fields {
out.push('\n');
write_ts_doc(&mut out, &field.doc);
let ty = match &field.ty {
FieldType::String => "string".to_string(),
FieldType::Bool => "boolean".to_string(),
FieldType::Json => "JsonValue".to_string(),
FieldType::ProviderMetadata => {
"{ [key in string]: { [key in string]: JsonValue } }".to_string()
}
FieldType::Ref { r#ref } => r#ref.clone(),
};
let q = if field.optional { "?" } else { "" };
let _ = write!(out, "{}{q}: {ty}, ", field.name);
}
out.push_str("};\n");
out
}

fn write_ts_doc(out: &mut String, doc: &[String]) {
out.push_str("/**\n");
for line in doc {
let _ = writeln!(out, " * {line}");
}
out.push_str(" */\n");
}

fn go_field_name(wire: &str) -> String {
let mut name = String::new();
let mut chars = wire.chars();
if let Some(first) = chars.next() {
name.extend(first.to_uppercase());
}
name.extend(chars);
// Go initialism convention for a trailing `Id`.
if let Some(stem) = name.strip_suffix("Id") {
return format!("{stem}ID");
}
name
}

/// A Go struct with `encoding/json` tags carrying the same wire names and
/// optionality (`omitempty` + pointer/raw for optional fields).
#[must_use]
pub fn render_go(spec: &TypeSpec, package: &str) -> String {
let mut out = String::new();
let _ = writeln!(out, "{GO_HEADER}\n");
let _ = writeln!(out, "package {package}\n");
let _ = writeln!(out, "import \"encoding/json\"\n");
for line in &spec.doc {
let _ = writeln!(out, "// {line}");
}
let _ = writeln!(out, "type {} struct {{", spec.name);
for field in &spec.fields {
for line in &field.doc {
let _ = writeln!(out, "\t// {line}");
}
let ty = match (&field.ty, field.optional) {
(FieldType::String, false) => "string",
(FieldType::String, true) => "*string",
(FieldType::Bool, false) => "bool",
(FieldType::Bool, true) => "*bool",
// Raw JSON keeps the value byte-for-byte; absent stays absent.
(FieldType::Json | FieldType::ProviderMetadata | FieldType::Ref { .. }, _) => {
"json.RawMessage"
}
};
let omit = if field.optional { ",omitempty" } else { "" };
let _ = writeln!(
out,
"\t{} {ty} `json:\"{}{omit}\"`",
go_field_name(&field.name),
field.name
);
}
out.push_str("}\n");
out
}

/// Every output for the specs in `spec_dir`, sorted by path.
///
/// # Errors
///
/// Returns a message for an unreadable or malformed spec.
pub fn generate(spec_dir: &Path) -> Result<Vec<Output>, String> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(spec_dir)
.map_err(|e| format!("{}: {e}", spec_dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
entries.sort();
let mut outputs = Vec::new();
for path in entries {
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let spec = parse_spec(&text).map_err(|e| format!("{}: {e}", path.display()))?;
outputs.push(Output {
path: PathBuf::from("ts").join(format!("{}.ts", spec.name)),
content: render_ts(&spec),
});
outputs.push(Output {
path: PathBuf::from("go").join(format!("{}.go", snake(&spec.name))),
content: render_go(&spec, "aimuxgen"),
});
}
Ok(outputs)
}

fn snake(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}

/// Write every output under `out_dir`.
///
/// # Errors
///
/// Returns a message for a spec or I/O failure.
pub fn write(spec_dir: &Path, out_dir: &Path) -> Result<(), String> {
for output in generate(spec_dir)? {
let path = out_dir.join(&output.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&path, output.content).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(())
}

/// Compare the committed outputs under `out_dir` with a fresh generation.
///
/// # Errors
///
/// Returns one message per missing or differing file.
pub fn check(spec_dir: &Path, out_dir: &Path) -> Result<(), Vec<String>> {
let outputs = generate(spec_dir).map_err(|e| vec![e])?;
let mut problems = Vec::new();
for output in outputs {
let path = out_dir.join(&output.path);
match std::fs::read_to_string(&path) {
Ok(text) if text == output.content => {}
Ok(_) => problems.push(format!("{} is out of date", path.display())),
Err(e) => problems.push(format!("{}: {e}", path.display())),
}
}
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
Loading
Loading