Skip to main content

objectiveai_sdk/cli/command/
request_base.rs

1//! Cross-cutting request envelope shared by every transform-capable
2//! leaf `Request`.
3//!
4//! Serde-FLATTENED onto the leaf structs, so the wire shape is
5//! identical to the old per-leaf `jq` field (a top-level `"jq"`
6//! property). All of the envelope's logic is delegated to methods
7//! here — argv flags ([`RequestBase::push_flags`]), clap parsing
8//! ([`RequestBaseArgs`]), and the transform set/clear contract used
9//! by every leaf's `execute` / `execute_transform` pair — so adding
10//! a future field touches exactly this file, never the leaves.
11//!
12//! Carried fields: `jq` + `python` (output transforms), and the
13//! optional execution caps `timeout_seconds` (`--timeout`,
14//! humantime) + `max_tokens` (`--max-tokens`). The caps'
15//! enforcement is leaf-specific: `db query` threads a present
16//! timeout to postgres (`statement_timeout` / `lock_timeout`);
17//! everywhere else both caps ride as forward-compatible envelope
18//! data.
19
20// The flattened envelope. One per transform-capable leaf `Request`,
21// as `#[serde(flatten)] pub base: RequestBase`. (Deliberately NOT a
22// doc comment: schemars merges a flattened struct's description into
23// every parent Request schema.)
24#[derive(
25    Debug,
26    Clone,
27    Default,
28    PartialEq,
29    Eq,
30    serde::Serialize,
31    serde::Deserialize,
32    schemars::JsonSchema,
33)]
34#[schemars(rename = "cli.command.RequestBase")]
35pub struct RequestBase {
36    /// jq filter applied to the JSON output. Ignored when `python`
37    /// is also set — python overrides jq.
38    pub jq: Option<String>,
39    /// Python transform applied to the JSON output. Overrides `jq`
40    /// when both are provided.
41    pub python: Option<String>,
42    /// Wall-clock execution cap, in whole seconds. Parsed from
43    /// `--timeout` (humantime: `30s`, `5m`, `1h30m`), `> 0`
44    /// enforced at parse time. `db query` threads it to postgres
45    /// when set; omit for uncapped.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    #[schemars(extend("omitempty" = true))]
48    pub timeout_seconds: Option<u64>,
49    /// Response token budget, `>= 1` (`0` is rejected at parse
50    /// time — omit entirely for unlimited). Forward-compatible
51    /// envelope data — no leaf enforces it yet.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    #[schemars(extend("omitempty" = true))]
54    pub max_tokens: Option<u64>,
55}
56
57impl RequestBase {
58    /// Append this envelope's argv flags (`--jq <filter>`,
59    /// `--python <code>`, …) — the counterpart of
60    /// [`RequestBaseArgs`]' parse side, called from every leaf's
61    /// `into_command`.
62    pub fn push_flags(&self, argv: &mut Vec<String>) {
63        if let Some(jq) = &self.jq {
64            argv.push("--jq".to_string());
65            argv.push(jq.clone());
66        }
67        if let Some(python) = &self.python {
68            argv.push("--python".to_string());
69            argv.push(python.clone());
70        }
71        if let Some(secs) = self.timeout_seconds {
72            argv.push("--timeout".to_string());
73            argv.push(
74                humantime::format_duration(std::time::Duration::from_secs(secs)).to_string(),
75            );
76        }
77        if let Some(n) = self.max_tokens {
78            argv.push("--max-tokens".to_string());
79            argv.push(n.to_string());
80        }
81    }
82
83    /// Drop every TRANSFORM field — `execute`'s typed-response
84    /// contract (a transform would turn the output into untyped
85    /// JSON the caller's `Response` type couldn't parse).
86    /// Non-transform envelope fields (`timeout_seconds`,
87    /// `max_tokens`) survive.
88    pub fn clear_transform(&mut self) {
89        self.jq = None;
90        self.python = None;
91    }
92
93    /// The active output transform, if any — the read side of the
94    /// `jq` / `python` pair, resolving the python-overrides-jq rule:
95    /// `python` set ⇒ `Transform::Python`, else `jq` set ⇒
96    /// `Transform::Jq`, else `None`. Clones the owned filter/code.
97    pub fn transform(&self) -> Option<Transform> {
98        if let Some(code) = &self.python {
99            Some(Transform::Python(code.clone()))
100        } else {
101            self.jq.as_ref().map(|filter| Transform::Jq(filter.clone()))
102        }
103    }
104
105    /// Install `transform`, displacing any other — `execute_transform`'s
106    /// contract: exactly the requested transform is active.
107    pub fn set_transform(&mut self, transform: Transform) {
108        self.clear_transform();
109        match transform {
110            Transform::Jq(filter) => self.jq = Some(filter),
111            Transform::Python(code) => self.python = Some(code),
112        }
113    }
114}
115
116/// Exactly one output transform. Extends by VARIANT without
117/// changing any leaf's `execute_transform` signature. (A
118/// hand-constructed [`RequestBase`] can carry both fields at once —
119/// there, python overrides jq.)
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Transform {
122    /// jq filter over the JSON output.
123    Jq(String),
124    /// Python transform over the JSON output.
125    Python(String),
126}
127
128/// Clap mirror of [`RequestBase`], `#[command(flatten)]`-ed into
129/// every transform-capable leaf's `Args`.
130#[derive(clap::Args)]
131pub struct RequestBaseArgs {
132    /// jq filter applied to the JSON output. Ignored when --python
133    /// is also set — python overrides jq.
134    #[arg(long)]
135    pub jq: Option<String>,
136    /// Python transform applied to the JSON output. Overrides --jq
137    /// when both are provided.
138    #[arg(long)]
139    pub python: Option<String>,
140    /// Wall-clock execution cap (humantime: `30s`, `5m`, `1h30m`).
141    /// Omit for uncapped.
142    #[arg(long, value_parser = parse_timeout_seconds)]
143    pub timeout: Option<u64>,
144    /// Response token budget. Must be `>= 1` if set (omit the flag
145    /// entirely for unlimited).
146    #[arg(long, value_parser = parse_max_tokens)]
147    pub max_tokens: Option<u64>,
148}
149
150/// `--timeout` value parser: humantime → whole seconds, `> 0`.
151/// Sub-second durations floor to 0 via `as_secs` and are rejected
152/// with the rest — the wire unit is whole seconds.
153fn parse_timeout_seconds(s: &str) -> Result<u64, String> {
154    let duration = humantime::parse_duration(s).map_err(|e| e.to_string())?;
155    match duration.as_secs() {
156        0 => Err("must be >= 1s".to_string()),
157        secs => Ok(secs),
158    }
159}
160
161/// `--max-tokens` value parser: `0` would mean "no limit", which is
162/// spelled by omitting the flag — reject it rather than guess.
163fn parse_max_tokens(s: &str) -> Result<u64, String> {
164    match s.parse::<u64>().map_err(|e| e.to_string())? {
165        0 => Err("must be >= 1 (omit the flag for unlimited)".to_string()),
166        n => Ok(n),
167    }
168}
169
170impl From<RequestBaseArgs> for RequestBase {
171    fn from(args: RequestBaseArgs) -> Self {
172        Self {
173            jq: args.jq,
174            python: args.python,
175            timeout_seconds: args.timeout,
176            max_tokens: args.max_tokens,
177        }
178    }
179}