Skip to main content

oxillama_server/threads/
types.rs

1//! OpenAI Assistants v2-compatible request/response types.
2//!
3//! This module defines the wire-format structs used by the Assistants API:
4//! threads, messages, runs, and the request bodies that create them.
5//!
6//! Field names and `object` strings match the OpenAI v2 specification so
7//! that clients written against the official SDK work without modification.
8
9use serde::{Deserialize, Serialize};
10
11// ── Thread ────────────────────────────────────────────────────────────────────
12
13/// An OpenAI-compatible thread object (persisted to disk).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Thread {
16    /// Stable identifier (`thread_<uuid>`).
17    pub id: String,
18    /// Always `"thread"`.
19    pub object: String,
20    /// Unix timestamp (seconds) when the thread was created.
21    pub created_at: i64,
22    /// Caller-supplied free-form metadata (JSON object).
23    pub metadata: serde_json::Value,
24}
25
26// ── Message types ─────────────────────────────────────────────────────────────
27
28/// Role of the message author.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum MessageRole {
32    /// User-authored message.
33    User,
34    /// Assistant-authored message.
35    Assistant,
36}
37
38/// Annotation placeholder inside a `TextContent` (reserved for future use).
39pub type Annotation = serde_json::Value;
40
41/// Text payload of a content block.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TextContent {
44    /// The text string.
45    pub value: String,
46    /// Annotations (empty for now).
47    pub annotations: Vec<Annotation>,
48}
49
50/// A single content block within a message.
51///
52/// Currently only the `text` type is supported; the `type` field is always `"text"`.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ContentBlock {
55    /// Content type — always `"text"` in this implementation.
56    pub r#type: String,
57    /// The text payload.
58    pub text: TextContent,
59}
60
61/// A message stored inside a thread.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ThreadMessage {
64    /// Stable identifier (`msg_<uuid>`).
65    pub id: String,
66    /// Always `"thread.message"`.
67    pub object: String,
68    /// Unix timestamp (seconds) when the message was created.
69    pub created_at: i64,
70    /// ID of the owning thread.
71    pub thread_id: String,
72    /// Role of the message author.
73    pub role: MessageRole,
74    /// Message content blocks.
75    pub content: Vec<ContentBlock>,
76    /// Run ID that produced this message (`None` for user messages).
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub run_id: Option<String>,
79}
80
81impl ThreadMessage {
82    /// Construct a new user message.
83    pub fn new_user(id: String, thread_id: String, content: String) -> Self {
84        Self {
85            id,
86            object: "thread.message".to_string(),
87            created_at: unix_now(),
88            thread_id,
89            role: MessageRole::User,
90            content: vec![ContentBlock {
91                r#type: "text".to_string(),
92                text: TextContent {
93                    value: content,
94                    annotations: vec![],
95                },
96            }],
97            run_id: None,
98        }
99    }
100
101    /// Construct a new assistant message produced by a run.
102    pub fn new_assistant(id: String, thread_id: String, run_id: String, content: String) -> Self {
103        Self {
104            id,
105            object: "thread.message".to_string(),
106            created_at: unix_now(),
107            thread_id,
108            role: MessageRole::Assistant,
109            content: vec![ContentBlock {
110                r#type: "text".to_string(),
111                text: TextContent {
112                    value: content,
113                    annotations: vec![],
114                },
115            }],
116            run_id: Some(run_id),
117        }
118    }
119
120    /// Extract the plain text value from the first content block.
121    pub fn text_content(&self) -> &str {
122        self.content
123            .first()
124            .map(|b| b.text.value.as_str())
125            .unwrap_or("")
126    }
127}
128
129// ── Run types ─────────────────────────────────────────────────────────────────
130
131/// Lifecycle status of a run.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum RunStatus {
135    /// Submitted, not yet picked up.
136    Queued,
137    /// Currently processing.
138    InProgress,
139    /// Finished successfully.
140    Completed,
141    /// Cancelled by a user request.
142    Cancelled,
143    /// Failed with an error.
144    Failed,
145    /// Timed out before completion.
146    Expired,
147}
148
149impl RunStatus {
150    /// Whether this status represents a terminal (non-resumable) state.
151    pub fn is_terminal(&self) -> bool {
152        matches!(
153            self,
154            RunStatus::Completed | RunStatus::Cancelled | RunStatus::Failed | RunStatus::Expired
155        )
156    }
157}
158
159/// A structured error attached to a failed run.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RunError {
162    /// Short machine-readable code (e.g. `"server_error"`).
163    pub code: String,
164    /// Human-readable description.
165    pub message: String,
166}
167
168/// A run object — one inference job against the messages in a thread.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Run {
171    /// Stable identifier (`run_<uuid>`).
172    pub id: String,
173    /// Always `"thread.run"`.
174    pub object: String,
175    /// Unix timestamp (seconds) when the run was created.
176    pub created_at: i64,
177    /// ID of the thread this run belongs to.
178    pub thread_id: String,
179    /// Current lifecycle status.
180    pub status: RunStatus,
181    /// Model override (empty = use server default).
182    pub model: String,
183    /// Error details if `status` is `Failed` or `Cancelled`.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub last_error: Option<RunError>,
186}
187
188// ── Run Step types ────────────────────────────────────────────────────────────
189
190/// Type of a run step.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum RunStepType {
194    /// The step created an assistant message.
195    MessageCreation,
196    /// The step invoked one or more tools.
197    ToolCalls,
198}
199
200/// Lifecycle status of a run step.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum RunStepStatus {
204    /// Step is currently executing.
205    InProgress,
206    /// Step finished successfully.
207    Completed,
208    /// Step failed with an error.
209    Failed,
210    /// Step was cancelled.
211    Cancelled,
212}
213
214impl RunStepStatus {
215    /// Whether this status represents a terminal (non-resumable) state.
216    pub fn is_terminal(&self) -> bool {
217        matches!(
218            self,
219            RunStepStatus::Completed | RunStepStatus::Failed | RunStepStatus::Cancelled
220        )
221    }
222}
223
224/// Details attached to a `MessageCreation` step.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct MessageCreationStepDetails {
227    /// The ID of the message created by this step.
228    pub message_id: String,
229}
230
231/// A single step within a run.
232///
233/// Exposes the sub-task breakdown of a run to clients: for the current
234/// implementation each run produces exactly one `MessageCreation` step.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct RunStep {
237    /// Stable identifier (`step-<uuid>`).
238    pub id: String,
239    /// Always `"thread.run.step"`.
240    pub object: String,
241    /// ID of the owning run.
242    pub run_id: String,
243    /// ID of the owning thread.
244    pub thread_id: String,
245    /// Type of this step.
246    pub step_type: RunStepType,
247    /// Current lifecycle status.
248    pub status: RunStepStatus,
249    /// Unix timestamp when the step was created.
250    pub created_at: u64,
251    /// Unix timestamp when the step completed successfully, if applicable.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub completed_at: Option<u64>,
254    /// Unix timestamp when the step failed, if applicable.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub failed_at: Option<u64>,
257    /// Human-readable error message when `status` is `Failed`.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub error: Option<String>,
260    /// Details for `MessageCreation` steps.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub step_details: Option<MessageCreationStepDetails>,
263}
264
265impl RunStep {
266    /// Create a new `MessageCreation` step in `InProgress` state.
267    pub fn new_message_creation(step_id: String, run_id: String, thread_id: String) -> Self {
268        Self {
269            id: step_id,
270            object: "thread.run.step".to_string(),
271            run_id,
272            thread_id,
273            step_type: RunStepType::MessageCreation,
274            status: RunStepStatus::InProgress,
275            created_at: unix_now() as u64,
276            completed_at: None,
277            failed_at: None,
278            error: None,
279            step_details: None,
280        }
281    }
282}
283
284// ── Request bodies ────────────────────────────────────────────────────────────
285
286/// Request body for `POST /v1/threads/:thread_id/messages`.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct CreateMessageRequest {
289    /// Role of the message author (must be `user` for API-created messages).
290    pub role: MessageRole,
291    /// Plain text content of the message.
292    pub content: String,
293}
294
295/// Request body for `POST /v1/threads`.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CreateThreadRequest {
298    /// Optional initial messages to seed the thread with.
299    #[serde(default)]
300    pub messages: Option<Vec<CreateMessageRequest>>,
301    /// Optional caller-supplied metadata (arbitrary JSON object).
302    #[serde(default)]
303    pub metadata: Option<serde_json::Value>,
304}
305
306/// Request body for `POST /v1/threads/:thread_id/runs`.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct CreateRunRequest {
309    /// Optional model override.  When absent the server's default model is used.
310    #[serde(default)]
311    pub model: Option<String>,
312    /// Optional system-level instructions prepended to the thread context.
313    #[serde(default)]
314    pub instructions: Option<String>,
315    /// Maximum tokens to generate.  Defaults to 512 when absent.
316    #[serde(default)]
317    pub max_tokens: Option<usize>,
318    /// When `true`, emit SSE events instead of returning the run object directly.
319    #[serde(default)]
320    pub stream: bool,
321}
322
323// ── Shared timestamp helper ───────────────────────────────────────────────────
324
325pub(crate) fn unix_now() -> i64 {
326    std::time::SystemTime::now()
327        .duration_since(std::time::UNIX_EPOCH)
328        .map(|d| d.as_secs() as i64)
329        .unwrap_or(0)
330}
331
332// ── Tests ─────────────────────────────────────────────────────────────────────
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn run_status_terminal_set_is_correct() {
340        assert!(RunStatus::Completed.is_terminal());
341        assert!(RunStatus::Cancelled.is_terminal());
342        assert!(RunStatus::Failed.is_terminal());
343        assert!(RunStatus::Expired.is_terminal());
344        assert!(!RunStatus::Queued.is_terminal());
345        assert!(!RunStatus::InProgress.is_terminal());
346    }
347
348    #[test]
349    fn thread_message_new_user_sets_fields() {
350        let msg = ThreadMessage::new_user("msg_1".into(), "thread_1".into(), "hello".into());
351        assert_eq!(msg.role, MessageRole::User);
352        assert_eq!(msg.text_content(), "hello");
353        assert!(msg.run_id.is_none());
354    }
355
356    #[test]
357    fn thread_message_new_assistant_sets_run_id() {
358        let msg = ThreadMessage::new_assistant(
359            "msg_2".into(),
360            "thread_1".into(),
361            "run_1".into(),
362            "hi!".into(),
363        );
364        assert_eq!(msg.role, MessageRole::Assistant);
365        assert_eq!(msg.run_id, Some("run_1".into()));
366    }
367
368    #[test]
369    fn run_status_serde_roundtrip() {
370        let s = serde_json::to_string(&RunStatus::InProgress).expect("serialize");
371        let d: RunStatus = serde_json::from_str(&s).expect("deserialize");
372        assert_eq!(d, RunStatus::InProgress);
373    }
374
375    #[test]
376    fn message_role_serde_lowercase() {
377        let json = serde_json::to_string(&MessageRole::User).expect("serialize");
378        assert_eq!(json, r#""user""#);
379        let back: MessageRole = serde_json::from_str(&json).expect("deserialize");
380        assert_eq!(back, MessageRole::User);
381    }
382}