Skip to main content

rai_sdk/
generation.rs

1//! Per-request generation settings.
2//!
3//! [`GenerationConfig`] carries the knobs that vary from request to request:
4//! sampling parameters, token limits, stop sequences, JSON/structured-output
5//! mode, and the tool-loop limit. It is built with chained `with_*` methods and
6//! can be attached to a client as a default or to a single request as an
7//! override.
8//!
9//! Not every provider honours every field. `top_k` is ignored by providers that
10//! do not support it, Anthropic alone honours `prompt_caching`, and OpenAI
11//! reasoning models drop `temperature`/`top_p`.
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use rai_sdk::GenerationConfig;
17//!
18//! let config = GenerationConfig::new()
19//!     .with_temperature(0.2)
20//!     .with_max_tokens(1024)
21//!     .with_stop_sequences(vec!["\n\n".to_string()]);
22//!
23//! assert_eq!(config.tool_round_limit(), 8);
24//! ```
25
26use schemars::{JsonSchema, generate::SchemaSettings};
27use serde::{Deserialize, Serialize};
28
29use crate::error;
30
31/// Configuration for text generation.
32///
33/// Every field is optional; unset fields are simply omitted from the provider
34/// request, so the provider default applies.
35///
36/// # Examples
37///
38/// ```no_run
39/// use rai_sdk::{GenerationConfig, JsonSchema};
40/// use serde::Deserialize;
41///
42/// #[derive(Deserialize, JsonSchema)]
43/// #[schemars(crate = "rai_sdk::schemars")]
44/// struct Summary {
45///     headline: String,
46///     bullets: Vec<String>,
47/// }
48///
49/// // Free-form generation.
50/// let creative = GenerationConfig::new().with_temperature(0.9);
51///
52/// // Schema-constrained generation derived from a Rust type.
53/// let strict = GenerationConfig::new()
54///     .with_temperature(0.0)
55///     .with_json_schema_for::<Summary>()?;
56/// # let _ = (creative, strict);
57/// # Ok::<(), rai_sdk::Error>(())
58/// ```
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct GenerationConfig {
61    /// Temperature for sampling (0.0 to 2.0 typically).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub temperature: Option<f64>,
64
65    /// Maximum number of tokens to generate.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub max_tokens: Option<i32>,
68
69    /// Top-p (nucleus) sampling.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub top_p: Option<f64>,
72
73    /// Top-k sampling (not supported by all providers).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub top_k: Option<i32>,
76
77    /// Stop sequences.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub stop_sequences: Option<Vec<String>>,
80
81    /// Whether to request JSON output (provider-dependent).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub json_mode: Option<bool>,
84
85    /// JSON Schema for structured output (provider-dependent).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub json_schema: Option<serde_json::Value>,
88
89    /// Maximum number of tool execution rounds before failing.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub max_tool_rounds: Option<usize>,
92
93    /// Whether Anthropic prompt caching is enabled.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub prompt_caching: Option<bool>,
96}
97
98impl GenerationConfig {
99    /// Start with an empty config — add only the overrides you need.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Set the sampling temperature. Higher values are more random.
105    ///
106    /// Ignored for OpenAI reasoning (o-series) models, which do not accept it.
107    pub fn with_temperature(mut self, temperature: f64) -> Self {
108        self.temperature = Some(temperature);
109        self
110    }
111
112    /// Cap the number of tokens the model may generate.
113    pub fn with_max_tokens(mut self, max_tokens: i32) -> Self {
114        self.max_tokens = Some(max_tokens);
115        self
116    }
117
118    /// Set nucleus (top-p) sampling.
119    ///
120    /// Ignored for OpenAI reasoning (o-series) models.
121    pub fn with_top_p(mut self, top_p: f64) -> Self {
122        self.top_p = Some(top_p);
123        self
124    }
125
126    /// Set top-k sampling. Only sent to providers that support it.
127    pub fn with_top_k(mut self, top_k: i32) -> Self {
128        self.top_k = Some(top_k);
129        self
130    }
131
132    /// Stop generating as soon as one of these sequences is produced.
133    pub fn with_stop_sequences(mut self, stop_sequences: Vec<String>) -> Self {
134        self.stop_sequences = Some(stop_sequences);
135        self
136    }
137
138    /// Ask the provider for syntactically valid JSON without constraining its
139    /// shape.
140    ///
141    /// A JSON schema set through [`GenerationConfig::with_json_schema`] or
142    /// [`GenerationConfig::with_json_schema_for`] takes precedence over this
143    /// flag.
144    pub fn with_json_mode(mut self, json_mode: bool) -> Self {
145        self.json_mode = Some(json_mode);
146        self
147    }
148
149    /// Constrain the response with a hand-written JSON Schema.
150    ///
151    /// Prefer [`GenerationConfig::with_json_schema_for`] when the shape is
152    /// already expressed as a Rust type.
153    pub fn with_json_schema(mut self, json_schema: serde_json::Value) -> Self {
154        self.json_schema = Some(json_schema);
155        self
156    }
157
158    /// Generate a JSON Schema from a Rust type and normalize object schemas
159    /// for strict structured-output providers.
160    ///
161    /// The schema is generated with `inline_subschemas = true` and no top-level
162    /// `"$schema"` key, so non-recursive nested types are inlined directly rather
163    /// than emitting `"$defs"`/`"$ref"`. This matters because Gemini's
164    /// `generation_config.response_schema` (reachable in this SDK through the
165    /// OpenRouter provider) rejects schemas containing `"$schema"`, `"$defs"`, or
166    /// `"$ref"` keys with a 400 INVALID_ARGUMENT error. See
167    /// the crate-internal schema normalizer's documentation for the limits of
168    /// this approach with recursive types.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`Error::Serialization`](crate::Error::Serialization) if the
173    /// schema generated for `T` cannot be converted to a JSON value.
174    pub fn with_json_schema_for<T>(mut self) -> error::Result<Self>
175    where
176        T: JsonSchema,
177    {
178        let generator = SchemaSettings::default()
179            .with(|settings| {
180                settings.inline_subschemas = true;
181                settings.meta_schema = None;
182            })
183            .into_generator();
184        let mut schema = serde_json::to_value(generator.into_root_schema_for::<T>())?;
185        normalize_strict_json_schema(&mut schema);
186        self.json_schema = Some(schema);
187        Ok(self)
188    }
189
190    /// Limit how many request/tool-execution rounds a single `generate()` call
191    /// may run before failing with
192    /// [`Error::ToolLoopLimitExceeded`](crate::Error::ToolLoopLimitExceeded).
193    pub fn with_max_tool_rounds(mut self, max_tool_rounds: usize) -> Self {
194        self.max_tool_rounds = Some(max_tool_rounds);
195        self
196    }
197
198    /// Enable or disable Anthropic prompt caching for this request.
199    ///
200    /// When enabled, Anthropic requests mark the system prompt and the final
201    /// tool definition as ephemeral cache breakpoints. Other providers silently
202    /// ignore this setting.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use rai_sdk::GenerationConfig;
208    ///
209    /// let config = GenerationConfig::new().with_prompt_caching(true);
210    /// assert_eq!(config.prompt_caching, Some(true));
211    /// ```
212    pub fn with_prompt_caching(mut self, enabled: bool) -> Self {
213        self.prompt_caching = Some(enabled);
214        self
215    }
216
217    /// The effective maximum number of tool-calling rounds (defaults to 8).
218    pub fn tool_round_limit(&self) -> usize {
219        self.max_tool_rounds.unwrap_or(8)
220    }
221}
222
223/// Normalize a generated JSON Schema for strict structured-output providers
224/// (notably Gemini's `generation_config.response_schema`, which rejects
225/// unrecognized keywords with a 400 INVALID_ARGUMENT error).
226///
227/// This recursively:
228/// - defaults `"additionalProperties"` to `false` on every object schema, without
229///   overriding an explicit value that's already present (existing behavior), and
230/// - strips any `"$schema"` key, wherever it appears, since Gemini rejects it.
231///
232/// Note on recursive types: [`GenerationConfig::with_json_schema_for`] configures
233/// the schemars generator with `inline_subschemas = true`, which inlines
234/// non-recursive nested types so no `"$defs"`/`"$ref"` keys are produced in the
235/// common case. However, schemars must still fall back to emitting
236/// `"$defs"`/`"$ref"` for *recursive* types (a type that transitively contains
237/// itself), since an infinitely-nested structure can't be inlined. This function
238/// deliberately does NOT attempt to resolve or flatten those references — doing
239/// so would require a general `$ref`-resolution pass, which is out of scope here.
240/// Structured-output types passed to `with_json_schema_for` must stay
241/// non-recursive to work with Gemini; recursive types will still be rejected.
242pub(crate) fn normalize_strict_json_schema(schema: &mut serde_json::Value) {
243    match schema {
244        serde_json::Value::Object(obj) => {
245            obj.remove("$schema");
246
247            let is_object_schema = obj.get("type").and_then(serde_json::Value::as_str)
248                == Some("object")
249                || obj.contains_key("properties");
250
251            if is_object_schema {
252                obj.entry("type")
253                    .or_insert(serde_json::Value::String("object".to_string()));
254                obj.entry("additionalProperties")
255                    .or_insert(serde_json::Value::Bool(false));
256            }
257
258            for value in obj.values_mut() {
259                normalize_strict_json_schema(value);
260            }
261        }
262        serde_json::Value::Array(items) => {
263            for item in items {
264                normalize_strict_json_schema(item);
265            }
266        }
267        _ => {}
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn builder_chain() {
277        let config = GenerationConfig::new()
278            .with_temperature(0.5)
279            .with_max_tokens(1024)
280            .with_top_p(0.9);
281
282        assert_eq!(config.temperature, Some(0.5));
283        assert_eq!(config.max_tokens, Some(1024));
284        assert_eq!(config.top_p, Some(0.9));
285    }
286
287    #[test]
288    fn tool_round_limit_default() {
289        assert_eq!(GenerationConfig::new().tool_round_limit(), 8);
290        assert_eq!(
291            GenerationConfig::new()
292                .with_max_tool_rounds(3)
293                .tool_round_limit(),
294            3
295        );
296    }
297
298    #[test]
299    fn normalize_adds_additional_properties() {
300        let mut schema = serde_json::json!({
301            "type": "object",
302            "properties": {
303                "name": { "type": "string" }
304            }
305        });
306        normalize_strict_json_schema(&mut schema);
307        assert_eq!(
308            schema["additionalProperties"],
309            serde_json::Value::Bool(false)
310        );
311    }
312
313    #[test]
314    fn normalize_adds_missing_object_type_when_properties_exist() {
315        let mut schema = serde_json::json!({
316            "properties": {
317                "name": { "type": "string" }
318            }
319        });
320        normalize_strict_json_schema(&mut schema);
321        assert_eq!(
322            schema["type"],
323            serde_json::Value::String("object".to_string())
324        );
325        assert_eq!(
326            schema["additionalProperties"],
327            serde_json::Value::Bool(false)
328        );
329    }
330
331    #[test]
332    fn normalize_preserves_explicit_additional_properties() {
333        let mut schema = serde_json::json!({
334            "type": "object",
335            "properties": {
336                "entries": {
337                    "type": "object",
338                    "additionalProperties": { "type": "string" }
339                }
340            }
341        });
342        normalize_strict_json_schema(&mut schema);
343        // Root gets `false` added
344        assert_eq!(
345            schema["additionalProperties"],
346            serde_json::Value::Bool(false)
347        );
348        // Nested explicit value is preserved
349        assert_eq!(
350            schema["properties"]["entries"]["additionalProperties"],
351            serde_json::json!({ "type": "string" })
352        );
353    }
354
355    #[allow(dead_code)]
356    #[derive(JsonSchema)]
357    struct StructuredAnswer {
358        answer: String,
359        confidence: f64,
360    }
361
362    #[allow(dead_code)]
363    #[derive(JsonSchema)]
364    struct NestedMetadata {
365        tags: Vec<String>,
366    }
367
368    #[allow(dead_code)]
369    #[derive(JsonSchema)]
370    struct StructuredEnvelope {
371        answer: StructuredAnswer,
372        metadata: NestedMetadata,
373    }
374
375    #[allow(dead_code)]
376    #[derive(JsonSchema)]
377    struct Inner {
378        a: u64,
379        b: String,
380    }
381
382    #[allow(dead_code)]
383    #[derive(JsonSchema)]
384    struct Outer {
385        items: Vec<Inner>,
386    }
387
388    #[allow(dead_code)]
389    #[derive(JsonSchema)]
390    enum StructuredChoice {
391        First,
392        Second,
393    }
394
395    #[allow(dead_code)]
396    #[derive(JsonSchema)]
397    struct StructuredWithOptionalAndEnum {
398        required_field: String,
399        optional_field: Option<String>,
400        choice: StructuredChoice,
401    }
402
403    /// Recursively asserts that no object key anywhere in the schema starts
404    /// with `$` (e.g. `$schema`, `$defs`, `$ref`), which Gemini rejects.
405    fn assert_no_dollar_keys(value: &serde_json::Value) {
406        match value {
407            serde_json::Value::Object(object) => {
408                for (key, nested) in object {
409                    assert!(
410                        !key.starts_with('$'),
411                        "schema should not contain a '{key}' keyword: {value}"
412                    );
413                    assert_no_dollar_keys(nested);
414                }
415            }
416            serde_json::Value::Array(items) => {
417                for item in items {
418                    assert_no_dollar_keys(item);
419                }
420            }
421            _ => {}
422        }
423    }
424
425    #[test]
426    fn test_generation_config_with_json_schema_for() -> error::Result<()> {
427        // schema_for! uses the default schemars settings (top-level "$schema",
428        // no inlining), which is not what `with_json_schema_for` produces
429        // anymore now that it targets Gemini compatibility. Build the expected
430        // value with the same settings `with_json_schema_for` uses instead of
431        // relying on the macro directly.
432        let generator = SchemaSettings::default()
433            .with(|settings| {
434                settings.inline_subschemas = true;
435                settings.meta_schema = None;
436            })
437            .into_generator();
438        let mut expected_schema =
439            serde_json::to_value(generator.into_root_schema_for::<StructuredAnswer>())?;
440        normalize_strict_json_schema(&mut expected_schema);
441        let config = GenerationConfig::new().with_json_schema_for::<StructuredAnswer>()?;
442
443        assert_eq!(config.json_schema, Some(expected_schema));
444
445        Ok(())
446    }
447
448    #[test]
449    fn test_generation_config_with_json_schema_for_inlines_nested_objects() -> error::Result<()> {
450        // Gemini's generation_config.response_schema rejects "$schema",
451        // "$defs", and "$ref" — non-recursive nested types must be inlined
452        // instead.
453        let config = GenerationConfig::new().with_json_schema_for::<StructuredEnvelope>()?;
454        let schema = config.json_schema.expect("schema should be present");
455
456        assert_no_dollar_keys(&schema);
457        assert!(schema.get("$defs").is_none());
458        assert!(schema.get("definitions").is_none());
459
460        assert_eq!(
461            schema["additionalProperties"],
462            serde_json::Value::Bool(false)
463        );
464        assert_eq!(
465            schema["properties"]["answer"]["additionalProperties"],
466            serde_json::Value::Bool(false)
467        );
468        assert_eq!(
469            schema["properties"]["metadata"]["additionalProperties"],
470            serde_json::Value::Bool(false)
471        );
472
473        Ok(())
474    }
475
476    #[test]
477    fn test_generation_config_with_json_schema_for_inlines_vec_of_nested_struct()
478    -> error::Result<()> {
479        let config = GenerationConfig::new().with_json_schema_for::<Outer>()?;
480        let schema = config.json_schema.expect("schema should be present");
481
482        assert_no_dollar_keys(&schema);
483
484        // `Inner`'s properties should be inlined directly under
485        // items.items.properties (Outer.items: Vec<Inner>) instead of being
486        // referenced via $defs/$ref.
487        let inner_properties = &schema["properties"]["items"]["items"]["properties"];
488        assert_eq!(inner_properties["a"]["type"], "integer");
489        assert_eq!(inner_properties["b"]["type"], "string");
490
491        assert_eq!(
492            schema["additionalProperties"],
493            serde_json::Value::Bool(false)
494        );
495        assert_eq!(
496            schema["properties"]["items"]["items"]["additionalProperties"],
497            serde_json::Value::Bool(false)
498        );
499
500        Ok(())
501    }
502
503    #[test]
504    fn test_generation_config_with_json_schema_for_optional_and_enum_fields() -> error::Result<()> {
505        let config =
506            GenerationConfig::new().with_json_schema_for::<StructuredWithOptionalAndEnum>()?;
507        let schema = config.json_schema.expect("schema should be present");
508
509        assert_no_dollar_keys(&schema);
510
511        let properties = &schema["properties"];
512        assert!(properties.get("required_field").is_some());
513        assert!(properties.get("optional_field").is_some());
514        assert!(properties.get("choice").is_some());
515
516        let required = schema["required"]
517            .as_array()
518            .expect("required array should be present");
519        let required_names: Vec<&str> = required
520            .iter()
521            .filter_map(serde_json::Value::as_str)
522            .collect();
523        assert!(required_names.contains(&"required_field"));
524        assert!(required_names.contains(&"choice"));
525
526        Ok(())
527    }
528}