Skip to main content

openai_api_dispatch/
task.rs

1use alloc::{
2    string::{String, ToString},
3    vec::Vec,
4};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::{queue::QueueProducer, utils};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11/// Builds a chat task; only the prompt is required at build time.
12///
13/// Prefer [`Self::new`] for a generated ID: derived `Default` uses ID zero.
14pub struct TaskBuilder {
15    /// Correlation ID; must be unique among outstanding memory-queue tasks.
16    pub id: u128,
17    /// Explicit model, overriding the producer/executor default when present.
18    pub model: Option<String>,
19    /// Requested output limit; the API executor casts this to `u32` unchecked.
20    pub max_tokens: Option<u64>,
21    /// Caller metadata carried through the response, not sent to the model.
22    pub payload: Option<Value>,
23    /// System instruction, separate from conversation history.
24    pub system: Option<String>,
25    /// Earlier turns; the API executor ignores system entries here.
26    pub history: Option<Vec<Interaction>>,
27    /// JSON schema requested as strict structured output; not locally validated.
28    pub schema: Option<Value>,
29    /// Current prompt; `None` fails to build, but an empty string is accepted.
30    pub prompt: Option<String>,
31}
32
33impl Default for TaskBuilder {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl TaskBuilder {
40    /// Creates an empty builder with an ID from [`utils::id`].
41    pub fn new() -> Self {
42        Self {
43            id: utils::id(),
44            model: None,
45            max_tokens: None,
46            payload: None,
47            system: None,
48            history: None,
49            schema: None,
50            prompt: None,
51        }
52    }
53
54    /// Overrides the default model; NATS uses this model to select a subject.
55    pub fn with_model<M: ToString>(mut self, model: M) -> Self {
56        self.model.replace(model.to_string());
57        self
58    }
59
60    /// Sets the requested output limit; keep it within `u32` for the API executor.
61    pub fn with_max_tokens(mut self, max_tokens: u64) -> Self {
62        self.max_tokens.replace(max_tokens);
63        self
64    }
65
66    /// Attaches caller metadata without including it in the model request.
67    ///
68    /// # Panics
69    ///
70    /// Panics if the payload cannot be serialized as JSON.
71    pub fn with_payload<P: Serialize>(mut self, payload: P) -> Self {
72        let payload = serde_json::to_value(&payload).expect("infallible serialization");
73        self.payload.replace(payload);
74        self
75    }
76
77    /// Sets the system instruction prepended to the model's messages.
78    pub fn with_system<S: ToString>(mut self, system: S) -> Self {
79        self.system.replace(system.to_string());
80        self
81    }
82
83    /// Replaces earlier turns; the API executor discards system entries.
84    pub fn with_history<H: IntoIterator<Item = Interaction>>(mut self, history: H) -> Self {
85        self.history.replace(history.into_iter().collect());
86        self
87    }
88
89    /// Sets a strict output schema without validating schema correctness.
90    ///
91    /// # Panics
92    ///
93    /// Panics if the schema cannot be serialized as JSON.
94    pub fn with_schema<S: Serialize>(mut self, schema: S) -> Self {
95        let schema = serde_json::to_value(&schema).expect("infallible serialization");
96        self.schema.replace(schema);
97        self
98    }
99
100    /// Sets the current prompt, sent as a user message by the API executor.
101    pub fn with_prompt<P: ToString>(mut self, prompt: P) -> Self {
102        self.prompt.replace(prompt.to_string());
103        self
104    }
105
106    /// Builds a chat task, returning an error if no prompt was supplied.
107    ///
108    /// Model availability, token limits, and schema correctness are not checked.
109    pub fn build_chat(self) -> anyhow::Result<Task> {
110        let Self {
111            id,
112            model,
113            max_tokens,
114            payload,
115            system,
116            history,
117            schema,
118            prompt,
119        } = self;
120        let prompt =
121            prompt.ok_or_else(|| anyhow::anyhow!("the prompt is mandatory for a chat task."))?;
122        let contents = serde_json::to_value(TaskChat {
123            system,
124            history,
125            schema,
126            prompt,
127        })
128        .expect("infallible serialization");
129
130        Ok(Task {
131            id,
132            task_type: TaskType::Chat,
133            contents,
134            model,
135            max_tokens,
136            payload,
137        })
138    }
139}
140
141#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
142/// Serializable envelope for work submitted to a queue or executor.
143///
144/// Use [`TaskBuilder`] for chat tasks. Derived `Default` has ID zero and null
145/// contents, so it is not an executable chat request.
146pub struct Task {
147    /// Correlation ID; memory queues require uniqueness for outstanding tasks.
148    pub id: u128,
149    /// Operation encoded in [`Self::contents`].
150    pub task_type: TaskType,
151    /// JSON input matching the task type; chat tasks contain a [`TaskChat`].
152    pub contents: Value,
153    /// Explicit model, or `None` to use the producer/executor default.
154    pub model: Option<String>,
155    /// Requested output limit; the API executor casts this to `u32` unchecked.
156    pub max_tokens: Option<u64>,
157    /// Opaque caller metadata preserved in the response task.
158    pub payload: Option<Value>,
159}
160
161impl Task {
162    /// Submits this task and retrieves its response, which may be unsuccessful.
163    ///
164    /// With `std`, `Some(seconds)` limits only the response wait after submission
165    /// and requires a Tokio runtime with time enabled. Expiry does not cancel
166    /// worker execution. `None` adds no timeout; backend polling semantics still
167    /// apply. Without `std`, a supplied timeout errors before sending.
168    ///
169    /// Submission/retrieval failures, timeout expiry, and `None` responses return
170    /// errors. Check the response's `success` field separately from the `Result`.
171    pub async fn send_and_wait<Q: QueueProducer>(
172        self,
173        queue: &Q,
174        timeout_secs: Option<u64>,
175    ) -> anyhow::Result<Response> {
176        match timeout_secs {
177            #[cfg(feature = "std")]
178            Some(t) => {
179                let timeout = core::time::Duration::from_secs(t);
180                let message = queue.send_task(self).await?;
181
182                let response = queue.receive_response(message);
183                let response = tokio::time::timeout(timeout, response).await??;
184
185                response.ok_or_else(|| anyhow::anyhow!("task response timeout"))
186            }
187
188            _ => {
189                anyhow::ensure!(timeout_secs.is_none(), "timeout not implemented on runtime");
190
191                let message = queue.send_task(self).await?;
192                let response = queue.receive_response(message).await?;
193
194                response.ok_or_else(|| anyhow::anyhow!("task response not available"))
195            }
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201/// Execution outcome, distinct from queue or executor transport errors.
202pub struct Response {
203    /// Original task; the API executor updates its history on successful output.
204    pub task: Task,
205    /// Whether contents represent output (`true`) or an execution failure (`false`).
206    pub success: bool,
207    /// Token usage; the API executor reports total tokens, or zero if absent.
208    pub tokens: u64,
209    /// Resolved request model; not necessarily the model name returned by the API.
210    pub model: String,
211    /// Output text or failure details; structured output remains JSON text.
212    pub contents: String,
213}
214
215#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
216/// Supported operations; only chat is currently implemented.
217pub enum TaskType {
218    /// A chat request with [`TaskChat`] contents.
219    #[default]
220    Chat,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224/// Chat inputs encoded in [`Task::contents`].
225pub struct TaskChat {
226    /// Optional system instruction prepended to the model's messages.
227    pub system: Option<String>,
228    /// Previous turns; system entries are ignored by the API executor.
229    pub history: Option<Vec<Interaction>>,
230    /// Strict output schema requested from the endpoint, not checked locally.
231    pub schema: Option<Value>,
232    /// Current prompt, sent as a user message after any prior history.
233    pub prompt: String,
234}
235
236impl TaskChat {
237    /// Creates chat inputs with a prompt and no system, history, or schema.
238    pub fn new<P: ToString>(prompt: P) -> Self {
239        Self {
240            prompt: prompt.to_string(),
241            system: None,
242            history: None,
243            schema: None,
244        }
245    }
246
247    /// Sets the system instruction, independently of any history entries.
248    pub fn with_system<S: ToString>(mut self, system: S) -> Self {
249        self.system.replace(system.to_string());
250        self
251    }
252
253    /// Sets a strict output schema without checking its validity.
254    ///
255    /// # Panics
256    ///
257    /// Panics if the schema cannot be serialized as JSON.
258    pub fn with_schema<S: Serialize>(mut self, schema: S) -> Self {
259        // failure serialization is considered unrecoverable from the API documentation
260        let schema = serde_json::to_value(&schema).expect("failed to serialize schema");
261        self.schema.replace(schema);
262        self
263    }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267/// A text-only conversation turn.
268pub enum Interaction {
269    /// Text previously returned by the model.
270    Assistant(String),
271    /// A system turn; ignored in history by the API executor.
272    System(String),
273    /// Text supplied by the user.
274    User(String),
275}
276
277impl Task {
278    /// Replaces the correlation ID; avoid duplicates among outstanding tasks.
279    pub fn with_id(mut self, id: u128) -> Self {
280        self.id = id;
281        self
282    }
283
284    /// Sets the output limit; the API executor casts it to `u32` unchecked.
285    pub fn with_max_tokens(mut self, max_tokens: u64) -> Self {
286        self.max_tokens.replace(max_tokens);
287        self
288    }
289
290    /// Attaches opaque caller metadata to be returned with the task.
291    ///
292    /// # Panics
293    ///
294    /// Panics if the payload cannot be serialized as JSON.
295    pub fn with_payload<P: Serialize>(mut self, payload: P) -> Self {
296        // failure serialization is considered unrecoverable from the API documentation
297        let payload = serde_json::to_value(&payload).expect("failed to serialize payload");
298        self.payload.replace(payload);
299        self
300    }
301
302    /// Overrides the model used for NATS routing and API execution.
303    pub fn with_model<M: ToString>(mut self, model: M) -> Self {
304        self.model.replace(model.to_string());
305        self
306    }
307
308    /// Decodes contents as [`TaskChat`], returning an error for incompatible JSON.
309    ///
310    /// Does not inspect [`Self::task_type`] or validate schema/model settings.
311    pub fn try_to_chat(&self) -> anyhow::Result<TaskChat> {
312        Ok(serde_json::from_value(self.contents.clone())?)
313    }
314
315    #[cfg(feature = "std")]
316    pub(crate) fn model(
317        &self,
318        default_model: &std::sync::Arc<Option<String>>,
319    ) -> anyhow::Result<String> {
320        self.model
321            .clone()
322            .or_else(|| default_model.as_ref().clone())
323            .ok_or_else(|| anyhow::anyhow!("no model provided"))
324    }
325}
326
327impl Response {
328    /// Creates a successful response without changing the task or its history.
329    pub fn success<M: ToString, C: ToString>(
330        task: Task,
331        tokens: u64,
332        model: M,
333        contents: C,
334    ) -> Self {
335        Self {
336            task,
337            success: true,
338            tokens,
339            model: model.to_string(),
340            contents: contents.to_string(),
341        }
342    }
343
344    /// Creates an unsuccessful response with failure details in `contents`.
345    pub fn error<M: ToString, C: ToString>(task: Task, tokens: u64, model: M, error: C) -> Self {
346        Self {
347            task,
348            success: false,
349            tokens,
350            model: model.to_string(),
351            contents: error.to_string(),
352        }
353    }
354
355    /// Replaces the history stored inside the returned task's JSON contents.
356    ///
357    /// # Panics
358    ///
359    /// Panics if task contents are neither a JSON object nor null.
360    pub fn with_history(mut self, history: Vec<Interaction>) -> Self {
361        self.task.contents["history"] =
362            serde_json::to_value(history).expect("infallible serialization");
363        self
364    }
365
366    /// Decodes returned history; missing, null, or malformed history is an error.
367    /// The API executor currently includes the latest user prompt twice.
368    pub fn chat_history(&self) -> anyhow::Result<Vec<Interaction>> {
369        let history = self
370            .task
371            .contents
372            .get("history")
373            .ok_or_else(|| anyhow::anyhow!("no history available"))?;
374
375        Ok(serde_json::from_value(history.clone())?)
376    }
377
378    /// Returns `tokens / max(max_tokens, 1)`, clamped to `[0, 1]`, or zero if unset.
379    ///
380    /// This is not a billing estimate: API usage includes input tokens, whereas
381    /// `max_tokens` limits output.
382    pub fn usage(&self) -> f64 {
383        self.task
384            .max_tokens
385            .map(|t| (self.tokens as f64 / t.max(1) as f64).clamp(0.0, 1.0))
386            .unwrap_or(0.0)
387    }
388}
389
390impl Interaction {
391    /// Creates an assistant turn from text.
392    pub fn assistant<A: ToString>(prompt: A) -> Self {
393        Self::Assistant(prompt.to_string())
394    }
395
396    /// Creates a user turn from text.
397    pub fn user<U: ToString>(prompt: U) -> Self {
398        Self::User(prompt.to_string())
399    }
400}