Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f6b954b
refactor(inference): route local models through tinyagents
senamakel Jul 28, 2026
308f1ec
build(tinyagents): advance local runtime provider support
senamakel Jul 28, 2026
d302345
refactor(inference): use tinyagents embedding rpc
senamakel Jul 28, 2026
2e16684
fix(inference): harden local rpc wrappers
senamakel Jul 28, 2026
d470bc8
style(inference): format rebased lm studio imports
senamakel Jul 28, 2026
24f6d2b
fix(inference): preserve ollama throughput metrics
senamakel Jul 28, 2026
a3c56bc
test(inference): cover local rpc boundary decisions
senamakel Jul 28, 2026
e48faf1
fix(inference): reject unknown embedding dimensions
senamakel Jul 28, 2026
4d7d385
test(inference): port local rpc fixtures to chat completions
senamakel Jul 28, 2026
ce0510d
fix(inference): reuse bounded local rpc client
senamakel Jul 28, 2026
f68c5f5
fix(inference): discover custom embedding dimensions
senamakel Jul 28, 2026
dc828b0
fix(inference): preserve reasoning-only local replies
senamakel Jul 28, 2026
e982993
fix(inference): resolve custom embedding identity atomically
senamakel Jul 28, 2026
a9a110d
test(inference): port raw coverage to ollama v1 rpc
senamakel Jul 28, 2026
d0cd469
chore(vendor): advance tinyagents review fixes
senamakel Jul 28, 2026
5c0fd5d
test(inference): align provider raw coverage with rpc
senamakel Jul 28, 2026
4b02dc1
test(inference): use canonical embedding dimensions
senamakel Jul 28, 2026
0b2b74f
test(channels): use sdk auth response envelope
senamakel Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 0 additions & 135 deletions src/openhuman/inference/local/lm_studio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,6 @@
use crate::openhuman::config::{Config, LocalAiConfig};
use serde::{Deserialize, Serialize};

fn strip_think_tags(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut rest = input;
loop {
let Some(start) = rest.find("<think>") else {
result.push_str(rest);
break;
};
result.push_str(&rest[..start]);
let Some(end) = rest[start..].find("</think>") else {
break;
};
rest = &rest[start + end + "</think>".len()..];
}
result.trim().to_string()
}

pub(crate) const DEFAULT_LM_STUDIO_BASE_URL: &str = "http://localhost:1234/v1";

pub(crate) fn lm_studio_base_url(config: &Config) -> String {
Expand Down Expand Up @@ -170,97 +153,6 @@ pub(crate) struct LmStudioModel {
pub owned_by: Option<String>,
}

#[derive(Debug, Serialize)]
pub(crate) struct LmStudioChatCompletionRequest {
pub model: String,
pub messages: Vec<LmStudioChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LmStudioChatMessage {
pub role: String,
pub content: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatCompletionResponse {
#[serde(default)]
pub choices: Vec<LmStudioChatChoice>,
#[serde(default)]
pub usage: Option<LmStudioUsage>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatChoice {
pub message: LmStudioChatResponseMessage,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatResponseMessage {
#[serde(default)]
pub content: Option<String>,
/// Local reasoning models expose chain-of-thought as `reasoning_content`
/// or `reasoning` depending on the runtime — accept both field names.
#[serde(default, alias = "reasoning")]
pub reasoning_content: Option<String>,
}

impl LmStudioChatResponseMessage {
pub(crate) fn effective_content(&self) -> String {
let content = self
.content
.as_deref()
.map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !content.is_empty() {
tracing::trace!(
source = "content",
output_chars = content.chars().count(),
"[lm-studio] effective content selected"
);
return content;
}

let reasoning = self
.reasoning_content
.as_deref()
.map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !reasoning.is_empty() {
tracing::trace!(
source = "reasoning_content",
output_chars = reasoning.chars().count(),
"[lm-studio] effective content selected"
);
return reasoning;
}

tracing::trace!(
source = "none",
output_chars = 0,
"[lm-studio] effective content empty"
);
String::new()
}
}

#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioUsage {
#[serde(default)]
pub prompt_tokens: Option<u32>,
#[serde(default)]
pub completion_tokens: Option<u32>,
}

/// LM Studio **native** REST (`GET /api/v0/models`) model entry.
///
/// Unlike the OpenAI-compatible `/v1/models` (which returns only
Expand Down Expand Up @@ -444,31 +336,4 @@ mod tests {
Some("http://127.0.0.1:1234/v1")
);
}

#[test]
fn effective_content_falls_back_to_reasoning_content() {
let msg = LmStudioChatResponseMessage {
content: Some("".into()),
reasoning_content: Some("thinking text".into()),
};
assert_eq!(msg.effective_content(), "thinking text");
}

#[test]
fn effective_content_strips_think_tags() {
let msg = LmStudioChatResponseMessage {
content: Some("<think>hidden</think>Visible reply".into()),
reasoning_content: None,
};
assert_eq!(msg.effective_content(), "Visible reply");
}

#[test]
fn reasoning_content_accepts_reasoning_alias() {
// Local runtimes that name the field `reasoning` must still be captured
// (issue #3094) so reasoning round-trips like the canonical field.
let msg: LmStudioChatResponseMessage =
serde_json::from_str(r#"{"content":null,"reasoning":"local cot"}"#).unwrap();
assert_eq!(msg.reasoning_content.as_deref(), Some("local cot"));
}
}
32 changes: 0 additions & 32 deletions src/openhuman/inference/local/ollama.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,44 +472,12 @@ pub(crate) struct OllamaGenerateResponse {
pub eval_duration: Option<u64>,
}

#[derive(Debug, Serialize)]
pub(crate) struct OllamaEmbedRequest {
pub model: String,
pub input: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct OllamaEmbedResponse {
#[serde(default)]
pub embeddings: Vec<Vec<f32>>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct OllamaChatMessage {
pub role: String,
pub content: String,
}

#[derive(Debug, Serialize)]
pub(crate) struct OllamaChatRequest {
pub model: String,
pub messages: Vec<OllamaChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<OllamaGenerateOptions>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct OllamaChatResponse {
pub message: OllamaChatMessage,
#[allow(dead_code)]
pub done: Option<bool>,
pub prompt_eval_count: Option<u32>,
pub prompt_eval_duration: Option<u64>,
pub eval_count: Option<u32>,
pub eval_duration: Option<u64>,
}

pub(crate) fn ns_to_tps(tokens: f32, duration_ns: u64) -> Option<f32> {
if duration_ns == 0 || tokens <= 0.0 {
return None;
Expand Down
120 changes: 1 addition & 119 deletions src/openhuman/inference/local/service/lm_studio.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::lm_studio::{
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url,
LmStudioChatCompletionRequest, LmStudioChatCompletionResponse, LmStudioChatMessage,
LmStudioModelsResponse,
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url, LmStudioModelsResponse,
};
use crate::openhuman::inference::local::ollama::{OllamaModelTag, OllamaTagsResponse};
use crate::openhuman::inference::model_ids;

use super::LocalAiService;

Expand All @@ -18,12 +15,6 @@ fn diagnostic_body_snippet(body: &str) -> String {
snippet
}

pub(in crate::openhuman::inference::local::service) struct LmStudioCompletionOutcome {
pub reply: String,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
}

impl LocalAiService {
pub(in crate::openhuman::inference::local::service) async fn ensure_lm_studio_available(
&self,
Expand Down Expand Up @@ -267,113 +258,4 @@ impl LocalAiService {
.into_iter()
.any(|m| m.name.to_ascii_lowercase() == target))
}

pub(in crate::openhuman::inference::local::service) async fn lm_studio_chat_completion(
&self,
config: &Config,
messages: Vec<LmStudioChatMessage>,
max_tokens: Option<u32>,
temperature: f32,
allow_empty: bool,
) -> Result<LmStudioCompletionOutcome, String> {
let base = lm_studio_base_url(config);
let url = format!("{base}/chat/completions");
let model = model_ids::effective_chat_model_id(config);

tracing::debug!(
target: "local_ai::lm_studio",
%url,
%model,
message_count = messages.len(),
max_tokens = ?max_tokens,
"[local_ai:lm_studio] chat completion: sending POST"
);

let body = LmStudioChatCompletionRequest {
model,
messages,
stream: false,
temperature: Some(temperature),
max_tokens,
};

let request = self
.http
.post(&url)
.timeout(std::time::Duration::from_secs(120))
.json(&body);
let response = apply_lm_studio_auth(request, config)
.send()
.await
.map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
"[local_ai:lm_studio] chat completion: request failed"
);
format!("lm studio chat request failed: {e}")
})?;

let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
tracing::debug!(
target: "local_ai::lm_studio",
%url,
%status,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] chat completion: non-success response"
);
return Err(format!(
"lm studio chat failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}

let body = response.text().await.map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
"[local_ai:lm_studio] chat completion: body read failed"
);
format!("lm studio chat response body read failed: {e}")
})?;
let payload: LmStudioChatCompletionResponse = serde_json::from_str(&body).map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] chat completion: parse failed"
);
format!("lm studio chat response parse failed: {e}")
})?;

let reply = payload
.choices
.first()
.map(|choice| choice.message.effective_content())
.unwrap_or_default();

if reply.is_empty() && !allow_empty {
return Err("lm studio returned empty content".to_string());
}

Ok(LmStudioCompletionOutcome {
reply,
prompt_tokens: payload.usage.as_ref().and_then(|usage| usage.prompt_tokens),
completion_tokens: payload
.usage
.as_ref()
.and_then(|usage| usage.completion_tokens),
})
}
}
1 change: 1 addition & 0 deletions src/openhuman/inference/local/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod assets;
mod bootstrap;
mod lm_studio;
mod model_rpc;
pub(crate) mod ollama_admin;
mod public_infer;
pub(crate) mod spawn_marker;
Expand Down
Loading
Loading