Skip to main content

synaps_cli/runtime/openai/
types.rs

1//! OpenAI-compatible wire types. Ported from the prototype `openai-runtime` crate.
2//!
3//! Note: the prototype's `StreamEvent` is renamed to `OaiEvent` to avoid clashing
4//! with `crate::runtime::types::StreamEvent`.
5
6use serde::ser::SerializeStruct;
7use serde::{Deserialize, Serialize, Serializer};
8use serde_json::Value;
9
10// ─── Tool definitions (request side) ──────────────────────────────────────────
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ToolDefinition {
14    #[serde(rename = "type")]
15    pub kind: String,
16    pub function: FunctionDefinition,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct FunctionDefinition {
21    pub name: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24    pub parameters: Value,
25}
26
27impl ToolDefinition {
28    pub fn function(
29        name: impl Into<String>,
30        description: impl Into<String>,
31        parameters: Value,
32    ) -> Self {
33        Self {
34            kind: "function".to_string(),
35            function: FunctionDefinition {
36                name: name.into(),
37                description: Some(description.into()),
38                parameters,
39            },
40        }
41    }
42}
43
44// ─── ToolChoice ──────────────────────────────────────────────────────────────
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ToolChoice {
48    None,
49    Auto,
50    Required,
51    Function(String),
52}
53
54impl Serialize for ToolChoice {
55    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
56        match self {
57            ToolChoice::None => ser.serialize_str("none"),
58            ToolChoice::Auto => ser.serialize_str("auto"),
59            ToolChoice::Required => ser.serialize_str("required"),
60            ToolChoice::Function(name) => {
61                #[derive(Serialize)]
62                struct Named<'a> {
63                    name: &'a str,
64                }
65                let mut s = ser.serialize_struct("ToolChoice", 2)?;
66                s.serialize_field("type", "function")?;
67                s.serialize_field("function", &Named { name })?;
68                s.end()
69            }
70        }
71    }
72}
73
74// ─── Tool calls (response side) ───────────────────────────────────────────────
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct ToolCall {
78    pub id: String,
79    #[serde(rename = "type")]
80    pub kind: String,
81    pub function: FunctionCall,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct FunctionCall {
86    pub name: String,
87    /// Raw JSON string. Do NOT parse mid-stream — only after
88    /// `ToolCallsComplete { truncated: false }`.
89    pub arguments: String,
90}
91
92// ─── ChatMessage ─────────────────────────────────────────────────────────────
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ChatMessage {
96    pub role: String,
97
98    /// `None` serializes as JSON `null` — required for assistant-with-tool-calls.
99    pub content: Option<String>,
100
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub tool_calls: Option<Vec<ToolCall>>,
103
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub tool_call_id: Option<String>,
106
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub name: Option<String>,
109}
110
111impl ChatMessage {
112    pub fn user(content: impl Into<String>) -> Self {
113        Self {
114            role: "user".into(),
115            content: Some(content.into()),
116            tool_calls: None,
117            tool_call_id: None,
118            name: None,
119        }
120    }
121    pub fn system(content: impl Into<String>) -> Self {
122        Self {
123            role: "system".into(),
124            content: Some(content.into()),
125            tool_calls: None,
126            tool_call_id: None,
127            name: None,
128        }
129    }
130    pub fn assistant(content: impl Into<String>) -> Self {
131        Self {
132            role: "assistant".into(),
133            content: Some(content.into()),
134            tool_calls: None,
135            tool_call_id: None,
136            name: None,
137        }
138    }
139    pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
140        Self {
141            role: "assistant".into(),
142            content: None,
143            tool_calls: Some(tool_calls),
144            tool_call_id: None,
145            name: None,
146        }
147    }
148    pub fn tool_result(
149        tool_call_id: impl Into<String>,
150        name: impl Into<String>,
151        content: impl Into<String>,
152    ) -> Self {
153        Self {
154            role: "tool".into(),
155            content: Some(content.into()),
156            tool_calls: None,
157            tool_call_id: Some(tool_call_id.into()),
158            name: Some(name.into()),
159        }
160    }
161
162    pub fn content(&self) -> Option<&str> {
163        self.content.as_deref()
164    }
165}
166
167// ─── Options + Request ───────────────────────────────────────────────────────
168
169#[derive(Debug, Clone, Default)]
170pub struct ChatOptions {
171    pub max_tokens: Option<u32>,
172    pub temperature: Option<f32>,
173    pub tools: Option<Vec<ToolDefinition>>,
174    pub tool_choice: Option<ToolChoice>,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct StreamOptions {
179    pub include_usage: bool,
180}
181
182#[derive(Debug, Clone, Serialize)]
183pub struct ChatRequest {
184    pub model: String,
185    pub messages: Vec<ChatMessage>,
186    pub stream: bool,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub stream_options: Option<StreamOptions>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub max_tokens: Option<u32>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub temperature: Option<f32>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub tools: Option<Vec<ToolDefinition>>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub tool_choice: Option<ToolChoice>,
197}
198
199// ─── Finish reason ───────────────────────────────────────────────────────────
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum FinishReason {
203    Stop,
204    Length,
205    ToolCalls,
206    ContentFilter,
207}
208
209// ─── Stream events (OpenAI-side) ─────────────────────────────────────────────
210
211#[derive(Debug, Clone)]
212#[non_exhaustive]
213pub enum OaiEvent {
214    RoleStart(String),
215    TextDelta(String),
216    ToolCallStart {
217        index: u32,
218        id: String,
219        name: String,
220    },
221    ToolCallArgumentsDelta {
222        index: u32,
223        id: String,
224        delta: String,
225    },
226    ToolCallsComplete {
227        calls: Vec<ToolCall>,
228        truncated: bool,
229    },
230    Usage {
231        prompt_tokens: u32,
232        completion_tokens: u32,
233        cached_tokens: u32,
234    },
235    Warning(String),
236    Done,
237}
238
239// ─── Provider config ─────────────────────────────────────────────────────────
240
241#[derive(Clone)]
242pub struct ProviderConfig {
243    pub base_url: String,
244    pub api_key: String,
245    pub model: String,
246    pub provider: String,
247}
248
249impl std::fmt::Debug for ProviderConfig {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("ProviderConfig")
252            .field("base_url", &self.base_url)
253            .field("api_key", &"[REDACTED]")
254            .field("model", &self.model)
255            .finish()
256    }
257}