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