Skip to main content

gproxy_protocol/protocol/openai/generate_content/
chat.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::super::common::*;
6use super::chat_stream::ChatCompletionChunk;
7use super::chat_tail::{
8    ChatAnnotation, ChatAudio, ChatAudioParam, ChatAudioRef, ChatChoiceLogprobs, ChatFileRef,
9    ChatWebSearchOptions, CompletionUsage, CustomToolCall, ImageUrl, InputAudio, PredictionContent,
10    StreamOptions,
11};
12
13pub type ChatCompletionWireModel = OpenAiWireModel<ChatCompletionRequest, ChatCompletionResponse>;
14pub type ChatCompletionStreamWireModel =
15    OpenAiWireModel<ChatCompletionRequest, ChatCompletionChunk>;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
18#[non_exhaustive]
19pub struct ChatCompletionRequest {
20    pub messages: Vec<ChatCompletionMessageParam>,
21    pub model: OpenAiModelId,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub audio: Option<ChatAudioParam>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub frequency_penalty: Option<f64>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub function_call: Option<LegacyFunctionCallChoice>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub functions: Option<Vec<LegacyFunctionDefinition>>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub logit_bias: Option<LogitBias>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub logprobs: Option<bool>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub max_completion_tokens: Option<u32>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub max_tokens: Option<u32>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub metadata: Option<Metadata>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub modalities: Option<Vec<TextOrAudioModality>>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub moderation: Option<ModerationConfig>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub n: Option<u32>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub parallel_tool_calls: Option<bool>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub prediction: Option<PredictionContent>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub presence_penalty: Option<f64>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub prompt_cache_key: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub prompt_cache_options: Option<PromptCacheOptions>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub prompt_cache_retention: Option<PromptCacheRetention>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub reasoning_effort: Option<ReasoningEffort>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub response_format: Option<ChatResponseFormat>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub safety_identifier: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub seed: Option<i64>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub service_tier: Option<ServiceTier>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub stop: Option<StringOrList>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub store: Option<bool>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub stream: Option<bool>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub stream_options: Option<StreamOptions>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub temperature: Option<f64>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub tool_choice: Option<ChatToolChoice>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub tools: Option<Vec<ChatTool>>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub top_logprobs: Option<u32>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub top_p: Option<f64>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub user: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub verbosity: Option<Verbosity>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub web_search_options: Option<ChatWebSearchOptions>,
92    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
93    pub extra: Extra,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "role")]
98#[allow(clippy::large_enum_variant)]
99#[non_exhaustive]
100pub enum ChatCompletionMessageParam {
101    #[serde(rename = "developer")]
102    Developer {
103        content: ChatTextContent,
104        #[serde(skip_serializing_if = "Option::is_none")]
105        name: Option<String>,
106        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
107        extra: Extra,
108    },
109    #[serde(rename = "system")]
110    System {
111        content: ChatTextContent,
112        #[serde(skip_serializing_if = "Option::is_none")]
113        name: Option<String>,
114        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
115        extra: Extra,
116    },
117    #[serde(rename = "user")]
118    User {
119        content: ChatContent,
120        #[serde(skip_serializing_if = "Option::is_none")]
121        name: Option<String>,
122        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
123        extra: Extra,
124    },
125    #[serde(rename = "assistant")]
126    Assistant {
127        #[serde(skip_serializing_if = "Option::is_none")]
128        content: Option<ChatAssistantContent>,
129        #[serde(skip_serializing_if = "Option::is_none")]
130        audio: Option<ChatAudioRef>,
131        #[serde(skip_serializing_if = "Option::is_none")]
132        function_call: Option<FunctionCall>,
133        #[serde(skip_serializing_if = "Option::is_none")]
134        name: Option<String>,
135        #[serde(skip_serializing_if = "Option::is_none")]
136        reasoning_content: Option<String>,
137        #[serde(skip_serializing_if = "Option::is_none")]
138        refusal: Option<String>,
139        #[serde(skip_serializing_if = "Option::is_none")]
140        tool_calls: Option<Vec<ChatToolCall>>,
141        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
142        extra: Extra,
143    },
144    #[serde(rename = "tool")]
145    Tool {
146        content: ChatTextContent,
147        tool_call_id: String,
148        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
149        extra: Extra,
150    },
151    #[serde(rename = "function")]
152    Function {
153        content: String,
154        name: String,
155        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
156        extra: Extra,
157    },
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(untagged)]
162#[non_exhaustive]
163pub enum ChatTextContent {
164    Text(String),
165    Parts(Vec<ChatTextContentPart>),
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(tag = "type")]
170#[non_exhaustive]
171pub enum ChatTextContentPart {
172    #[serde(rename = "text")]
173    Text {
174        text: String,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
177        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
178        extra: Extra,
179    },
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(untagged)]
184#[non_exhaustive]
185pub enum ChatAssistantContent {
186    Text(String),
187    Parts(Vec<ChatAssistantContentPart>),
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "type")]
192#[non_exhaustive]
193pub enum ChatAssistantContentPart {
194    #[serde(rename = "text")]
195    Text {
196        text: String,
197        #[serde(skip_serializing_if = "Option::is_none")]
198        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
199        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
200        extra: Extra,
201    },
202    #[serde(rename = "refusal")]
203    Refusal {
204        refusal: String,
205        #[serde(skip_serializing_if = "Option::is_none")]
206        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
207        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
208        extra: Extra,
209    },
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(untagged)]
214#[non_exhaustive]
215pub enum ChatContent {
216    Text(String),
217    Parts(Vec<ChatContentPart>),
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(tag = "type")]
222#[non_exhaustive]
223pub enum ChatContentPart {
224    #[serde(rename = "text")]
225    Text {
226        text: String,
227        #[serde(skip_serializing_if = "Option::is_none")]
228        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
229        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
230        extra: Extra,
231    },
232    #[serde(rename = "image_url")]
233    ImageUrl {
234        image_url: ImageUrl,
235        #[serde(skip_serializing_if = "Option::is_none")]
236        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
237        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
238        extra: Extra,
239    },
240    #[serde(rename = "input_audio")]
241    InputAudio {
242        input_audio: InputAudio,
243        #[serde(skip_serializing_if = "Option::is_none")]
244        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
245        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
246        extra: Extra,
247    },
248    #[serde(rename = "file")]
249    File {
250        file: ChatFileRef,
251        #[serde(skip_serializing_if = "Option::is_none")]
252        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
253        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
254        extra: Extra,
255    },
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[serde(tag = "type")]
260#[non_exhaustive]
261pub enum ChatTool {
262    #[serde(rename = "function")]
263    Function {
264        function: FunctionDefinition,
265        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
266        extra: Extra,
267    },
268    #[serde(rename = "custom")]
269    Custom {
270        custom: CustomToolDefinition,
271        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
272        extra: Extra,
273    },
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(tag = "type")]
278#[non_exhaustive]
279pub enum ChatToolCall {
280    #[serde(rename = "function")]
281    Function {
282        id: String,
283        function: FunctionCall,
284        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
285        extra: Extra,
286    },
287    #[serde(rename = "custom")]
288    Custom {
289        id: String,
290        custom: CustomToolCall,
291        #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
292        extra: Extra,
293    },
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
297#[non_exhaustive]
298pub struct ChatCompletionResponse {
299    pub id: String,
300    pub choices: Vec<ChatCompletionChoice>,
301    // Some OpenAI-compatible upstreams (e.g. Copilot) omit `created`; tolerate it
302    // so the chat->claude/responses response transform doesn't 502 on decode.
303    #[serde(default)]
304    pub created: u64,
305    pub model: OpenAiModelId,
306    // Minimal OpenAI-compatible upstreams (e.g. Copilot) omit `object` too.
307    #[serde(default)]
308    pub object: ChatCompletionObjectType,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub moderation: Option<ChatCompletionModeration>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub service_tier: Option<ServiceTier>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub system_fingerprint: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub usage: Option<CompletionUsage>,
317    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
318    pub extra: Extra,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
322#[non_exhaustive]
323pub struct ChatCompletionChoice {
324    pub finish_reason: ChatFinishReason,
325    pub index: u32,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub logprobs: Option<ChatChoiceLogprobs>,
328    pub message: ChatMessage,
329    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
330    pub extra: Extra,
331}
332
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
334#[non_exhaustive]
335pub struct ChatCompletionModeration {
336    pub input: ChatCompletionModerationOutcome,
337    pub output: ChatCompletionModerationOutcome,
338    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
339    pub extra: Extra,
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343#[serde(untagged)]
344#[non_exhaustive]
345pub enum ChatCompletionModerationOutcome {
346    Results(ChatCompletionModerationResults),
347    Error(ModerationError),
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
351#[non_exhaustive]
352pub struct ChatCompletionModerationResults {
353    pub model: OpenAiModelId,
354    pub results: Vec<ModerationResult>,
355    #[serde(rename = "type")]
356    pub type_: ChatCompletionModerationResultsType,
357    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
358    pub extra: Extra,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[non_exhaustive]
363pub enum ChatCompletionModerationResultsType {
364    #[serde(rename = "moderation_results")]
365    ModerationResults,
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
369#[non_exhaustive]
370pub struct ChatMessage {
371    pub role: ChatCompletionMessageRole,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub content: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub refusal: Option<String>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub annotations: Option<Vec<ChatAnnotation>>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub audio: Option<ChatAudio>,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub function_call: Option<FunctionCall>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub reasoning_content: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub tool_calls: Option<Vec<ChatToolCall>>,
386    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
387    pub extra: Extra,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[non_exhaustive]
392pub enum ChatCompletionMessageRole {
393    #[serde(rename = "assistant")]
394    Assistant,
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    /// Minimal OpenAI-compatible upstreams (e.g. Copilot) omit `created`/`object`,
402    /// and providers like Groq report a `service_tier` outside the documented set
403    /// (`on_demand`). The chat response must still decode, or the chat->claude /
404    /// chat->responses RESPONSE transform 502s (regression).
405    #[test]
406    fn chat_response_tolerates_minimal_and_unknown_tier() {
407        let r: ChatCompletionResponse =
408            serde_json::from_str(r#"{"id":"x","choices":[],"model":"gpt-4o-mini"}"#).unwrap();
409        assert_eq!(r.created, 0);
410
411        let r: ChatCompletionResponse = serde_json::from_str(
412            r#"{"id":"x","choices":[],"model":"m","service_tier":"on_demand"}"#,
413        )
414        .unwrap();
415        assert!(r.service_tier.is_some());
416    }
417}