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}