Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:36:14.650Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

lmstudio.rs

530 lines · 15.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.