nemo-relay-types 0.9.4

Shared serializable data model types for NeMo Relay.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! LLM request codec types and trait.
//!
//! This module defines the [`AnnotatedLlmRequest`] type system for structured
//! LLM request representation and the [`crate::codec::traits::LlmCodec`] trait
//! for bidirectional translation between opaque [`crate::api::llm::LlmRequest`]
//! payloads and typed form.

use serde::{Deserialize, Serialize};

use crate::Json;

/// Versioned JSON envelope schema for [`AnnotatedLlmRequest`].
///
/// Version 2 adds tagged request components that older exhaustive Rust
/// consumers cannot deserialize safely.
pub const ANNOTATED_LLM_REQUEST_SCHEMA: &str = "nemo.relay.AnnotatedLlmRequest@2";

// ---------------------------------------------------------------------------
// AnnotatedLlmRequest type hierarchy
// ---------------------------------------------------------------------------

/// Structured view of an LLM request, produced by a Codec from opaque
/// [`LlmRequest`](crate::api::llm::LlmRequest) content.
///
/// The `extra` field captures unknown future top-level keys. Modeled
/// provider-specific controls belong in [`AnnotatedLlmRequest::api_specific`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AnnotatedLlmRequest {
    /// Parsed conversation messages.
    #[serde(default)]
    pub messages: Vec<Message>,
    /// Provider-level instructions that are not part of the conversation array.
    ///
    /// Anthropic encodes this as `system`; OpenAI Responses uses `instructions`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instructions: Option<MessageContent>,
    /// Model identifier (e.g., `"gpt-4"`, `"claude-sonnet-4-20250514"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Common generation parameters, normalized.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub params: Option<GenerationParams>,
    /// Tool definitions (function schemas) available to the model.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tools: Option<Vec<ToolDefinition>>,
    /// Tool choice control.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_choice: Option<ToolChoice>,
    /// OpenAI Responses: whether to persist response state server-side.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub store: Option<bool>,
    /// OpenAI Responses: prior response to continue from.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub previous_response_id: Option<String>,
    /// OpenAI Responses: context truncation behavior.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub truncation: Option<Json>,
    /// OpenAI Responses: reasoning configuration object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<Json>,
    /// OpenAI Responses: include filter for additional output/state items.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include: Option<Json>,
    /// OpenAI user identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// OpenAI metadata map/object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<Json>,
    /// OpenAI service tier preference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub service_tier: Option<String>,
    /// OpenAI tool parallelism toggle.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parallel_tool_calls: Option<bool>,
    /// OpenAI Responses max output token limit.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_output_tokens: Option<u64>,
    /// OpenAI Responses max tool calls.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_tool_calls: Option<u64>,
    /// OpenAI logprob fanout count.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub top_logprobs: Option<u64>,
    /// OpenAI streaming toggle.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stream: Option<bool>,
    /// API-specific request data that does not have portable semantics.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub api_specific: Option<ApiSpecificRequest>,
    /// Unknown future top-level fields.
    ///
    /// Baseline-aware codecs remove deleted keys and overlay changed or added
    /// keys without rebuilding untouched provider JSON.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, Json>,
}

/// A single message in a conversation, tagged by role.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "lowercase")]
pub enum Message {
    /// A system instruction message.
    System {
        /// The message content.
        content: MessageContent,
        /// Optional sender name.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
    /// A user message.
    User {
        /// The message content.
        content: MessageContent,
        /// Optional sender name.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
    /// A developer instruction message used by OpenAI APIs.
    Developer {
        /// The message content.
        content: MessageContent,
        /// Optional sender name.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
    /// An assistant response, optionally containing tool calls.
    Assistant {
        /// The message content (optional — may be absent when tool calls are present).
        #[serde(skip_serializing_if = "Option::is_none")]
        content: Option<MessageContent>,
        /// Tool calls requested by the assistant.
        #[serde(skip_serializing_if = "Option::is_none")]
        tool_calls: Option<Vec<ToolCall>>,
        /// Optional sender name.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
    /// A tool result message.
    Tool {
        /// The tool execution result.
        content: MessageContent,
        /// The ID of the tool call this result corresponds to.
        tool_call_id: String,
    },
    /// A legacy OpenAI function-result message.
    Function {
        /// The function result. OpenAI permits an explicit null value.
        content: Option<String>,
        /// Function name.
        name: String,
    },
    /// A portable top-level tool-call item, primarily used by OpenAI Responses.
    #[serde(rename = "tool_call")]
    ToolCallItem {
        /// Optional provider item ID.
        #[serde(skip_serializing_if = "Option::is_none")]
        id: Option<String>,
        /// Provider call ID used to correlate the result.
        call_id: String,
        /// Tool/function name.
        name: String,
        /// Parsed tool arguments.
        arguments: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// A portable top-level tool-result item, primarily used by OpenAI Responses.
    #[serde(rename = "tool_result")]
    ToolResultItem {
        /// Optional provider item ID.
        #[serde(skip_serializing_if = "Option::is_none")]
        id: Option<String>,
        /// Provider call ID used to correlate the result.
        call_id: String,
        /// Provider result payload.
        output: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Lossless provider-native request item.
    #[serde(rename = "provider_native")]
    ProviderNative {
        /// Provider surface that owns the value.
        provider: String,
        /// Native discriminator or a descriptive fallback.
        kind: String,
        /// Exact provider JSON value.
        value: Json,
    },
}

/// Message content: either a plain string or multimodal parts array.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
    /// Plain text content.
    Text(String),
    /// Multimodal content parts.
    Parts(Vec<ContentPart>),
}

/// A single content part within a multimodal message.
///
/// v1 supports text only. Future versions may add `ImageUrl`, `Audio`, etc.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
    /// A text content part.
    Text {
        /// The text content.
        text: String,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// An image URL content part.
    ImageUrl {
        /// Image URL payload.
        image_url: OpenAiImageUrl,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Portable image input payload.
    Image {
        /// Provider-neutral image data object.
        image: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Portable audio input payload.
    Audio {
        /// Provider-neutral audio data object.
        audio: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Portable file or document input payload.
    File {
        /// Provider-neutral file data object.
        file: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Assistant refusal content.
    Refusal {
        /// Refusal text.
        refusal: String,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Tool call embedded in a provider content-block array.
    ToolUse {
        /// Tool call identifier.
        id: String,
        /// Tool name.
        name: String,
        /// Parsed arguments.
        input: Json,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Tool result embedded in a provider content-block array.
    ToolResult {
        /// Tool call identifier.
        tool_use_id: String,
        /// Tool result payload.
        content: Json,
        /// Whether the tool failed.
        #[serde(skip_serializing_if = "Option::is_none")]
        is_error: Option<bool>,
        /// Provider fields without portable semantics.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Lossless provider-native content block.
    ProviderNative {
        /// Provider surface that owns the value.
        provider: String,
        /// Native block discriminator or a descriptive fallback.
        kind: String,
        /// Exact provider JSON value.
        value: Json,
    },
}

/// OpenAI image URL payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpenAiImageUrl {
    /// URL for the image.
    pub url: String,
    /// Optional provider-specific detail hint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// A tool call requested by the assistant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Unique identifier for this tool call.
    pub id: String,
    /// The type of tool call (typically `"function"`).
    #[serde(rename = "type")]
    pub call_type: String,
    /// The function to call.
    pub function: FunctionCall,
}

/// A function call within a tool call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCall {
    /// The name of the function to call.
    pub name: String,
    /// The function arguments as a JSON string (per OpenAI convention).
    pub arguments: String,
}

/// A tool definition available to the model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolDefinition {
    /// Portable function tool.
    Function {
        /// The function definition.
        function: FunctionDefinition,
        /// Provider fields on the function-tool wrapper.
        #[serde(default, flatten)]
        extra: serde_json::Map<String, Json>,
    },
    /// Lossless provider-native tool definition.
    ProviderNative {
        /// Provider surface that owns the value.
        provider: String,
        /// Native tool discriminator or a descriptive fallback.
        kind: String,
        /// Exact provider JSON value.
        value: Json,
    },
}

/// A function definition within a tool definition.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionDefinition {
    /// The name of the function.
    pub name: String,
    /// A description of what the function does.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The JSON Schema for the function parameters.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parameters: Option<Json>,
    /// Whether the provider should enforce the parameter schema strictly.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub strict: Option<bool>,
    /// Provider fields without portable semantics.
    #[serde(default, flatten)]
    pub extra: serde_json::Map<String, Json>,
}

/// Tool choice control: how the model should use available tools.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolChoice {
    /// Let the model decide whether to call a tool.
    Auto,
    /// Do not call any tools.
    None,
    /// The model must call at least one tool.
    Required,
    /// Force a specific function by name.
    #[serde(untagged)]
    Specific(ToolChoiceFunction),
    /// Lossless provider-native tool choice.
    #[serde(untagged)]
    ProviderNative(ProviderNativeComponent),
}

/// Lossless provider-native component embedded in the annotated request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderNativeComponent {
    /// Provider surface that owns the value.
    pub provider: String,
    /// Native discriminator or a descriptive fallback.
    pub kind: String,
    /// Exact provider JSON value.
    pub value: Json,
}

/// API-specific request fields that do not have portable semantics.
#[allow(
    clippy::large_enum_variant,
    reason = "provider wire-schema fields stay directly mutable on each public variant"
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "api")]
pub enum ApiSpecificRequest {
    /// Anthropic Messages-specific request fields.
    #[serde(rename = "anthropic_messages")]
    AnthropicMessages {
        /// Top-level prompt cache control.
        #[serde(skip_serializing_if = "Option::is_none")]
        cache_control: Option<Json>,
        /// Reusable container identifier.
        #[serde(skip_serializing_if = "Option::is_none")]
        container: Option<String>,
        /// Requested inference geography.
        #[serde(skip_serializing_if = "Option::is_none")]
        inference_geo: Option<String>,
        /// Provider output configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        output_config: Option<Json>,
        /// Extended-thinking configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        thinking: Option<Json>,
        /// Top-k sampling limit.
        #[serde(skip_serializing_if = "Option::is_none")]
        top_k: Option<u64>,
        /// User profile attribution identifier.
        #[serde(skip_serializing_if = "Option::is_none")]
        user_profile_id: Option<String>,
    },
    /// OpenAI Chat Completions-specific request fields.
    #[serde(rename = "openai_chat")]
    OpenAIChat {
        /// Audio output configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        audio: Option<Json>,
        /// Frequency penalty.
        #[serde(skip_serializing_if = "Option::is_none")]
        frequency_penalty: Option<f64>,
        /// Deprecated function-call control.
        #[serde(skip_serializing_if = "Option::is_none")]
        function_call: Option<Json>,
        /// Deprecated function definitions.
        #[serde(skip_serializing_if = "Option::is_none")]
        functions: Option<Vec<Json>>,
        /// Token logit bias map.
        #[serde(skip_serializing_if = "Option::is_none")]
        logit_bias: Option<Json>,
        /// Whether token log probabilities are requested.
        #[serde(skip_serializing_if = "Option::is_none")]
        logprobs: Option<bool>,
        /// Requested output modalities.
        #[serde(skip_serializing_if = "Option::is_none")]
        modalities: Option<Vec<String>>,
        /// Request moderation configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        moderation: Option<Json>,
        /// Number of completion choices.
        #[serde(skip_serializing_if = "Option::is_none")]
        n: Option<u64>,
        /// Predicted output content.
        #[serde(skip_serializing_if = "Option::is_none")]
        prediction: Option<Json>,
        /// Presence penalty.
        #[serde(skip_serializing_if = "Option::is_none")]
        presence_penalty: Option<f64>,
        /// Prompt cache routing key.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_key: Option<String>,
        /// Prompt cache configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_options: Option<Json>,
        /// Deprecated prompt cache retention policy.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_retention: Option<String>,
        /// Requested reasoning effort.
        #[serde(skip_serializing_if = "Option::is_none")]
        reasoning_effort: Option<String>,
        /// Structured response format configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        response_format: Option<Json>,
        /// Stable safety identifier.
        #[serde(skip_serializing_if = "Option::is_none")]
        safety_identifier: Option<String>,
        /// Best-effort deterministic sampling seed.
        #[serde(skip_serializing_if = "Option::is_none")]
        seed: Option<i64>,
        /// Streaming response configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        stream_options: Option<Json>,
        /// Requested response verbosity.
        #[serde(skip_serializing_if = "Option::is_none")]
        verbosity: Option<String>,
        /// Web-search configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        web_search_options: Option<Json>,
    },
    /// OpenAI Responses-specific request fields.
    #[serde(rename = "openai_responses")]
    OpenAIResponses {
        /// Whether the response should run in the background.
        #[serde(skip_serializing_if = "Option::is_none")]
        background: Option<bool>,
        /// Context-management entries.
        #[serde(skip_serializing_if = "Option::is_none")]
        context_management: Option<Json>,
        /// Conversation identifier or object.
        #[serde(skip_serializing_if = "Option::is_none")]
        conversation: Option<Json>,
        /// Request moderation configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        moderation: Option<Json>,
        /// Reusable prompt template reference.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt: Option<Json>,
        /// Prompt cache routing key.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_key: Option<String>,
        /// Prompt cache configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_options: Option<Json>,
        /// Deprecated prompt cache retention policy.
        #[serde(skip_serializing_if = "Option::is_none")]
        prompt_cache_retention: Option<String>,
        /// Stable safety identifier.
        #[serde(skip_serializing_if = "Option::is_none")]
        safety_identifier: Option<String>,
        /// Streaming response configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        stream_options: Option<Json>,
        /// Text output configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        text: Option<Json>,
    },
    /// OCI Generative AI-specific request fields.
    #[serde(rename = "oci_genai")]
    OCIGenAI {
        /// Compartment OCID from the `ChatDetails` envelope.
        #[serde(skip_serializing_if = "Option::is_none")]
        compartment_id: Option<String>,
        /// Serving mode object (`servingType` plus `modelId` or `endpointId`).
        #[serde(skip_serializing_if = "Option::is_none")]
        serving_mode: Option<Json>,
        /// Chat request API format (`GENERIC`, `COHERE`, or `COHEREV2`).
        #[serde(skip_serializing_if = "Option::is_none")]
        api_format: Option<String>,
    },
    /// Custom provider request fields.
    #[serde(rename = "custom")]
    Custom {
        /// Custom API identifier.
        api_name: String,
        /// Opaque custom API data.
        data: Json,
    },
}

/// A specific tool choice that forces a named function.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolChoiceFunction {
    /// The type (typically `"function"`).
    #[serde(rename = "type")]
    pub choice_type: String,
    /// The function to call.
    pub function: ToolChoiceFunctionName,
}

/// The name component of a specific tool choice.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolChoiceFunctionName {
    /// The function name.
    pub name: String,
}

/// Normalized generation parameters across providers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct GenerationParams {
    /// Sampling temperature.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f64>,
    /// Maximum number of tokens to generate.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_tokens: Option<u64>,
    /// Nucleus sampling probability.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub top_p: Option<f64>,
    /// Stop sequences.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stop: Option<Vec<String>>,
}

// ---------------------------------------------------------------------------
// Helper methods
// ---------------------------------------------------------------------------

impl AnnotatedLlmRequest {
    /// Extract the text content of the first system message, if any.
    ///
    /// For [`MessageContent::Text`], returns the string directly.
    /// For [`MessageContent::Parts`], returns the text of the first
    /// [`ContentPart::Text`] part.
    pub fn system_prompt(&self) -> Option<&str> {
        if let Some(text) = self.instructions.as_ref().and_then(first_content_text) {
            return Some(text);
        }
        self.messages.iter().find_map(|m| match m {
            Message::System { content, .. } | Message::Developer { content, .. } => {
                first_content_text(content)
            }
            _ => None,
        })
    }

    /// Get the text content of the last user message, if any.
    ///
    /// Searches messages in reverse order and returns the first user
    /// message found. For [`MessageContent::Parts`], returns the text of
    /// the first [`ContentPart::Text`] part.
    pub fn last_user_message(&self) -> Option<&str> {
        self.messages.iter().rev().find_map(|m| match m {
            Message::User { content, .. } => first_content_text(content),
            Message::ProviderNative {
                provider, value, ..
            } if provider == "openai_responses"
                && value.get("role").and_then(Json::as_str) == Some("user") =>
            {
                native_message_text(value)
            }
            _ => None,
        })
    }

    /// Check if any assistant message in the conversation contains tool calls.
    ///
    /// Returns `true` if at least one [`Message::Assistant`] variant has a
    /// non-empty `tool_calls` field.
    pub fn has_tool_calls(&self) -> bool {
        self.messages.iter().any(|m| match m {
            Message::Assistant {
                tool_calls: Some(calls),
                content,
                ..
            } => !calls.is_empty() || content.as_ref().is_some_and(content_has_tool_use),
            Message::Assistant {
                content: Some(content),
                ..
            } => content_has_tool_use(content),
            Message::ToolCallItem { .. } => true,
            Message::ProviderNative { value, .. } => matches!(
                value.get("type").and_then(Json::as_str),
                Some("function_call" | "custom_tool_call" | "tool_use")
            ),
            _ => false,
        })
    }
}

fn first_content_text(content: &MessageContent) -> Option<&str> {
    match content {
        MessageContent::Text(text) => Some(text.as_str()),
        MessageContent::Parts(parts) => parts.iter().find_map(|part| match part {
            ContentPart::Text { text, .. } => Some(text.as_str()),
            ContentPart::ProviderNative { value, .. } => value
                .get("text")
                .and_then(Json::as_str)
                .or_else(|| value.get("refusal").and_then(Json::as_str)),
            ContentPart::ImageUrl { .. }
            | ContentPart::Image { .. }
            | ContentPart::Audio { .. }
            | ContentPart::File { .. }
            | ContentPart::Refusal { .. }
            | ContentPart::ToolUse { .. }
            | ContentPart::ToolResult { .. } => None,
        }),
    }
}

fn content_has_tool_use(content: &MessageContent) -> bool {
    match content {
        MessageContent::Text(_) => false,
        MessageContent::Parts(parts) => parts.iter().any(|part| match part {
            ContentPart::ToolUse { .. } => true,
            ContentPart::ProviderNative { value, .. } => matches!(
                value.get("type").and_then(Json::as_str),
                Some("tool_use" | "mcp_tool_use" | "server_tool_use")
            ),
            _ => false,
        }),
    }
}

fn native_message_text(value: &Json) -> Option<&str> {
    match value.get("content")? {
        Json::String(text) => Some(text.as_str()),
        Json::Array(parts) => parts.iter().find_map(|part| {
            part.get("text")
                .and_then(Json::as_str)
                .or_else(|| part.get("refusal").and_then(Json::as_str))
        }),
        _ => None,
    }
}