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}