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