Skip to repository content530 lines · 15.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:36:14.650Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
lmstudio.rs
1use anyhow::{Context as _, Result, anyhow};
2use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
3use http_client::{
4 AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
5};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::{convert::TryFrom, time::Duration};
9
10pub const LMSTUDIO_API_URL: &str = "http://localhost:1234/api/v0";
11
12#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum Role {
15 User,
16 Assistant,
17 System,
18 Tool,
19}
20
21impl TryFrom<String> for Role {
22 type Error = anyhow::Error;
23
24 fn try_from(value: String) -> Result<Self> {
25 match value.as_str() {
26 "user" => Ok(Self::User),
27 "assistant" => Ok(Self::Assistant),
28 "system" => Ok(Self::System),
29 "tool" => Ok(Self::Tool),
30 _ => anyhow::bail!("invalid role '{value}'"),
31 }
32 }
33}
34
35impl From<Role> for String {
36 fn from(val: Role) -> Self {
37 match val {
38 Role::User => "user".to_owned(),
39 Role::Assistant => "assistant".to_owned(),
40 Role::System => "system".to_owned(),
41 Role::Tool => "tool".to_owned(),
42 }
43 }
44}
45
46#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
47#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
48pub struct Model {
49 pub name: String,
50 pub display_name: Option<String>,
51 pub max_tokens: u64,
52 pub supports_tool_calls: bool,
53 pub supports_images: bool,
54}
55
56impl Model {
57 pub fn new(
58 name: &str,
59 display_name: Option<&str>,
60 max_tokens: Option<u64>,
61 supports_tool_calls: bool,
62 supports_images: bool,
63 ) -> Self {
64 Self {
65 name: name.to_owned(),
66 display_name: display_name.map(|s| s.to_owned()),
67 max_tokens: max_tokens.unwrap_or(2048),
68 supports_tool_calls,
69 supports_images,
70 }
71 }
72
73 pub fn id(&self) -> &str {
74 &self.name
75 }
76
77 pub fn display_name(&self) -> &str {
78 self.display_name.as_ref().unwrap_or(&self.name)
79 }
80
81 pub fn max_token_count(&self) -> u64 {
82 self.max_tokens
83 }
84
85 pub fn supports_tool_calls(&self) -> bool {
86 self.supports_tool_calls
87 }
88}
89
90#[derive(Debug, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum ToolChoice {
93 Auto,
94 Required,
95 None,
96 #[serde(untagged)]
97 Other(ToolDefinition),
98}
99
100#[derive(Clone, Deserialize, Serialize, Debug)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum ToolDefinition {
103 #[allow(dead_code)]
104 Function { function: FunctionDefinition },
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize)]
108pub struct FunctionDefinition {
109 pub name: String,
110 pub description: Option<String>,
111 pub parameters: Option<Value>,
112}
113
114#[derive(Serialize, Deserialize, Debug)]
115#[serde(tag = "role", rename_all = "lowercase")]
116pub enum ChatMessage {
117 Assistant {
118 #[serde(default)]
119 content: Option<MessageContent>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 tool_calls: Vec<ToolCall>,
122 },
123 User {
124 content: MessageContent,
125 },
126 System {
127 content: MessageContent,
128 },
129 Tool {
130 content: MessageContent,
131 tool_call_id: String,
132 },
133}
134
135#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
136#[serde(untagged)]
137pub enum MessageContent {
138 Plain(String),
139 Multipart(Vec<MessagePart>),
140}
141
142impl MessageContent {
143 pub fn empty() -> Self {
144 MessageContent::Multipart(vec![])
145 }
146
147 pub fn push_part(&mut self, part: MessagePart) {
148 match self {
149 MessageContent::Plain(text) => {
150 *self =
151 MessageContent::Multipart(vec![MessagePart::Text { text: text.clone() }, part]);
152 }
153 MessageContent::Multipart(parts) if parts.is_empty() => match part {
154 MessagePart::Text { text } => *self = MessageContent::Plain(text),
155 MessagePart::Image { .. } => *self = MessageContent::Multipart(vec![part]),
156 },
157 MessageContent::Multipart(parts) => parts.push(part),
158 }
159 }
160}
161
162impl From<Vec<MessagePart>> for MessageContent {
163 fn from(mut parts: Vec<MessagePart>) -> Self {
164 if let [MessagePart::Text { text }] = parts.as_mut_slice() {
165 MessageContent::Plain(std::mem::take(text))
166 } else {
167 MessageContent::Multipart(parts)
168 }
169 }
170}
171
172#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
173#[serde(tag = "type", rename_all = "snake_case")]
174pub enum MessagePart {
175 Text {
176 text: String,
177 },
178 #[serde(rename = "image_url")]
179 Image {
180 image_url: ImageUrl,
181 },
182}
183
184#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
185pub struct ImageUrl {
186 pub url: String,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub detail: Option<String>,
189}
190
191#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
192pub struct ToolCall {
193 pub id: String,
194 #[serde(flatten)]
195 pub content: ToolCallContent,
196}
197
198#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
199#[serde(tag = "type", rename_all = "lowercase")]
200pub enum ToolCallContent {
201 Function { function: FunctionContent },
202}
203
204#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
205pub struct FunctionContent {
206 pub name: String,
207 pub arguments: String,
208}
209
210#[derive(Serialize, Debug)]
211pub struct StreamOptions {
212 pub include_usage: bool,
213}
214
215#[derive(Serialize, Debug)]
216pub struct ChatCompletionRequest {
217 pub model: String,
218 pub messages: Vec<ChatMessage>,
219 pub stream: bool,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub stream_options: Option<StreamOptions>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub max_tokens: Option<i32>,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub stop: Option<Vec<String>>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub temperature: Option<f32>,
228 #[serde(skip_serializing_if = "Vec::is_empty")]
229 pub tools: Vec<ToolDefinition>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub tool_choice: Option<ToolChoice>,
232}
233
234#[derive(Serialize, Deserialize, Debug)]
235pub struct ChatResponse {
236 pub id: String,
237 pub object: String,
238 pub created: u64,
239 pub model: String,
240 pub choices: Vec<ChoiceDelta>,
241}
242
243#[derive(Serialize, Deserialize, Debug)]
244pub struct ChoiceDelta {
245 pub index: u32,
246 pub delta: ResponseMessageDelta,
247 pub finish_reason: Option<String>,
248}
249
250#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
251pub struct ToolCallChunk {
252 pub index: usize,
253 pub id: Option<String>,
254
255 // There is also an optional `type` field that would determine if a
256 // function is there. Sometimes this streams in with the `function` before
257 // it streams in the `type`
258 pub function: Option<FunctionChunk>,
259}
260
261#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
262pub struct FunctionChunk {
263 pub name: Option<String>,
264 pub arguments: Option<String>,
265}
266
267#[derive(Serialize, Deserialize, Debug)]
268pub struct Usage {
269 pub prompt_tokens: u64,
270 pub completion_tokens: u64,
271 pub total_tokens: u64,
272}
273
274#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
275#[serde(transparent)]
276pub struct Capabilities(Vec<String>);
277
278impl Capabilities {
279 pub fn supports_tool_calls(&self) -> bool {
280 self.0.iter().any(|cap| cap == "tool_use")
281 }
282
283 pub fn supports_images(&self) -> bool {
284 self.0.iter().any(|cap| cap == "vision")
285 }
286}
287
288#[derive(Serialize, Deserialize, Debug)]
289pub struct LmStudioError {
290 pub message: String,
291}
292
293#[derive(Serialize, Deserialize, Debug)]
294#[serde(untagged)]
295pub enum ResponseStreamResult {
296 Ok(ResponseStreamEvent),
297 Err { error: LmStudioError },
298}
299
300#[derive(Serialize, Deserialize, Debug)]
301pub struct ResponseStreamEvent {
302 pub created: u32,
303 pub model: String,
304 pub object: String,
305 pub choices: Vec<ChoiceDelta>,
306 pub usage: Option<Usage>,
307}
308
309#[derive(Deserialize)]
310pub struct ListModelsResponse {
311 pub data: Vec<ModelEntry>,
312}
313
314#[derive(Clone, Debug, Deserialize, PartialEq)]
315pub struct ModelEntry {
316 pub id: String,
317 pub object: String,
318 pub r#type: ModelType,
319 pub publisher: String,
320 pub arch: Option<String>,
321 pub compatibility_type: CompatibilityType,
322 pub quantization: Option<String>,
323 pub state: ModelState,
324 pub max_context_length: Option<u64>,
325 pub loaded_context_length: Option<u64>,
326 #[serde(default)]
327 pub capabilities: Capabilities,
328}
329
330#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
331#[serde(rename_all = "lowercase")]
332pub enum ModelType {
333 Llm,
334 Embeddings,
335 Vlm,
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
339#[serde(rename_all = "kebab-case")]
340pub enum ModelState {
341 Loaded,
342 Loading,
343 NotLoaded,
344}
345
346#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
347#[serde(rename_all = "lowercase")]
348pub enum CompatibilityType {
349 Gguf,
350 Mlx,
351}
352
353#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
354pub struct ResponseMessageDelta {
355 pub role: Option<Role>,
356 pub content: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub reasoning_content: Option<String>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub tool_calls: Option<Vec<ToolCallChunk>>,
361}
362
363pub async fn complete(
364 client: &dyn HttpClient,
365 api_url: &str,
366 api_key: Option<&str>,
367 request: ChatCompletionRequest,
368 extra_headers: &CustomHeaders,
369) -> Result<ChatResponse> {
370 let uri = format!("{api_url}/chat/completions");
371 let mut request_builder = HttpRequest::builder()
372 .method(Method::POST)
373 .uri(uri)
374 .header("Content-Type", "application/json");
375
376 if let Some(api_key) = api_key {
377 request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
378 }
379
380 let serialized_request = serde_json::to_string(&request)?;
381 let request = request_builder
382 .extra_headers(extra_headers)
383 .body(AsyncBody::from(serialized_request))?;
384
385 let mut response = client.send(request).await?;
386 if response.status().is_success() {
387 let mut body = Vec::new();
388 response.body_mut().read_to_end(&mut body).await?;
389 let response_message: ChatResponse = serde_json::from_slice(&body)?;
390 Ok(response_message)
391 } else {
392 let mut body = Vec::new();
393 response.body_mut().read_to_end(&mut body).await?;
394 let body_str = std::str::from_utf8(&body)?;
395 anyhow::bail!(
396 "Failed to connect to API: {} {}",
397 response.status(),
398 body_str
399 );
400 }
401}
402
403pub async fn stream_chat_completion(
404 client: &dyn HttpClient,
405 api_url: &str,
406 api_key: Option<&str>,
407 request: ChatCompletionRequest,
408 extra_headers: &CustomHeaders,
409) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
410 let uri = format!("{api_url}/chat/completions");
411 let mut request_builder = http::Request::builder()
412 .method(Method::POST)
413 .uri(uri)
414 .header("Content-Type", "application/json");
415
416 if let Some(api_key) = api_key {
417 request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
418 }
419
420 let request = request_builder
421 .extra_headers(extra_headers)
422 .body(AsyncBody::from(serde_json::to_string(&request)?))?;
423 let mut response = client.send(request).await?;
424 if response.status().is_success() {
425 let reader = BufReader::new(response.into_body());
426 Ok(reader
427 .lines()
428 .filter_map(|line| async move {
429 match line {
430 Ok(line) => {
431 let line = line.strip_prefix("data: ")?;
432 if line == "[DONE]" {
433 None
434 } else {
435 match serde_json::from_str(line) {
436 Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
437 Ok(ResponseStreamResult::Err { error, .. }) => {
438 Some(Err(anyhow!(error.message)))
439 }
440 Err(error) => Some(Err(anyhow!(error))),
441 }
442 }
443 }
444 Err(error) => Some(Err(anyhow!(error))),
445 }
446 })
447 .boxed())
448 } else {
449 let mut body = String::new();
450 response.body_mut().read_to_string(&mut body).await?;
451 anyhow::bail!(
452 "Failed to connect to LM Studio API: {} {}",
453 response.status(),
454 body,
455 );
456 }
457}
458
459pub async fn get_models(
460 client: &dyn HttpClient,
461 api_url: &str,
462 api_key: Option<&str>,
463 _: Option<Duration>,
464 extra_headers: &CustomHeaders,
465) -> Result<Vec<ModelEntry>> {
466 let uri = format!("{api_url}/models");
467 let mut request_builder = HttpRequest::builder()
468 .method(Method::GET)
469 .uri(uri)
470 .header("Accept", "application/json");
471
472 if let Some(api_key) = api_key {
473 request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
474 }
475
476 let request = request_builder
477 .extra_headers(extra_headers)
478 .body(AsyncBody::default())?;
479
480 let mut response = client.send(request).await?;
481
482 let mut body = String::new();
483 response.body_mut().read_to_string(&mut body).await?;
484
485 anyhow::ensure!(
486 response.status().is_success(),
487 "Failed to connect to LM Studio API: {} {}",
488 response.status(),
489 body,
490 );
491 let response: ListModelsResponse =
492 serde_json::from_str(&body).context("Unable to parse LM Studio models response")?;
493 Ok(response.data)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn test_image_message_part_serialization() {
502 let image_part = MessagePart::Image {
503 image_url: ImageUrl {
504 url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==".to_string(),
505 detail: None,
506 },
507 };
508
509 let json = serde_json::to_string(&image_part).unwrap();
510 println!("Serialized image part: {}", json);
511
512 // Verify the structure matches what LM Studio expects
513 let expected_structure = r#"{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="}}"#;
514 assert_eq!(json, expected_structure);
515 }
516
517 #[test]
518 fn test_text_message_part_serialization() {
519 let text_part = MessagePart::Text {
520 text: "Hello, world!".to_string(),
521 };
522
523 let json = serde_json::to_string(&text_part).unwrap();
524 println!("Serialized text part: {}", json);
525
526 let expected_structure = r#"{"type":"text","text":"Hello, world!"}"#;
527 assert_eq!(json, expected_structure);
528 }
529}
530