neuromance_client/openai/mod.rs
1//! OpenAI API types and client implementation.
2//!
3//! This module provides types for the OpenAI chat completions API
4//! and a client implementation that works with any OpenAI-compatible endpoint.
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11use typed_builder::TypedBuilder;
12
13use neuromance_common::chat::{Message, MessageRole};
14use neuromance_common::client::{ChatRequest, Config, Usage};
15use neuromance_common::tools::{FunctionCall, Tool, ToolCall};
16
17pub mod client;
18pub use client::{OpenAIClient, convert_chunk_to_chat_chunk};
19
20/// A single choice from a chat completion response.
21///
22/// When `n > 1`, the API returns multiple choices. Each choice has an index
23/// and its own message and finish reason.
24#[derive(Debug, Deserialize)]
25pub struct ChatChoice {
26 /// The index of this choice in the response array.
27 pub index: u32,
28 /// The generated message for this choice.
29 pub message: OpenAIMessage,
30 /// Why generation stopped for this choice.
31 ///
32 /// Common values: "stop", "length", "tool_calls", "content_filter"
33 pub finish_reason: Option<String>,
34}
35
36/// OpenAI-compatible message format.
37///
38/// Wrapper type for serializing/deserializing messages to the OpenAI API format.
39#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
40pub struct OpenAIMessage {
41 /// The role of the message author (user, assistant, system, or tool).
42 pub role: MessageRole,
43 /// The text content of the message (optional for tool calls).
44 #[builder(default)]
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub content: Option<String>,
47 /// Optional name of the message author.
48 #[builder(default)]
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub name: Option<String>,
51 /// Tool calls requested by the assistant (optional).
52 #[builder(default)]
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub tool_calls: Option<SmallVec<[OpenAIToolCall; 2]>>,
55 /// ID of the tool call this message is responding to (for tool messages).
56 #[builder(default)]
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub tool_call_id: Option<String>,
59}
60
61impl From<&Message> for OpenAIMessage {
62 fn from(message: &Message) -> Self {
63 let tool_calls = if !message.tool_calls.is_empty() {
64 Some(
65 message
66 .tool_calls
67 .iter()
68 .map(OpenAIToolCall::from)
69 .collect(),
70 )
71 } else {
72 None
73 };
74
75 // Only include content if it's non-empty
76 let content = if !message.content.is_empty() {
77 Some(message.content.clone())
78 } else {
79 None
80 };
81
82 OpenAIMessage::builder()
83 .role(message.role)
84 .content(content)
85 .name(message.name.clone())
86 .tool_calls(tool_calls)
87 .tool_call_id(message.tool_call_id.clone())
88 .build()
89 }
90}
91
92/// OpenAI-compatible tool call format.
93///
94/// Represents a request from the model to call a function/tool.
95///
96/// Uses `Cow<'static, str>` to avoid allocations for static strings.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct OpenAIToolCall {
99 /// Unique identifier for this tool call.
100 pub id: Cow<'static, str>,
101 /// Type of the tool call, typically "function".
102 #[serde(rename = "type", default = "default_tool_call_type")]
103 pub r#type: Cow<'static, str>,
104 /// The function to call with its arguments.
105 pub function: OpenAIFunction,
106}
107
108/// Conversion from a generic `ToolCall` to OpenAI-specific format.
109impl From<&ToolCall> for OpenAIToolCall {
110 fn from(tool_call: &ToolCall) -> Self {
111 Self {
112 id: Cow::Owned(tool_call.id.clone()),
113 r#type: Cow::Owned(tool_call.call_type.clone()),
114 function: OpenAIFunction::from(&tool_call.function),
115 }
116 }
117}
118
119fn default_tool_call_type() -> Cow<'static, str> {
120 Cow::Borrowed("function")
121}
122
123/// OpenAI-compatible function call format.
124///
125/// Contains the function name and JSON-serialized arguments.
126///
127/// Uses `Cow<'static, str>` to avoid allocations for static strings.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct OpenAIFunction {
130 /// The name of the function to call.
131 pub name: Cow<'static, str>,
132 /// The arguments as a JSON-serialized string.
133 pub arguments: Cow<'static, str>,
134}
135
136impl From<&FunctionCall> for OpenAIFunction {
137 fn from(function_call: &FunctionCall) -> Self {
138 Self {
139 name: Cow::Owned(function_call.name.clone()),
140 // OpenAI expects a single JSON string, take the first argument (which should be the JSON string)
141 arguments: function_call
142 .arguments
143 .first()
144 .map(|s| Cow::Owned(s.clone()))
145 .unwrap_or(Cow::Borrowed("")),
146 }
147 }
148}
149
150/// Request for a chat completion.
151///
152/// Contains all parameters for the OpenAI chat completions API.
153///
154/// # Examples
155///
156/// ```no_run
157/// use neuromance_client::openai::ChatCompletionRequest;
158/// # use neuromance_client::openai::OpenAIMessage;
159/// # use neuromance_common::MessageRole;
160///
161/// let request = ChatCompletionRequest::builder()
162/// .model("gpt-4".to_string())
163/// .messages(vec![])
164/// .temperature(Some(0.7))
165/// .max_tokens(Some(1000))
166/// .build();
167/// ```
168#[derive(Debug, Clone, Serialize, TypedBuilder)]
169pub struct ChatCompletionRequest {
170 /// The model identifier to use (e.g., "gpt-4", "gpt-3.5-turbo").
171 pub model: String,
172 /// The conversation messages in OpenAI format.
173 pub messages: Vec<OpenAIMessage>,
174 /// Maximum tokens to generate (optional).
175 #[builder(default)]
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub max_tokens: Option<u32>,
178 /// Sampling temperature 0.0 to 2.0 (optional).
179 #[builder(default)]
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub temperature: Option<f32>,
182 /// Nucleus sampling threshold 0.0 to 1.0 (optional).
183 #[builder(default)]
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub top_p: Option<f32>,
186 /// Number of completions to generate (optional, default 1).
187 #[builder(default)]
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub n: Option<u32>,
190 /// Stop sequences (optional).
191 #[builder(default)]
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub stop: Option<Vec<String>>,
194 /// Presence penalty -2.0 to 2.0 (optional).
195 #[builder(default)]
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub presence_penalty: Option<f32>,
198 /// Frequency penalty -2.0 to 2.0 (optional).
199 #[builder(default)]
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub frequency_penalty: Option<f32>,
202 /// Token bias map for modifying likelihoods (optional).
203 #[builder(default)]
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub logit_bias: Option<HashMap<String, f32>>,
206 /// End-user identifier for tracking (optional).
207 #[builder(default)]
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub user: Option<String>,
210 /// Whether to stream the response (optional, default false).
211 #[builder(default = Some(false))]
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub stream: Option<bool>,
214 /// Tools available for function calling (optional).
215 #[builder(default)]
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub tools: Option<Vec<Tool>>,
218 /// Tool selection strategy (optional).
219 #[builder(default)]
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub tool_choice: Option<serde_json::Value>,
222 /// Whether to enable thinking mode (vendor-specific, optional).
223 #[builder(default)]
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub enable_thinking: Option<bool>,
226 /// Stream options for controlling streaming behavior (optional).
227 #[builder(default)]
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub stream_options: Option<serde_json::Value>,
230}
231
232/// Conversion from a generic `ChatRequest` to OpenAI-specific format.
233///
234/// Maps common request parameters to the OpenAI API format, using the provided
235/// configuration for defaults like the model name.
236impl From<(&ChatRequest, &Config)> for ChatCompletionRequest {
237 fn from((request, config): (&ChatRequest, &Config)) -> Self {
238 let openai_messages: Vec<OpenAIMessage> =
239 request.messages.iter().map(OpenAIMessage::from).collect();
240
241 let tools: Option<Vec<Tool>> = request.tools.clone();
242
243 ChatCompletionRequest::builder()
244 .model(
245 request
246 .model
247 .clone()
248 .unwrap_or_else(|| config.model.clone()),
249 )
250 .messages(openai_messages)
251 .max_tokens(request.max_tokens)
252 .temperature(request.temperature)
253 .top_p(request.top_p)
254 .stop(request.stop.clone())
255 .presence_penalty(request.presence_penalty)
256 .frequency_penalty(request.frequency_penalty)
257 .user(request.user.clone())
258 .stream(Some(request.stream))
259 .tools(tools)
260 .tool_choice(request.tool_choice.as_ref().map(|tc| tc.clone().into()))
261 .enable_thinking(request.enable_thinking)
262 .build()
263 }
264}
265
266impl ChatCompletionRequest {
267 /// Add logit bias to the request.
268 ///
269 /// Modifies the likelihood of specified tokens appearing in the completion.
270 pub fn with_logit_bias(mut self, logit_bias: HashMap<String, f32>) -> Self {
271 self.logit_bias = Some(logit_bias);
272 self
273 }
274}
275
276/// Response from a chat completion request.
277///
278/// Contains the model's response along with usage statistics and metadata.
279///
280/// # Examples
281///
282/// ```no_run
283/// # use neuromance_client::openai::ChatCompletionResponse;
284/// # let response: ChatCompletionResponse = serde_json::from_str("{}").unwrap();
285/// // Access the first choice's message
286/// if let Some(choice) = response.choices.first() {
287/// println!("Response: {:?}", choice.message);
288/// }
289///
290/// // Check token usage
291/// if let Some(usage) = &response.usage {
292/// println!("Tokens used: {}", usage.total_tokens);
293/// }
294/// ```
295#[derive(Debug, Deserialize)]
296pub struct ChatCompletionResponse {
297 /// Unique identifier for this completion.
298 pub id: String,
299 /// Object type, typically "chat.completion".
300 pub object: String,
301 /// Unix timestamp of when the completion was created.
302 pub created: u64,
303 /// The model that generated this completion.
304 pub model: String,
305 /// Array of generated completions (length equals `n` parameter).
306 pub choices: Vec<ChatChoice>,
307 /// Token usage statistics (if available).
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub usage: Option<Usage>,
310}
311
312/// A single choice from a streaming chat completion chunk.
313///
314/// Each chunk contains a delta with incremental updates to the message.
315#[derive(Debug, Deserialize)]
316pub struct ChatStreamChoice {
317 /// The index of this choice in the response array.
318 pub index: u32,
319 /// Incremental message delta for this chunk.
320 pub delta: OpenAIMessageDelta,
321 /// Why generation stopped (only present in final chunk).
322 pub finish_reason: Option<String>,
323}
324
325/// Delta representing incremental changes to a message.
326///
327/// Used in streaming responses to communicate partial updates.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct OpenAIMessageDelta {
330 /// The role (only present in first chunk).
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub role: Option<MessageRole>,
333 /// Incremental content added in this chunk.
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub content: Option<String>,
336 /// Incremental tool calls (for function calling).
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub tool_calls: Option<SmallVec<[OpenAIToolCallDelta; 2]>>,
339}
340
341/// Delta representing incremental changes to a tool call.
342///
343/// Tool calls may be built across multiple streaming chunks.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct OpenAIToolCallDelta {
346 /// Index of this tool call in the array.
347 pub index: u32,
348 /// Unique identifier (only present in first chunk for this tool call).
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub id: Option<String>,
351 /// Type of tool call (only present in first chunk).
352 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
353 pub r#type: Option<String>,
354 /// Incremental function call data.
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub function: Option<OpenAIFunctionDelta>,
357}
358
359/// Delta representing incremental changes to a function call.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct OpenAIFunctionDelta {
362 /// Function name (only present in first chunk).
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub name: Option<String>,
365 /// Incremental arguments added in this chunk.
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub arguments: Option<String>,
368}
369
370/// Chunk from a streaming chat completion.
371///
372/// Each chunk represents an incremental update to the response.
373///
374/// # Examples
375///
376/// ```no_run
377/// # use neuromance_client::openai::ChatCompletionChunk;
378/// # let chunk: ChatCompletionChunk = serde_json::from_str("{}").unwrap();
379/// // Process streaming delta
380/// if let Some(choice) = chunk.choices.first() {
381/// if let Some(content) = &choice.delta.content {
382/// print!("{}", content);
383/// }
384/// }
385/// ```
386#[derive(Debug, Deserialize)]
387pub struct ChatCompletionChunk {
388 /// Unique identifier for this completion stream.
389 pub id: String,
390 /// Object type, typically "chat.completion.chunk".
391 pub object: String,
392 /// Unix timestamp of when this chunk was created.
393 pub created: u64,
394 /// The model generating this stream.
395 pub model: String,
396 /// Array of delta choices.
397 pub choices: Vec<ChatStreamChoice>,
398 /// Token usage (only present in final chunk for some providers).
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub usage: Option<Usage>,
401}