diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ec208c042..c1b7335284a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -# Generated by gunbc-codegen cigen. Do not edit manually. -# Regenerate with: cargo run -p gunbc-codegen -- cigen +# Generated by gunbc-codegen +# DO NOT EDIT - regenerate with: cargo run -p gunbc-codegen -- cigen name: ci @@ -13,6 +13,7 @@ on: env: CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings jobs: ci: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d7d29e1c62a..a2865fe2e59 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,10 +1,11 @@ -# Generated by gunbc-codegen cigen. Do not edit manually. -# Regenerate with: cargo run -p gunbc-codegen -- cigen +# Generated by gunbc-codegen +# DO NOT EDIT - regenerate with: cargo run -p gunbc-codegen -- cigen image: rust:latest variables: CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" stages: - ci diff --git a/Cargo.lock b/Cargo.lock index 574ba21b7e9..5726d73be3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,11 @@ dependencies = [ "gunbc-gist", "gunbc-ir", "gunbc-lib-gist-ops", + "gunbc-lib-llm-ops", "gunbc-lib-transport", "gunbc-primitives", "gunbc-test", + "gunbc-testgen", "serde_json", ] @@ -151,6 +153,17 @@ dependencies = [ "gunbc-test", ] +[[package]] +name = "gunbc-lib-llm-ops" +version = "0.1.0" +dependencies = [ + "gunbc-exec", + "gunbc-ir", + "gunbc-lib-transport", + "gunbc-test", + "serde_json", +] + [[package]] name = "gunbc-lib-markdown" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4447ba54bd4..e128cbd731d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ # Library crates (DAG op libraries) "lib/primitives", "lib/gist-ops", + "lib/llm-ops", "lib/markdown", "lib/transport", diff --git a/Makefile b/Makefile index 9ae8acdb1b1..35c54c79ae0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ .DEFAULT_GOAL := help -.PHONY: help codegen ensure-codegen build clean fmt-fix lint-fix test test-fix check check-fix clippy clippy-fix fmt fmt-check ci-yaml gist gist-dry buck2 buck2-dry makegen makegen-dry deps deps-dry ci ci-dry bootstrap bootstrap-dry +.PHONY: help codegen ensure-codegen build clean fmt-fix lint-fix test test-fix check check-fix clippy clippy-fix fmt fmt-check ci-yaml gist gist-dry buck2 buck2-dry makegen makegen-dry deps deps-dry ci ci-dry bootstrap bootstrap-dry testgen testgen-dry # Ensure codegen has run (upsert pattern: check stamp -> run if missing) ensure-codegen: @@ -61,6 +61,8 @@ help: @echo " ci - Run CI pipeline" @echo " bootstrap - Generate Makefile and .gitignore" @echo "" + @echo " testgen - Generate tests from DAG structures and MockSpecs" + @echo "" @echo "Add -dry suffix for dry-run (e.g., make gist-dry)" # ============================================================================ @@ -152,3 +154,10 @@ bootstrap: ensure-codegen bootstrap-dry: ensure-codegen @cargo run -p gunbc-dag --bin gunbc-bootstrap -- --dry-run +# gunbc-testgen: Generate tests from DAG structures and MockSpecs +testgen: + @cargo run -p gunbc-dag --bin gunbc-testgen -- + +testgen-dry: + @cargo run -p gunbc-dag --bin gunbc-testgen -- --dry-run + diff --git a/core/codegen/src/main.rs b/core/codegen/src/main.rs index a0b4e19c5c0..05b45c82a07 100644 --- a/core/codegen/src/main.rs +++ b/core/codegen/src/main.rs @@ -31,7 +31,9 @@ use gunbc_clippy::ClippyConfigRenderer; use gunbc_codegen::{ all_cleanable_outputs, all_tools, generate_cli_with_import, generate_graph_rs, FileWriter, }; -use gunbc_ir::transport::ci::{CiRenderer, GitHubActionsProvider, GitLabCiProvider, RenderConfig}; +use gunbc_ir::transport::ci::{ + yaml_block, CacheConfig, CiRenderer, GitHubActionsProvider, GitLabCiProvider, RenderConfig, +}; use gunbc_ir::Renderable; use std::env; use std::fs; @@ -292,48 +294,34 @@ fn cmd_cigen(dry_run: bool) { &codegen.binary, &format!("{} -- cigen", codegen.command()), ) - .with_runner("ubuntu-latest") - .with_env("CARGO_TERM_COLOR", "always") - .with_branches(vec!["main"]); - - // Generate GitHub Actions YAML - let ci_yaml = generate_github_actions_template(&config); - let github_path = github_provider.output_path("ci"); - - match writer.write(Path::new(&github_path), &ci_yaml) { - Ok(result) => { - let status = if result.written { - if result.changed { "written" } else { "unchanged" } - } else { - "dry-run" - }; - println!(" [ci] {} ({})", github_path, status); - } - Err(e) => { - eprintln!(" [ci] GitHub Actions ERROR: {}", e); - } - } - - // Generate GitLab CI YAML - let gitlab_yaml = generate_gitlab_ci_template(&config); - let gitlab_path = gitlab_provider.output_path("ci"); - - match writer.write(Path::new(&gitlab_path), &gitlab_yaml) { - Ok(result) => { - let status = if result.written { - if result.changed { "written" } else { "unchanged" } - } else { - "dry-run" - }; - println!(" [ci] {} ({})", gitlab_path, status); - } - Err(e) => { - eprintln!(" [ci] GitLab CI ERROR: {}", e); + .with_runner(gunbc_ir::transport::github_actions::ubuntu_latest()) + .with_cargo_env(gunbc_ir::CargoEnv::ci()) + .with_git(gunbc_ir::GitConfig::default()) + .with_cache(CacheConfig::rust()); + + let outputs: Vec<(&str, String, String)> = vec![ + ("GitHub Actions", generate_github_actions_template(&config), github_provider.output_path("ci")), + ("GitLab CI", generate_gitlab_ci_template(&config), gitlab_provider.output_path("ci")), + ]; + + for (label, yaml, path) in &outputs { + match writer.write(Path::new(path), yaml) { + Ok(result) => { + let status = if result.written { + if result.changed { "written" } else { "unchanged" } + } else { + "dry-run" + }; + println!(" [ci] {} ({})", path, status); + } + Err(e) => { + eprintln!(" [ci] {} ERROR: {}", label, e); + } } } - + println!(); - println!("Generated: 2 CI files"); + println!("Generated: {} CI files", outputs.len()); } /// Generate clippy.toml from ClippyConfig. @@ -372,74 +360,57 @@ fn cmd_clippy_toml(dry_run: bool) { } /// Generate GitHub Actions YAML template. +/// +/// Renders checkout, cache, and steps from `RenderConfig` model types +/// rather than hardcoding them in the template. fn generate_github_actions_template(config: &RenderConfig) -> String { let mut yaml = String::new(); yaml.push_str(&config.header("#")); yaml.push_str(&format!("\n\nname: {}\n\n", config.workflow_name)); - - // Triggers - yaml.push_str("on:\n"); - yaml.push_str(" push:\n"); - yaml.push_str(" branches:\n"); - for branch in &config.branches { - yaml.push_str(&format!(" - {}\n", branch)); - } + + // Triggers — derived from git config + let branches = config.git.ci_branches(); + yaml.push_str("on:\n push:\n"); + yaml_block(&mut yaml, " branches:", &branches, |b| format!(" - {}", b)); yaml.push_str(" pull_request:\n"); - yaml.push_str(" branches:\n"); - for branch in &config.branches { - yaml.push_str(&format!(" - {}\n", branch)); - } - yaml.push('\n'); - - // Environment - yaml.push_str("env:\n"); - yaml.push_str(" CARGO_TERM_COLOR: always\n"); - for (key, value) in &config.env { - if key != "CARGO_TERM_COLOR" { - yaml.push_str(&format!(" {}: {}\n", key, value)); + yaml_block(&mut yaml, " branches:", &branches, |b| format!(" - {}", b)); + + // Environment — derived from cargo env + manual overrides + yaml_block(&mut yaml, "env:", &config.all_env(), |(k, v)| format!(" {}: {}", k, v)); + + // Job + yaml.push_str(&format!( + "jobs:\n {}:\n runs-on: {}\n steps:\n", + config.workflow_name, config.runner.id, + )); + + // Checkout (from config model) + if let Some(checkout) = &config.checkout { + yaml.push_str(" - name: Checkout\n uses: actions/checkout@v4\n"); + if let Some(depth) = checkout.fetch_depth { + yaml.push_str(&format!(" with:\n fetch-depth: {}\n", depth)); } + yaml.push('\n'); } - yaml.push('\n'); - - // Job - yaml.push_str("jobs:\n"); - yaml.push_str(&format!(" {}:\n", config.workflow_name)); - yaml.push_str(&format!(" runs-on: {}\n", config.runner)); - yaml.push_str(" steps:\n"); - yaml.push_str(" - name: Checkout\n"); - yaml.push_str(" uses: actions/checkout@v4\n"); - yaml.push_str(" with:\n"); - yaml.push_str(" fetch-depth: 1\n"); - yaml.push('\n'); - + // Rust toolchain - yaml.push_str(" - name: Setup Rust\n"); - yaml.push_str(" uses: dtolnay/rust-toolchain@stable\n"); - yaml.push('\n'); - - // Cache - yaml.push_str(" - name: Cache Cargo\n"); - yaml.push_str(" uses: actions/cache@v4\n"); - yaml.push_str(" with:\n"); - yaml.push_str(" path: |\n"); - yaml.push_str(" ~/.cargo/bin/\n"); - yaml.push_str(" ~/.cargo/registry/index/\n"); - yaml.push_str(" ~/.cargo/registry/cache/\n"); - yaml.push_str(" ~/.cargo/git/db/\n"); - yaml.push_str(" target/\n"); - yaml.push_str(" key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}\n"); - yaml.push_str(" restore-keys: |\n"); - yaml.push_str(" cargo-${{ runner.os }}-\n"); - yaml.push('\n'); - + yaml.push_str(" - name: Setup Rust\n uses: dtolnay/rust-toolchain@stable\n\n"); + + // Cache (from config model) + if let Some(cache) = &config.cache { + yaml.push_str(" - name: Cache Cargo\n uses: actions/cache@v4\n with:\n"); + yaml_block(&mut yaml, " path: |", &cache.paths, |p| format!(" {}", p)); + yaml.push_str(&format!(" key: {}\n", cache.key)); + yaml_block(&mut yaml, " restore-keys: |", &cache.restore_keys, |k| format!(" {}", k)); + } + // Run CI Pipeline - // gunbc-ci has a handwritten main.rs that handles codegen internally via the prep node. - // The prep node uses the resource acquisition (upsert) pattern: check if generated - // files exist, generate them if not. This makes CI self-healing. - yaml.push_str(" - name: Run CI Pipeline\n"); - yaml.push_str(&format!(" run: {} --release\n", config.tool.command())); - + yaml.push_str(&format!( + " - name: Run CI Pipeline\n run: {} --release\n", + config.tool.command(), + )); + yaml } @@ -448,38 +419,22 @@ fn generate_gitlab_ci_template(config: &RenderConfig) -> String { let mut yaml = String::new(); yaml.push_str(&config.header("#")); - yaml.push_str("\n\n"); - - // Image - yaml.push_str("image: rust:latest\n\n"); - - // Variables - yaml.push_str("variables:\n"); - yaml.push_str(" CARGO_TERM_COLOR: always\n"); - for (key, value) in &config.env { - if key != "CARGO_TERM_COLOR" { - yaml.push_str(&format!(" {}: \"{}\"\n", key, value)); - } - } - yaml.push('\n'); - - // Stages - yaml.push_str("stages:\n"); - yaml.push_str(" - ci\n\n"); - - // Cache - yaml.push_str("cache:\n"); - yaml.push_str(" key: cargo-${CI_COMMIT_REF_SLUG}\n"); - yaml.push_str(" paths:\n"); - yaml.push_str(" - .cargo/\n"); - yaml.push_str(" - target/\n\n"); - - // CI job - gunbc-ci handles codegen internally via the prep node - yaml.push_str(&format!("{}:\n", config.workflow_name)); - yaml.push_str(" stage: ci\n"); - yaml.push_str(" script:\n"); - yaml.push_str(&format!(" - {} --release\n", config.tool.command())); - + yaml.push_str("\n\nimage: rust:latest\n\n"); + + // Variables — derived from cargo env + manual overrides + yaml_block(&mut yaml, "variables:", &config.all_env(), |(k, v)| format!(" {}: \"{}\"", k, v)); + + yaml.push_str("stages:\n - ci\n\n"); + + // Cache — GitLab uses CI_COMMIT_REF_SLUG and relative paths + yaml.push_str("cache:\n key: cargo-${CI_COMMIT_REF_SLUG}\n paths:\n - .cargo/\n - target/\n\n"); + + // CI job + yaml.push_str(&format!( + "{}:\n stage: ci\n script:\n - {} --release\n", + config.workflow_name, config.tool.command(), + )); + yaml } diff --git a/core/exec/src/execute.rs b/core/exec/src/execute.rs index b56f7192f8a..6178e2b7862 100644 --- a/core/exec/src/execute.rs +++ b/core/exec/src/execute.rs @@ -504,6 +504,16 @@ fn execute_flat( } }; + // Mask any secret values in CI context so that CI runners + // (GitHub Actions, GitLab CI) redact them from all output. + if let Some(ref mut ci) = ci_ctx { + for value in outputs.values() { + if let Value::Secret(s) = value { + ci.mask(s.expose()); + } + } + } + node_outputs.insert(node_id.0.clone(), outputs.clone()); let entry = LogEntry { node_id: node_id.0.clone(), @@ -531,9 +541,16 @@ fn execute_flat( /// /// Used inside CI groups so that node outputs appear within the /// collapsible section rather than in a flat summary after all groups. +/// +/// Secret values are always redacted — they print as `***` regardless +/// of context. This is the last line of defense against credential leaks. fn print_log_entry(entry: &LogEntry) { for (port, value) in &entry.outputs { match value { + Value::Secret(_) => { + // Always redact secrets — never print actual values + println!(" {port}: ***"); + } Value::Str(s) => { if port.ends_with("stderr") || port.ends_with("stdout") { if !s.is_empty() { diff --git a/core/ir/src/cargo.rs b/core/ir/src/cargo.rs index aa57202ff2d..04f3d3bca58 100644 --- a/core/ir/src/cargo.rs +++ b/core/ir/src/cargo.rs @@ -40,7 +40,7 @@ pub fn name(component: &str) -> String { /// Prefer [`CargoInvocation::standalone`] and [`CargoInvocation::composed`] /// over [`CargoInvocation::new`] and [`CargoInvocation::in_package`] to /// ensure names are composed from components rather than hardcoded. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CargoInvocation { /// The binary name (e.g., "gunbc-ci"). pub binary: String, @@ -158,6 +158,293 @@ impl CargoInvocation { } } +// ============================================================================ +// Cargo Command Model +// ============================================================================ + +/// Cargo subcommand. +/// +/// Models the `cargo ` process tree. Each variant corresponds +/// to a top-level cargo subcommand with its own set of applicable flags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Subcommand { + Build, + Test, + Check, + Clippy, + Fmt, + /// `cargo run` with a specific binary invocation. + Run(CargoInvocation), +} + +impl Subcommand { + /// The subcommand string as used on the command line. + pub fn as_str(&self) -> &str { + match self { + Self::Build => "build", + Self::Test => "test", + Self::Check => "check", + Self::Clippy => "clippy", + Self::Fmt => "fmt", + Self::Run(_) => "run", + } + } + + /// Whether this subcommand invokes the Rust compiler (affected by RUSTFLAGS). + pub fn compiles(&self) -> bool { + matches!(self, Self::Build | Self::Test | Self::Check) + } +} + +/// How compiler warnings should be treated. +/// +/// The rendering layer owns how this is expressed per subcommand: +/// - `Clippy`: `-- -D warnings` (clippy driver flag) +/// - `Build`/`Test`/`Check`: `RUSTFLAGS="-D warnings"` (env var) +/// - `Fmt`/`Run`: no effect +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Warnings { + /// Default compiler behavior (warnings are warnings). + #[default] + Default, + /// Promote all warnings to compile errors (`-D warnings`). + Deny, +} + +/// Cargo terminal color output mode. +/// +/// Maps to the `CARGO_TERM_COLOR` environment variable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TermColor { + /// Automatic detection (default). + #[default] + Auto, + /// Always emit color. + Always, + /// Never emit color. + Never, +} + +impl TermColor { + /// The value for `CARGO_TERM_COLOR`. + pub fn as_env_value(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } +} + +/// Cargo environment configuration. +/// +/// Repo-level cargo settings that affect all commands. These are expressed +/// as environment variables by the rendering layer. +#[derive(Debug, Clone, Default)] +pub struct CargoEnv { + /// Terminal color output mode (`CARGO_TERM_COLOR`). + pub term_color: TermColor, + /// Warning policy applied to compilation subcommands. + pub warnings: Warnings, +} + +impl CargoEnv { + /// Standard CI configuration: colored output + warnings-as-errors. + pub fn ci() -> Self { + Self { + term_color: TermColor::Always, + warnings: Warnings::Deny, + } + } + + /// Build the environment variable map for CI/shell contexts. + /// + /// Returns all cargo-related env vars that should be set. The rendering + /// layer decides where to place them (workflow-level env, Makefile export, + /// or inline shell prefix). + pub fn to_env_map(&self) -> Vec<(String, String)> { + let mut env = Vec::new(); + // Always emit CARGO_TERM_COLOR unless it's the default (auto) + if self.term_color != TermColor::Auto { + env.push(( + "CARGO_TERM_COLOR".to_string(), + self.term_color.as_env_value().to_string(), + )); + } + // RUSTFLAGS for warning denial (applies to build/test/check) + if self.warnings == Warnings::Deny { + env.push(("RUSTFLAGS".to_string(), "-D warnings".to_string())); + } + env + } +} + +/// A fully-specified cargo command invocation. +/// +/// Combines a subcommand with its flags and the repo's warning policy. +/// The rendering methods produce correct output per subcommand: +/// +/// ```text +/// CargoCommand::new(Subcommand::Clippy) +/// .all_targets() +/// .warnings(Warnings::Deny) +/// +/// → args: ["cargo", "clippy", "--all-targets", "--", "-D", "warnings"] +/// → env: [] (clippy handles it via trailing args) +/// +/// CargoCommand::new(Subcommand::Test) +/// .warnings(Warnings::Deny) +/// +/// → args: ["cargo", "test"] +/// → env: [("RUSTFLAGS", "-D warnings")] +/// ``` +#[derive(Debug, Clone)] +pub struct CargoCommand { + pub subcommand: Subcommand, + pub with_all_targets: bool, + pub with_release: bool, + pub with_warnings: Warnings, + /// Additional subcommand-specific flags (before `--`). + pub flags: Vec, + /// Arguments passed after `--`. + pub trailing_args: Vec, +} + +impl CargoCommand { + /// Create a new cargo command for the given subcommand. + pub fn new(subcommand: Subcommand) -> Self { + Self { + subcommand, + with_all_targets: false, + with_release: false, + with_warnings: Warnings::Default, + flags: Vec::new(), + trailing_args: Vec::new(), + } + } + + /// Enable `--all-targets`. + pub fn all_targets(mut self) -> Self { + self.with_all_targets = true; + self + } + + /// Enable `--release`. + pub fn release(mut self) -> Self { + self.with_release = true; + self + } + + /// Set the warning policy. + pub fn warnings(mut self, w: Warnings) -> Self { + self.with_warnings = w; + self + } + + /// Add a subcommand-specific flag (before `--`). + pub fn flag(mut self, flag: &str) -> Self { + self.flags.push(flag.to_string()); + self + } + + /// Add an argument after `--`. + pub fn trailing_arg(mut self, arg: &str) -> Self { + self.trailing_args.push(arg.to_string()); + self + } + + /// Build the full command-line argument vector. + /// + /// For `Clippy` with `Warnings::Deny`, the `-D warnings` flags are + /// appended to the trailing args (after `--`). For compilation subcommands, + /// the warning policy is expressed via `env()` instead. + pub fn to_args(&self) -> Vec { + let mut args = vec!["cargo".to_string()]; + + match &self.subcommand { + Subcommand::Run(inv) => { + args.push("run".to_string()); + match &inv.package { + Some(pkg) => { + args.extend(["-p".to_string(), pkg.clone(), "--bin".to_string(), inv.binary.clone()]); + } + None => { + args.extend(["-p".to_string(), inv.binary.clone()]); + } + } + } + sub => args.push(sub.as_str().to_string()), + } + + if self.with_all_targets { + args.push("--all-targets".to_string()); + } + if self.with_release { + args.push("--release".to_string()); + } + + args.extend(self.flags.iter().cloned()); + + // Build trailing args, including clippy's -D warnings + let mut trailing = self.trailing_args.clone(); + if self.with_warnings == Warnings::Deny && self.subcommand == Subcommand::Clippy { + trailing.extend(["-D".to_string(), "warnings".to_string()]); + } + + if !trailing.is_empty() { + args.push("--".to_string()); + args.extend(trailing); + } + + args + } + + /// Environment variables required by this command's configuration. + /// + /// For compilation subcommands (`build`, `test`, `check`) with + /// `Warnings::Deny`, returns `RUSTFLAGS="-D warnings"`. + /// Clippy handles warnings via trailing args instead. + pub fn env(&self) -> Vec<(String, String)> { + let mut env = Vec::new(); + if self.with_warnings == Warnings::Deny && self.subcommand.compiles() { + env.push(("RUSTFLAGS".to_string(), "-D warnings".to_string())); + } + env + } + + /// Render as a shell command string (without env prefix). + pub fn to_shell(&self) -> String { + self.to_args().join(" ") + } + + /// Render as a shell command string with env prefix. + /// + /// Produces `RUSTFLAGS="-D warnings" cargo test` when env vars are needed. + pub fn to_shell_with_env(&self) -> String { + let env = self.env(); + if env.is_empty() { + return self.to_shell(); + } + let prefix: Vec = env + .iter() + .map(|(k, v)| format!("{k}=\"{v}\"")) + .collect(); + format!("{} {}", prefix.join(" "), self.to_shell()) + } + + /// Convert to a `ShellRequest` for transport execution. + pub fn to_shell_request(&self) -> crate::transport::ShellRequest { + let args = self.to_args(); + crate::transport::ShellRequest { + command: args[0].clone(), + args: args[1..].to_vec(), + cwd: None, + env: self.env().into_iter().collect(), + stdin: None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -227,4 +514,134 @@ mod tests { vec!["cargo", "run", "-p", "gunbc-codegen", "--release", "--", "codegen"] ); } + + // ======================================================================== + // CargoCommand Tests + // ======================================================================== + + #[test] + fn test_cargo_build_basic() { + let cmd = CargoCommand::new(Subcommand::Build); + assert_eq!(cmd.to_args(), vec!["cargo", "build"]); + assert!(cmd.env().is_empty()); + } + + #[test] + fn test_cargo_build_all_targets() { + let cmd = CargoCommand::new(Subcommand::Build).all_targets(); + assert_eq!(cmd.to_args(), vec!["cargo", "build", "--all-targets"]); + } + + #[test] + fn test_cargo_test_deny_warnings() { + let cmd = CargoCommand::new(Subcommand::Test).warnings(Warnings::Deny); + // Test doesn't add trailing args for warnings - uses env instead + assert_eq!(cmd.to_args(), vec!["cargo", "test"]); + assert_eq!(cmd.env(), vec![("RUSTFLAGS".to_string(), "-D warnings".to_string())]); + } + + #[test] + fn test_cargo_clippy_deny_warnings() { + let cmd = CargoCommand::new(Subcommand::Clippy) + .all_targets() + .warnings(Warnings::Deny); + // Clippy uses trailing args for -D warnings, not env + assert_eq!( + cmd.to_args(), + vec!["cargo", "clippy", "--all-targets", "--", "-D", "warnings"] + ); + assert!(cmd.env().is_empty()); + } + + #[test] + fn test_cargo_clippy_fix() { + let cmd = CargoCommand::new(Subcommand::Clippy) + .flag("--fix") + .flag("--workspace") + .flag("--allow-dirty") + .flag("--allow-staged") + .warnings(Warnings::Deny); + assert_eq!( + cmd.to_args(), + vec![ + "cargo", "clippy", + "--fix", "--workspace", "--allow-dirty", "--allow-staged", + "--", "-D", "warnings" + ] + ); + } + + #[test] + fn test_cargo_fmt_check() { + let cmd = CargoCommand::new(Subcommand::Fmt).trailing_arg("--check"); + assert_eq!(cmd.to_args(), vec!["cargo", "fmt", "--", "--check"]); + } + + #[test] + fn test_cargo_run_release() { + let inv = CargoInvocation::standalone("codegen"); + let cmd = CargoCommand::new(Subcommand::Run(inv)) + .release() + .trailing_arg("codegen"); + assert_eq!( + cmd.to_args(), + vec!["cargo", "run", "-p", "gunbc-codegen", "--release", "--", "codegen"] + ); + } + + #[test] + fn test_to_shell() { + let cmd = CargoCommand::new(Subcommand::Build).all_targets(); + assert_eq!(cmd.to_shell(), "cargo build --all-targets"); + } + + #[test] + fn test_to_shell_with_env() { + let cmd = CargoCommand::new(Subcommand::Test).warnings(Warnings::Deny); + assert_eq!(cmd.to_shell_with_env(), "RUSTFLAGS=\"-D warnings\" cargo test"); + } + + #[test] + fn test_to_shell_with_env_no_env() { + let cmd = CargoCommand::new(Subcommand::Clippy) + .all_targets() + .warnings(Warnings::Deny); + // Clippy has no env (uses trailing args), so to_shell_with_env == to_shell + assert_eq!(cmd.to_shell_with_env(), "cargo clippy --all-targets -- -D warnings"); + } + + #[test] + fn test_cargo_env_ci() { + let env = CargoEnv::ci(); + let map = env.to_env_map(); + assert!(map.contains(&("CARGO_TERM_COLOR".to_string(), "always".to_string()))); + assert!(map.contains(&("RUSTFLAGS".to_string(), "-D warnings".to_string()))); + } + + #[test] + fn test_cargo_env_default() { + let env = CargoEnv::default(); + // Default produces no env vars (auto color, no warning denial) + assert!(env.to_env_map().is_empty()); + } + + #[test] + fn test_subcommand_compiles() { + assert!(Subcommand::Build.compiles()); + assert!(Subcommand::Test.compiles()); + assert!(Subcommand::Check.compiles()); + assert!(!Subcommand::Clippy.compiles()); + assert!(!Subcommand::Fmt.compiles()); + } + + #[test] + fn test_to_shell_request() { + let cmd = CargoCommand::new(Subcommand::Build) + .all_targets() + .warnings(Warnings::Deny); + let req = cmd.to_shell_request(); + assert_eq!(req.command, "cargo"); + assert_eq!(req.args, vec!["build", "--all-targets"]); + assert_eq!(req.env.get("RUSTFLAGS"), Some(&"-D warnings".to_string())); + } } diff --git a/core/ir/src/git.rs b/core/ir/src/git.rs new file mode 100644 index 00000000000..8e71b6f349c --- /dev/null +++ b/core/ir/src/git.rs @@ -0,0 +1,68 @@ +//! Git repository configuration. +//! +//! Models git conventions used by a repository. The CI and rendering layers +//! reference this model instead of hardcoding branch names. +//! +//! # Ownership +//! +//! - This module defines the model (what git offers). +//! - Repo-specific choices (e.g., "our default branch is main") live in +//! `gunbc-dag` alongside other repo config. + +/// Git repository conventions. +/// +/// Captures the branching strategy and conventions for a repository. +/// CI renderers use this to determine trigger branches, and other tools +/// use it to know the default target for operations. +#[derive(Debug, Clone)] +pub struct GitConfig { + /// The default branch name (e.g., "main", "master", "develop"). + pub default_branch: String, +} + +impl GitConfig { + /// Create a new git config with the given default branch. + pub fn new(default_branch: &str) -> Self { + Self { + default_branch: default_branch.to_string(), + } + } + + /// Branches that CI should trigger on (push and PR). + /// + /// By default, returns just the default branch. Override or extend + /// for repos that need CI on additional branches. + pub fn ci_branches(&self) -> Vec<&str> { + vec![&self.default_branch] + } +} + +impl Default for GitConfig { + /// Defaults to "main" as the default branch, matching modern git convention. + fn default() -> Self { + Self::new("main") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_branch() { + let config = GitConfig::default(); + assert_eq!(config.default_branch, "main"); + } + + #[test] + fn test_custom_branch() { + let config = GitConfig::new("develop"); + assert_eq!(config.default_branch, "develop"); + } + + #[test] + fn test_ci_branches() { + let config = GitConfig::default(); + assert_eq!(config.ci_branches(), vec!["main"]); + } +} diff --git a/core/ir/src/lib.rs b/core/ir/src/lib.rs index bd2954d9fc6..518b4ae5681 100644 --- a/core/ir/src/lib.rs +++ b/core/ir/src/lib.rs @@ -52,6 +52,7 @@ pub mod compose; pub mod contract; pub mod dag; pub mod entrypoint; +pub mod git; pub mod language; pub mod node; pub mod patterns; @@ -78,12 +79,16 @@ pub use patterns::{ pub use signature::{infer_signature, SignatureError, SignaturePort, WorkflowSignature}; pub use contract::TypeContract; pub use resource::{AccessMode, ResourceAccess, ResourceConflict, ResourceId}; -pub use cargo::{CargoInvocation, PREFIX as CARGO_PREFIX}; +pub use cargo::{ + CargoCommand, CargoEnv, CargoInvocation, Subcommand, TermColor, Warnings, + PREFIX as CARGO_PREFIX, +}; +pub use git::GitConfig; pub use transport::{TransportRequest, TransportResponse}; pub use type_op::{BaseType, Coercion, Predicate, PredicateValue, TypeOp, WrapperKind}; pub use type_registry::{TypeNotFoundError, TypeRegistry}; pub use types::{Cardinality, CardinalityCase, CardinalityMismatch, NodeId, PortName, TypeId}; -pub use value::Value; +pub use value::{SecretString, Value}; pub use render::Renderable; // Re-exports from language module for common use diff --git a/core/ir/src/transport/ci/mod.rs b/core/ir/src/transport/ci/mod.rs index 34dcb1e8dc0..f7879348dda 100644 --- a/core/ir/src/transport/ci/mod.rs +++ b/core/ir/src/transport/ci/mod.rs @@ -64,7 +64,8 @@ pub use command::{AnnotationLevel, FileLocation, WorkflowCommand}; pub use provider::{detect_provider, is_ci, CiProvider}; pub use providers::{GitHubActionsProvider, GitLabCiProvider, PlainTextProvider}; pub use render::{ - dag_to_shared_steps, CacheConfig, CheckoutConfig, CiRenderer, RenderConfig, SharedStep, + dag_to_shared_steps, yaml_block, CacheConfig, CheckoutConfig, CiRenderer, RenderConfig, + SharedStep, }; pub use runner::{ all_gitlab_runners, gitlab_saas_linux_large, gitlab_saas_linux_medium, gitlab_saas_linux_small, diff --git a/core/ir/src/transport/ci/providers/github.rs b/core/ir/src/transport/ci/providers/github.rs index b995b76aa9b..2ced69c0a11 100644 --- a/core/ir/src/transport/ci/providers/github.rs +++ b/core/ir/src/transport/ci/providers/github.rs @@ -175,42 +175,29 @@ impl CiRenderer for GitHubActionsProvider { /// Render a GitHub Actions workflow from shared steps. fn render_github_workflow(steps: &[SharedStep], config: &RenderConfig) -> String { + use crate::transport::ci::yaml_block; + let mut yaml = String::new(); - // Header from render config — generator name and regen command are set by the caller yaml.push_str(&config.header("#")); yaml.push_str(&format!("\n\nname: {}\n\n", config.workflow_name)); - // Triggers - yaml.push_str("on:\n"); - yaml.push_str(" push:\n"); - yaml.push_str(" branches:\n"); - for branch in &config.branches { - yaml.push_str(&format!(" - {}\n", branch)); - } + // Triggers — derived from git config + let branches = config.git.ci_branches(); + yaml.push_str("on:\n push:\n"); + yaml_block(&mut yaml, " branches:", &branches, |b| format!(" - {}", b)); yaml.push_str(" pull_request:\n"); - yaml.push_str(" branches:\n"); - for branch in &config.branches { - yaml.push_str(&format!(" - {}\n", branch)); - } - yaml.push('\n'); + yaml_block(&mut yaml, " branches:", &branches, |b| format!(" - {}", b)); - // Environment variables - if !config.env.is_empty() { - yaml.push_str("env:\n"); - for (key, value) in &config.env { - yaml.push_str(&format!(" {}: {}\n", key, value)); - } - yaml.push('\n'); - } + // Environment — derived from cargo env + manual overrides + yaml_block(&mut yaml, "env:", &config.all_env(), |(k, v)| format!(" {}: {}", k, v)); - // Jobs - yaml.push_str("jobs:\n"); - yaml.push_str(&format!(" {}:\n", config.workflow_name)); - yaml.push_str(&format!(" runs-on: {}\n", config.runner)); - yaml.push_str(" steps:\n"); + // Job + yaml.push_str(&format!( + "jobs:\n {}:\n runs-on: {}\n steps:\n", + config.workflow_name, config.runner.id, + )); - // Render each step for step in steps { yaml.push_str(&render_github_step(step, config)); } @@ -370,8 +357,8 @@ mod tests { let provider = GitHubActionsProvider; let tool = CargoInvocation::composed("ci", "dag"); let config = RenderConfig::new("ci", tool) - .with_runner("ubuntu-latest") - .with_env("CARGO_TERM_COLOR", "always"); + .with_runner(ubuntu_latest()) + .with_cargo_env(crate::cargo::CargoEnv::ci()); let yaml = provider.render(&dag, &config); @@ -383,6 +370,7 @@ mod tests { assert!(yaml.contains(&format!("{ci_name} step build"))); assert!(yaml.contains(&format!("{ci_name} step test"))); assert!(yaml.contains("CARGO_TERM_COLOR: always")); + assert!(yaml.contains("RUSTFLAGS: -D warnings")); } #[test] diff --git a/core/ir/src/transport/ci/providers/gitlab.rs b/core/ir/src/transport/ci/providers/gitlab.rs index 82f5f2f0deb..f953c296113 100644 --- a/core/ir/src/transport/ci/providers/gitlab.rs +++ b/core/ir/src/transport/ci/providers/gitlab.rs @@ -219,35 +219,19 @@ impl CiRenderer for GitLabCiProvider { /// Render a GitLab CI configuration from shared steps. fn render_gitlab_ci(steps: &[SharedStep], config: &RenderConfig) -> String { + use crate::transport::ci::yaml_block; + let mut yaml = String::new(); - // Header from render config — generator name and regen command are set by the caller yaml.push_str(&config.header("#")); - yaml.push_str("\n\n"); - - // Default image - yaml.push_str(&format!("image: {}\n\n", config.runner)); + yaml.push_str(&format!("\n\nimage: {}\n\n", config.runner.id)); - // Variables - if !config.env.is_empty() { - yaml.push_str("variables:\n"); - for (key, value) in &config.env { - yaml.push_str(&format!(" {}: \"{}\"\n", key, value)); - } - yaml.push('\n'); - } + // Variables — derived from cargo env + manual overrides + yaml_block(&mut yaml, "variables:", &config.all_env(), |(k, v)| format!(" {}: \"{}\"", k, v)); - // Compute stages from DAG structure - let stages = compute_stages(steps); - if !stages.is_empty() { - yaml.push_str("stages:\n"); - for stage in &stages { - yaml.push_str(&format!(" - {}\n", stage)); - } - yaml.push('\n'); - } + // Stages — computed from DAG structure + yaml_block(&mut yaml, "stages:", &compute_stages(steps), |s| format!(" - {}", s)); - // Render each job for step in steps { yaml.push_str(&render_gitlab_job(step, config)); } @@ -475,15 +459,19 @@ mod tests { let provider = test_provider(); let tool = CargoInvocation::composed("ci", "dag"); + let cargo_env = crate::cargo::CargoEnv { + term_color: crate::cargo::TermColor::Always, + warnings: crate::cargo::Warnings::Default, + }; let config = RenderConfig::new("ci", tool) - .with_runner("rust:latest") - .with_env("CARGO_TERM_COLOR", "always"); + .with_runner(crate::transport::github_actions::ubuntu_latest()) + .with_cargo_env(cargo_env); let yaml = provider.render(&dag, &config); let ci_name = crate::cargo::name("ci"); // Check structure - assert!(yaml.contains("image: rust:latest")); + assert!(yaml.contains("image: ubuntu-latest")); assert!(yaml.contains("stages:")); assert!(yaml.contains(&format!("{ci_name} step build"))); assert!(yaml.contains(&format!("{ci_name} step test"))); diff --git a/core/ir/src/transport/ci/render.rs b/core/ir/src/transport/ci/render.rs index 3d875754b3c..16108cb0a6f 100644 --- a/core/ir/src/transport/ci/render.rs +++ b/core/ir/src/transport/ci/render.rs @@ -28,8 +28,10 @@ //! 3. **Step granularity**: Each DAG node becomes a CI step for visibility //! 4. **Data passing**: Node outputs become step outputs (GitHub) or artifacts (GitLab) -use crate::cargo::CargoInvocation; +use crate::cargo::{CargoEnv, CargoInvocation}; +use crate::git::GitConfig; use crate::language::traits::comment::generated_header; +use crate::transport::github_actions::RunnerImage; use crate::{Dag, NodeId}; use std::collections::HashMap; @@ -62,7 +64,7 @@ pub trait CiRenderer { } /// Configuration for CI YAML rendering. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct RenderConfig { /// Name of the workflow/pipeline. pub workflow_name: String, @@ -78,41 +80,58 @@ pub struct RenderConfig { /// Used in the "DO NOT EDIT - regenerate with:" header. pub regenerate_command: String, - /// Runner/image to use (e.g., "ubuntu-latest", "saas-linux-small-amd64"). - pub runner: String, + /// Runner image to use. Selected from the provider's catalog + /// (e.g., `github_actions::ubuntu_latest()`). + pub runner: RunnerImage, /// Whether to use step mode (each node as separate CI step). pub step_mode: bool, - /// Additional environment variables to set. + /// Cargo environment configuration (term color, warnings policy). + /// The rendering layer derives env vars from this model. + pub cargo_env: CargoEnv, + + /// Additional non-cargo environment variables. pub env: HashMap, /// Checkout action/config (provider-specific). pub checkout: Option, - /// Branches to trigger on (for push/PR). - pub branches: Vec, + /// Git configuration (default branch, branching strategy). + /// CI triggers are derived from this model. + pub git: GitConfig, /// Caching configuration. pub cache: Option, } -impl RenderConfig { - /// Create a new render config with defaults. - pub fn new(workflow_name: &str, tool: CargoInvocation) -> Self { +impl Default for RenderConfig { + fn default() -> Self { Self { - workflow_name: workflow_name.to_string(), - tool, + workflow_name: String::new(), + tool: CargoInvocation::default(), generator_name: String::new(), regenerate_command: String::new(), - runner: "ubuntu-latest".to_string(), + runner: crate::transport::github_actions::ubuntu_latest(), step_mode: true, + cargo_env: CargoEnv::default(), env: HashMap::new(), checkout: Some(CheckoutConfig::default()), - branches: vec!["main".to_string()], + git: GitConfig::default(), cache: None, } } +} + +impl RenderConfig { + /// Create a new render config with defaults. + pub fn new(workflow_name: &str, tool: CargoInvocation) -> Self { + Self { + workflow_name: workflow_name.to_string(), + tool, + ..Self::default() + } + } /// Set the generator name and regeneration command. /// @@ -135,9 +154,9 @@ impl RenderConfig { generated_header(&self.generator_name, &self.regenerate_command, comment_prefix) } - /// Set the runner. - pub fn with_runner(mut self, runner: &str) -> Self { - self.runner = runner.to_string(); + /// Set the runner image (selected from the provider's catalog). + pub fn with_runner(mut self, runner: RunnerImage) -> Self { + self.runner = runner; self } @@ -147,15 +166,21 @@ impl RenderConfig { self } - /// Add an environment variable. + /// Set cargo environment configuration. + pub fn with_cargo_env(mut self, cargo_env: CargoEnv) -> Self { + self.cargo_env = cargo_env; + self + } + + /// Add a non-cargo environment variable. pub fn with_env(mut self, key: &str, value: &str) -> Self { self.env.insert(key.to_string(), value.to_string()); self } - /// Set branches to trigger on. - pub fn with_branches(mut self, branches: Vec<&str>) -> Self { - self.branches = branches.into_iter().map(String::from).collect(); + /// Set git configuration (default branch, branching strategy). + pub fn with_git(mut self, git: GitConfig) -> Self { + self.git = git; self } @@ -164,6 +189,43 @@ impl RenderConfig { self.cache = Some(cache); self } + + /// Build the complete set of environment variables for CI rendering. + /// + /// Merges cargo-derived env vars with any manually-added env vars. + /// Cargo env takes precedence for keys it owns (CARGO_TERM_COLOR, RUSTFLAGS). + pub fn all_env(&self) -> Vec<(String, String)> { + let mut result: Vec<(String, String)> = self.cargo_env.to_env_map(); + for (k, v) in &self.env { + // Don't duplicate keys that cargo_env already provides + if !result.iter().any(|(rk, _)| rk == k) { + result.push((k.clone(), v.clone())); + } + } + result + } +} + +// ============================================================================ +// YAML Rendering Utilities +// ============================================================================ + +/// Emit a YAML block only if `items` is non-empty. +/// +/// Writes `header\n`, then each formatted item as a line, then a trailing blank line. +/// Skips entirely when `items` is empty. This is the standard pattern for +/// YAML sections like `env:`, `variables:`, `branches:`, `stages:`, etc. +pub fn yaml_block(yaml: &mut String, header: &str, items: &[T], fmt: impl Fn(&T) -> String) { + if items.is_empty() { + return; + } + yaml.push_str(header); + yaml.push('\n'); + for item in items { + yaml.push_str(&fmt(item)); + yaml.push('\n'); + } + yaml.push('\n'); } /// Checkout configuration. @@ -372,14 +434,19 @@ mod tests { #[test] fn test_render_config_builder() { + use crate::transport::github_actions::ubuntu_22_04; + let tool = CargoInvocation::composed("ci", "dag"); let config = RenderConfig::new("ci", tool) - .with_runner("ubuntu-22.04") - .with_env("CARGO_TERM_COLOR", "always") - .with_branches(vec!["main", "develop"]); + .with_runner(ubuntu_22_04()) + .with_cargo_env(CargoEnv::ci()) + .with_git(GitConfig::new("develop")); + + assert_eq!(config.runner.id, "ubuntu-22.04"); + assert_eq!(config.git.default_branch, "develop"); - assert_eq!(config.runner, "ubuntu-22.04"); - assert_eq!(config.env.get("CARGO_TERM_COLOR"), Some(&"always".to_string())); - assert_eq!(config.branches, vec!["main", "develop"]); + let env = config.all_env(); + assert!(env.contains(&("CARGO_TERM_COLOR".to_string(), "always".to_string()))); + assert!(env.contains(&("RUSTFLAGS".to_string(), "-D warnings".to_string()))); } } diff --git a/core/ir/src/transport/llm/anthropic.rs b/core/ir/src/transport/llm/anthropic.rs new file mode 100644 index 00000000000..5c5c533b54b --- /dev/null +++ b/core/ir/src/transport/llm/anthropic.rs @@ -0,0 +1,661 @@ +//! Anthropic Messages API conversions. +//! +//! Pure functions that convert between gunbc chat types and +//! Anthropic-specific REST request/response formats. +//! +//! # API Reference +//! +//! Anthropic Messages API: +//! - Endpoint: POST /v1/messages +//! - Auth: x-api-key header +//! - Request body: { model, messages, system?, max_tokens, temperature?, thinking? } +//! - Response body: { content: [{type, text}], model, stop_reason, usage } +//! +//! # Prompt Caching +//! +//! Anthropic supports explicit prompt caching via `cache_control` on content blocks: +//! - System messages as content block arrays with `cache_control: {"type": "ephemeral"}` +//! - Min cacheable: 1024 tokens (Sonnet/Opus 4), 2048 (Haiku 3), 4096 (Opus 4.5, Haiku 4.5) +//! - Up to 4 breakpoints; caches from request start to each breakpoint +//! - 5-minute default TTL; cache reads cost 10%, writes cost 125% of base +//! +//! Ref: +//! +//! # Extended Thinking +//! +//! Enabled via `thinking: {type: "enabled", budget_tokens: N}` in the request. +//! Response includes `thinking` content blocks before `text` blocks. +//! Budget must be < max_tokens. Incompatible with temperature/top_k. +//! Supported: Claude Sonnet 3.7+, Haiku 4.5, Opus 4+. +//! Ref: +//! +//! # Key Differences from OpenAI +//! +//! - System messages are a separate top-level field, not in the messages array +//! - System supports content block arrays (required for cache_control) +//! - Auth uses a custom header (x-api-key) instead of Bearer token +//! - Requires anthropic-version header +//! - Response content is an array of content blocks, not a single string +//! - max_tokens is required (not optional) +//! - Extended thinking uses budget_tokens (vs OpenAI's effort level) + +use super::chat::{ + CacheControl, ChatMessage, ChatRequest, ChatResponse, ContentBlock, FinishReason, + MessageContent, ResponseBlock, Role, ThinkingConfig, Usage, +}; +use super::provider::anthropic_provider; +use crate::transport::http::HttpMethod; +use crate::transport::rest::{AuthMethod, RestRequest, RestResponse}; + +/// Default max_tokens for Anthropic requests when not specified. +/// +/// Anthropic requires max_tokens to be set. This provides a reasonable default. +const DEFAULT_MAX_TOKENS: u64 = 4096; + +/// Serialize a message's content to a JSON value for the Anthropic API. +/// +/// Simple text becomes a plain string. Structured blocks become an array +/// of content block objects with optional `cache_control`. +fn serialize_content(content: &MessageContent) -> serde_json::Value { + match content { + MessageContent::Text(s) => serde_json::json!(s), + MessageContent::Blocks(blocks) => { + let items: Vec = blocks + .iter() + .map(|block| match block { + ContentBlock::Text { + text, + cache_control, + } => { + let mut obj = serde_json::json!({"type": "text", "text": text}); + if let Some(CacheControl { .. }) = cache_control { + obj["cache_control"] = serde_json::json!({"type": "ephemeral"}); + } + obj + } + ContentBlock::Thinking { + thinking, + signature, + } => serde_json::json!({ + "type": "thinking", + "thinking": thinking, + "signature": signature, + }), + ContentBlock::RedactedThinking { data } => { + serde_json::json!({"type": "redacted_thinking", "data": data}) + } + }) + .collect(); + serde_json::json!(items) + } + } +} + +/// Build an Anthropic Messages API request from a chat request. +/// +/// This is a PURE function - no I/O. The resulting `RestRequest` should be +/// executed via `TransportOps::Execute`. +/// +/// Handles the Anthropic-specific format: +/// - System messages as content block array (supports cache_control breakpoints) +/// - Auth via `x-api-key` header (using `AuthMethod::EnvVarHeader`) +/// - Required `anthropic-version` header +/// - `max_tokens` is required (defaults to 4096 if not set) +/// - Extended thinking via `thinking` param when configured +pub fn build_anthropic_request(chat: &ChatRequest) -> RestRequest { + let provider = anthropic_provider(); + + // System messages → top-level `system` field. + // If any system message uses content blocks (for cache_control), we emit + // the full content block array format. Otherwise, a simple string. + let system_msgs = chat.system_messages(); + let has_blocks = system_msgs + .iter() + .any(|m| m.content.is_blocks()); + + let system_value = if system_msgs.is_empty() { + None + } else if has_blocks { + // Content block array format (required for cache_control) + let mut blocks: Vec = Vec::new(); + for msg in &system_msgs { + match &msg.content { + MessageContent::Text(s) => { + blocks.push(serde_json::json!({"type": "text", "text": s})); + } + MessageContent::Blocks(msg_blocks) => { + for block in msg_blocks { + if let ContentBlock::Text { + text, + cache_control, + } = block + { + let mut obj = + serde_json::json!({"type": "text", "text": text}); + if let Some(CacheControl { .. }) = cache_control { + obj["cache_control"] = + serde_json::json!({"type": "ephemeral"}); + } + blocks.push(obj); + } + } + } + } + } + Some(serde_json::json!(blocks)) + } else { + // Simple string format (no cache_control needed) + let text: String = system_msgs + .iter() + .map(|m| m.text()) + .collect::>() + .join("\n\n"); + Some(serde_json::json!(text)) + }; + + let messages: Vec = chat + .non_system_messages() + .iter() + .map(|m| { + serde_json::json!({ + "role": m.role.as_str(), + "content": serialize_content(&m.content), + }) + }) + .collect(); + + let max_tokens = chat.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS); + + let mut body = serde_json::json!({ + "model": chat.model, + "messages": messages, + "max_tokens": max_tokens, + }); + + if let Some(system) = system_value { + body["system"] = system; + } + if let Some(temp) = chat.temperature { + body["temperature"] = serde_json::json!(temp); + } + if !chat.stop.is_empty() { + body["stop_sequences"] = serde_json::json!(chat.stop); + } + + // Extended thinking + if let Some(ThinkingConfig::Anthropic { budget_tokens }) = &chat.thinking { + body["thinking"] = serde_json::json!({ + "type": "enabled", + "budget_tokens": budget_tokens, + }); + } + + let mut headers = std::collections::HashMap::new(); + headers.insert( + "Content-Type".to_string(), + "application/json".to_string(), + ); + for (k, v) in &provider.extra_headers { + headers.insert(k.clone(), v.clone()); + } + + RestRequest { + url: provider.chat_url(), + method: HttpMethod::Post, + headers, + body: Some(body), + auth: Some(AuthMethod::EnvVarHeader { + header: "x-api-key".to_string(), + env_var: provider.api_key_env.0.clone(), + }), + query: Default::default(), + timeout_ms: Some(120_000), + } +} + +/// Parse an Anthropic Messages API response. +/// +/// Extracts the assistant's message content from the content blocks, +/// along with stop reason, usage statistics, and thinking blocks. +/// +/// Returns `Err` if the response body doesn't match the expected format. +pub fn parse_anthropic_response(response: &RestResponse) -> Result { + if !response.is_success() { + let error_msg = response + .body + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!( + "Anthropic API error (status {}): {}", + response.status, error_msg + )); + } + + // Parse content blocks + let mut text_parts: Vec = Vec::new(); + let mut thinking_parts: Vec = Vec::new(); + let mut response_blocks: Vec = Vec::new(); + + if let Some(content_array) = response.body.get("content").and_then(|c| c.as_array()) { + for block in content_array { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match block_type { + "text" => { + let text = block + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + text_parts.push(text.clone()); + response_blocks.push(ResponseBlock::Text { text }); + } + "thinking" => { + let thinking = block + .get("thinking") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let signature = block + .get("signature") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + thinking_parts.push(thinking.clone()); + response_blocks.push(ResponseBlock::Thinking { + thinking, + signature: Some(signature), + }); + } + "redacted_thinking" => { + let data = block + .get("data") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + response_blocks.push(ResponseBlock::RedactedThinking { data }); + } + _ => {} // ignore unknown block types + } + } + } + + let content = text_parts.join(""); + + let model = response + .body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + let finish_reason = response + .body + .get("stop_reason") + .and_then(|r| r.as_str()) + .map(FinishReason::from_anthropic) + .unwrap_or(FinishReason::Stop); + + let usage = response + .body + .get("usage") + .map(|u| Usage { + input_tokens: u + .get("input_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + output_tokens: u + .get("output_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + cache_creation_input_tokens: u + .get("cache_creation_input_tokens") + .and_then(|t| t.as_u64()), + cache_read_input_tokens: u + .get("cache_read_input_tokens") + .and_then(|t| t.as_u64()), + cached_tokens: None, + reasoning_tokens: None, + }) + .unwrap_or_default(); + + let thinking = if thinking_parts.is_empty() { + None + } else { + Some(thinking_parts.join("\n")) + }; + + // Only include content_blocks if there were non-text blocks + let has_non_text = response_blocks + .iter() + .any(|b| !matches!(b, ResponseBlock::Text { .. })); + let content_blocks = if has_non_text { + response_blocks + } else { + Vec::new() + }; + + Ok(ChatResponse { + content, + model, + finish_reason, + usage, + thinking, + content_blocks, + }) +} + +/// Convert a `ChatResponse` back into a `ChatMessage` for multi-turn conversations. +pub fn response_to_message(response: &ChatResponse) -> ChatMessage { + if response.content_blocks.is_empty() { + ChatMessage { + role: Role::Assistant, + content: MessageContent::Text(response.content.clone()), + } + } else { + // Preserve thinking blocks for multi-turn tool use + let blocks = response + .content_blocks + .iter() + .map(|b| match b { + ResponseBlock::Text { text } => ContentBlock::text(text), + ResponseBlock::Thinking { + thinking, + signature, + } => ContentBlock::Thinking { + thinking: thinking.clone(), + signature: signature.clone().unwrap_or_default(), + }, + ResponseBlock::RedactedThinking { data } => ContentBlock::RedactedThinking { + data: data.clone(), + }, + }) + .collect(); + ChatMessage::assistant_blocks(blocks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::llm::chat::CacheControl; + + fn sample_request() -> ChatRequest { + ChatRequest::new( + "claude-sonnet-4-20250514", + vec![ + ChatMessage::system("You are a code reviewer."), + ChatMessage::user("Review this function."), + ], + ) + .temperature(0.3) + .max_tokens(2048) + } + + #[test] + fn test_build_anthropic_request() { + let req = build_anthropic_request(&sample_request()); + + assert_eq!(req.url, "https://api.anthropic.com/v1/messages"); + assert_eq!(req.method, HttpMethod::Post); + + // Check anthropic-version header + assert_eq!( + req.headers.get("anthropic-version"), + Some(&"2023-06-01".to_string()) + ); + + let body = req.body.unwrap(); + assert_eq!(body["model"], "claude-sonnet-4-20250514"); + assert_eq!(body["max_tokens"], 2048); + assert_eq!(body["temperature"], 0.3); + + // System should be a top-level field, not in messages + assert_eq!(body["system"], "You are a code reviewer."); + + // Messages should only contain non-system messages + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + } + + #[test] + fn test_build_anthropic_request_no_system() { + let req = build_anthropic_request(&ChatRequest::new( + "claude-sonnet-4-20250514", + vec![ChatMessage::user("Hello")], + )); + + let body = req.body.unwrap(); + assert!(body.get("system").is_none()); + } + + #[test] + fn test_build_anthropic_request_default_max_tokens() { + let req = build_anthropic_request(&ChatRequest::new( + "claude-sonnet-4-20250514", + vec![ChatMessage::user("Hello")], + )); + + let body = req.body.unwrap(); + assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS); + } + + #[test] + fn test_build_anthropic_request_cache_control() { + let req = build_anthropic_request(&ChatRequest::new( + "claude-sonnet-4-20250514", + vec![ + ChatMessage::system_blocks(vec![ + ContentBlock::text("Long system prompt here...") + .with_cache(CacheControl::ephemeral()), + ContentBlock::text("Variable instructions"), + ]), + ChatMessage::user("Hello"), + ], + )); + + let body = req.body.unwrap(); + let system = body["system"].as_array().unwrap(); + assert_eq!(system.len(), 2); + assert_eq!(system[0]["type"], "text"); + assert_eq!(system[0]["text"], "Long system prompt here..."); + assert_eq!( + system[0]["cache_control"], + serde_json::json!({"type": "ephemeral"}) + ); + assert!(system[1].get("cache_control").is_none()); + } + + #[test] + fn test_build_anthropic_request_thinking() { + let req = build_anthropic_request( + &ChatRequest::new( + "claude-sonnet-4-5", + vec![ChatMessage::user("Think hard.")], + ) + .max_tokens(16000) + .thinking(ThinkingConfig::anthropic(10000)), + ); + + let body = req.body.unwrap(); + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], 10000); + // Temperature should not be set when thinking is enabled + assert!(body.get("temperature").is_none()); + } + + #[test] + fn test_parse_anthropic_response_success() { + let response = RestResponse::ok(serde_json::json!({ + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "The function looks good overall." + } + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 30, + "output_tokens": 15 + } + })); + + let chat = parse_anthropic_response(&response).unwrap(); + assert_eq!(chat.content, "The function looks good overall."); + assert_eq!(chat.model, "claude-sonnet-4-20250514"); + assert_eq!(chat.finish_reason, FinishReason::Stop); + assert_eq!(chat.usage.input_tokens, 30); + assert_eq!(chat.usage.output_tokens, 15); + assert!(chat.thinking.is_none()); + assert!(chat.content_blocks.is_empty()); + } + + #[test] + fn test_parse_anthropic_response_multi_block() { + let response = RestResponse::ok(serde_json::json!({ + "content": [ + { "type": "text", "text": "First part. " }, + { "type": "text", "text": "Second part." } + ], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": { "input_tokens": 10, "output_tokens": 20 } + })); + + let chat = parse_anthropic_response(&response).unwrap(); + assert_eq!(chat.content, "First part. Second part."); + } + + #[test] + fn test_parse_anthropic_response_with_thinking() { + let response = RestResponse::ok(serde_json::json!({ + "content": [ + { + "type": "thinking", + "thinking": "Let me analyze step by step...", + "signature": "abc123sig" + }, + { + "type": "text", + "text": "Based on my analysis..." + } + ], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": { "input_tokens": 30, "output_tokens": 500 } + })); + + let chat = parse_anthropic_response(&response).unwrap(); + assert_eq!(chat.content, "Based on my analysis..."); + assert_eq!( + chat.thinking, + Some("Let me analyze step by step...".to_string()) + ); + assert_eq!(chat.content_blocks.len(), 2); + assert!(matches!( + &chat.content_blocks[0], + ResponseBlock::Thinking { thinking, signature: Some(sig) } + if thinking == "Let me analyze step by step..." && sig == "abc123sig" + )); + } + + #[test] + fn test_parse_anthropic_response_cache_usage() { + let response = RestResponse::ok(serde_json::json!({ + "content": [{ "type": "text", "text": "Cached response" }], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 17, + "output_tokens": 700, + "cache_creation_input_tokens": 1370, + "cache_read_input_tokens": 0 + } + })); + + let chat = parse_anthropic_response(&response).unwrap(); + assert_eq!(chat.usage.cache_creation_input_tokens, Some(1370)); + assert_eq!(chat.usage.cache_read_input_tokens, Some(0)); + assert!(chat.usage.cached_tokens.is_none()); + } + + #[test] + fn test_parse_anthropic_response_error() { + let response = RestResponse::new( + 401, + serde_json::json!({ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid x-api-key" + } + }), + ); + + let err = parse_anthropic_response(&response).unwrap_err(); + assert!(err.contains("invalid x-api-key")); + assert!(err.contains("401")); + } + + #[test] + fn test_parse_anthropic_response_max_tokens() { + let response = RestResponse::ok(serde_json::json!({ + "content": [{ "type": "text", "text": "partial..." }], + "model": "claude-sonnet-4-20250514", + "stop_reason": "max_tokens", + "usage": { "input_tokens": 10, "output_tokens": 100 } + })); + + let chat = parse_anthropic_response(&response).unwrap(); + assert_eq!(chat.finish_reason, FinishReason::Length); + } + + #[test] + fn test_response_to_message_simple() { + let response = ChatResponse { + content: "Hello!".to_string(), + model: "test".to_string(), + finish_reason: FinishReason::Stop, + usage: Usage::default(), + thinking: None, + content_blocks: Vec::new(), + }; + + let msg = response_to_message(&response); + assert_eq!(msg.role, Role::Assistant); + assert_eq!(msg.text(), "Hello!"); + } + + #[test] + fn test_response_to_message_with_thinking() { + let response = ChatResponse { + content: "Answer".to_string(), + model: "test".to_string(), + finish_reason: FinishReason::Stop, + usage: Usage::default(), + thinking: Some("Reasoning...".to_string()), + content_blocks: vec![ + ResponseBlock::Thinking { + thinking: "Reasoning...".to_string(), + signature: Some("sig123".to_string()), + }, + ResponseBlock::Text { + text: "Answer".to_string(), + }, + ], + }; + + let msg = response_to_message(&response); + assert_eq!(msg.role, Role::Assistant); + assert!(msg.content.is_blocks()); + let blocks = msg.content.blocks().unwrap(); + assert_eq!(blocks.len(), 2); + assert!(matches!(&blocks[0], ContentBlock::Thinking { .. })); + } +} diff --git a/core/ir/src/transport/llm/chat.rs b/core/ir/src/transport/llm/chat.rs new file mode 100644 index 00000000000..c3a3db5daa8 --- /dev/null +++ b/core/ir/src/transport/llm/chat.rs @@ -0,0 +1,783 @@ +//! Chat completion types for LLM providers. +//! +//! These types provide a unified interface for chat-based LLM APIs +//! (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages). +//! +//! Provider-specific serialization is handled by conversion functions +//! in the provider modules (openai.rs, anthropic.rs, openai_responses.rs). +//! +//! # Content Model +//! +//! Messages support both simple text and structured content blocks: +//! +//! ```ignore +//! // Simple text (most common) +//! ChatMessage::user("Hello!") +//! +//! // Structured blocks with cache hints +//! ChatMessage::user_blocks(vec![ +//! ContentBlock::text("Long system context...").with_cache(CacheControl::ephemeral()), +//! ContentBlock::text("Actual question"), +//! ]) +//! ``` +//! +//! Provider modules translate cache hints to provider-specific formats: +//! - **Anthropic**: `cache_control: {"type": "ephemeral"}` on content blocks +//! - **OpenAI**: Automatic prefix caching (no API changes; hints ignored) +//! +//! # Thinking / Reasoning +//! +//! Extended thinking is configured per-request via `ThinkingConfig`: +//! +//! ```ignore +//! let req = ChatRequest::new("claude-sonnet-4-5", messages) +//! .thinking(ThinkingConfig::anthropic(10000)); +//! +//! let req = ChatRequest::new("o3", messages) +//! .thinking(ThinkingConfig::openai(ReasoningEffort::High)); +//! ``` +//! +//! # API References +//! +//! - Anthropic Messages: +//! - Anthropic Prompt Caching: +//! - Anthropic Extended Thinking: +//! - OpenAI Chat Completions: +//! - OpenAI Responses: +//! - OpenAI Prompt Caching: +//! - OpenAI Reasoning: + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Content blocks and cache control +// --------------------------------------------------------------------------- + +/// Cache hint for a content block. +/// +/// Providers translate this to their native format: +/// - **Anthropic**: `cache_control: {"type": "ephemeral"}` on the content block. +/// Caches everything from the start of the request up to this breakpoint. +/// 5-minute TTL by default; min 1024 tokens (Sonnet/Opus 4), 2048 (Haiku 3). +/// Cache reads cost 10% of base input price; writes cost 125%. +/// Ref: +/// +/// - **OpenAI**: Automatic prefix caching for identical prefixes >= 1024 tokens. +/// No API changes needed; place static content first for best cache hit rate. +/// Cached tokens cost 50% of input price. 5-10min TTL (up to 1hr off-peak). +/// Ref: +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheControl { + /// The caching strategy. + pub cache_type: CacheType, +} + +impl CacheControl { + /// Mark content as cacheable with the default ephemeral (5-minute) TTL. + pub fn ephemeral() -> Self { + Self { + cache_type: CacheType::Ephemeral, + } + } +} + +/// Cache strategy type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum CacheType { + /// Cache with a short TTL (5 minutes on Anthropic; automatic on OpenAI). + Ephemeral, +} + +/// A structured content block within a message. +/// +/// Most messages use simple text, but content blocks enable cache hints +/// and multi-part content (e.g., a cached system prompt followed by +/// variable instructions). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ContentBlock { + /// Plain text content with an optional cache hint. + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + /// Thinking block from an extended-thinking response (Anthropic). + /// Must be preserved verbatim when passing back in multi-turn tool-use. + Thinking { + thinking: String, + signature: String, + }, + /// Redacted thinking block (Anthropic safety filter). + /// Must be preserved verbatim for reasoning continuity. + RedactedThinking { + data: String, + }, +} + +impl ContentBlock { + /// Create a plain text block. + pub fn text(text: impl Into) -> Self { + ContentBlock::Text { + text: text.into(), + cache_control: None, + } + } + + /// Attach a cache hint to this block. + pub fn with_cache(self, cc: CacheControl) -> Self { + match self { + ContentBlock::Text { text, .. } => ContentBlock::Text { + text, + cache_control: Some(cc), + }, + other => other, // cache hints only apply to text blocks + } + } + + /// Get the text content, if this is a text block. + pub fn as_text(&self) -> Option<&str> { + match self { + ContentBlock::Text { text, .. } => Some(text), + _ => None, + } + } +} + +/// Message content — either simple text or structured content blocks. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + /// Simple text content (the common case). + Text(String), + /// Structured content blocks with optional cache hints. + Blocks(Vec), +} + +impl MessageContent { + /// Get the text content. For blocks, concatenates all text blocks. + pub fn text(&self) -> String { + match self { + MessageContent::Text(s) => s.clone(), + MessageContent::Blocks(blocks) => blocks + .iter() + .filter_map(|b| b.as_text()) + .collect::>() + .join(""), + } + } + + /// Whether this is simple text content. + pub fn is_text(&self) -> bool { + matches!(self, MessageContent::Text(_)) + } + + /// Whether this uses structured content blocks. + pub fn is_blocks(&self) -> bool { + matches!(self, MessageContent::Blocks(_)) + } + + /// Get the content blocks, if structured. + pub fn blocks(&self) -> Option<&[ContentBlock]> { + match self { + MessageContent::Blocks(blocks) => Some(blocks), + _ => None, + } + } +} + +impl From for MessageContent { + fn from(s: String) -> Self { + MessageContent::Text(s) + } +} + +impl From<&str> for MessageContent { + fn from(s: &str) -> Self { + MessageContent::Text(s.to_string()) + } +} + +// --------------------------------------------------------------------------- +// Thinking / Reasoning configuration +// --------------------------------------------------------------------------- + +/// Extended thinking / reasoning configuration. +/// +/// Each provider implements thinking differently: +/// +/// - **Anthropic** (`thinking` request param): Enables step-by-step reasoning +/// with a token budget. Returns `thinking` content blocks in the response. +/// Budget must be < `max_tokens`. Incompatible with `temperature` / `top_k`. +/// Supported on: Claude Sonnet 3.7+, Haiku 4.5, Opus 4+. +/// Ref: +/// +/// - **OpenAI** (`reasoning` request param): Controls reasoning depth via an +/// effort level. Reasoning tokens are hidden; only summaries are visible. +/// Uses `max_completion_tokens` (Chat Completions) or `max_output_tokens` +/// (Responses API) to cap total output. Supported on: o1, o3, o4-mini. +/// Ref: +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ThinkingConfig { + /// Anthropic extended thinking. + Anthropic { + /// Maximum tokens for internal reasoning. Must be < max_tokens. + budget_tokens: u64, + }, + /// OpenAI reasoning. + OpenAI { + /// How much effort to spend on reasoning (low/medium/high). + effort: ReasoningEffort, + /// Whether to include a reasoning summary in the response. + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + }, +} + +impl ThinkingConfig { + /// Anthropic extended thinking with the given token budget. + pub fn anthropic(budget_tokens: u64) -> Self { + ThinkingConfig::Anthropic { budget_tokens } + } + + /// OpenAI reasoning with the given effort level. + pub fn openai(effort: ReasoningEffort) -> Self { + ThinkingConfig::OpenAI { + effort, + summary: None, + } + } + + /// OpenAI reasoning with effort and summary. + pub fn openai_with_summary(effort: ReasoningEffort, summary: ReasoningSummary) -> Self { + ThinkingConfig::OpenAI { + effort, + summary: Some(summary), + } + } +} + +/// Reasoning effort level for OpenAI models (o1, o3, o4-mini). +/// +/// Controls the trade-off between reasoning depth and speed/cost. +/// Default is `Medium`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + Low, + Medium, + High, +} + +impl ReasoningEffort { + pub fn as_str(&self) -> &'static str { + match self { + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High => "high", + } + } +} + +/// Reasoning summary verbosity for OpenAI models. +/// +/// Controls how much of the model's reasoning is returned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningSummary { + /// Brief summary of the reasoning process. + Concise, + /// More complete summary of the reasoning process. + Detailed, +} + +impl ReasoningSummary { + pub fn as_str(&self) -> &'static str { + match self { + ReasoningSummary::Concise => "concise", + ReasoningSummary::Detailed => "detailed", + } + } +} + +// --------------------------------------------------------------------------- +// Roles and messages +// --------------------------------------------------------------------------- + +/// Role in a chat conversation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// System instructions that guide the model's behavior. + System, + /// User input. + User, + /// Assistant (model) response. + Assistant, +} + +impl Role { + /// Parse a role from a string. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "system" => Some(Role::System), + "user" => Some(Role::User), + "assistant" => Some(Role::Assistant), + _ => None, + } + } + + /// Get the role as a string. + pub fn as_str(&self) -> &'static str { + match self { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A single message in a chat conversation. +/// +/// Content can be simple text or structured content blocks with cache hints: +/// +/// ```ignore +/// ChatMessage::user("Simple text") +/// ChatMessage::user_blocks(vec![ +/// ContentBlock::text("Cached context").with_cache(CacheControl::ephemeral()), +/// ContentBlock::text("Variable question"), +/// ]) +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatMessage { + /// The role of the message author. + pub role: Role, + /// The content of the message. + pub content: MessageContent, +} + +impl ChatMessage { + /// Create a system message. + pub fn system(content: impl Into) -> Self { + Self { + role: Role::System, + content: MessageContent::Text(content.into()), + } + } + + /// Create a user message. + pub fn user(content: impl Into) -> Self { + Self { + role: Role::User, + content: MessageContent::Text(content.into()), + } + } + + /// Create an assistant message. + pub fn assistant(content: impl Into) -> Self { + Self { + role: Role::Assistant, + content: MessageContent::Text(content.into()), + } + } + + /// Create a system message with structured content blocks. + pub fn system_blocks(blocks: Vec) -> Self { + Self { + role: Role::System, + content: MessageContent::Blocks(blocks), + } + } + + /// Create a user message with structured content blocks. + pub fn user_blocks(blocks: Vec) -> Self { + Self { + role: Role::User, + content: MessageContent::Blocks(blocks), + } + } + + /// Create an assistant message with structured content blocks. + /// Used to pass back thinking + tool_use blocks in multi-turn conversations. + pub fn assistant_blocks(blocks: Vec) -> Self { + Self { + role: Role::Assistant, + content: MessageContent::Blocks(blocks), + } + } + + /// Get the text content of this message. + pub fn text(&self) -> String { + self.content.text() + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +/// Parameters for a chat completion request. +/// +/// Works with all supported APIs: +/// - OpenAI Chat Completions (`/v1/chat/completions`) +/// - OpenAI Responses (`/v1/responses`) +/// - Anthropic Messages (`/v1/messages`) +/// +/// Provider modules translate to the appropriate wire format. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatRequest { + /// Model identifier (e.g., "gpt-4o", "o3", "claude-sonnet-4-5"). + pub model: String, + /// Conversation messages. + pub messages: Vec, + /// Sampling temperature (0.0 - 2.0). Lower = more deterministic. + /// Incompatible with Anthropic extended thinking. + pub temperature: Option, + /// Maximum tokens to generate (includes thinking budget for Anthropic). + pub max_tokens: Option, + /// Stop sequences. + #[serde(default)] + pub stop: Vec, + /// Extended thinking / reasoning configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, +} + +impl ChatRequest { + /// Create a new chat request with the given model and messages. + pub fn new(model: impl Into, messages: Vec) -> Self { + Self { + model: model.into(), + messages, + temperature: None, + max_tokens: None, + stop: Vec::new(), + thinking: None, + } + } + + /// Set the sampling temperature. + pub fn temperature(mut self, t: f64) -> Self { + self.temperature = Some(t); + self + } + + /// Set the maximum tokens to generate. + pub fn max_tokens(mut self, n: u64) -> Self { + self.max_tokens = Some(n); + self + } + + /// Add a stop sequence. + pub fn stop(mut self, s: impl Into) -> Self { + self.stop.push(s.into()); + self + } + + /// Enable extended thinking / reasoning. + pub fn thinking(mut self, config: ThinkingConfig) -> Self { + self.thinking = Some(config); + self + } + + /// Get system messages from the conversation. + pub fn system_messages(&self) -> Vec<&ChatMessage> { + self.messages + .iter() + .filter(|m| m.role == Role::System) + .collect() + } + + /// Get non-system messages from the conversation. + pub fn non_system_messages(&self) -> Vec<&ChatMessage> { + self.messages + .iter() + .filter(|m| m.role != Role::System) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Response +// --------------------------------------------------------------------------- + +/// Reason the model stopped generating. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FinishReason { + /// Model reached a natural stopping point. + Stop, + /// Hit the max_tokens limit. + Length, + /// Content was filtered. + ContentFilter, + /// Unknown or provider-specific reason. + Other(String), +} + +impl FinishReason { + /// Parse an OpenAI-style finish reason. + pub fn from_openai(s: &str) -> Self { + match s { + "stop" => FinishReason::Stop, + "length" => FinishReason::Length, + "content_filter" => FinishReason::ContentFilter, + other => FinishReason::Other(other.to_string()), + } + } + + /// Parse an Anthropic-style stop reason. + pub fn from_anthropic(s: &str) -> Self { + match s { + "end_turn" => FinishReason::Stop, + "max_tokens" => FinishReason::Length, + other => FinishReason::Other(other.to_string()), + } + } +} + +/// A block of content in a response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ResponseBlock { + /// Generated text. + Text { text: String }, + /// Thinking / reasoning output (Anthropic extended thinking, or OpenAI + /// reasoning summary). + Thinking { + thinking: String, + /// Encrypted signature for Anthropic (must be preserved for multi-turn). + /// `None` for OpenAI reasoning summaries. + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + }, + /// Redacted thinking (Anthropic safety filter). Must be preserved verbatim. + RedactedThinking { data: String }, +} + +/// Token usage statistics. +/// +/// Extends basic input/output counts with provider-specific cache and +/// reasoning token tracking. +/// +/// # Anthropic Cache Tokens +/// +/// When prompt caching is active, Anthropic reports: +/// - `cache_creation_input_tokens`: tokens written to cache (billed at 125%) +/// - `cache_read_input_tokens`: tokens read from cache (billed at 10%) +/// +/// These are separate from `input_tokens` (non-cached input). +/// +/// # OpenAI Cache and Reasoning Tokens +/// +/// OpenAI reports `cached_tokens` inside `prompt_tokens_details` (50% discount) +/// and `reasoning_tokens` inside `completion_tokens_details`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Usage { + /// Tokens in the prompt/input. + pub input_tokens: u64, + /// Tokens in the completion/output. + pub output_tokens: u64, + /// Anthropic: tokens written to the prompt cache on this request. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_creation_input_tokens: Option, + /// Anthropic: tokens read from the prompt cache on this request. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_input_tokens: Option, + /// OpenAI: tokens served from the automatic prompt cache. + #[serde(skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, + /// Tokens used for reasoning/thinking (both providers). + /// Anthropic: thinking output tokens. OpenAI: reasoning_tokens. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, +} + +impl Usage { + /// Total tokens used (input + output). + pub fn total(&self) -> u64 { + self.input_tokens + self.output_tokens + } +} + +/// Parsed response from a chat completion API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatResponse { + /// The generated text content. + pub content: String, + /// Model that generated the response. + pub model: String, + /// Why the model stopped generating. + pub finish_reason: FinishReason, + /// Token usage statistics (includes cache and reasoning token details). + pub usage: Usage, + /// Thinking / reasoning text, if extended thinking was enabled. + /// For Anthropic: the summarized (or full, for 3.7) thinking output. + /// For OpenAI: the reasoning summary (if requested). + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + /// Full structured response blocks (text, thinking, redacted_thinking). + /// Empty if the response only contained plain text. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content_blocks: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chat_message_constructors() { + let sys = ChatMessage::system("You are helpful."); + assert_eq!(sys.role, Role::System); + assert_eq!(sys.text(), "You are helpful."); + + let user = ChatMessage::user("Hello!"); + assert_eq!(user.role, Role::User); + + let asst = ChatMessage::assistant("Hi there!"); + assert_eq!(asst.role, Role::Assistant); + } + + #[test] + fn test_chat_message_blocks() { + let msg = ChatMessage::system_blocks(vec![ + ContentBlock::text("Cached system prompt").with_cache(CacheControl::ephemeral()), + ContentBlock::text("Variable instructions"), + ]); + + assert_eq!(msg.role, Role::System); + assert!(msg.content.is_blocks()); + assert_eq!(msg.text(), "Cached system promptVariable instructions"); + + let blocks = msg.content.blocks().unwrap(); + assert_eq!(blocks.len(), 2); + assert!(matches!(&blocks[0], ContentBlock::Text { cache_control: Some(_), .. })); + assert!(matches!(&blocks[1], ContentBlock::Text { cache_control: None, .. })); + } + + #[test] + fn test_chat_request_builder() { + let req = ChatRequest::new( + "gpt-4o", + vec![ + ChatMessage::system("Be concise."), + ChatMessage::user("What is 2+2?"), + ], + ) + .temperature(0.7) + .max_tokens(100) + .stop("\n"); + + assert_eq!(req.model, "gpt-4o"); + assert_eq!(req.messages.len(), 2); + assert_eq!(req.temperature, Some(0.7)); + assert_eq!(req.max_tokens, Some(100)); + assert_eq!(req.stop, vec!["\n"]); + assert!(req.thinking.is_none()); + } + + #[test] + fn test_chat_request_thinking() { + let req = ChatRequest::new( + "claude-sonnet-4-5", + vec![ChatMessage::user("Think about this.")], + ) + .max_tokens(16000) + .thinking(ThinkingConfig::anthropic(10000)); + + assert!(matches!( + req.thinking, + Some(ThinkingConfig::Anthropic { budget_tokens: 10000 }) + )); + + let req = ChatRequest::new("o3", vec![ChatMessage::user("Reason about this.")]) + .thinking(ThinkingConfig::openai_with_summary( + ReasoningEffort::High, + ReasoningSummary::Concise, + )); + + assert!(matches!( + req.thinking, + Some(ThinkingConfig::OpenAI { + effort: ReasoningEffort::High, + summary: Some(ReasoningSummary::Concise), + }) + )); + } + + #[test] + fn test_system_message_extraction() { + let req = ChatRequest::new( + "test", + vec![ + ChatMessage::system("System prompt"), + ChatMessage::user("Hello"), + ChatMessage::assistant("Hi"), + ], + ); + + assert_eq!(req.system_messages().len(), 1); + assert_eq!(req.non_system_messages().len(), 2); + } + + #[test] + fn test_role_parse() { + assert_eq!(Role::parse("system"), Some(Role::System)); + assert_eq!(Role::parse("USER"), Some(Role::User)); + assert_eq!(Role::parse("Assistant"), Some(Role::Assistant)); + assert_eq!(Role::parse("invalid"), None); + } + + #[test] + fn test_finish_reason_parsing() { + assert_eq!(FinishReason::from_openai("stop"), FinishReason::Stop); + assert_eq!(FinishReason::from_openai("length"), FinishReason::Length); + assert_eq!(FinishReason::from_anthropic("end_turn"), FinishReason::Stop); + assert_eq!( + FinishReason::from_anthropic("max_tokens"), + FinishReason::Length + ); + } + + #[test] + fn test_usage_total() { + let usage = Usage { + input_tokens: 10, + output_tokens: 20, + ..Default::default() + }; + assert_eq!(usage.total(), 30); + } + + #[test] + fn test_usage_cache_fields() { + let usage = Usage { + input_tokens: 17, + output_tokens: 700, + cache_creation_input_tokens: Some(1370), + cache_read_input_tokens: Some(0), + ..Default::default() + }; + assert_eq!(usage.cache_creation_input_tokens, Some(1370)); + } + + #[test] + fn test_content_block_text() { + let block = ContentBlock::text("Hello"); + assert_eq!(block.as_text(), Some("Hello")); + + let cached = ContentBlock::text("System").with_cache(CacheControl::ephemeral()); + assert!(matches!(cached, ContentBlock::Text { cache_control: Some(_), .. })); + } + + #[test] + fn test_message_content_from_str() { + let mc: MessageContent = "hello".into(); + assert!(mc.is_text()); + assert_eq!(mc.text(), "hello"); + } +} diff --git a/core/ir/src/transport/llm/mock.rs b/core/ir/src/transport/llm/mock.rs new file mode 100644 index 00000000000..a6aa3383437 --- /dev/null +++ b/core/ir/src/transport/llm/mock.rs @@ -0,0 +1,182 @@ +//! Mock response builders for LLM provider testing. +//! +//! These builders create realistic mock responses for dry-run testing +//! of LLM DAG nodes. Each provider has its own response format, and +//! these helpers produce structurally valid responses without requiring +//! actual API calls. + +use crate::transport::rest::RestResponse; + +/// Build a mock OpenAI chat completion response. +/// +/// Creates a structurally valid OpenAI API response for dry-run testing. +pub fn mock_openai_response(content: &str) -> RestResponse { + mock_openai_response_full(content, "gpt-4o", "stop", 10, 20) +} + +/// Build a mock OpenAI response with full control over fields. +pub fn mock_openai_response_full( + content: &str, + model: &str, + finish_reason: &str, + prompt_tokens: u64, + completion_tokens: u64, +) -> RestResponse { + RestResponse::ok(serde_json::json!({ + "id": "chatcmpl-mock-test", + "object": "chat.completion", + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": content, + }, + "finish_reason": finish_reason, + }], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + } + })) +} + +/// Build a mock OpenAI error response. +pub fn mock_openai_error(status: u16, error_type: &str, message: &str) -> RestResponse { + RestResponse::new( + status, + serde_json::json!({ + "error": { + "message": message, + "type": error_type, + } + }), + ) +} + +/// Build a mock Anthropic Messages API response. +/// +/// Creates a structurally valid Anthropic API response for dry-run testing. +pub fn mock_anthropic_response(content: &str) -> RestResponse { + mock_anthropic_response_full(content, "claude-sonnet-4-20250514", "end_turn", 10, 20) +} + +/// Build a mock Anthropic response with full control over fields. +pub fn mock_anthropic_response_full( + content: &str, + model: &str, + stop_reason: &str, + input_tokens: u64, + output_tokens: u64, +) -> RestResponse { + RestResponse::ok(serde_json::json!({ + "id": "msg_mock_test", + "type": "message", + "role": "assistant", + "content": [{ + "type": "text", + "text": content, + }], + "model": model, + "stop_reason": stop_reason, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + })) +} + +/// Build a mock Anthropic error response. +pub fn mock_anthropic_error(status: u16, error_type: &str, message: &str) -> RestResponse { + RestResponse::new( + status, + serde_json::json!({ + "type": "error", + "error": { + "type": error_type, + "message": message, + } + }), + ) +} + +/// Build a mock REST response wrapping a provider-specific response. +/// +/// Dispatches to the appropriate mock builder based on provider ID. +pub fn mock_llm_response(provider_id: &str, content: &str) -> Option { + match provider_id { + "openai" => Some(mock_openai_response(content)), + "anthropic" => Some(mock_anthropic_response(content)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::llm::{openai, anthropic}; + + #[test] + fn test_mock_openai_response_parses() { + let resp = mock_openai_response("Hello, world!"); + let parsed = openai::parse_openai_response(&resp).unwrap(); + assert_eq!(parsed.content, "Hello, world!"); + assert_eq!(parsed.model, "gpt-4o"); + } + + #[test] + fn test_mock_openai_response_full_parses() { + let resp = mock_openai_response_full("Test", "gpt-4o-mini", "length", 5, 50); + let parsed = openai::parse_openai_response(&resp).unwrap(); + assert_eq!(parsed.content, "Test"); + assert_eq!(parsed.model, "gpt-4o-mini"); + assert_eq!( + parsed.finish_reason, + crate::transport::llm::FinishReason::Length + ); + assert_eq!(parsed.usage.input_tokens, 5); + assert_eq!(parsed.usage.output_tokens, 50); + } + + #[test] + fn test_mock_openai_error_parses() { + let resp = mock_openai_error(401, "invalid_api_key", "bad key"); + let err = openai::parse_openai_response(&resp).unwrap_err(); + assert!(err.contains("bad key")); + } + + #[test] + fn test_mock_anthropic_response_parses() { + let resp = mock_anthropic_response("Hello!"); + let parsed = anthropic::parse_anthropic_response(&resp).unwrap(); + assert_eq!(parsed.content, "Hello!"); + assert_eq!(parsed.model, "claude-sonnet-4-20250514"); + } + + #[test] + fn test_mock_anthropic_response_full_parses() { + let resp = + mock_anthropic_response_full("Test", "claude-haiku-3-20250414", "max_tokens", 8, 100); + let parsed = anthropic::parse_anthropic_response(&resp).unwrap(); + assert_eq!(parsed.content, "Test"); + assert_eq!( + parsed.finish_reason, + crate::transport::llm::FinishReason::Length + ); + } + + #[test] + fn test_mock_anthropic_error_parses() { + let resp = mock_anthropic_error(429, "rate_limit_error", "too many requests"); + let err = anthropic::parse_anthropic_response(&resp).unwrap_err(); + assert!(err.contains("too many requests")); + } + + #[test] + fn test_mock_llm_response_dispatch() { + assert!(mock_llm_response("openai", "hi").is_some()); + assert!(mock_llm_response("anthropic", "hi").is_some()); + assert!(mock_llm_response("unknown", "hi").is_none()); + } +} diff --git a/core/ir/src/transport/llm/mod.rs b/core/ir/src/transport/llm/mod.rs new file mode 100644 index 00000000000..fdfcd0b0e61 --- /dev/null +++ b/core/ir/src/transport/llm/mod.rs @@ -0,0 +1,181 @@ +//! LLM provider integration types. +//! +//! This module provides a unified interface for interacting with LLM APIs: +//! - **OpenAI Chat Completions** (`/v1/chat/completions`) +//! - **OpenAI Responses** (`/v1/responses`) — recommended for reasoning models +//! - **Anthropic Messages** (`/v1/messages`) +//! +//! # Architecture +//! +//! Following the transport pattern: Prepare (pure) -> Execute (boundary) -> Parse (pure) +//! +//! - **Chat types** (`chat.rs`): Unified `ChatRequest`/`ChatResponse` types with +//! content blocks, cache hints, and thinking/reasoning configuration +//! - **Provider definitions** (`provider.rs`): Data-driven provider structs +//! - **OpenAI Chat Completions** (`openai.rs`): Standard chat completion endpoint +//! - **OpenAI Responses** (`openai_responses.rs`): Responses API with reasoning summaries +//! - **Anthropic** (`anthropic.rs`): Messages API with prompt caching and extended thinking +//! +//! # Usage +//! +//! ```ignore +//! use gunbc_ir::transport::llm::*; +//! +//! // Simple chat completion +//! let chat = ChatRequest::new("gpt-4o", vec![ +//! ChatMessage::system("You are a code reviewer."), +//! ChatMessage::user("Review this function."), +//! ]).temperature(0.3); +//! let rest_request = build_chat_request("openai", &chat).unwrap(); +//! +//! // Reasoning model via Responses API +//! let chat = ChatRequest::new("o3", vec![ +//! ChatMessage::user("Solve this problem."), +//! ]).thinking(ThinkingConfig::openai_with_summary( +//! ReasoningEffort::High, ReasoningSummary::Concise, +//! )); +//! let rest_request = build_responses_request("openai", &chat).unwrap(); +//! +//! // Anthropic with caching and extended thinking +//! let chat = ChatRequest::new("claude-sonnet-4-5", vec![ +//! ChatMessage::system_blocks(vec![ +//! ContentBlock::text("Long context...").with_cache(CacheControl::ephemeral()), +//! ]), +//! ChatMessage::user("Analyze this."), +//! ]).max_tokens(16000).thinking(ThinkingConfig::anthropic(10000)); +//! let rest_request = build_chat_request("anthropic", &chat).unwrap(); +//! ``` + +pub mod anthropic; +pub mod chat; +pub mod mock; +pub mod openai; +pub mod openai_responses; +pub mod provider; + +pub use chat::{ + CacheControl, CacheType, ChatMessage, ChatRequest, ChatResponse, ContentBlock, FinishReason, + MessageContent, ReasoningEffort, ReasoningSummary, ResponseBlock, Role, ThinkingConfig, Usage, +}; +pub use provider::{ + anthropic_provider, builtin_provider_ids, openai_provider, provider_by_id, ApiKeyEnvVar, + LlmAuthStyle, LlmProvider, +}; + +use crate::transport::rest::{RestRequest, RestResponse}; + +/// Build a REST request for the Chat Completions / Messages endpoint. +/// +/// Dispatches to the appropriate provider-specific builder: +/// - `"openai"` → OpenAI Chat Completions (`/v1/chat/completions`) +/// - `"anthropic"` → Anthropic Messages (`/v1/messages`) +/// +/// For OpenAI reasoning models with summaries, use `build_responses_request` instead. +/// +/// # Errors +/// +/// Returns `Err` if the provider ID is not recognized. +pub fn build_chat_request(provider_id: &str, chat: &ChatRequest) -> Result { + match provider_id { + "openai" => Ok(openai::build_openai_request(chat)), + "anthropic" => Ok(anthropic::build_anthropic_request(chat)), + _ => Err(format!("unknown LLM provider: '{}'", provider_id)), + } +} + +/// Build a REST request for the OpenAI Responses API (`/v1/responses`). +/// +/// The Responses API is recommended for reasoning models (o1, o3, o4-mini) +/// because it supports reasoning summaries, persisted reasoning between tool +/// calls, and better cache utilization. +/// +/// # Errors +/// +/// Returns `Err` if the provider doesn't support the Responses API. +pub fn build_responses_request( + provider_id: &str, + chat: &ChatRequest, +) -> Result { + match provider_id { + "openai" => Ok(openai_responses::build_openai_responses_request(chat)), + _ => Err(format!( + "provider '{}' does not support the Responses API", + provider_id + )), + } +} + +/// Parse a REST response from the Chat Completions / Messages endpoint. +/// +/// Dispatches to the appropriate provider-specific parser. +/// +/// # Errors +/// +/// Returns `Err` if the provider ID is not recognized or the response +/// cannot be parsed. +pub fn parse_chat_response( + provider_id: &str, + response: &RestResponse, +) -> Result { + match provider_id { + "openai" => openai::parse_openai_response(response), + "anthropic" => anthropic::parse_anthropic_response(response), + _ => Err(format!("unknown LLM provider: '{}'", provider_id)), + } +} + +/// Parse a REST response from the OpenAI Responses API. +/// +/// # Errors +/// +/// Returns `Err` if the provider doesn't support the Responses API or the +/// response cannot be parsed. +pub fn parse_responses_response( + provider_id: &str, + response: &RestResponse, +) -> Result { + match provider_id { + "openai" => openai_responses::parse_openai_responses_response(response), + _ => Err(format!( + "provider '{}' does not support the Responses API", + provider_id + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_chat_request_dispatch() { + let chat = ChatRequest::new("gpt-4o", vec![ChatMessage::user("Hello")]); + + assert!(build_chat_request("openai", &chat).is_ok()); + assert!(build_chat_request("anthropic", &chat).is_ok()); + assert!(build_chat_request("unknown", &chat).is_err()); + } + + #[test] + fn test_parse_chat_response_dispatch() { + let openai_resp = RestResponse::ok(serde_json::json!({ + "model": "gpt-4o", + "choices": [{ + "message": { "role": "assistant", "content": "Hi" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6 } + })); + + let anthropic_resp = RestResponse::ok(serde_json::json!({ + "content": [{ "type": "text", "text": "Hi" }], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": { "input_tokens": 5, "output_tokens": 1 } + })); + + assert!(parse_chat_response("openai", &openai_resp).is_ok()); + assert!(parse_chat_response("anthropic", &anthropic_resp).is_ok()); + assert!(parse_chat_response("unknown", &openai_resp).is_err()); + } +} diff --git a/core/ir/src/transport/llm/openai.rs b/core/ir/src/transport/llm/openai.rs new file mode 100644 index 00000000000..984af932a2c --- /dev/null +++ b/core/ir/src/transport/llm/openai.rs @@ -0,0 +1,415 @@ +//! OpenAI Chat Completions API conversions. +//! +//! Pure functions that convert between gunbc chat types and +//! OpenAI-specific REST request/response formats. +//! +//! # API Reference +//! +//! OpenAI Chat Completions API: +//! - Endpoint: POST /v1/chat/completions +//! - Auth: Bearer token (Authorization header) +//! - Request body: { model, messages, temperature?, max_completion_tokens?, reasoning_effort? } +//! - Response body: { choices: [{message, finish_reason}], usage } +//! +//! # Prompt Caching +//! +//! OpenAI applies automatic prefix caching for identical prefixes >= 1024 tokens. +//! No API changes needed; place static content (system prompt, examples) first +//! for best cache hit rate. Cached tokens cost 50% of input price. +//! Response includes `usage.prompt_tokens_details.cached_tokens`. +//! Ref: +//! +//! # Reasoning Models +//! +//! Reasoning models (o1, o3, o4-mini) use the `reasoning_effort` parameter: +//! - `reasoning_effort`: "low" | "medium" | "high" (default: "medium") +//! - Uses `max_completion_tokens` instead of `max_tokens` to include reasoning tokens +//! - Chat Completions does NOT support reasoning summaries (use Responses API for that) +//! +//! Ref: +//! +//! # Note on the Responses API +//! +//! For reasoning models, the Responses API (`/v1/responses`) is recommended over +//! Chat Completions. It supports reasoning summaries, persisted reasoning between +//! tool calls, and built-in tools. See `openai_responses.rs` for that endpoint. + +use super::chat::{ + ChatRequest, ChatResponse, FinishReason, MessageContent, ThinkingConfig, Usage, +}; +use super::provider::openai_provider; +use crate::transport::http::HttpMethod; +use crate::transport::rest::{AuthMethod, RestRequest, RestResponse}; + +/// Build an OpenAI Chat Completions API request from a chat request. +/// +/// This is a PURE function - no I/O. +/// +/// Handles OpenAI-specific format: +/// - System messages stay in the messages array (unlike Anthropic) +/// - Auth via Bearer token +/// - Reasoning models use `max_completion_tokens` and `reasoning_effort` +/// - Content blocks with cache hints are flattened to strings (OpenAI caching +/// is automatic; no API-level cache control) +pub fn build_openai_request(chat: &ChatRequest) -> RestRequest { + let provider = openai_provider(); + + let messages: Vec = chat + .messages + .iter() + .map(|m| { + serde_json::json!({ + "role": m.role.as_str(), + "content": content_to_string(&m.content), + }) + }) + .collect(); + + let has_reasoning = matches!(chat.thinking, Some(ThinkingConfig::OpenAI { .. })); + + let mut body = serde_json::json!({ + "model": chat.model, + "messages": messages, + }); + + // Reasoning models use max_completion_tokens (includes reasoning tokens) + if let Some(max) = chat.max_tokens { + if has_reasoning { + body["max_completion_tokens"] = serde_json::json!(max); + } else { + body["max_tokens"] = serde_json::json!(max); + } + } + + if let Some(temp) = chat.temperature { + body["temperature"] = serde_json::json!(temp); + } + if !chat.stop.is_empty() { + body["stop"] = serde_json::json!(chat.stop); + } + + // Reasoning effort for o1/o3/o4-mini + if let Some(ThinkingConfig::OpenAI { effort, .. }) = &chat.thinking { + body["reasoning_effort"] = serde_json::json!(effort.as_str()); + } + + let mut headers = std::collections::HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + + RestRequest { + url: provider.chat_url(), + method: HttpMethod::Post, + headers, + body: Some(body), + auth: Some(AuthMethod::EnvVar(provider.api_key_env.0.clone())), + query: Default::default(), + timeout_ms: Some(120_000), + } +} + +/// Flatten message content to a plain string for the OpenAI Chat Completions API. +/// +/// OpenAI Chat Completions expects string content. Content blocks are +/// concatenated; cache hints are silently ignored (caching is automatic). +fn content_to_string(content: &MessageContent) -> String { + content.text() +} + +/// Parse an OpenAI Chat Completions API response. +/// +/// Extracts the assistant message content, finish reason, and token usage. +/// Includes cached_tokens and reasoning_tokens from detailed usage when present. +/// +/// Returns `Err` if the response body doesn't match the expected format. +pub fn parse_openai_response(response: &RestResponse) -> Result { + if !response.is_success() { + let error_msg = response + .body + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!( + "OpenAI API error (status {}): {}", + response.status, error_msg + )); + } + + let choices = response + .body + .get("choices") + .and_then(|c| c.as_array()) + .ok_or("missing 'choices' array in response")?; + + let first = choices.first().ok_or("empty 'choices' array")?; + + let content = first + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + + let model = response + .body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + let finish_reason = first + .get("finish_reason") + .and_then(|r| r.as_str()) + .map(FinishReason::from_openai) + .unwrap_or(FinishReason::Stop); + + let usage = response + .body + .get("usage") + .map(|u| { + // Detailed token breakdown (if present) + let cached_tokens = u + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|t| t.as_u64()); + + let reasoning_tokens = u + .get("completion_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(|t| t.as_u64()); + + Usage { + input_tokens: u + .get("prompt_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + output_tokens: u + .get("completion_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + cached_tokens, + reasoning_tokens, + } + }) + .unwrap_or_default(); + + Ok(ChatResponse { + content, + model, + finish_reason, + usage, + thinking: None, // Chat Completions doesn't expose reasoning text + content_blocks: Vec::new(), + }) +} + +/// Build a request for an OpenAI-compatible provider with a custom base URL. +/// +/// This enables use of OpenAI-compatible APIs (e.g., local models via Ollama, +/// Azure OpenAI, etc.) by overriding the endpoint URL while keeping the +/// same request format. +pub fn build_openai_compatible_request( + chat: &ChatRequest, + base_url: &str, + api_key_env: &str, +) -> RestRequest { + let mut req = build_openai_request(chat); + req.url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + req.auth = Some(AuthMethod::EnvVar(api_key_env.to_string())); + req +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::llm::chat::{ + ChatMessage, ContentBlock, ReasoningEffort, ReasoningSummary, + }; + + fn sample_request() -> ChatRequest { + ChatRequest::new( + "gpt-4o", + vec![ + ChatMessage::system("You are a code reviewer."), + ChatMessage::user("Review this function."), + ], + ) + .temperature(0.3) + .max_tokens(2048) + } + + #[test] + fn test_build_openai_request() { + let req = build_openai_request(&sample_request()); + + assert_eq!(req.url, "https://api.openai.com/v1/chat/completions"); + assert_eq!(req.method, HttpMethod::Post); + assert!(matches!(req.auth, Some(AuthMethod::EnvVar(ref v)) if v == "OPENAI_API_KEY")); + + let body = req.body.unwrap(); + assert_eq!(body["model"], "gpt-4o"); + assert_eq!(body["temperature"], 0.3); + assert_eq!(body["max_tokens"], 2048); + + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + } + + #[test] + fn test_build_openai_request_minimal() { + let req = build_openai_request(&ChatRequest::new( + "gpt-4o-mini", + vec![ChatMessage::user("Hello")], + )); + + let body = req.body.unwrap(); + assert!(body.get("temperature").is_none()); + assert!(body.get("max_tokens").is_none()); + assert!(body.get("stop").is_none()); + } + + #[test] + fn test_build_openai_request_reasoning() { + let req = build_openai_request( + &ChatRequest::new("o3", vec![ChatMessage::user("Reason about this.")]) + .max_tokens(8000) + .thinking(ThinkingConfig::openai_with_summary( + ReasoningEffort::High, + ReasoningSummary::Concise, + )), + ); + + let body = req.body.unwrap(); + // Should use max_completion_tokens for reasoning models + assert!(body.get("max_tokens").is_none()); + assert_eq!(body["max_completion_tokens"], 8000); + assert_eq!(body["reasoning_effort"], "high"); + } + + #[test] + fn test_build_openai_request_content_blocks_flattened() { + let req = build_openai_request(&ChatRequest::new( + "gpt-4o", + vec![ + ChatMessage::system_blocks(vec![ + ContentBlock::text("Part 1"), + ContentBlock::text("Part 2"), + ]), + ChatMessage::user("Hello"), + ], + )); + + let body = req.body.unwrap(); + let messages = body["messages"].as_array().unwrap(); + // Content blocks flattened to concatenated string + assert_eq!(messages[0]["content"], "Part 1Part 2"); + } + + #[test] + fn test_parse_openai_response_success() { + let response = RestResponse::ok(serde_json::json!({ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "model": "gpt-4o-2024-08-06", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The function looks good." + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 25, + "completion_tokens": 10, + "total_tokens": 35 + } + })); + + let chat = parse_openai_response(&response).unwrap(); + assert_eq!(chat.content, "The function looks good."); + assert_eq!(chat.model, "gpt-4o-2024-08-06"); + assert_eq!(chat.finish_reason, FinishReason::Stop); + assert_eq!(chat.usage.input_tokens, 25); + assert_eq!(chat.usage.output_tokens, 10); + assert_eq!(chat.usage.total(), 35); + assert!(chat.thinking.is_none()); + } + + #[test] + fn test_parse_openai_response_with_cache_and_reasoning_tokens() { + let response = RestResponse::ok(serde_json::json!({ + "model": "o3", + "choices": [{ + "message": { "role": "assistant", "content": "Result" }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 500, + "completion_tokens": 1200, + "total_tokens": 1700, + "prompt_tokens_details": { + "cached_tokens": 400 + }, + "completion_tokens_details": { + "reasoning_tokens": 900 + } + } + })); + + let chat = parse_openai_response(&response).unwrap(); + assert_eq!(chat.usage.input_tokens, 500); + assert_eq!(chat.usage.output_tokens, 1200); + assert_eq!(chat.usage.cached_tokens, Some(400)); + assert_eq!(chat.usage.reasoning_tokens, Some(900)); + assert!(chat.usage.cache_creation_input_tokens.is_none()); + } + + #[test] + fn test_parse_openai_response_error() { + let response = RestResponse::new( + 401, + serde_json::json!({ + "error": { + "message": "Incorrect API key provided", + "type": "invalid_request_error" + } + }), + ); + + let err = parse_openai_response(&response).unwrap_err(); + assert!(err.contains("Incorrect API key")); + assert!(err.contains("401")); + } + + #[test] + fn test_parse_openai_response_length() { + let response = RestResponse::ok(serde_json::json!({ + "model": "gpt-4o", + "choices": [{ + "message": { "role": "assistant", "content": "partial..." }, + "finish_reason": "length" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 100, "total_tokens": 110 } + })); + + let chat = parse_openai_response(&response).unwrap(); + assert_eq!(chat.finish_reason, FinishReason::Length); + } + + #[test] + fn test_openai_compatible_request() { + let chat = sample_request(); + let req = build_openai_compatible_request(&chat, "http://localhost:11434", "OLLAMA_KEY"); + + assert_eq!( + req.url, + "http://localhost:11434/v1/chat/completions" + ); + assert!(matches!(req.auth, Some(AuthMethod::EnvVar(ref v)) if v == "OLLAMA_KEY")); + } +} diff --git a/core/ir/src/transport/llm/openai_responses.rs b/core/ir/src/transport/llm/openai_responses.rs new file mode 100644 index 00000000000..055f331584f --- /dev/null +++ b/core/ir/src/transport/llm/openai_responses.rs @@ -0,0 +1,485 @@ +//! OpenAI Responses API conversions. +//! +//! Pure functions that convert between gunbc chat types and the +//! OpenAI Responses API format (`POST /v1/responses`). +//! +//! # API Reference +//! +//! OpenAI Responses API: +//! - Endpoint: POST /v1/responses +//! - Auth: Bearer token (Authorization header) +//! - Request body: { model, input, instructions?, reasoning?, max_output_tokens? } +//! - Response body: { output: [{type, content}], output_text, usage } +//! +//! Ref: +//! +//! # Why Responses API? +//! +//! For reasoning models (o1, o3, o4-mini), the Responses API is recommended over +//! Chat Completions because it: +//! - Supports **reasoning summaries** (concise/detailed) +//! - Persists reasoning tokens between tool calls for better performance +//! - Provides 40-80% better cache utilization +//! - Returns richer output (reasoning items, structured output) +//! +//! Ref: +//! +//! # Key Differences from Chat Completions +//! +//! | Feature | Chat Completions | Responses | +//! |---------|-----------------|-----------| +//! | Endpoint | `/v1/chat/completions` | `/v1/responses` | +//! | System prompt | `messages[0].role="system"` | `instructions` field | +//! | Token limit | `max_completion_tokens` | `max_output_tokens` | +//! | Reasoning | `reasoning_effort` only | `reasoning.effort` + `reasoning.summary` | +//! | Output | `choices[0].message.content` | `output_text` / `output[]` items | +//! | Caching | Automatic prefix | Automatic, 40-80% better than CC | + +use super::chat::{ + ChatRequest, ChatResponse, FinishReason, MessageContent, ResponseBlock, ThinkingConfig, Usage, +}; +use super::provider::openai_provider; +use crate::transport::http::HttpMethod; +use crate::transport::rest::{AuthMethod, RestRequest, RestResponse}; + +/// Build an OpenAI Responses API request from a chat request. +/// +/// Converts the unified `ChatRequest` to the Responses API format: +/// - System messages → `instructions` field +/// - Non-system messages → `input` items +/// - Thinking config → `reasoning` parameter with effort and summary +/// - max_tokens → `max_output_tokens` +/// - Content blocks flattened to strings (OpenAI caching is automatic) +pub fn build_openai_responses_request(chat: &ChatRequest) -> RestRequest { + let provider = openai_provider(); + let responses_url = provider + .responses_url() + .expect("OpenAI provider must have responses endpoint"); + + // System messages → instructions + let system_msgs = chat.system_messages(); + let instructions: Option = if system_msgs.is_empty() { + None + } else { + Some( + system_msgs + .iter() + .map(|m| content_to_string(&m.content)) + .collect::>() + .join("\n\n"), + ) + }; + + // Non-system messages → input items + let input: Vec = chat + .non_system_messages() + .iter() + .map(|m| { + serde_json::json!({ + "role": m.role.as_str(), + "content": content_to_string(&m.content), + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "model": chat.model, + "input": input, + }); + + if let Some(inst) = instructions { + body["instructions"] = serde_json::json!(inst); + } + if let Some(max) = chat.max_tokens { + body["max_output_tokens"] = serde_json::json!(max); + } + if let Some(temp) = chat.temperature { + body["temperature"] = serde_json::json!(temp); + } + + // Reasoning configuration (the main reason to use Responses API) + if let Some(ThinkingConfig::OpenAI { effort, summary }) = &chat.thinking { + let mut reasoning = serde_json::json!({ + "effort": effort.as_str(), + }); + if let Some(s) = summary { + reasoning["summary"] = serde_json::json!(s.as_str()); + } + body["reasoning"] = reasoning; + } + + let mut headers = std::collections::HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + + RestRequest { + url: responses_url, + method: HttpMethod::Post, + headers, + body: Some(body), + auth: Some(AuthMethod::EnvVar(provider.api_key_env.0.clone())), + query: Default::default(), + timeout_ms: Some(120_000), + } +} + +fn content_to_string(content: &MessageContent) -> String { + content.text() +} + +/// Parse an OpenAI Responses API response. +/// +/// The Responses API returns output as an array of items. This parser extracts: +/// - Text content from `output_text` (convenience field) or output items +/// - Reasoning summary from `reasoning` output items +/// - Token usage including `cached_tokens` and `reasoning_tokens` +/// - Finish status from the response `status` field +pub fn parse_openai_responses_response( + response: &RestResponse, +) -> Result { + if !response.is_success() { + let error_msg = response + .body + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!( + "OpenAI Responses API error (status {}): {}", + response.status, error_msg + )); + } + + // Use output_text convenience field if available + let content = response + .body + .get("output_text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| extract_text_from_output(&response.body)); + + let model = response + .body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + // Status field: "completed", "failed", "incomplete" + let finish_reason = response + .body + .get("status") + .and_then(|s| s.as_str()) + .map(|s| match s { + "completed" => FinishReason::Stop, + "incomplete" => FinishReason::Length, + other => FinishReason::Other(other.to_string()), + }) + .unwrap_or(FinishReason::Stop); + + let usage = response + .body + .get("usage") + .map(|u| { + let cached_tokens = u + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|t| t.as_u64()); + + let reasoning_tokens = u + .get("output_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(|t| t.as_u64()); + + Usage { + input_tokens: u + .get("input_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + output_tokens: u + .get("output_tokens") + .and_then(|t| t.as_u64()) + .unwrap_or(0), + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + cached_tokens, + reasoning_tokens, + } + }) + .unwrap_or_default(); + + // Extract reasoning summary from output items + let (thinking, content_blocks) = extract_reasoning_from_output(&response.body); + + Ok(ChatResponse { + content, + model, + finish_reason, + usage, + thinking, + content_blocks, + }) +} + +/// Extract text content from the output array when output_text is not available. +fn extract_text_from_output(body: &serde_json::Value) -> String { + let mut parts = Vec::new(); + if let Some(output) = body.get("output").and_then(|o| o.as_array()) { + for item in output { + let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if item_type == "message" { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + parts.push(text.to_string()); + } + } + } + } + } + } + } + parts.join("") +} + +/// Extract reasoning summary and build response blocks from output items. +fn extract_reasoning_from_output( + body: &serde_json::Value, +) -> (Option, Vec) { + let mut thinking_parts = Vec::new(); + let mut blocks = Vec::new(); + + if let Some(output) = body.get("output").and_then(|o| o.as_array()) { + for item in output { + let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match item_type { + "reasoning" => { + // Reasoning summary items + if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) { + for s in summary { + if let Some(text) = s.get("text").and_then(|t| t.as_str()) { + thinking_parts.push(text.to_string()); + blocks.push(ResponseBlock::Thinking { + thinking: text.to_string(), + signature: None, + }); + } + } + } + } + "message" => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + blocks.push(ResponseBlock::Text { + text: text.to_string(), + }); + } + } + } + } + } + _ => {} + } + } + } + + let thinking = if thinking_parts.is_empty() { + None + } else { + Some(thinking_parts.join("\n")) + }; + + // Only return blocks if there was reasoning (otherwise keep it flat) + if thinking.is_none() { + blocks.clear(); + } + + (thinking, blocks) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::llm::chat::{ChatMessage, ReasoningEffort, ReasoningSummary}; + + #[test] + fn test_build_responses_request_simple() { + let req = build_openai_responses_request(&ChatRequest::new( + "gpt-4o", + vec![ + ChatMessage::system("You are helpful."), + ChatMessage::user("Hello"), + ], + )); + + assert_eq!(req.url, "https://api.openai.com/v1/responses"); + let body = req.body.unwrap(); + assert_eq!(body["model"], "gpt-4o"); + assert_eq!(body["instructions"], "You are helpful."); + + let input = body["input"].as_array().unwrap(); + assert_eq!(input.len(), 1); + assert_eq!(input[0]["role"], "user"); + } + + #[test] + fn test_build_responses_request_reasoning() { + let req = build_openai_responses_request( + &ChatRequest::new("o3", vec![ChatMessage::user("Think about X.")]) + .max_tokens(8000) + .thinking(ThinkingConfig::openai_with_summary( + ReasoningEffort::High, + ReasoningSummary::Concise, + )), + ); + + let body = req.body.unwrap(); + assert_eq!(body["reasoning"]["effort"], "high"); + assert_eq!(body["reasoning"]["summary"], "concise"); + assert_eq!(body["max_output_tokens"], 8000); + // Should NOT have max_tokens + assert!(body.get("max_tokens").is_none()); + } + + #[test] + fn test_parse_responses_simple() { + let response = RestResponse::ok(serde_json::json!({ + "id": "resp_abc123", + "object": "response", + "status": "completed", + "model": "gpt-4o", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hello! How can I help?" + }] + }], + "output_text": "Hello! How can I help?", + "usage": { + "input_tokens": 20, + "output_tokens": 10, + "total_tokens": 30 + } + })); + + let chat = parse_openai_responses_response(&response).unwrap(); + assert_eq!(chat.content, "Hello! How can I help?"); + assert_eq!(chat.model, "gpt-4o"); + assert_eq!(chat.finish_reason, FinishReason::Stop); + assert_eq!(chat.usage.input_tokens, 20); + assert!(chat.thinking.is_none()); + } + + #[test] + fn test_parse_responses_with_reasoning() { + let response = RestResponse::ok(serde_json::json!({ + "id": "resp_abc123", + "status": "completed", + "model": "o3", + "output": [ + { + "type": "reasoning", + "id": "rs_abc", + "summary": [{ + "type": "summary_text", + "text": "I analyzed the problem by considering..." + }] + }, + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "The answer is 42." + }] + } + ], + "output_text": "The answer is 42.", + "usage": { + "input_tokens": 50, + "output_tokens": 500, + "total_tokens": 550, + "output_tokens_details": { + "reasoning_tokens": 400 + } + } + })); + + let chat = parse_openai_responses_response(&response).unwrap(); + assert_eq!(chat.content, "The answer is 42."); + assert_eq!( + chat.thinking, + Some("I analyzed the problem by considering...".to_string()) + ); + assert_eq!(chat.usage.reasoning_tokens, Some(400)); + assert_eq!(chat.content_blocks.len(), 2); + assert!(matches!(&chat.content_blocks[0], ResponseBlock::Thinking { .. })); + assert!(matches!(&chat.content_blocks[1], ResponseBlock::Text { .. })); + } + + #[test] + fn test_parse_responses_with_cache() { + let response = RestResponse::ok(serde_json::json!({ + "id": "resp_abc", + "status": "completed", + "model": "gpt-4o", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "cached response"}] + }], + "output_text": "cached response", + "usage": { + "input_tokens": 500, + "output_tokens": 10, + "total_tokens": 510, + "input_tokens_details": { + "cached_tokens": 450 + } + } + })); + + let chat = parse_openai_responses_response(&response).unwrap(); + assert_eq!(chat.usage.cached_tokens, Some(450)); + } + + #[test] + fn test_parse_responses_error() { + let response = RestResponse::new( + 400, + serde_json::json!({ + "error": { + "message": "Invalid model", + "type": "invalid_request_error" + } + }), + ); + + let err = parse_openai_responses_response(&response).unwrap_err(); + assert!(err.contains("Invalid model")); + assert!(err.contains("400")); + } + + #[test] + fn test_parse_responses_incomplete() { + let response = RestResponse::ok(serde_json::json!({ + "id": "resp_abc", + "status": "incomplete", + "model": "gpt-4o", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "partial..."}] + }], + "output_text": "partial...", + "usage": { "input_tokens": 10, "output_tokens": 100, "total_tokens": 110 } + })); + + let chat = parse_openai_responses_response(&response).unwrap(); + assert_eq!(chat.finish_reason, FinishReason::Length); + } +} diff --git a/core/ir/src/transport/llm/provider.rs b/core/ir/src/transport/llm/provider.rs new file mode 100644 index 00000000000..d11bb504db7 --- /dev/null +++ b/core/ir/src/transport/llm/provider.rs @@ -0,0 +1,164 @@ +//! LLM provider definitions. +//! +//! Providers are data-driven structs identified by string IDs, +//! following the project convention of avoiding enums for extensible concepts. +//! +//! Each provider defines: +//! - API endpoint and authentication pattern +//! - How to build a `RestRequest` from a `ChatRequest` (pure) +//! - How to parse a `RestResponse` into a `ChatResponse` (pure) + +use serde::{Deserialize, Serialize}; + +/// Environment variable name for the API key. +/// +/// Providers reference env vars by name; resolution happens at execution time +/// through `AuthMethod::EnvVar` (Bearer) or `AuthMethod::EnvVarHeader` (custom header). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiKeyEnvVar(pub String); + +/// Authentication style for the LLM API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LlmAuthStyle { + /// Bearer token in Authorization header (OpenAI, most providers). + BearerToken, + /// Custom header for API key (Anthropic uses x-api-key). + CustomHeader { + /// Header name (e.g., "x-api-key"). + header: String, + }, +} + +/// LLM provider definition. +/// +/// Data-driven struct that describes how to interact with an LLM API. +/// Provider-specific request building and response parsing are handled +/// by the conversion functions in openai.rs / anthropic.rs / openai_responses.rs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LlmProvider { + /// Provider identifier (e.g., "openai", "anthropic"). + pub id: String, + /// Human-readable name (e.g., "OpenAI", "Anthropic"). + pub name: String, + /// Base URL for the API. + pub api_base: String, + /// Chat completions endpoint path (e.g., "/v1/chat/completions", "/v1/messages"). + pub chat_endpoint: String, + /// Responses API endpoint path, if supported (e.g., "/v1/responses"). + #[serde(skip_serializing_if = "Option::is_none")] + pub responses_endpoint: Option, + /// Authentication style. + pub auth_style: LlmAuthStyle, + /// Environment variable containing the API key. + pub api_key_env: ApiKeyEnvVar, + /// Additional required headers (e.g., anthropic-version). + #[serde(default)] + pub extra_headers: Vec<(String, String)>, +} + +impl LlmProvider { + /// Full URL for the chat completion endpoint. + pub fn chat_url(&self) -> String { + format!("{}{}", self.api_base, self.chat_endpoint) + } + + /// Full URL for the responses API endpoint, if supported. + pub fn responses_url(&self) -> Option { + self.responses_endpoint + .as_ref() + .map(|ep| format!("{}{}", self.api_base, ep)) + } +} + +/// OpenAI provider definition. +/// +/// Supports both Chat Completions and Responses API endpoints. +pub fn openai_provider() -> LlmProvider { + LlmProvider { + id: "openai".to_string(), + name: "OpenAI".to_string(), + api_base: "https://api.openai.com".to_string(), + chat_endpoint: "/v1/chat/completions".to_string(), + responses_endpoint: Some("/v1/responses".to_string()), + auth_style: LlmAuthStyle::BearerToken, + api_key_env: ApiKeyEnvVar("OPENAI_API_KEY".to_string()), + extra_headers: vec![], + } +} + +/// Anthropic provider definition. +pub fn anthropic_provider() -> LlmProvider { + LlmProvider { + id: "anthropic".to_string(), + name: "Anthropic".to_string(), + api_base: "https://api.anthropic.com".to_string(), + chat_endpoint: "/v1/messages".to_string(), + responses_endpoint: None, + auth_style: LlmAuthStyle::CustomHeader { + header: "x-api-key".to_string(), + }, + api_key_env: ApiKeyEnvVar("ANTHROPIC_API_KEY".to_string()), + extra_headers: vec![("anthropic-version".to_string(), "2023-06-01".to_string())], + } +} + +/// Look up a provider by ID. +/// +/// Returns `None` for unknown provider IDs. Callers that need custom +/// providers should construct `LlmProvider` directly. +pub fn provider_by_id(id: &str) -> Option { + match id { + "openai" => Some(openai_provider()), + "anthropic" => Some(anthropic_provider()), + _ => None, + } +} + +/// List all built-in provider IDs. +pub fn builtin_provider_ids() -> &'static [&'static str] { + &["openai", "anthropic"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_openai_provider() { + let p = openai_provider(); + assert_eq!(p.id, "openai"); + assert_eq!( + p.chat_url(), + "https://api.openai.com/v1/chat/completions" + ); + assert!(matches!(p.auth_style, LlmAuthStyle::BearerToken)); + assert_eq!(p.api_key_env.0, "OPENAI_API_KEY"); + } + + #[test] + fn test_anthropic_provider() { + let p = anthropic_provider(); + assert_eq!(p.id, "anthropic"); + assert_eq!(p.chat_url(), "https://api.anthropic.com/v1/messages"); + assert!(matches!( + p.auth_style, + LlmAuthStyle::CustomHeader { .. } + )); + assert_eq!(p.api_key_env.0, "ANTHROPIC_API_KEY"); + assert!(!p.extra_headers.is_empty()); + } + + #[test] + fn test_provider_lookup() { + assert!(provider_by_id("openai").is_some()); + assert!(provider_by_id("anthropic").is_some()); + assert!(provider_by_id("unknown").is_none()); + } + + #[test] + fn test_builtin_ids() { + let ids = builtin_provider_ids(); + assert!(ids.contains(&"openai")); + assert!(ids.contains(&"anthropic")); + } +} diff --git a/core/ir/src/transport/mod.rs b/core/ir/src/transport/mod.rs index 73f4fe7d1ea..9ce10b34ea6 100644 --- a/core/ir/src/transport/mod.rs +++ b/core/ir/src/transport/mod.rs @@ -8,6 +8,7 @@ //! - GitHub platform (API + CLI) //! - GitHub Actions for CI/CD integration //! - CI provider abstraction for workflow commands +//! - LLM provider integration (OpenAI, Anthropic) //! //! The key insight is that all world I/O can be modeled as request/response pairs, //! allowing business logic to remain pure while transport execution happens at @@ -29,6 +30,12 @@ //! ├── provider.rs (CiProvider trait) //! ├── runner.rs (Runner trait) //! └── providers/ (GitHub, GitLab, Plain) +//! +//! llm/ ← LLM provider abstraction +//! ├── chat.rs (ChatMessage, ChatRequest, ChatResponse) +//! ├── provider.rs (LlmProvider, provider registry) +//! ├── openai.rs (OpenAI conversions) +//! └── anthropic.rs (Anthropic conversions) //! ``` pub mod ci; @@ -38,6 +45,7 @@ pub mod gist; pub mod github; pub mod github_actions; pub mod http; +pub mod llm; pub mod rest; pub mod tcp; pub mod tool; @@ -77,6 +85,12 @@ pub use ci::{ detect_provider, is_ci, AnnotationLevel, CiProvider, FileLocation, GitHubActionsProvider, GitLabCiProvider, GitLabRunner, PlainTextProvider, Runner, WorkflowCommand, }; +pub use llm::{ + build_chat_request, parse_chat_response, + anthropic_provider, openai_provider, provider_by_id, builtin_provider_ids, + ChatMessage, ChatRequest, ChatResponse, FinishReason, Role, Usage, + LlmProvider, LlmAuthStyle, ApiKeyEnvVar, +}; use serde::{Deserialize, Serialize}; diff --git a/core/ir/src/transport/rest.rs b/core/ir/src/transport/rest.rs index 0f783cd713e..ebfc7f35ae4 100644 --- a/core/ir/src/transport/rest.rs +++ b/core/ir/src/transport/rest.rs @@ -3,9 +3,14 @@ use super::http::HttpMethod; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; /// Authentication method for REST APIs. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// +/// Debug and Display output redacts credential values to prevent +/// accidental leakage in logs. Only the auth variant and non-sensitive +/// metadata (header names, env var names) are shown. +#[derive(Clone, PartialEq, Serialize, Deserialize)] pub enum AuthMethod { /// No authentication None, @@ -13,10 +18,50 @@ pub enum AuthMethod { Bearer(String), /// Basic authentication (username:password base64 encoded) Basic { username: String, password: String }, - /// API key in header + /// API key in header (literal value) ApiKey { header: String, key: String }, - /// Environment variable reference (resolved at execution time) + /// Environment variable resolved at execution time → `Authorization: Bearer {value}`. EnvVar(String), + /// Environment variable resolved at execution time → custom header. + /// + /// Like `EnvVar` but inserts the value into the specified header instead of + /// `Authorization: Bearer`. Used for APIs that authenticate via a custom + /// header (e.g., Anthropic's `x-api-key`). + EnvVarHeader { header: String, env_var: String }, +} + +impl fmt::Debug for AuthMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AuthMethod::None => write!(f, "AuthMethod::None"), + AuthMethod::Bearer(_) => write!(f, "AuthMethod::Bearer(***)"), + AuthMethod::Basic { username, .. } => { + write!(f, "AuthMethod::Basic {{ username: {username:?}, password: *** }}") + } + AuthMethod::ApiKey { header, .. } => { + write!(f, "AuthMethod::ApiKey {{ header: {header:?}, key: *** }}") + } + AuthMethod::EnvVar(var) => write!(f, "AuthMethod::EnvVar({var:?})"), + AuthMethod::EnvVarHeader { header, env_var } => { + write!(f, "AuthMethod::EnvVarHeader {{ header: {header:?}, env_var: {env_var:?} }}") + } + } + } +} + +impl fmt::Display for AuthMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AuthMethod::None => write!(f, "none"), + AuthMethod::Bearer(_) => write!(f, "bearer(***)"), + AuthMethod::Basic { username, .. } => write!(f, "basic({username}, ***)"), + AuthMethod::ApiKey { header, .. } => write!(f, "api-key({header}, ***)"), + AuthMethod::EnvVar(var) => write!(f, "env({var})"), + AuthMethod::EnvVarHeader { header, env_var } => { + write!(f, "env-header({header}, {env_var})") + } + } + } } /// REST API request. diff --git a/core/ir/src/value.rs b/core/ir/src/value.rs index 5419cede8c6..8141e5deadc 100644 --- a/core/ir/src/value.rs +++ b/core/ir/src/value.rs @@ -5,6 +5,61 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; +/// A string value that redacts its content in Display and Debug output. +/// +/// Secrets flow through the DAG like normal values but are automatically +/// redacted when logged, printed, or formatted. The inner value is only +/// accessible via `expose()`, making accidental leakage structurally +/// harder. +/// +/// # Design +/// +/// Secrets are normal I/O that get resolved (upserted/ensured) at +/// execution time. Between DAG nodes they flow as values, but any +/// logging or display shows `***` instead of the actual content. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct SecretString { + #[serde(rename = "secret")] + inner: String, +} + +impl SecretString { + /// Create a new secret string. + pub fn new(value: impl Into) -> Self { + Self { + inner: value.into(), + } + } + + /// Expose the secret value. Use sparingly — only at I/O boundaries + /// where the actual value is needed (e.g., setting an HTTP header). + pub fn expose(&self) -> &str { + &self.inner + } + + /// Length of the secret (safe to expose for diagnostics). + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Whether the secret is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SecretString(***)") + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "***") + } +} + /// Runtime value flowing between nodes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub enum Value { @@ -27,6 +82,8 @@ pub enum Value { Request(TransportRequest), /// Transport response (from I/O operations) Response(TransportResponse), + /// Secret value (redacted in logs/display, exposed only at I/O boundaries) + Secret(SecretString), /// Node was skipped (guard evaluated to false) Skipped, } @@ -110,6 +167,22 @@ impl Value { _ => None, } } + + /// Check if this is a secret value. + pub fn is_secret(&self) -> bool { + matches!(self, Value::Secret(_)) + } + + /// Try to extract the secret string (exposed value). + /// + /// Returns `None` if the value is not a secret. Use sparingly — + /// the whole point of `Secret` is to avoid accidental exposure. + pub fn as_secret(&self) -> Option<&SecretString> { + match self { + Value::Secret(s) => Some(s), + _ => None, + } + } } impl fmt::Display for Value { @@ -124,6 +197,7 @@ impl fmt::Display for Value { Value::Json(j) => write!(f, "{}", j), Value::Request(r) => write!(f, "", std::mem::discriminant(r)), Value::Response(r) => write!(f, "", std::mem::discriminant(r)), + Value::Secret(_) => write!(f, "***"), Value::Skipped => write!(f, ""), } } diff --git a/core/testgen/src/codegen.rs b/core/testgen/src/codegen.rs index 11e2019d4b0..763737f021a 100644 --- a/core/testgen/src/codegen.rs +++ b/core/testgen/src/codegen.rs @@ -68,11 +68,12 @@ impl<'a, T> TestGenerator<'a, T> { let analysis = analyze_dag(self.dag); let mut code = String::new(); - // Module header using language module's generated_header - let doc_prefix = RUST_COMMENTS.doc_prefix.unwrap_or("//!"); - code.push_str(&format!("{} Generated tests for {} DAG.\n", doc_prefix, module_name)); - code.push_str(&format!("{}\n", doc_prefix)); - code.push_str(&generated_header(&gunbc_ir::cargo::name("testgen"), "make testgen", doc_prefix)); + // Module header - use line comments (not doc comments) since these files + // are include!()'d into modules and doc comments would attach to `use` items. + let prefix = RUST_COMMENTS.line_prefix; + code.push_str(&format!("{} Generated tests for {} DAG.\n", prefix, module_name)); + code.push_str(&format!("{}\n", prefix)); + code.push_str(&generated_header(&gunbc_ir::cargo::name("testgen"), "make testgen", prefix)); code.push_str("\n\n"); // Imports @@ -276,7 +277,7 @@ impl<'a, T> TestGenerator<'a, T> { for resource in &spec.resource_mocks.resources { let test_name = format!( "test_resource_{}_acquire", - resource.resource_id.replace([':', '-'], "_") + sanitize_resource_id(&resource.resource_id) ); let resource_type = match &resource.resource_type { @@ -333,7 +334,7 @@ impl<'a, T> TestGenerator<'a, T> { if let gunbc_test::ResourceType::Lease { duration_ms } = resource.resource_type { let timeout_test = format!( "test_resource_{}_timeout", - resource.resource_id.replace([':', '-'], "_") + sanitize_resource_id(&resource.resource_id) ); code.push_str(&format!( "/// Test resource '{}' lease expiration after {}ms.\n", @@ -362,6 +363,36 @@ impl<'a, T> TestGenerator<'a, T> { } } +/// Sanitize a resource ID into a valid snake_case Rust identifier. +/// +/// Replaces non-alphanumeric characters with `_`, lowercases, and collapses +/// consecutive underscores (e.g. `fs:.gitignore` → `fs_gitignore`). +fn sanitize_resource_id(id: &str) -> String { + let raw: String = id + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + // Collapse runs of underscores and strip leading/trailing underscores + let mut result = String::with_capacity(raw.len()); + let mut prev_underscore = true; // treat start as underscore to strip leading _ + for c in raw.chars() { + if c == '_' { + if !prev_underscore { + result.push('_'); + } + prev_underscore = true; + } else { + result.push(c.to_ascii_lowercase()); + prev_underscore = false; + } + } + // Strip trailing underscore + if result.ends_with('_') { + result.pop(); + } + result +} + /// Convert a Value to a Rust literal string. fn value_to_rust_literal(value: &Value) -> String { match value { @@ -378,6 +409,9 @@ fn value_to_rust_literal(value: &Value) -> String { Value::Json(json) => { format!("Value::Json(serde_json::json!({}))", json) } + Value::Secret(_) => { + "Value::Secret(gunbc_ir::SecretString::new(\"\"))".to_string() + } _ => "Value::Str(\"\".to_string())".to_string(), } } @@ -389,6 +423,9 @@ fn default_mock_for_type(type_id: &str) -> String { "Bool" => "Value::Bool(true)".to_string(), "Int" | "i64" | "i32" => "Value::Int(0)".to_string(), "StrList" => "Value::StrList(vec![\"\".to_string()])".to_string(), + "Secret" => { + "Value::Secret(gunbc_ir::SecretString::new(\"\"))".to_string() + } "TransportResponse" => { "Value::Response(gunbc_ir::transport::TransportResponse::Shell(\ gunbc_ir::transport::ShellResponse { \ diff --git a/docs/design/testgen.md b/docs/design/testgen.md new file mode 100644 index 00000000000..79f617a9b98 --- /dev/null +++ b/docs/design/testgen.md @@ -0,0 +1,293 @@ +# Testgen: Design & TODO + +> **Goal**: Given that nodes are pure and we control I/O at transport boundaries, +> testgen should verify flows and business logic for mocked scenarios — +> mock one side of a pipeline and check that pure nodes produce expected outputs. + +--- + +## Current State + +### What exists + +The testgen system (`core/testgen`) generates test files from DAG definitions +and `MockSpec` values. There are 7 generated test files across `gunbc-dag` and +`lib/llm-ops`. + +### What it generates today + +| Test type | What it does | Useful? | +|-----------|-------------|---------| +| Mock spec self-consistency | Asserts `mock_spec().get_boundary_mock(node, port).is_some()` | **No** — circular; tests that the spec defines what it defines | +| Input expectations count | Asserts `spec.input_expectations.len() == N` | **No** — fragile snapshot of a number | +| Resource acquire | Calls `resource.acquire()`, asserts `Acquired` | **Marginal** — trivial happy path on mock framework | +| Lease timeout | Checks `should_timeout()` before/after duration | **Yes** — tests real semantic behavior | + +### What it doesn't generate (but could) + +**Boundary tests** are disabled everywhere (`boundary_tests: false`) with this +note in `testgen.rs`: + +``` +// NOTE: boundary_tests disabled until testgen supports entrypoint input mocking +// (dry-run only intercepts transport executors; pure nodes still need inputs) +``` + +**Flow verification tests** — the big win — don't exist yet. + +### Hand-written tests are better + +The `graph_mock.rs` files have hand-written tests (e.g., `ci/graph_mock.rs`) +that verify meaningful properties: success vs failure mock variants, contention +handling, report content. These provide more value than all 7 generated files +combined. + +--- + +## Key Insight: DryRun Executes Pure Nodes + +The execution model (`core/exec`) has a critical property that testgen +should exploit: + +``` +DryRun mode: + - Transport executor nodes (inputs include TransportRequest) → INTERCEPTED, return mock values + - Pure nodes (no TransportRequest inputs) → EXECUTE NORMALLY +``` + +This means a DryRun with mocked transport responses runs the **entire pure +logic chain**. Mocked `TransportResponse` values flow into parse nodes, which +produce real outputs, which flow into prepare nodes, which produce real outputs, +all the way to the final report/output node. + +### Example: CI Graph DryRun + +``` +prepare_scan ─req─▶ [execute_scan] ─resp─▶ parse_scan ─▶ prepare_build ─req─▶ [execute_build] ─resp─▶ parse_build ─▶ report + pure MOCKED pure pure MOCKED pure pure + runs returns mock runs runs returns mock runs runs +``` + +Every `[bracketed]` node is mocked. Every other node executes its real logic. +The `report` node at the end produces a real result derived from mocked I/O. +**This result is verifiable.** + +### The Gap: Entrypoint Inputs + +Some DAGs have external inputs — ports that receive values from outside the +DAG (not from upstream nodes). The LLM graphs need `provider`, `model`, +`prompt`, `api_key` injected at the entrypoint. The current `BoundaryMocks` +only provides values for intercepted transport executor outputs; there's no +mechanism for injecting external DAG inputs. + +DAGs with no external inputs (bootstrap, CI, makegen — their root nodes are +`prepare_*` nodes with no inputs) could run DryRun today. + +--- + +## Design: What Testgen Should Do + +### Level 1: Flow Verification (high value) + +Mock transport boundaries, execute the full DAG in DryRun, verify that the +pure node chain produces expected outputs at the terminal nodes. + +```rust +// Generated test — CI success flow +#[test] +fn test_ci_flow_all_pass() { + let dag = build_ci_graph().unwrap(); + let mocks = ci_success_mocks(); // mock all transport responses as "pass" + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + // Verify the report node (pure) computed the right result + let report = log.get("report").unwrap(); + assert_eq!(report.outputs["overall_success"], Value::Bool(true)); + assert!(report.outputs["report"].as_str().contains("SUCCESS")); +} + +#[test] +fn test_ci_flow_build_fails() { + let dag = build_ci_graph().unwrap(); + let mocks = ci_build_fail_mocks(); // mock build transport as exit_code: 1 + let log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)).unwrap(); + + let report = log.get("report").unwrap(); + assert_eq!(report.outputs["overall_success"], Value::Bool(false)); + // Test and lint should be skipped when build fails + assert!(log.get("parse_test").unwrap().outputs.get("skip").is_some() + || report.outputs["report"].as_str().contains("SKIPPED")); +} +``` + +This tests the **real business logic**: does the CI pipeline correctly skip +downstream stages when build fails? Does the report correctly aggregate +results? These are the kinds of bugs that actually happen. + +**Requirements:** +- `BoundaryMocks` maps (node_id, port) → mock Value for transport executor outputs +- MockSpec variants for each scenario (success, build-fail, test-fail, lint-fail) +- The graph builder functions already exist; the DAGs are available at test time + +**Which DAGs work today (no external inputs):** +- `bootstrap` — root is `prepare_scan_workspace` (no inputs) +- `ci` — root is `prepare_deps_exists` (no inputs) +- `makegen` — root is `prepare_scan` (no inputs) + +### Level 2: Entrypoint Input Injection (unblocks LLM DAGs) + +Add `EntrypointInputs` to `ExecutionMode::DryRun` so that DAGs with external +inputs can also run flow verification. + +```rust +pub enum ExecutionMode { + Real, + DryRun(BoundaryMocks), + DryRunWithInputs { // new variant + mocks: BoundaryMocks, + inputs: HashMap, // external inputs for entrypoint nodes + }, + Simulate(SimConfig), +} +``` + +Alternatively, inject entrypoint values as edges from a synthetic "env" node, +keeping the execution model unchanged. This is a design choice — the synthetic +node approach is cleaner because it doesn't add a new ExecutionMode variant. + +**Which DAGs need this:** +- All LLM graphs (need `provider`, `model`, `prompt`, `api_key`) + +### Level 3: Cross-DAG Chain Validation + +`validate_chain` is already implemented — it checks that upstream MockSpec +outputs satisfy downstream InputConstraints. But testgen never calls it. + +```rust +// Generated test — verify LLM output feeds into code-review input +#[test] +fn test_chain_llm_to_code_review() { + let upstream = openai_mock_spec(); + let downstream = code_review_mock_spec(); + let mapping = HashMap::from([ + ("content".into(), "review_input".into()), + ]); + let result = validate_chain(&upstream, &downstream, &mapping); + assert!(result.errors.is_empty(), "Chain errors: {:?}", result.errors); +} +``` + +This verifies that when two DAGs compose, the upstream's mocked outputs satisfy +the downstream's input constraints. Catches interface mismatches between +independently developed DAGs. + +### Level 4: Scenario Matrix + +For each DAG, generate tests for every MockSpec variant (success, each failure +mode, contention, timeout). The CI graph already has 6 mock spec variants in +`graph_mock.rs` — testgen should generate a flow test for each. + +```rust +// testgen generates one flow test per MockSpec variant +fn test_ci_flow_success() { run_flow(ci_mock_spec(), expect_success()); } +fn test_ci_flow_test_fails() { run_flow(ci_mock_spec_test_fails(), expect_failure("Test")); } +fn test_ci_flow_build_fails() { run_flow(ci_mock_spec_build_fails(), expect_failure("Build")); } +fn test_ci_flow_prep_fails() { run_flow(ci_mock_spec_prep_fails(), expect_failure("Prep")); } +fn test_ci_flow_lint_fails() { run_flow(ci_mock_spec_lint_fails(), expect_failure("Lint")); } +fn test_ci_flow_contended() { run_flow(ci_mock_spec_build_contended(), expect_failure("blocked")); } +``` + +--- + +## What To Remove + +The current generated tests (mock spec self-consistency, input expectation +counts, trivial resource acquire) should be replaced once flow verification +is working. They test the mock infrastructure, not the DAGs. + +The hand-written `graph_mock.rs` tests should stay — they test the mock specs +themselves and are intentionally authored. + +--- + +## TODO + +### Phase 1: Flow Verification for Cargo DAGs + +These DAGs have no external inputs and can run DryRun today. + +- [ ] **Convert MockSpec boundary mocks to BoundaryMocks** + Add `MockSpec::to_boundary_mocks() -> BoundaryMocks` that translates + mock spec boundary values into the format `execute_with_mode` expects. + The key mapping: MockSpec boundaries are keyed by the *boundary node* + (unconnected outputs), but DryRun mocks are keyed by *transport executor + nodes*. Need to bridge this gap — either change MockSpec to use transport + node IDs, or add a mapping step that uses the DAG edges. + +- [ ] **Add flow test generation to TestGenerator** + New method: `generate_flow_tests()`. For each MockSpec variant, generate + a test that builds the DAG, runs DryRun, and asserts on terminal node + outputs. + +- [ ] **Define expected outputs per scenario in MockSpec** + Add `expected_outputs: Vec` to MockSpec: + ```rust + pub struct ExpectedOutput { + pub node: String, + pub port: String, + pub expected: Value, + } + ``` + This lets mock specs declare: "given these mocked transport responses, + the terminal node should produce these values." + +- [ ] **Enable flow tests for bootstrap, ci, makegen** + Update `testgen.rs` targets to generate flow tests. Remove the + `boundary_tests: false` workaround. + +- [ ] **Remove circular tests** + Drop `test_mock_spec_self_consistent` and `test_input_expectations_documented` + from generated output once flow tests are in place. + +### Phase 2: Entrypoint Input Injection + +- [ ] **Design input injection mechanism** + Choose between: (a) new `ExecutionMode` variant with explicit inputs map, + or (b) synthetic "env" source node prepended to the DAG. Option (b) is + cleaner for the execution model. + +- [ ] **Implement input injection in executor** + Modify `execute_with_mode` (or add a DAG transform) to support + externally-provided inputs for entrypoint nodes. + +- [ ] **Add MockSpec entrypoint values** + ```rust + MockSpec::new("llm") + .entrypoint("provider", Value::Str("openai".into())) + .entrypoint("model", Value::Str("gpt-4".into())) + .entrypoint("prompt", Value::Str("review this code".into())) + .entrypoint("api_key", Value::Secret(...)) + ``` + +- [ ] **Enable flow tests for LLM DAGs** + Update LLM testgen targets to use entrypoint injection + flow tests. + +### Phase 3: Cross-DAG Chain Validation + +- [ ] **Generate chain validation tests** + For known DAG composition points (LLM → code-review, etc.), generate + `validate_chain` tests that verify interface compatibility. + +- [ ] **Define composition registry** + A simple list of (upstream_spec, downstream_spec, port_mapping) tuples + that testgen iterates to produce chain tests. + +### Phase 4: Scenario Matrix + +- [ ] **Auto-discover MockSpec variants** + Convention: `graph_mock.rs` exports functions matching `*_mock_spec*()`. + Testgen discovers them and generates one flow test per variant. + +- [ ] **Add failure-mode assertions** + MockSpec variants annotated with expected terminal outputs enable + automatic assertion generation for each scenario. diff --git a/gunbc-dag/Cargo.toml b/gunbc-dag/Cargo.toml index 1c7c6145f90..a80ac8f00c1 100644 --- a/gunbc-dag/Cargo.toml +++ b/gunbc-dag/Cargo.toml @@ -22,6 +22,10 @@ gunbc-deps = { path = "../lib/tools/deps" } gunbc-gist = { path = "../lib/tools/gist" } gunbc-buck2 = { path = "../lib/tools/buck2" } gunbc-clippy = { path = "../lib/tools/clippy" } +gunbc-lib-llm-ops = { path = "../lib/llm-ops" } + +# Test generation +gunbc-testgen = { path = "../core/testgen" } serde_json = { workspace = true } @@ -43,3 +47,7 @@ path = "src/bin/bootstrap.rs" [[bin]] name = "gunbc-dag-viz" path = "src/bin/dag-viz.rs" + +[[bin]] +name = "gunbc-testgen" +path = "src/bin/testgen.rs" diff --git a/gunbc-dag/src/bin/testgen.rs b/gunbc-dag/src/bin/testgen.rs new file mode 100644 index 00000000000..d5bfc977037 --- /dev/null +++ b/gunbc-dag/src/bin/testgen.rs @@ -0,0 +1,233 @@ +//! gunbc-testgen main entry point. +//! +//! Generates test files from DAG structures and MockSpecs. +//! +//! Usage: +//! cargo run -p gunbc-dag --bin gunbc-testgen +//! cargo run -p gunbc-dag --bin gunbc-testgen -- --dry-run +//! cargo run -p gunbc-dag --bin gunbc-testgen -- --output-dir /path/to/output + +use gunbc_testgen::{TestConfig, TestGenerator}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process; + +/// A test generation target: a DAG + MockSpec + output path. +struct TestgenTarget { + /// Human-readable name + name: &'static str, + /// Relative path from workspace root where the generated file goes + output_path: &'static str, + /// Function to generate the test code + generate: fn() -> String, +} + +// Code generator — needs direct filesystem access (same exemption as gunbc-codegen). +#[allow(clippy::disallowed_methods)] +fn main() { + let args: Vec = env::args().collect(); + + let mut output_dir = PathBuf::from("."); + let mut dry_run = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-o" | "--output-dir" => { + i += 1; + if i < args.len() { + output_dir = PathBuf::from(&args[i]); + } + } + "-n" | "--dry-run" => dry_run = true, + "-h" | "--help" => { + print_help(); + return; + } + _ => {} + } + i += 1; + } + + println!("testgen"); + println!(" output_dir: {}", output_dir.display()); + println!(" mode: {}", if dry_run { "dry-run" } else { "real" }); + println!(); + + let targets = all_targets(); + + let mut generated = 0; + let mut errors = 0; + + for target in &targets { + let code = (target.generate)(); + let output_path = output_dir.join(target.output_path); + + if dry_run { + println!("[{}] would write to: {}", target.name, output_path.display()); + println!(" {} bytes, {} lines", code.len(), code.lines().count()); + } else { + // Ensure parent directory exists + if let Some(parent) = output_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!("[{}] error creating dir {}: {}", target.name, parent.display(), e); + errors += 1; + continue; + } + } + + match fs::write(&output_path, &code) { + Ok(_) => { + println!( + "[{}] wrote {} ({} bytes)", + target.name, + output_path.display(), + code.len() + ); + generated += 1; + } + Err(e) => { + eprintln!("[{}] error writing {}: {}", target.name, output_path.display(), e); + errors += 1; + } + } + } + } + + println!(); + if dry_run { + println!("dry-run complete: {} targets", targets.len()); + } else { + println!( + "generated: {} files, {} errors", + generated, errors + ); + } + + if errors > 0 { + process::exit(1); + } +} + +/// All testgen targets in the project. +fn all_targets() -> Vec { + vec![ + // gunbc-dag internal DAGs + // NOTE: boundary_tests disabled until testgen supports entrypoint input mocking + // (dry-run only intercepts transport executors; pure nodes still need inputs) + TestgenTarget { + name: "bootstrap", + output_path: "gunbc-dag/src/bootstrap/generated_tests.rs", + generate: || { + let dag = gunbc_dag::build_bootstrap_graph().unwrap(); + let spec = gunbc_dag::bootstrap::graph_mock::bootstrap_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("bootstrap_generated_tests", "crate::build_bootstrap_graph().unwrap()") + }, + }, + TestgenTarget { + name: "ci", + output_path: "gunbc-dag/src/ci/generated_tests.rs", + generate: || { + let dag = gunbc_dag::build_ci_graph().unwrap(); + let spec = gunbc_dag::ci::graph_mock::ci_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("ci_generated_tests", "crate::build_ci_graph().unwrap()") + }, + }, + TestgenTarget { + name: "makegen", + output_path: "gunbc-dag/src/makegen/generated_tests.rs", + generate: || { + let dag = gunbc_dag::build_makegen_graph().unwrap(); + let spec = gunbc_dag::makegen::graph_mock::makegen_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("makegen_generated_tests", "crate::build_makegen_graph().unwrap()") + }, + }, + // LLM ops (composable sub-DAGs — no standalone boundary execution tests) + TestgenTarget { + name: "llm-openai", + output_path: "lib/llm-ops/src/generated_tests.rs", + generate: || { + let dag = gunbc_lib_llm_ops::graph::build_chat_completion_graph(); + let spec = gunbc_lib_llm_ops::graph_mock::openai_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("llm_openai_generated_tests", "crate::graph::build_chat_completion_graph()") + }, + }, + TestgenTarget { + name: "llm-anthropic", + output_path: "lib/llm-ops/src/generated_tests_anthropic.rs", + generate: || { + let dag = gunbc_lib_llm_ops::graph::build_chat_completion_graph(); + let spec = gunbc_lib_llm_ops::graph_mock::anthropic_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("llm_anthropic_generated_tests", "crate::graph::build_chat_completion_graph()") + }, + }, + TestgenTarget { + name: "llm-code-review", + output_path: "lib/llm-ops/src/generated_tests_code_review.rs", + generate: || { + let dag = gunbc_lib_llm_ops::graph::build_chat_completion_graph(); + let spec = gunbc_lib_llm_ops::graph_mock::code_review_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("llm_code_review_generated_tests", "crate::graph::build_chat_completion_graph()") + }, + }, + TestgenTarget { + name: "llm-secrets", + output_path: "lib/llm-ops/src/generated_tests_secrets.rs", + generate: || { + let dag = gunbc_lib_llm_ops::graph::build_chat_completion_graph(); + let spec = gunbc_lib_llm_ops::graph_mock::secret_api_key_mock_spec(); + let config = TestConfig { boundary_tests: false, ..TestConfig::default() }; + TestGenerator::new(&dag) + .with_config(config) + .with_mock_spec(spec) + .generate_test_module("llm_secrets_generated_tests", "crate::graph::build_chat_completion_graph()") + }, + }, + ] +} + +fn print_help() { + println!("testgen - Generate tests from DAG structures and MockSpecs"); + println!(); + println!("USAGE:"); + println!(" gunbc-testgen [OPTIONS]"); + println!(); + println!("OPTIONS:"); + println!(" -o, --output-dir Output base directory (default: .)"); + println!(" -n, --dry-run Don't write files, just show what would be generated"); + println!(" -h, --help Print this help"); + println!(); + println!("Generated test files:"); + println!(" gunbc-dag/src/bootstrap/generated_tests.rs"); + println!(" gunbc-dag/src/ci/generated_tests.rs"); + println!(" gunbc-dag/src/makegen/generated_tests.rs"); + println!(" lib/llm-ops/src/generated_tests.rs"); + println!(" lib/llm-ops/src/generated_tests_anthropic.rs"); + println!(" lib/llm-ops/src/generated_tests_code_review.rs"); + println!(" lib/llm-ops/src/generated_tests_secrets.rs"); +} diff --git a/gunbc-dag/src/bootstrap/generated_tests.rs b/gunbc-dag/src/bootstrap/generated_tests.rs new file mode 100644 index 00000000000..e4d58fcfb44 --- /dev/null +++ b/gunbc-dag/src/bootstrap/generated_tests.rs @@ -0,0 +1,50 @@ +// Generated tests for bootstrap_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; +use gunbc_test::{ResourceAcquireResult, ResourceSimulation}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("write_files", "files_written").is_some(), + "MockSpec should have boundary mock for write_files.files_written"); + assert!(spec.get_boundary_mock("write_files", "write_count").is_some(), + "MockSpec should have boundary mock for write_files.write_count"); +} + +// ============================================================================ +// Resource Simulation Tests +// These tests verify behavior under different resource acquisition scenarios. +// ============================================================================ + +/// Test resource 'fs:Makefile' (Lock) acquisition. +#[test] +fn test_resource_fs_makefile_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("fs:Makefile").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + +/// Test resource 'fs:.gitignore' (Lock) acquisition. +#[test] +fn test_resource_fs_gitignore_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("fs:.gitignore").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + diff --git a/gunbc-dag/src/bootstrap/mod.rs b/gunbc-dag/src/bootstrap/mod.rs index 314f2af8081..a125e83ef68 100644 --- a/gunbc-dag/src/bootstrap/mod.rs +++ b/gunbc-dag/src/bootstrap/mod.rs @@ -5,8 +5,16 @@ pub mod graph; pub mod ops; -#[cfg(test)] pub mod graph_mock; pub use graph::{build_bootstrap_graph, bootstrap_signature, BootstrapGraphOp}; pub use ops::BootstrapOp; + +#[cfg(test)] +mod generated_tests { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::bootstrap::graph_mock::bootstrap_mock_spec() + } + include!("generated_tests.rs"); +} diff --git a/gunbc-dag/src/ci/generated_tests.rs b/gunbc-dag/src/ci/generated_tests.rs new file mode 100644 index 00000000000..15fb1a15e3f --- /dev/null +++ b/gunbc-dag/src/ci/generated_tests.rs @@ -0,0 +1,59 @@ +// Generated tests for ci_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; +use gunbc_test::{ResourceAcquireResult, ResourceSimulation}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("report", "overall_success").is_some(), + "MockSpec should have boundary mock for report.overall_success"); + assert!(spec.get_boundary_mock("report", "report").is_some(), + "MockSpec should have boundary mock for report.report"); +} + +// ============================================================================ +// Resource Simulation Tests +// These tests verify behavior under different resource acquisition scenarios. +// ============================================================================ + +/// Test resource 'cargo:build' (Lock) acquisition. +#[test] +fn test_resource_cargo_build_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("cargo:build").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + +/// Test resource 'cargo:test' (Lock) acquisition. +#[test] +fn test_resource_cargo_test_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("cargo:test").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + +/// Test resource 'cargo:clippy' (Lock) acquisition. +#[test] +fn test_resource_cargo_clippy_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("cargo:clippy").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + diff --git a/gunbc-dag/src/ci/mod.rs b/gunbc-dag/src/ci/mod.rs index ac84c9561c5..de3dc89a695 100644 --- a/gunbc-dag/src/ci/mod.rs +++ b/gunbc-dag/src/ci/mod.rs @@ -5,7 +5,6 @@ pub mod graph; pub mod ops; -#[cfg(test)] pub mod graph_mock; pub use graph::{ @@ -15,3 +14,12 @@ pub use graph::{ pub use gunbc_primitives::EmbeddedFileExistsOp; pub use gunbc_ir::transport::github_actions::WorkflowConfig; pub use ops::CIOp; + +#[cfg(test)] +mod generated_tests { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::ci::graph_mock::ci_mock_spec() + } + include!("generated_tests.rs"); +} diff --git a/gunbc-dag/src/ci/ops.rs b/gunbc-dag/src/ci/ops.rs index 6f220437e7a..ff1c100a364 100644 --- a/gunbc-dag/src/ci/ops.rs +++ b/gunbc-dag/src/ci/ops.rs @@ -13,7 +13,7 @@ //! ``` use gunbc_exec::{ExecError, Executable}; -use gunbc_ir::transport::{FileRequest, ShellRequest, TransportRequest, TransportResponse}; +use gunbc_ir::transport::{FileRequest, TransportRequest, TransportResponse}; use gunbc_ir::Value; use crate::makegen::BuildConfig; use std::collections::HashMap; @@ -180,14 +180,7 @@ fn execute_prepare_codegen_command(inputs: HashMap) -> Result = config.codegen_command[1..].iter().map(|s| s.to_string()).collect(); - let request = TransportRequest::Shell(ShellRequest { - command: config.codegen_command[0].to_string(), - args, - cwd: None, - env: HashMap::new(), - stdin: None, - }); + let request = TransportRequest::Shell(config.codegen.to_shell_request()); out.insert("request".to_string(), Value::Request(request)); out.insert("skip".to_string(), Value::Bool(false)); @@ -255,14 +248,7 @@ fn execute_prepare_build_command(inputs: HashMap) -> Result = config.build_command[1..].iter().map(|s| s.to_string()).collect(); - let request = TransportRequest::Shell(ShellRequest { - command: config.build_command[0].to_string(), - args, - cwd: None, - env: HashMap::new(), - stdin: None, - }); + let request = TransportRequest::Shell(config.build.to_shell_request()); out.insert("request".to_string(), Value::Request(request)); out.insert("skip".to_string(), Value::Bool(false)); @@ -327,14 +313,7 @@ fn execute_prepare_test_command(inputs: HashMap) -> Result = config.test_command[1..].iter().map(|s| s.to_string()).collect(); - let request = TransportRequest::Shell(ShellRequest { - command: config.test_command[0].to_string(), - args, - cwd: None, - env: HashMap::new(), - stdin: None, - }); + let request = TransportRequest::Shell(config.test.to_shell_request()); out.insert("request".to_string(), Value::Request(request)); out.insert("skip".to_string(), Value::Bool(false)); @@ -571,14 +550,7 @@ impl Mockable for CIOp { } CIOp::PrepareBuildCommand => { let config = BuildConfig::cargo(); - let args: Vec = config.build_command[1..].iter().map(|s| s.to_string()).collect(); - let request = TransportRequest::Shell(ShellRequest { - command: config.build_command[0].to_string(), - args, - cwd: None, - env: HashMap::new(), - stdin: None, - }); + let request = TransportRequest::Shell(config.build.to_shell_request()); let mut out = HashMap::new(); out.insert("request".to_string(), Value::Request(request)); out.insert("skip".to_string(), Value::Bool(false)); @@ -594,14 +566,7 @@ impl Mockable for CIOp { } CIOp::PrepareTestCommand => { let config = BuildConfig::cargo(); - let args: Vec = config.test_command[1..].iter().map(|s| s.to_string()).collect(); - let request = TransportRequest::Shell(ShellRequest { - command: config.test_command[0].to_string(), - args, - cwd: None, - env: HashMap::new(), - stdin: None, - }); + let request = TransportRequest::Shell(config.test.to_shell_request()); let mut out = HashMap::new(); out.insert("request".to_string(), Value::Request(request)); out.insert("skip".to_string(), Value::Bool(false)); diff --git a/gunbc-dag/src/makegen/generated_tests.rs b/gunbc-dag/src/makegen/generated_tests.rs new file mode 100644 index 00000000000..2a207c2df6e --- /dev/null +++ b/gunbc-dag/src/makegen/generated_tests.rs @@ -0,0 +1,51 @@ +// Generated tests for makegen_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; +use gunbc_test::{ResourceAcquireResult, ResourceSimulation}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("write_makefile", "written_path").is_some(), + "MockSpec should have boundary mock for write_makefile.written_path"); + assert!(spec.get_boundary_mock("write_makefile", "content").is_some(), + "MockSpec should have boundary mock for write_makefile.content"); + assert!(spec.get_boundary_mock("write_makefile", "changed").is_some(), + "MockSpec should have boundary mock for write_makefile.changed"); +} + +/// Test that input expectations are documented. +#[test] +fn test_input_expectations_documented() { + let spec = mock_spec(); + // Port 'output_path' expects: Any + assert_eq!(spec.input_expectations.len(), 1); +} + +// ============================================================================ +// Resource Simulation Tests +// These tests verify behavior under different resource acquisition scenarios. +// ============================================================================ + +/// Test resource 'fs:Makefile' (Lock) acquisition. +#[test] +fn test_resource_fs_makefile_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("fs:Makefile").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + diff --git a/gunbc-dag/src/makegen/mod.rs b/gunbc-dag/src/makegen/mod.rs index 7bf6fec7b54..1ef46aaa52f 100644 --- a/gunbc-dag/src/makegen/mod.rs +++ b/gunbc-dag/src/makegen/mod.rs @@ -8,7 +8,6 @@ pub mod ops; pub mod registry; pub mod render; -#[cfg(test)] pub mod graph_mock; pub use gitignore::{derive_categories, render_gitignore, GitignoreRenderer, IgnoreCategory}; @@ -19,3 +18,12 @@ pub use registry::{ EntrypointParam, MetaTarget, PrepLevel, ToolInfo, ToolRegistry, }; pub use render::{render_makefile, render_makefile_with_config}; + +#[cfg(test)] +mod generated_tests { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::makegen::graph_mock::makegen_mock_spec() + } + include!("generated_tests.rs"); +} diff --git a/gunbc-dag/src/makegen/registry.rs b/gunbc-dag/src/makegen/registry.rs index 528b1e61e37..a2024510c6d 100644 --- a/gunbc-dag/src/makegen/registry.rs +++ b/gunbc-dag/src/makegen/registry.rs @@ -9,8 +9,11 @@ //! commands. This eliminates duplicate hardcoded commands across the codebase. use gunbc_deps::DEFAULT_MANIFEST_FILENAME; +use gunbc_ir::cargo::{CargoCommand, Subcommand, Warnings}; +use gunbc_ir::transport::ShellRequest; use gunbc_ir::CargoInvocation; use gunbc_ir::DEFAULT_MAKEFILE_FILENAME; +use std::collections::HashMap; // ============================================================================ // Build Configuration - Single source of truth for build commands @@ -25,121 +28,209 @@ pub enum BuildSystem { Buck2, } +/// A build command that can be either a structured cargo command or a raw +/// shell command (for non-cargo build tools like buck2). +/// +/// `Cargo` variants carry full semantic information (subcommand, flags, +/// warning policy) and render themselves via [`CargoCommand`]. `Shell` +/// variants are opaque command vectors for tools outside the cargo model. +#[derive(Debug, Clone)] +pub enum BuildCommand { + /// A structured cargo command with full semantic rendering. + Cargo(CargoCommand), + /// A raw shell command (for non-cargo tools like buck2). + Shell(Vec), +} + +impl BuildCommand { + /// Render as a shell command string. + pub fn to_shell(&self) -> String { + match self { + BuildCommand::Cargo(cmd) => cmd.to_shell(), + BuildCommand::Shell(parts) => parts.join(" "), + } + } + + /// Convert to a `ShellRequest` for transport execution. + pub fn to_shell_request(&self) -> ShellRequest { + match self { + BuildCommand::Cargo(cmd) => cmd.to_shell_request(), + BuildCommand::Shell(parts) => { + let (command, args) = parts.split_first().expect("empty command"); + ShellRequest { + command: command.clone(), + args: args.to_vec(), + cwd: None, + env: HashMap::new(), + stdin: None, + } + } + } + } +} + /// Unified build system configuration. /// Single source of truth for build/test/lint operations. /// -/// Command vectors use `String` to support composed binary names -/// via [`cargo::name`]. +/// Commands are modeled as [`BuildCommand`] values — either structured +/// [`CargoCommand`] values (for cargo-based builds) or raw shell commands +/// (for non-cargo tools like buck2). The warning policy is applied to all +/// cargo compilation subcommands. #[derive(Debug, Clone)] pub struct BuildConfig { /// Build system (cargo, buck2) pub build_system: BuildSystem, + /// Repo-level warning policy (applied to all compilation commands). + pub warnings: Warnings, /// Command to run codegen - pub codegen_command: Vec, + pub codegen: BuildCommand, /// Command to run daggen - pub daggen_command: Vec, + pub daggen: BuildCommand, /// Command to build all targets - pub build_command: Vec, + pub build: BuildCommand, /// Command to run tests - pub test_command: Vec, + pub test: BuildCommand, /// Command to run linter - pub lint_command: Vec, + pub lint: BuildCommand, /// Command to auto-fix lint issues (cargo clippy --fix) - pub lint_fix_command: Vec, + pub lint_fix: BuildCommand, /// Command to format code - pub fmt_command: Vec, + pub fmt: BuildCommand, /// Command to check formatting - pub fmt_check_command: Vec, + pub fmt_check: BuildCommand, /// Command to type-check without full build - pub check_command: Vec, + pub check: BuildCommand, /// Command to generate CI YAML - pub ci_yaml_command: Vec, -} - -/// Helper to convert a list of &str into Vec. -fn strs(parts: &[&str]) -> Vec { - parts.iter().map(|s| s.to_string()).collect() + pub ci_yaml: BuildCommand, } impl BuildConfig { /// Default cargo-based build config. + /// + /// Warning policy is `Deny` — all warnings are promoted to errors. + /// This is the repo's standard policy for both CI and local builds. pub fn cargo() -> Self { - let codegen = CargoInvocation::standalone("codegen"); + let w = Warnings::Deny; + let codegen_inv = CargoInvocation::standalone("codegen"); + let c = |cmd: CargoCommand| BuildCommand::Cargo(cmd); Self { build_system: BuildSystem::Cargo, - codegen_command: codegen.run_with_args(&["--release", "--", "codegen"]), - daggen_command: codegen.run_with_args(&["--release", "--", "daggen"]), - build_command: strs(&["cargo", "build", "--all-targets"]), - test_command: strs(&["cargo", "test"]), - lint_command: strs(&["cargo", "clippy", "--all-targets", "--", "-D", "warnings"]), - lint_fix_command: strs(&["cargo", "clippy", "--fix", "--workspace", "--allow-dirty", "--allow-staged", "--", "-D", "warnings"]), - fmt_command: strs(&["cargo", "fmt"]), - fmt_check_command: strs(&["cargo", "fmt", "--", "--check"]), - check_command: strs(&["cargo", "check", "--all-targets"]), - ci_yaml_command: codegen.run_with_args(&["--release", "--", "cigen"]), + warnings: w, + codegen: c(CargoCommand::new(Subcommand::Run(codegen_inv.clone())) + .release() + .trailing_arg("codegen")), + daggen: c(CargoCommand::new(Subcommand::Run(codegen_inv.clone())) + .release() + .trailing_arg("daggen")), + build: c(CargoCommand::new(Subcommand::Build) + .all_targets() + .warnings(w)), + test: c(CargoCommand::new(Subcommand::Test) + .warnings(w)), + lint: c(CargoCommand::new(Subcommand::Clippy) + .all_targets() + .warnings(w)), + lint_fix: c(CargoCommand::new(Subcommand::Clippy) + .flag("--fix") + .flag("--workspace") + .flag("--allow-dirty") + .flag("--allow-staged") + .warnings(w)), + fmt: c(CargoCommand::new(Subcommand::Fmt)), + fmt_check: c(CargoCommand::new(Subcommand::Fmt) + .trailing_arg("--check")), + check: c(CargoCommand::new(Subcommand::Check) + .all_targets() + .warnings(w)), + ci_yaml: c(CargoCommand::new(Subcommand::Run(codegen_inv)) + .release() + .trailing_arg("cigen")), } } /// Buck2-based build config (for future use). + /// + /// Uses `BuildCommand::Shell` for buck2-native commands (build, test, lint, check) + /// and `BuildCommand::Cargo` for commands that still use cargo (codegen, fmt). pub fn buck2() -> Self { - let codegen = CargoInvocation::standalone("codegen"); + let w = Warnings::Deny; + let codegen_inv = CargoInvocation::standalone("codegen"); + let c = |cmd: CargoCommand| BuildCommand::Cargo(cmd); + let sh = |parts: &[&str]| BuildCommand::Shell(parts.iter().map(|s| s.to_string()).collect()); Self { build_system: BuildSystem::Buck2, - codegen_command: codegen.run_with_args(&["--release", "--", "codegen"]), - daggen_command: codegen.run_with_args(&["--release", "--", "daggen"]), - build_command: strs(&["buck2", "build", "//..."]), - test_command: strs(&["buck2", "test", "//..."]), - lint_command: strs(&["buck2", "run", "//tools:clippy"]), - lint_fix_command: strs(&["cargo", "clippy", "--fix", "--workspace", "--allow-dirty", "--allow-staged", "--", "-D", "warnings"]), - fmt_command: strs(&["cargo", "fmt"]), // fmt stays cargo - fmt_check_command: strs(&["cargo", "fmt", "--", "--check"]), - check_command: strs(&["buck2", "build", "//..."]), // buck2 check is same as build - ci_yaml_command: codegen.run_with_args(&["--release", "--", "cigen"]), + warnings: w, + codegen: c(CargoCommand::new(Subcommand::Run(codegen_inv.clone())) + .release() + .trailing_arg("codegen")), + daggen: c(CargoCommand::new(Subcommand::Run(codegen_inv.clone())) + .release() + .trailing_arg("daggen")), + // Buck2-native commands + build: sh(&["buck2", "build", "//..."]), + test: sh(&["buck2", "test", "//..."]), + lint: sh(&["buck2", "run", "//tools:clippy"]), + // lint-fix still uses cargo (buck2 doesn't have an equivalent) + lint_fix: c(CargoCommand::new(Subcommand::Clippy) + .flag("--fix") + .flag("--workspace") + .flag("--allow-dirty") + .flag("--allow-staged") + .warnings(w)), + // fmt stays cargo (buck2 delegates to cargo fmt) + fmt: c(CargoCommand::new(Subcommand::Fmt)), + fmt_check: c(CargoCommand::new(Subcommand::Fmt) + .trailing_arg("--check")), + check: sh(&["buck2", "build", "//..."]), + ci_yaml: c(CargoCommand::new(Subcommand::Run(codegen_inv)) + .release() + .trailing_arg("cigen")), } } - /// Get the command as a shell string (for Makefile generation). + /// Get the codegen command as a shell string (for Makefile generation). pub fn codegen_shell(&self) -> String { - format!("@{}", self.codegen_command.join(" ")) + format!("@{}", self.codegen.to_shell()) } /// Get the build command as a shell string. pub fn build_shell(&self) -> String { - format!("@{}", self.build_command.join(" ")) + format!("@{}", self.build.to_shell()) } /// Get the test command as a shell string. pub fn test_shell(&self) -> String { - format!("@{}", self.test_command.join(" ")) + format!("@{}", self.test.to_shell()) } /// Get the lint command as a shell string. pub fn lint_shell(&self) -> String { - format!("@{}", self.lint_command.join(" ")) + format!("@{}", self.lint.to_shell()) } /// Get the lint-fix command as a shell string. pub fn lint_fix_shell(&self) -> String { - format!("@{}", self.lint_fix_command.join(" ")) + format!("@{}", self.lint_fix.to_shell()) } /// Get the fmt command as a shell string. pub fn fmt_shell(&self) -> String { - format!("@{}", self.fmt_command.join(" ")) + format!("@{}", self.fmt.to_shell()) } /// Get the fmt-check command as a shell string. pub fn fmt_check_shell(&self) -> String { - format!("@{}", self.fmt_check_command.join(" ")) + format!("@{}", self.fmt_check.to_shell()) } /// Get the check command as a shell string. pub fn check_shell(&self) -> String { - format!("@{}", self.check_command.join(" ")) + format!("@{}", self.check.to_shell()) } + /// Get the CI YAML generation command as a shell string. pub fn ci_yaml_shell(&self) -> String { - format!("@{}", self.ci_yaml_command.join(" ")) + format!("@{}", self.ci_yaml.to_shell()) } } @@ -740,15 +831,16 @@ mod tests { fn test_build_config_cargo() { let config = BuildConfig::cargo(); assert_eq!(config.build_system, BuildSystem::Cargo); - assert!(config.build_command.iter().any(|s| s == "cargo")); - assert!(config.test_command.iter().any(|s| s == "test")); + assert_eq!(config.warnings, Warnings::Deny); + assert!(config.build.to_shell().contains("cargo build")); + assert!(config.test.to_shell().contains("cargo test")); } #[test] fn test_build_config_buck2() { let config = BuildConfig::buck2(); assert_eq!(config.build_system, BuildSystem::Buck2); - assert!(config.build_command.iter().any(|s| s == "buck2")); + assert!(config.build.to_shell().contains("buck2 build")); } #[test] diff --git a/gunbc-dag/src/makegen/render.rs b/gunbc-dag/src/makegen/render.rs index 0449f39847c..b2a33c9eb37 100644 --- a/gunbc-dag/src/makegen/render.rs +++ b/gunbc-dag/src/makegen/render.rs @@ -156,7 +156,7 @@ fn render_core_targets(config: &BuildConfig) -> String { output.push_str(&format!("{}@if [ ! -f buck-out/gen/.codegen-stamp ]; then \\\n", INDENT)); output.push_str(&format!( "{}{}{} && touch buck-out/gen/.codegen-stamp; \\\n", - INDENT, INDENT, config.codegen_command.join(" ") + INDENT, INDENT, config.codegen.to_shell() )); output.push_str(&format!("{}fi\n\n", INDENT)); diff --git a/lib/llm-ops/Cargo.toml b/lib/llm-ops/Cargo.toml new file mode 100644 index 00000000000..dc31d774d16 --- /dev/null +++ b/lib/llm-ops/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "gunbc-lib-llm-ops" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LLM operations library for gunbc DAGs" + +[dependencies] +gunbc-ir = { path = "../../core/ir" } +gunbc-exec = { path = "../../core/exec" } +gunbc-lib-transport = { path = "../transport" } +gunbc-test = { path = "../../core/test" } +serde_json = { workspace = true } diff --git a/lib/llm-ops/src/cache.rs b/lib/llm-ops/src/cache.rs new file mode 100644 index 00000000000..ffd03fadf13 --- /dev/null +++ b/lib/llm-ops/src/cache.rs @@ -0,0 +1,380 @@ +//! Content-addressable response caching for LLM providers. +//! +//! Caches LLM responses keyed by a hash of (provider, model, messages, params). +//! Each provider manages its own cache namespace so that OpenAI and Anthropic +//! caches don't collide. +//! +//! # Design +//! +//! The cache sits above the transport layer — it intercepts at the LlmOps level, +//! not the REST level. This means: +//! +//! - Cache keys are based on semantic content (messages, model), not HTTP details +//! - Provider-specific formatting differences don't affect cache hits +//! - The same logical request to different providers gets different cache entries +//! +//! # Cache Key +//! +//! The cache key is a deterministic hash of: +//! - Provider ID +//! - Model name +//! - All messages (role + content) +//! - Temperature (if set) +//! - Max tokens (if set) +//! +//! Stop sequences and other parameters are excluded from the key since they +//! typically don't change the fundamental response character. + +use gunbc_ir::transport::llm::{ChatRequest, ChatResponse}; +use std::collections::HashMap; + +/// Cache key for an LLM request. +/// +/// Deterministic string built from the semantically significant parts +/// of a chat completion request. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CacheKey(String); + +impl CacheKey { + /// Build a cache key from a provider ID and chat request. + pub fn from_request(provider_id: &str, request: &ChatRequest) -> Self { + let mut parts = Vec::new(); + + parts.push(format!("provider:{}", provider_id)); + parts.push(format!("model:{}", request.model)); + + for msg in &request.messages { + parts.push(format!("msg:{}:{}", msg.role, msg.text())); + } + + if let Some(t) = request.temperature { + // Normalize to 2 decimal places to avoid float comparison issues + parts.push(format!("temp:{:.2}", t)); + } + + if let Some(n) = request.max_tokens { + parts.push(format!("max_tokens:{}", n)); + } + + // Simple deterministic hash: join all parts with null separator + // then compute a portable hash. We use a simple string key for + // now (debuggable, serializable) rather than a cryptographic hash. + CacheKey(parts.join("\0")) + } + + /// Get the raw key string (for debugging/logging). + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Cached LLM response entry. +#[derive(Debug, Clone)] +pub struct CacheEntry { + /// The cached response. + pub response: ChatResponse, + /// Number of times this entry has been hit. + pub hit_count: u64, +} + +/// Per-provider LLM response cache. +/// +/// Each provider has its own cache instance, allowing independent +/// eviction policies and size limits. +#[derive(Debug)] +pub struct LlmCache { + /// Provider ID this cache belongs to. + provider_id: String, + /// Cached entries keyed by request hash. + entries: HashMap, + /// Maximum number of entries (0 = unlimited). + max_entries: usize, +} + +impl LlmCache { + /// Create a new cache for a provider. + pub fn new(provider_id: impl Into) -> Self { + Self { + provider_id: provider_id.into(), + entries: HashMap::new(), + max_entries: 1000, + } + } + + /// Set the maximum number of cache entries. + pub fn with_max_entries(mut self, max: usize) -> Self { + self.max_entries = max; + self + } + + /// Look up a cached response. + pub fn get(&mut self, request: &ChatRequest) -> Option<&ChatResponse> { + let key = CacheKey::from_request(&self.provider_id, request); + if let Some(entry) = self.entries.get_mut(&key) { + entry.hit_count += 1; + Some(&entry.response) + } else { + None + } + } + + /// Store a response in the cache. + pub fn put(&mut self, request: &ChatRequest, response: ChatResponse) { + let key = CacheKey::from_request(&self.provider_id, request); + + // Simple eviction: if at capacity, remove the least-hit entry + if self.max_entries > 0 && self.entries.len() >= self.max_entries { + self.evict_one(); + } + + self.entries.insert( + key, + CacheEntry { + response, + hit_count: 0, + }, + ); + } + + /// Number of cached entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the cache is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Clear all cached entries. + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Provider ID. + pub fn provider_id(&self) -> &str { + &self.provider_id + } + + /// Evict the least-hit entry. + fn evict_one(&mut self) { + if let Some(key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.hit_count) + .map(|(k, _)| k.clone()) + { + self.entries.remove(&key); + } + } +} + +/// Registry of per-provider caches. +/// +/// Manages one `LlmCache` per provider, lazily created on first access. +#[derive(Debug, Default)] +pub struct CacheRegistry { + caches: HashMap, + /// Default max entries for new caches. + default_max_entries: usize, +} + +impl CacheRegistry { + /// Create a new cache registry. + pub fn new() -> Self { + Self { + caches: HashMap::new(), + default_max_entries: 1000, + } + } + + /// Set default max entries for new caches. + pub fn with_default_max(mut self, max: usize) -> Self { + self.default_max_entries = max; + self + } + + /// Get or create the cache for a provider. + pub fn cache_for(&mut self, provider_id: &str) -> &mut LlmCache { + let max = self.default_max_entries; + self.caches + .entry(provider_id.to_string()) + .or_insert_with(|| LlmCache::new(provider_id).with_max_entries(max)) + } + + /// Look up a cached response for a provider. + pub fn get( + &mut self, + provider_id: &str, + request: &ChatRequest, + ) -> Option<&ChatResponse> { + // We need a two-step lookup to satisfy the borrow checker + if !self.caches.contains_key(provider_id) { + return None; + } + self.caches.get_mut(provider_id).and_then(|c| c.get(request)) + } + + /// Store a response in the provider's cache. + pub fn put( + &mut self, + provider_id: &str, + request: &ChatRequest, + response: ChatResponse, + ) { + self.cache_for(provider_id).put(request, response); + } + + /// Clear all caches. + pub fn clear_all(&mut self) { + self.caches.clear(); + } + + /// Clear a specific provider's cache. + pub fn clear_provider(&mut self, provider_id: &str) { + if let Some(cache) = self.caches.get_mut(provider_id) { + cache.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::transport::llm::{ChatMessage, FinishReason, Usage}; + + fn sample_request() -> ChatRequest { + ChatRequest::new( + "gpt-4o", + vec![ + ChatMessage::system("Be helpful."), + ChatMessage::user("Hello!"), + ], + ) + .temperature(0.7) + } + + fn sample_response() -> ChatResponse { + ChatResponse { + content: "Hi there!".to_string(), + model: "gpt-4o".to_string(), + finish_reason: FinishReason::Stop, + usage: Usage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }, + thinking: None, + content_blocks: Vec::new(), + } + } + + #[test] + fn test_cache_key_deterministic() { + let req = sample_request(); + let k1 = CacheKey::from_request("openai", &req); + let k2 = CacheKey::from_request("openai", &req); + assert_eq!(k1, k2); + } + + #[test] + fn test_cache_key_provider_differs() { + let req = sample_request(); + let k1 = CacheKey::from_request("openai", &req); + let k2 = CacheKey::from_request("anthropic", &req); + assert_ne!(k1, k2); + } + + #[test] + fn test_cache_key_message_differs() { + let req1 = sample_request(); + let req2 = ChatRequest::new( + "gpt-4o", + vec![ChatMessage::user("Different message")], + ) + .temperature(0.7); + + let k1 = CacheKey::from_request("openai", &req1); + let k2 = CacheKey::from_request("openai", &req2); + assert_ne!(k1, k2); + } + + #[test] + fn test_cache_put_and_get() { + let mut cache = LlmCache::new("openai"); + let req = sample_request(); + let resp = sample_response(); + + cache.put(&req, resp.clone()); + let cached = cache.get(&req).unwrap(); + assert_eq!(cached.content, "Hi there!"); + } + + #[test] + fn test_cache_miss() { + let mut cache = LlmCache::new("openai"); + let req = sample_request(); + assert!(cache.get(&req).is_none()); + } + + #[test] + fn test_cache_hit_count() { + let mut cache = LlmCache::new("openai"); + let req = sample_request(); + cache.put(&req, sample_response()); + + cache.get(&req); + cache.get(&req); + cache.get(&req); + + let key = CacheKey::from_request("openai", &req); + assert_eq!(cache.entries[&key].hit_count, 3); + } + + #[test] + fn test_cache_eviction() { + let mut cache = LlmCache::new("openai").with_max_entries(2); + + let req1 = ChatRequest::new("m1", vec![ChatMessage::user("a")]); + let req2 = ChatRequest::new("m2", vec![ChatMessage::user("b")]); + let req3 = ChatRequest::new("m3", vec![ChatMessage::user("c")]); + + cache.put(&req1, sample_response()); + cache.put(&req2, sample_response()); + + // Hit req2 so it has higher hit_count + cache.get(&req2); + + // This should evict req1 (least hit) + cache.put(&req3, sample_response()); + + assert_eq!(cache.len(), 2); + assert!(cache.get(&req1).is_none()); + assert!(cache.get(&req2).is_some()); + } + + #[test] + fn test_registry_per_provider() { + let mut registry = CacheRegistry::new(); + + let req = sample_request(); + registry.put("openai", &req, sample_response()); + + assert!(registry.get("openai", &req).is_some()); + assert!(registry.get("anthropic", &req).is_none()); + } + + #[test] + fn test_registry_clear_provider() { + let mut registry = CacheRegistry::new(); + + let req = sample_request(); + registry.put("openai", &req, sample_response()); + registry.put("anthropic", &req, sample_response()); + + registry.clear_provider("openai"); + + assert!(registry.get("openai", &req).is_none()); + assert!(registry.get("anthropic", &req).is_some()); + } +} diff --git a/lib/llm-ops/src/generated_tests.rs b/lib/llm-ops/src/generated_tests.rs new file mode 100644 index 00000000000..107307e7daa --- /dev/null +++ b/lib/llm-ops/src/generated_tests.rs @@ -0,0 +1,44 @@ +// Generated tests for llm_openai_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("execute", "response").is_some(), + "MockSpec should have boundary mock for execute.response"); + assert!(spec.get_boundary_mock("parse", "content").is_some(), + "MockSpec should have boundary mock for parse.content"); + assert!(spec.get_boundary_mock("parse", "model").is_some(), + "MockSpec should have boundary mock for parse.model"); + assert!(spec.get_boundary_mock("parse", "finish_reason").is_some(), + "MockSpec should have boundary mock for parse.finish_reason"); + assert!(spec.get_boundary_mock("parse", "input_tokens").is_some(), + "MockSpec should have boundary mock for parse.input_tokens"); + assert!(spec.get_boundary_mock("parse", "output_tokens").is_some(), + "MockSpec should have boundary mock for parse.output_tokens"); +} + +/// Test that input expectations are documented. +#[test] +fn test_input_expectations_documented() { + let spec = mock_spec(); + // Port 'provider' expects: OneOf(...) + // Port 'model' expects: NonEmpty + // Port 'messages' expects: NonEmpty + assert_eq!(spec.input_expectations.len(), 3); +} + diff --git a/lib/llm-ops/src/generated_tests_anthropic.rs b/lib/llm-ops/src/generated_tests_anthropic.rs new file mode 100644 index 00000000000..fde6c25074c --- /dev/null +++ b/lib/llm-ops/src/generated_tests_anthropic.rs @@ -0,0 +1,44 @@ +// Generated tests for llm_anthropic_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("execute", "response").is_some(), + "MockSpec should have boundary mock for execute.response"); + assert!(spec.get_boundary_mock("parse", "content").is_some(), + "MockSpec should have boundary mock for parse.content"); + assert!(spec.get_boundary_mock("parse", "model").is_some(), + "MockSpec should have boundary mock for parse.model"); + assert!(spec.get_boundary_mock("parse", "finish_reason").is_some(), + "MockSpec should have boundary mock for parse.finish_reason"); + assert!(spec.get_boundary_mock("parse", "input_tokens").is_some(), + "MockSpec should have boundary mock for parse.input_tokens"); + assert!(spec.get_boundary_mock("parse", "output_tokens").is_some(), + "MockSpec should have boundary mock for parse.output_tokens"); +} + +/// Test that input expectations are documented. +#[test] +fn test_input_expectations_documented() { + let spec = mock_spec(); + // Port 'provider' expects: OneOf(...) + // Port 'model' expects: NonEmpty + // Port 'messages' expects: NonEmpty + assert_eq!(spec.input_expectations.len(), 3); +} + diff --git a/lib/llm-ops/src/generated_tests_code_review.rs b/lib/llm-ops/src/generated_tests_code_review.rs new file mode 100644 index 00000000000..0da3f4ff0aa --- /dev/null +++ b/lib/llm-ops/src/generated_tests_code_review.rs @@ -0,0 +1,44 @@ +// Generated tests for llm_code_review_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("execute", "response").is_some(), + "MockSpec should have boundary mock for execute.response"); + assert!(spec.get_boundary_mock("parse", "content").is_some(), + "MockSpec should have boundary mock for parse.content"); + assert!(spec.get_boundary_mock("parse", "model").is_some(), + "MockSpec should have boundary mock for parse.model"); + assert!(spec.get_boundary_mock("parse", "finish_reason").is_some(), + "MockSpec should have boundary mock for parse.finish_reason"); + assert!(spec.get_boundary_mock("parse", "input_tokens").is_some(), + "MockSpec should have boundary mock for parse.input_tokens"); + assert!(spec.get_boundary_mock("parse", "output_tokens").is_some(), + "MockSpec should have boundary mock for parse.output_tokens"); +} + +/// Test that input expectations are documented. +#[test] +fn test_input_expectations_documented() { + let spec = mock_spec(); + // Port 'provider' expects: NonEmpty + // Port 'model' expects: NonEmpty + // Port 'messages' expects: NonEmpty + assert_eq!(spec.input_expectations.len(), 3); +} + diff --git a/lib/llm-ops/src/generated_tests_secrets.rs b/lib/llm-ops/src/generated_tests_secrets.rs new file mode 100644 index 00000000000..9303608b732 --- /dev/null +++ b/lib/llm-ops/src/generated_tests_secrets.rs @@ -0,0 +1,70 @@ +// Generated tests for llm_secrets_generated_tests DAG. +// +// Generated by gunbc-testgen +// DO NOT EDIT - regenerate with: make testgen + +use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; +use gunbc_ir::{detect_boundaries, Cardinality, Value}; +use gunbc_test::{assert_boundary_mockable, assert_types_compatible, default_mocks}; +use gunbc_test::{validate_chain, MockSpec, InputConstraint}; +use gunbc_test::{ResourceAcquireResult, ResourceSimulation}; + +// ============================================================================ +// Chain Validation Tests +// These tests verify that mock outputs satisfy downstream input expectations. +// ============================================================================ + +/// Test that this tool's mock spec is self-consistent. +#[test] +fn test_mock_spec_self_consistent() { + let spec = mock_spec(); + // Verify all boundary mocks are present + assert!(spec.get_boundary_mock("resolve_api_key", "api_key").is_some(), + "MockSpec should have boundary mock for resolve_api_key.api_key"); + assert!(spec.get_boundary_mock("execute", "response").is_some(), + "MockSpec should have boundary mock for execute.response"); + assert!(spec.get_boundary_mock("parse", "content").is_some(), + "MockSpec should have boundary mock for parse.content"); + assert!(spec.get_boundary_mock("parse", "model").is_some(), + "MockSpec should have boundary mock for parse.model"); + assert!(spec.get_boundary_mock("parse", "finish_reason").is_some(), + "MockSpec should have boundary mock for parse.finish_reason"); + assert!(spec.get_boundary_mock("parse", "input_tokens").is_some(), + "MockSpec should have boundary mock for parse.input_tokens"); + assert!(spec.get_boundary_mock("parse", "output_tokens").is_some(), + "MockSpec should have boundary mock for parse.output_tokens"); +} + +/// Test that input expectations are documented. +#[test] +fn test_input_expectations_documented() { + let spec = mock_spec(); + // Port 'provider' expects: NonEmpty + // Port 'model' expects: NonEmpty + assert_eq!(spec.input_expectations.len(), 2); +} + +// ============================================================================ +// Resource Simulation Tests +// These tests verify behavior under different resource acquisition scenarios. +// ============================================================================ + +/// Test resource 'api:openai_key' lease behavior (3600000ms). +/// Test resource 'api:openai_key' (Lease) acquisition. +#[test] +fn test_resource_api_openai_key_acquire() { + let spec = mock_spec(); + let resource = spec.get_resource("api:openai_key").expect("resource should exist"); + let result = resource.acquire(); + assert!(matches!(result, ResourceAcquireResult::Acquired), "should acquire successfully"); +} + +/// Test resource 'api:openai_key' lease expiration after 3600000ms. +#[test] +fn test_resource_api_openai_key_timeout() { + let spec = mock_spec(); + let resource = spec.get_resource("api:openai_key").expect("resource should exist"); + assert!(!resource.should_timeout(1800000), "should not timeout before duration"); + assert!(resource.should_timeout(3600001), "should timeout after duration"); +} + diff --git a/lib/llm-ops/src/graph.rs b/lib/llm-ops/src/graph.rs new file mode 100644 index 00000000000..3ea3b6914ce --- /dev/null +++ b/lib/llm-ops/src/graph.rs @@ -0,0 +1,147 @@ +//! Graph builder for LLM chat completion workflows. +//! +//! Provides a composable DAG for the LLM chat completion pattern: +//! +//! ```text +//! PrepareChatRequest (pure) → TransportOps::Execute (I/O) → ParseChatResponse (pure) +//! ``` +//! +//! This graph can be embedded as a sub-DAG in larger workflows that need +//! LLM capabilities (code review, code generation, etc.). + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::{build::*, Dag, Node, Value}; +use gunbc_lib_transport::TransportOps; +use std::collections::HashMap; + +use crate::LlmOps; + +/// Operation type for LLM chat completion graphs. +/// +/// Union of pure LLM ops and the transport boundary. +#[derive(Debug, Clone)] +pub enum LlmGraphOp { + /// Prepare a chat completion request (PURE - no I/O) + Llm(LlmOps), + /// Transport execution (BOUNDARY - actual I/O) + Transport(TransportOps), +} + +impl Executable for LlmGraphOp { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + LlmGraphOp::Llm(op) => op.execute(inputs), + LlmGraphOp::Transport(op) => op.execute(inputs), + } + } +} + +/// Build a chat completion DAG. +/// +/// The resulting DAG has these entrypoints (unconnected inputs): +/// - `prepare.provider`: String — provider ID ("openai", "anthropic") +/// - `prepare.model`: String — model identifier +/// - `prepare.messages`: JSON array of {role, content} +/// - `prepare.system_prompt`: Optional string +/// - `prepare.temperature`: Optional f64 +/// - `prepare.max_tokens`: Optional i64 +/// +/// And these boundaries (unconnected outputs): +/// - `parse.content`: String — generated text +/// - `parse.model`: String — model that responded +/// - `parse.finish_reason`: String +/// - `parse.input_tokens`: Int +/// - `parse.output_tokens`: Int +pub fn build_chat_completion_graph() -> Dag { + let mut dag = Dag::new(); + + // Node 1: PrepareChatRequest (pure) + dag.add_node(Node::opaque( + "prepare", + vec![ + port("provider", "String"), + port("model", "String"), + port("messages", "Json"), + optional("system_prompt", "String"), + optional("temperature", "Json"), + optional("max_tokens", "Int"), + ], + vec![ + port("request", "TransportRequest"), + port("provider", "String"), + ], + LlmGraphOp::Llm(LlmOps::PrepareChatRequest), + )); + + // Node 2: Execute transport (I/O boundary) + dag.add_node(Node::opaque( + "execute", + vec![port("request", "TransportRequest")], + vec![port("response", "TransportResponse")], + LlmGraphOp::Transport(TransportOps::Execute), + )); + + // Node 3: ParseChatResponse (pure) + dag.add_node(Node::opaque( + "parse", + vec![ + port("provider", "String"), + port("response", "TransportResponse"), + ], + vec![ + port("content", "String"), + port("model", "String"), + port("finish_reason", "String"), + port("input_tokens", "Int"), + port("output_tokens", "Int"), + ], + LlmGraphOp::Llm(LlmOps::ParseChatResponse), + )); + + // Edges: prepare -> execute -> parse + dag.add_edge(edge("prepare", "request", "execute", "request")); + dag.add_edge(edge("execute", "response", "parse", "response")); + dag.add_edge(edge("prepare", "provider", "parse", "provider")); + + dag +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::{detect_boundaries, detect_entrypoints}; + + #[test] + fn test_chat_completion_graph_structure() { + let dag = build_chat_completion_graph(); + assert_eq!(dag.nodes.len(), 3); + assert_eq!(dag.edges.len(), 3); + } + + #[test] + fn test_chat_completion_graph_boundaries() { + let dag = build_chat_completion_graph(); + let boundaries = detect_boundaries(&dag); + + // The parse node's outputs are boundaries (no downstream) + assert!( + boundaries.is_boundary_node(&"parse".into()), + "parse should be a boundary" + ); + + // execute node's response feeds into parse, so not a boundary output + // But execute itself is the transport boundary (I/O happens here) + } + + #[test] + fn test_chat_completion_graph_entrypoints() { + let dag = build_chat_completion_graph(); + let entrypoints = detect_entrypoints(&dag); + + // prepare node's inputs are entrypoints + assert!( + entrypoints.is_entrypoint_node(&"prepare".into()), + "prepare should have entrypoints" + ); + } +} diff --git a/lib/llm-ops/src/graph_mock.rs b/lib/llm-ops/src/graph_mock.rs new file mode 100644 index 00000000000..b30e3f4c27b --- /dev/null +++ b/lib/llm-ops/src/graph_mock.rs @@ -0,0 +1,298 @@ +//! Mock specification for LLM chat completion workflows. +//! +//! This file declares: +//! - What mock values boundary nodes provide +//! - What input constraints upstream must satisfy +//! +//! Used by testgen for: +//! - Dry-run testing with realistic mock values +//! - Chain validation with other tools +//! +//! # Provider-specific mocks +//! +//! Each provider has its own mock spec since their response formats differ. +//! The `execute` boundary returns provider-specific REST responses that +//! the `parse` node then converts to unified outputs. + +use gunbc_ir::transport::llm::mock; +use gunbc_ir::{Value, SecretString}; +use gunbc_test::{InputConstraint, MockSpec}; + +/// Mock specification for OpenAI chat completion. +/// +/// # Boundary Mocks +/// +/// - `execute`: Transport boundary returning an OpenAI-format response +/// - `parse`: Final outputs (content, model, tokens) +/// +/// # Input Expectations +/// +/// - `provider`: Must be "openai" +/// - `model`: Non-empty string (e.g., "gpt-4o") +/// - `messages`: JSON array of {role, content} +pub fn openai_mock_spec() -> MockSpec { + let response = mock::mock_openai_response("The code looks good. No issues found."); + + MockSpec::new("llm-openai") + // Boundary: execute (transport) outputs + .boundary( + "execute", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Rest(response)), + ) + // Boundary: parse outputs (what downstream consumers see) + .boundary( + "parse", + "content", + Value::Str("The code looks good. No issues found.".into()), + ) + .boundary( + "parse", + "model", + Value::Str("gpt-4o".into()), + ) + .boundary( + "parse", + "finish_reason", + Value::Str("Stop".into()), + ) + .boundary("parse", "input_tokens", Value::Int(10)) + .boundary("parse", "output_tokens", Value::Int(20)) + // Input expectations + .expects_input("provider", InputConstraint::OneOf(vec![Value::Str("openai".into())])) + .expects_input("model", InputConstraint::NonEmpty) + .expects_input("messages", InputConstraint::NonEmpty) +} + +/// Mock specification for Anthropic chat completion. +/// +/// # Boundary Mocks +/// +/// Same structure as OpenAI but with Anthropic-format responses. +pub fn anthropic_mock_spec() -> MockSpec { + let response = mock::mock_anthropic_response( + "The function handles edge cases well. Consider adding a doc comment.", + ); + + MockSpec::new("llm-anthropic") + // Boundary: execute (transport) outputs + .boundary( + "execute", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Rest(response)), + ) + // Boundary: parse outputs + .boundary( + "parse", + "content", + Value::Str( + "The function handles edge cases well. Consider adding a doc comment.".into(), + ), + ) + .boundary( + "parse", + "model", + Value::Str("claude-sonnet-4-20250514".into()), + ) + .boundary( + "parse", + "finish_reason", + Value::Str("Stop".into()), + ) + .boundary("parse", "input_tokens", Value::Int(10)) + .boundary("parse", "output_tokens", Value::Int(20)) + // Input expectations + .expects_input( + "provider", + InputConstraint::OneOf(vec![Value::Str("anthropic".into())]), + ) + .expects_input("model", InputConstraint::NonEmpty) + .expects_input("messages", InputConstraint::NonEmpty) +} + +/// Mock specification for code review workflow. +/// +/// Simulates a code review request with a detailed response. +pub fn code_review_mock_spec() -> MockSpec { + let review_content = "\ +## Code Review\n\n\ +### Issues Found\n\ +1. **Missing error handling** on line 42 — `unwrap()` should be replaced with `?`\n\ +2. **Unused import** `std::io` on line 3\n\n\ +### Suggestions\n\ +- Add doc comments to public functions\n\ +- Consider extracting the validation logic into a separate function\n\n\ +Overall: The code is clean and well-structured. Minor fixes recommended."; + + let response = mock::mock_openai_response_full(review_content, "gpt-4o", "stop", 150, 85); + + MockSpec::new("llm-code-review") + .boundary( + "execute", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Rest(response)), + ) + .boundary( + "parse", + "content", + Value::Str(review_content.into()), + ) + .boundary("parse", "model", Value::Str("gpt-4o".into())) + .boundary("parse", "finish_reason", Value::Str("Stop".into())) + .boundary("parse", "input_tokens", Value::Int(150)) + .boundary("parse", "output_tokens", Value::Int(85)) + .expects_input("provider", InputConstraint::NonEmpty) + .expects_input("model", InputConstraint::NonEmpty) + .expects_input("messages", InputConstraint::NonEmpty) +} + +/// Mock specification for testing API key as secret. +/// +/// Models the secret resolution pattern: the API key flows as a +/// `Value::Secret` and gets resolved at the transport boundary. +pub fn secret_api_key_mock_spec() -> MockSpec { + MockSpec::new("llm-secrets") + .boundary( + "resolve_api_key", + "api_key", + Value::Secret(SecretString::new("")), + ) + .boundary( + "execute", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Rest( + mock::mock_openai_response("Response with secret auth."), + )), + ) + .boundary( + "parse", + "content", + Value::Str("Response with secret auth.".into()), + ) + .boundary("parse", "model", Value::Str("gpt-4o".into())) + .boundary("parse", "finish_reason", Value::Str("Stop".into())) + .boundary("parse", "input_tokens", Value::Int(10)) + .boundary("parse", "output_tokens", Value::Int(10)) + .expects_input("provider", InputConstraint::NonEmpty) + .expects_input("model", InputConstraint::NonEmpty) + // API key resource: lease-based (keys can expire) + .resource_lease("api:openai_key", 3_600_000) // 1 hour +} + +/// Mock specification for testing rate limiting / error scenarios. +pub fn rate_limited_mock_spec() -> MockSpec { + let error_response = mock::mock_openai_error( + 429, + "rate_limit_exceeded", + "Rate limit exceeded. Please retry after 60 seconds.", + ); + + MockSpec::new("llm-rate-limited") + .boundary( + "execute", + "response", + Value::Response(gunbc_ir::transport::TransportResponse::Rest(error_response)), + ) + .expects_input("provider", InputConstraint::NonEmpty) + .expects_input("model", InputConstraint::NonEmpty) + .expects_input("messages", InputConstraint::NonEmpty) +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_test::validate_chain; + use std::collections::HashMap; + + #[test] + fn test_openai_mock_spec_has_boundaries() { + let spec = openai_mock_spec(); + assert!(spec.get_boundary_mock("execute", "response").is_some()); + assert!(spec.get_boundary_mock("parse", "content").is_some()); + assert!(spec.get_boundary_mock("parse", "model").is_some()); + } + + #[test] + fn test_openai_mock_spec_content() { + let spec = openai_mock_spec(); + let content = spec.get_boundary_mock("parse", "content").unwrap(); + if let Value::Str(s) = content { + assert!(s.contains("looks good")); + } else { + panic!("Expected string content"); + } + } + + #[test] + fn test_anthropic_mock_spec_has_boundaries() { + let spec = anthropic_mock_spec(); + assert!(spec.get_boundary_mock("execute", "response").is_some()); + assert!(spec.get_boundary_mock("parse", "content").is_some()); + } + + #[test] + fn test_anthropic_mock_spec_content() { + let spec = anthropic_mock_spec(); + let content = spec.get_boundary_mock("parse", "content").unwrap(); + if let Value::Str(s) = content { + assert!(s.contains("doc comment")); + } else { + panic!("Expected string content"); + } + } + + #[test] + fn test_code_review_mock_spec() { + let spec = code_review_mock_spec(); + let content = spec.get_boundary_mock("parse", "content").unwrap(); + if let Value::Str(s) = content { + assert!(s.contains("Code Review")); + assert!(s.contains("Missing error handling")); + } else { + panic!("Expected string content"); + } + } + + #[test] + fn test_secret_api_key_mock_spec() { + let spec = secret_api_key_mock_spec(); + + // Secret value should be present + let api_key = spec + .get_boundary_mock("resolve_api_key", "api_key") + .unwrap(); + assert!(matches!(api_key, Value::Secret(_))); + + // Secret should be redacted in display + assert_eq!(format!("{}", api_key), "***"); + + // API key lease should be present + assert!(spec.get_resource("api:openai_key").is_some()); + } + + #[test] + fn test_rate_limited_mock_spec() { + let spec = rate_limited_mock_spec(); + let response = spec.get_boundary_mock("execute", "response").unwrap(); + assert!(matches!(response, Value::Response(_))); + } + + #[test] + fn test_chain_validation_self() { + let spec = openai_mock_spec(); + let mapping = HashMap::new(); + let result = validate_chain(&spec, &spec, &mapping); + assert!(result.is_ok()); + } + + #[test] + fn test_secret_lease_expiration() { + let spec = secret_api_key_mock_spec(); + let resource = spec.get_resource("api:openai_key").unwrap(); + + // Should not timeout before duration + assert!(!resource.should_timeout(1_800_000)); // 30 min + // Should timeout after duration + assert!(resource.should_timeout(3_600_001)); // 1 hour + 1ms + } +} diff --git a/lib/llm-ops/src/lib.rs b/lib/llm-ops/src/lib.rs new file mode 100644 index 00000000000..be04846aab9 --- /dev/null +++ b/lib/llm-ops/src/lib.rs @@ -0,0 +1,586 @@ +//! LLM operations for gunbc DAGs. +//! +//! Pure operations for preparing LLM chat requests and parsing responses. +//! All operations are PURE (no I/O). I/O happens through `TransportOps::Execute` nodes. +//! +//! # Transport Pattern +//! +//! ```text +//! PrepareChatRequest (pure) → TransportOps::Execute (I/O) → ParseChatResponse (pure) +//! ``` +//! +//! # Caching +//! +//! Response caching is available via the [`cache`] module. Each provider has +//! its own cache namespace, keyed by (provider, model, messages, params). +//! +//! # Example +//! +//! ```ignore +//! use gunbc_lib_llm_ops::LlmOps; +//! +//! // In a DAG: +//! // 1. PrepareChatRequest builds a RestRequest from provider_id + model + messages +//! // 2. TransportOps::Execute sends the request (I/O boundary) +//! // 3. ParseChatResponse extracts content from the provider-specific response +//! ``` + +pub mod cache; +pub mod graph; + +pub mod graph_mock; + +use gunbc_exec::{ExecError, Executable}; +use gunbc_ir::transport::llm::{self, ChatMessage, ChatRequest, MessageContent, Role}; +use gunbc_ir::transport::{TransportRequest, TransportResponse}; +use gunbc_ir::Value; +use std::collections::HashMap; + +/// LLM operations for use in DAG nodes. +/// +/// All operations are PURE - no I/O. Use `TransportOps::Execute` for actual I/O. +#[derive(Debug, Clone)] +pub enum LlmOps { + /// Build a chat completion REST request from inputs. + /// + /// Inputs: + /// - `provider`: String - provider ID (e.g., "openai", "anthropic") + /// - `model`: String - model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514") + /// - `messages`: JSON array of {role, content} objects + /// - `temperature` (optional): f64 + /// - `max_tokens` (optional): i64 + /// - `system_prompt` (optional): String - prepended as a system message + /// + /// Outputs: + /// - `request`: TransportRequest (Rest) + /// - `provider`: String - echoed for use in ParseChatResponse + PrepareChatRequest, + + /// Parse a chat completion response. + /// + /// Inputs: + /// - `provider`: String - provider ID (must match the one used in PrepareChatRequest) + /// - `response`: TransportResponse (Rest) + /// + /// Outputs: + /// - `content`: String - the generated text + /// - `model`: String - model that generated the response + /// - `finish_reason`: String - why generation stopped + /// - `input_tokens`: i64 - tokens in the prompt + /// - `output_tokens`: i64 - tokens in the completion + ParseChatResponse, +} + +impl Executable for LlmOps { + fn execute(&self, inputs: HashMap) -> Result, ExecError> { + match self { + LlmOps::PrepareChatRequest => execute_prepare_chat_request(inputs), + LlmOps::ParseChatResponse => execute_parse_chat_response(inputs), + } + } +} + +/// Build a `ChatRequest` from DAG inputs and convert it to a `RestRequest`. +fn execute_prepare_chat_request( + inputs: HashMap, +) -> Result, ExecError> { + let provider_id = inputs + .get("provider") + .and_then(|v| v.as_str()) + .ok_or_else(|| ExecError::new("missing or invalid 'provider' input"))? + .to_string(); + + let model = inputs + .get("model") + .and_then(|v| v.as_str()) + .ok_or_else(|| ExecError::new("missing or invalid 'model' input"))?; + + // Build messages list + let mut messages = Vec::new(); + + // Optional system prompt (convenience: added as first system message) + if let Some(system_prompt) = inputs.get("system_prompt").and_then(|v| v.as_str()) { + if !system_prompt.is_empty() { + messages.push(ChatMessage::system(system_prompt)); + } + } + + // Parse messages from JSON array or string list + if let Some(Value::Json(json_messages)) = inputs.get("messages") { + let parsed = parse_messages_from_json(json_messages)?; + messages.extend(parsed); + } else if let Some(Value::Str(user_message)) = inputs.get("messages") { + // Single string treated as a user message + messages.push(ChatMessage::user(user_message)); + } else if messages.is_empty() { + return Err(ExecError::new( + "missing 'messages' input: provide JSON array of {role, content} or a string", + )); + } + + // Build the chat request + let mut chat = ChatRequest::new(model, messages); + + if let Some(Value::Json(serde_json::Value::Number(n))) = inputs.get("temperature") { + if let Some(t) = n.as_f64() { + chat = chat.temperature(t); + } + } + + if let Some(Value::Int(n)) = inputs.get("max_tokens") { + chat = chat.max_tokens(*n as u64); + } + + // Convert to REST request via provider-specific builder + let rest_request = llm::build_chat_request(&provider_id, &chat) + .map_err(ExecError::new)?; + + let mut out = HashMap::new(); + out.insert( + "request".to_string(), + Value::Request(TransportRequest::Rest(rest_request)), + ); + out.insert("provider".to_string(), Value::Str(provider_id)); + Ok(out) +} + +/// Parse a provider-specific REST response into structured chat output. +fn execute_parse_chat_response( + inputs: HashMap, +) -> Result, ExecError> { + let provider_id = inputs + .get("provider") + .and_then(|v| v.as_str()) + .ok_or_else(|| ExecError::new("missing or invalid 'provider' input"))?; + + let response = inputs + .get("response") + .and_then(|v| v.as_response()) + .ok_or_else(|| ExecError::new("missing or invalid 'response' input"))?; + + let rest_response = match response { + TransportResponse::Rest(r) => r, + _ => { + return Err(ExecError::new( + "expected REST response from LLM API, got different transport type", + )) + } + }; + + let chat_response = llm::parse_chat_response(provider_id, rest_response) + .map_err(ExecError::new)?; + + let mut out = HashMap::new(); + out.insert("content".to_string(), Value::Str(chat_response.content)); + out.insert("model".to_string(), Value::Str(chat_response.model)); + out.insert( + "finish_reason".to_string(), + Value::Str(format!("{:?}", chat_response.finish_reason)), + ); + out.insert( + "input_tokens".to_string(), + Value::Int(chat_response.usage.input_tokens as i64), + ); + out.insert( + "output_tokens".to_string(), + Value::Int(chat_response.usage.output_tokens as i64), + ); + Ok(out) +} + +/// Parse chat messages from a JSON value. +/// +/// Accepts either: +/// - A JSON array of `{role: string, content: string}` objects +/// - A single string (interpreted as a user message) +fn parse_messages_from_json( + json: &serde_json::Value, +) -> Result, ExecError> { + match json { + serde_json::Value::Array(arr) => { + let mut messages = Vec::with_capacity(arr.len()); + for (i, item) in arr.iter().enumerate() { + let role_str = item + .get("role") + .and_then(|r| r.as_str()) + .ok_or_else(|| { + ExecError::new(format!("message[{}]: missing or invalid 'role'", i)) + })?; + + let role = Role::parse(role_str).ok_or_else(|| { + ExecError::new(format!( + "message[{}]: invalid role '{}' (expected system, user, or assistant)", + i, role_str + )) + })?; + + let content = item + .get("content") + .and_then(|c| c.as_str()) + .ok_or_else(|| { + ExecError::new(format!("message[{}]: missing or invalid 'content'", i)) + })?; + + messages.push(ChatMessage { + role, + content: MessageContent::Text(content.to_string()), + }); + } + Ok(messages) + } + serde_json::Value::String(s) => Ok(vec![ChatMessage::user(s)]), + _ => Err(ExecError::new( + "messages must be a JSON array of {role, content} objects or a string", + )), + } +} + +// ============================================================================ +// Convenience functions for building common LLM requests +// ============================================================================ + +/// Build a code review chat request. +/// +/// Creates a `ChatRequest` configured for code review with appropriate +/// system prompting and parameters. +pub fn code_review_request( + provider_id: &str, + model: &str, + code: &str, + context: &str, +) -> Result { + llm::provider_by_id(provider_id) + .ok_or_else(|| format!("unknown provider: {}", provider_id))?; + + let system = "You are an expert code reviewer. Analyze the code for:\n\ + - Correctness and potential bugs\n\ + - Security vulnerabilities\n\ + - Performance issues\n\ + - Code style and readability\n\ + Provide specific, actionable feedback."; + + let user_message = if context.is_empty() { + format!("Review this code:\n\n```\n{}\n```", code) + } else { + format!( + "Context: {}\n\nReview this code:\n\n```\n{}\n```", + context, code + ) + }; + + Ok(ChatRequest::new( + model, + vec![ + ChatMessage::system(system), + ChatMessage::user(user_message), + ], + ) + .temperature(0.3) + .max_tokens(4096)) +} + +/// Build a code generation chat request. +/// +/// Creates a `ChatRequest` configured for code generation. +pub fn code_generation_request( + provider_id: &str, + model: &str, + description: &str, + language: &str, +) -> Result { + llm::provider_by_id(provider_id) + .ok_or_else(|| format!("unknown provider: {}", provider_id))?; + + let system = format!( + "You are an expert {} programmer. Generate clean, idiomatic code \ + that follows best practices. Include appropriate error handling. \ + Return only the code without explanation unless asked.", + language + ); + + Ok(ChatRequest::new( + model, + vec![ + ChatMessage::system(system), + ChatMessage::user(description), + ], + ) + .temperature(0.2) + .max_tokens(4096)) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prepare_chat_request_openai() { + let mut inputs = HashMap::new(); + inputs.insert("provider".to_string(), Value::Str("openai".to_string())); + inputs.insert("model".to_string(), Value::Str("gpt-4o".to_string())); + inputs.insert( + "messages".to_string(), + Value::Json(serde_json::json!([ + {"role": "user", "content": "Hello!"} + ])), + ); + + let op = LlmOps::PrepareChatRequest; + let result = op.execute(inputs).unwrap(); + + assert!(result.contains_key("request")); + assert_eq!( + result.get("provider"), + Some(&Value::Str("openai".to_string())) + ); + + match result.get("request") { + Some(Value::Request(TransportRequest::Rest(req))) => { + assert_eq!(req.url, "https://api.openai.com/v1/chat/completions"); + } + _ => panic!("expected REST request"), + } + } + + #[test] + fn test_prepare_chat_request_anthropic() { + let mut inputs = HashMap::new(); + inputs.insert( + "provider".to_string(), + Value::Str("anthropic".to_string()), + ); + inputs.insert( + "model".to_string(), + Value::Str("claude-sonnet-4-20250514".to_string()), + ); + inputs.insert( + "system_prompt".to_string(), + Value::Str("Be helpful.".to_string()), + ); + inputs.insert( + "messages".to_string(), + Value::Json(serde_json::json!([ + {"role": "user", "content": "Hello!"} + ])), + ); + + let result = LlmOps::PrepareChatRequest.execute(inputs).unwrap(); + + match result.get("request") { + Some(Value::Request(TransportRequest::Rest(req))) => { + assert_eq!(req.url, "https://api.anthropic.com/v1/messages"); + let body = req.body.as_ref().unwrap(); + assert_eq!(body["system"], "Be helpful."); + } + _ => panic!("expected REST request"), + } + } + + #[test] + fn test_prepare_chat_request_with_string_message() { + let mut inputs = HashMap::new(); + inputs.insert("provider".to_string(), Value::Str("openai".to_string())); + inputs.insert( + "model".to_string(), + Value::Str("gpt-4o-mini".to_string()), + ); + inputs.insert( + "messages".to_string(), + Value::Str("What is 2+2?".to_string()), + ); + + let result = LlmOps::PrepareChatRequest.execute(inputs).unwrap(); + assert!(result.contains_key("request")); + } + + #[test] + fn test_prepare_chat_request_missing_provider() { + let mut inputs = HashMap::new(); + inputs.insert("model".to_string(), Value::Str("gpt-4o".to_string())); + + let err = LlmOps::PrepareChatRequest.execute(inputs).unwrap_err(); + assert!(err.0.contains("provider")); + } + + #[test] + fn test_prepare_chat_request_unknown_provider() { + let mut inputs = HashMap::new(); + inputs.insert("provider".to_string(), Value::Str("unknown".to_string())); + inputs.insert("model".to_string(), Value::Str("test".to_string())); + inputs.insert( + "messages".to_string(), + Value::Json(serde_json::json!([{"role": "user", "content": "hi"}])), + ); + + let err = LlmOps::PrepareChatRequest.execute(inputs).unwrap_err(); + assert!(err.0.contains("unknown")); + } + + #[test] + fn test_parse_chat_response_openai() { + let response = TransportResponse::Rest( + gunbc_ir::transport::RestResponse::ok(serde_json::json!({ + "model": "gpt-4o", + "choices": [{ + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7} + })), + ); + + let mut inputs = HashMap::new(); + inputs.insert("provider".to_string(), Value::Str("openai".to_string())); + inputs.insert("response".to_string(), Value::Response(response)); + + let result = LlmOps::ParseChatResponse.execute(inputs).unwrap(); + + assert_eq!( + result.get("content"), + Some(&Value::Str("Hello!".to_string())) + ); + assert_eq!( + result.get("model"), + Some(&Value::Str("gpt-4o".to_string())) + ); + assert_eq!(result.get("input_tokens"), Some(&Value::Int(5))); + assert_eq!(result.get("output_tokens"), Some(&Value::Int(2))); + } + + #[test] + fn test_parse_chat_response_anthropic() { + let response = TransportResponse::Rest( + gunbc_ir::transport::RestResponse::ok(serde_json::json!({ + "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-sonnet-4-20250514", + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 2} + })), + ); + + let mut inputs = HashMap::new(); + inputs.insert( + "provider".to_string(), + Value::Str("anthropic".to_string()), + ); + inputs.insert("response".to_string(), Value::Response(response)); + + let result = LlmOps::ParseChatResponse.execute(inputs).unwrap(); + assert_eq!( + result.get("content"), + Some(&Value::Str("Hello!".to_string())) + ); + } + + #[test] + fn test_parse_messages_from_json_array() { + let json = serde_json::json!([ + {"role": "system", "content": "Be helpful"}, + {"role": "user", "content": "Hello"} + ]); + + let messages = parse_messages_from_json(&json).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, Role::System); + assert_eq!(messages[1].role, Role::User); + } + + #[test] + fn test_parse_messages_from_json_string() { + let json = serde_json::json!("Hello!"); + + let messages = parse_messages_from_json(&json).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, Role::User); + assert_eq!(messages[0].text(), "Hello!"); + } + + #[test] + fn test_parse_messages_invalid_role() { + let json = serde_json::json!([{"role": "villain", "content": "mwahaha"}]); + + let err = parse_messages_from_json(&json).unwrap_err(); + assert!(err.0.contains("invalid role")); + } + + #[test] + fn test_code_review_request() { + let req = code_review_request("openai", "gpt-4o", "fn add(a: i32, b: i32) -> i32 { a + b }", "") + .unwrap(); + + assert_eq!(req.model, "gpt-4o"); + assert_eq!(req.messages.len(), 2); + assert_eq!(req.messages[0].role, Role::System); + assert!(req.messages[1].text().contains("fn add")); + assert_eq!(req.temperature, Some(0.3)); + } + + #[test] + fn test_code_review_request_with_context() { + let req = + code_review_request("anthropic", "claude-sonnet-4-20250514", "x = 1", "Python code").unwrap(); + + assert!(req.messages[1].text().contains("Python code")); + } + + #[test] + fn test_code_generation_request() { + let req = + code_generation_request("openai", "gpt-4o", "A function to sort a list", "Rust") + .unwrap(); + + assert_eq!(req.messages.len(), 2); + assert!(req.messages[0].text().contains("Rust")); + assert_eq!(req.temperature, Some(0.2)); + } + + #[test] + fn test_code_review_request_unknown_provider() { + let err = code_review_request("unknown", "test", "code", "").unwrap_err(); + assert!(err.contains("unknown provider")); + } +} + +// ============================================================================ +// Generated Tests (from `make testgen`) +// ============================================================================ + +#[cfg(test)] +mod generated_tests { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::graph_mock::openai_mock_spec() + } + include!("generated_tests.rs"); +} + +#[cfg(test)] +mod generated_tests_anthropic { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::graph_mock::anthropic_mock_spec() + } + include!("generated_tests_anthropic.rs"); +} + +#[cfg(test)] +mod generated_tests_code_review { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::graph_mock::code_review_mock_spec() + } + include!("generated_tests_code_review.rs"); +} + +#[cfg(test)] +mod generated_tests_secrets { + #![allow(unused_imports)] + fn mock_spec() -> gunbc_test::MockSpec { + crate::graph_mock::secret_api_key_mock_spec() + } + include!("generated_tests_secrets.rs"); +} diff --git a/lib/transport/src/executor.rs b/lib/transport/src/executor.rs index 689bdf991ea..6920cfebe22 100644 --- a/lib/transport/src/executor.rs +++ b/lib/transport/src/executor.rs @@ -93,6 +93,11 @@ fn execute_rest(request: &RestRequest) -> Result { gunbc_ir::transport::AuthMethod::ApiKey { header, key } => { http_req.headers.insert(header.clone(), key.clone()); } + gunbc_ir::transport::AuthMethod::EnvVarHeader { header, env_var } => { + if let Ok(value) = std::env::var(env_var) { + http_req.headers.insert(header.clone(), value); + } + } gunbc_ir::transport::AuthMethod::None => {} } }