feat: initial llm-chat v0.1.0 (llm.chat@0.1.0)
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m57s
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m57s
Generic Ollama-compatible LLM chat adapter. The lower-level
counterpart to orchestrator-llm: orchestrator-llm wraps the LLM
in a planning prompt that emits a structured F∆I Plan; this
module is the plain-prompt adapter that flows compose for
summarisation, translation, free-form Q&A, etc.
Capability surface:
Inputs:
prompt : text
endpoint : text (Ollama /api/chat URL)
model : text
api_key : text (optional bearer token)
system_prompt : text (optional)
Outputs:
response : text (assistant reply)
model_endpoint : text (audit correlation)
model_name : text (audit correlation)
model_digest : text (Ollama /api/show probe; empty
for non-Ollama or transient failures)
Permissions: net to localhost / 127.0.0.1 / api.openai.com /
api.anthropic.com.
The audit-field trio (endpoint + name + digest) closes the same
forensic gap that orchestrator-llm v0.3.1 closed for plan
generation: any historical chat invocation can be traced to the
exact model that produced it.
Implementation reuses the same defensive Ollama client pattern
from orchestrator-llm — derive_show_url + extract_show_digest
+ best-effort probe_model_digest. Duplication accepted at the
two-module mark; a shared crate refactor lands once a third
module needs the same plumbing.
12 host-side tests cover prompt building, Ollama-shaped
response parsing, URL transform, digest extraction (top-level
+ nested), end-to-end success, end-to-end probe-failure
swallow, end-to-end skip-for-non-Ollama, and the missing-input
guards.
Wasm artifact: 294 KB. Verified to build with v1.0 fai:platform
imports baked in.
Bootstrapped via 'fai new module llm.chat' (workspace v0.10.13)
which now produces an SDK-based template directly.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
commit
4c10a5ff3d
11 changed files with 1419 additions and 0 deletions
113
src/lib.rs
Normal file
113
src/lib.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! `llm.chat` — generic Ollama-compatible LLM chat adapter.
|
||||
//!
|
||||
//! Sends a single user prompt (with optional system prompt) to an
|
||||
//! Ollama `/api/chat` endpoint and returns the assistant text.
|
||||
//! Reports `model_endpoint`, `model_name`, and `model_digest` as
|
||||
//! separate outputs for audit consumers.
|
||||
//!
|
||||
//! v0.1.0 targets Ollama only. OpenAI / Anthropic adapters land
|
||||
//! when a flow needs them.
|
||||
|
||||
mod llm;
|
||||
|
||||
use fai_module_sdk::prelude::*;
|
||||
|
||||
#[fai_module]
|
||||
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||
let prompt = inputs.require_text("prompt")?.to_string();
|
||||
let endpoint = inputs.require_text("endpoint")?.to_string();
|
||||
let model = inputs.require_text("model")?.to_string();
|
||||
let api_key = inputs
|
||||
.get("api_key")
|
||||
.and_then(payload_text)
|
||||
.unwrap_or_default();
|
||||
let system_prompt = inputs
|
||||
.get("system_prompt")
|
||||
.and_then(payload_text)
|
||||
.unwrap_or_default();
|
||||
|
||||
let client = make_client();
|
||||
let params = crate::llm::ChatParams {
|
||||
endpoint: &endpoint,
|
||||
model: &model,
|
||||
api_key: &api_key,
|
||||
system_prompt: &system_prompt,
|
||||
prompt: &prompt,
|
||||
};
|
||||
let result = crate::llm::chat_with_identity(&client, ¶ms)
|
||||
.map_err(|e| ModuleError::internal(e.to_string()))?;
|
||||
|
||||
Ok(Outputs::new()
|
||||
.with_text("response", result.response)
|
||||
.with_text("model_endpoint", endpoint)
|
||||
.with_text("model_name", model)
|
||||
.with_text("model_digest", result.model_digest.unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn payload_text(p: &Payload) -> Option<String> {
|
||||
match p {
|
||||
Payload::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn make_client() -> WakiClient {
|
||||
WakiClient
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn make_client() -> HostStubClient {
|
||||
HostStubClient
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct HostStubClient;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
impl crate::llm::LlmClient for HostStubClient {
|
||||
fn post_json(
|
||||
&self,
|
||||
_url: &str,
|
||||
_body: &str,
|
||||
_api_key: &str,
|
||||
) -> Result<String, crate::llm::LlmError> {
|
||||
Err(crate::llm::LlmError::Http(
|
||||
"LLM HTTP path is unavailable on the host build; only wasm32 supports outbound HTTP"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
struct WakiClient;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl crate::llm::LlmClient for WakiClient {
|
||||
fn post_json(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &str,
|
||||
api_key: &str,
|
||||
) -> Result<String, crate::llm::LlmError> {
|
||||
let mut request = waki::Client::new()
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string());
|
||||
if !api_key.is_empty() {
|
||||
request = request.header("Authorization", &format!("Bearer {api_key}"));
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|e| crate::llm::LlmError::Http(e.to_string()))?;
|
||||
let status = response.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(crate::llm::LlmError::Status(status));
|
||||
}
|
||||
let bytes = response
|
||||
.body()
|
||||
.map_err(|e| crate::llm::LlmError::Http(e.to_string()))?;
|
||||
String::from_utf8(bytes).map_err(|e| crate::llm::LlmError::Decode(e.to_string()))
|
||||
}
|
||||
}
|
||||
323
src/llm.rs
Normal file
323
src/llm.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
//! Ollama-shaped chat client.
|
||||
//!
|
||||
//! v0.1.0 targets the Ollama `/api/chat` endpoint. OpenAI- and
|
||||
//! Anthropic-compatible adapters are deliberately deferred — each
|
||||
//! has its own request/response shape that warrants its own crate
|
||||
//! (or at least its own module here) once a flow needs it.
|
||||
//!
|
||||
//! All HTTP I/O lives behind a `LlmClient` trait so unit tests can
|
||||
//! exercise prompt building, response parsing, and the digest
|
||||
//! probe on the host without making real network calls.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LlmError {
|
||||
#[error("missing required input '{0}'")]
|
||||
MissingInput(&'static str),
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
#[error("non-success status: {0}")]
|
||||
Status(u16),
|
||||
#[error("response body could not be parsed as Ollama schema: {0}")]
|
||||
Decode(String),
|
||||
}
|
||||
|
||||
pub trait LlmClient {
|
||||
fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result<String, LlmError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatParams<'a> {
|
||||
pub endpoint: &'a str,
|
||||
pub model: &'a str,
|
||||
pub api_key: &'a str,
|
||||
pub system_prompt: &'a str,
|
||||
pub prompt: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaMessage<'a> {
|
||||
role: &'a str,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaRequest<'a> {
|
||||
model: &'a str,
|
||||
messages: Vec<OllamaMessage<'a>>,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
/// Build the request body for an Ollama `/api/chat` invocation.
|
||||
/// The system prompt is omitted from the messages list when empty
|
||||
/// so a deployment can use the model's built-in system prompt.
|
||||
pub fn build_ollama_body(p: &ChatParams) -> String {
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if !p.system_prompt.is_empty() {
|
||||
messages.push(OllamaMessage {
|
||||
role: "system",
|
||||
content: p.system_prompt,
|
||||
});
|
||||
}
|
||||
messages.push(OllamaMessage {
|
||||
role: "user",
|
||||
content: p.prompt,
|
||||
});
|
||||
let req = OllamaRequest {
|
||||
model: p.model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}"))
|
||||
}
|
||||
|
||||
/// Extract the assistant's message text from an Ollama
|
||||
/// /api/chat response.
|
||||
pub fn extract_ollama_content(body: &str) -> Result<String, LlmError> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?;
|
||||
v.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| LlmError::Decode("missing message.content".into()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatWithIdentity {
|
||||
pub response: String,
|
||||
pub model_digest: Option<String>,
|
||||
}
|
||||
|
||||
/// Run an Ollama chat call AND probe the model digest. Probe is
|
||||
/// best-effort — non-Ollama endpoints (no `/api/chat` suffix) and
|
||||
/// transient failures yield `model_digest = None` rather than
|
||||
/// failing the whole call.
|
||||
pub fn chat_with_identity<C: LlmClient>(
|
||||
client: &C,
|
||||
p: &ChatParams,
|
||||
) -> Result<ChatWithIdentity, LlmError> {
|
||||
if p.endpoint.is_empty() {
|
||||
return Err(LlmError::MissingInput("endpoint"));
|
||||
}
|
||||
if p.model.is_empty() {
|
||||
return Err(LlmError::MissingInput("model"));
|
||||
}
|
||||
if p.prompt.is_empty() {
|
||||
return Err(LlmError::MissingInput("prompt"));
|
||||
}
|
||||
let body = build_ollama_body(p);
|
||||
let response_body = client.post_json(p.endpoint, &body, p.api_key)?;
|
||||
let response = extract_ollama_content(&response_body)?;
|
||||
let model_digest = probe_model_digest(client, p);
|
||||
Ok(ChatWithIdentity {
|
||||
response,
|
||||
model_digest,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_model_digest<C: LlmClient>(client: &C, p: &ChatParams) -> Option<String> {
|
||||
let show_url = derive_show_url(p.endpoint)?;
|
||||
let body = serde_json::to_string(&serde_json::json!({ "name": p.model })).ok()?;
|
||||
let response = client.post_json(&show_url, &body, p.api_key).ok()?;
|
||||
extract_show_digest(&response)
|
||||
}
|
||||
|
||||
pub fn derive_show_url(chat_endpoint: &str) -> Option<String> {
|
||||
if chat_endpoint.ends_with("/api/chat") {
|
||||
let head_len = chat_endpoint.len() - "/api/chat".len();
|
||||
let mut url = String::with_capacity(head_len + "/api/show".len());
|
||||
url.push_str(&chat_endpoint[..head_len]);
|
||||
url.push_str("/api/show");
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_show_digest(body: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let candidates = [
|
||||
v.get("digest"),
|
||||
v.get("details").and_then(|d| d.get("digest")),
|
||||
v.get("model_info").and_then(|d| d.get("digest")),
|
||||
];
|
||||
for cand in candidates {
|
||||
if let Some(s) = cand.and_then(|v| v.as_str()) {
|
||||
if !s.is_empty() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct MockClient {
|
||||
responses: RefCell<Vec<Result<String, LlmError>>>,
|
||||
}
|
||||
|
||||
impl MockClient {
|
||||
fn new(responses: Vec<Result<String, LlmError>>) -> Self {
|
||||
Self {
|
||||
responses: RefCell::new(responses),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LlmClient for MockClient {
|
||||
fn post_json(&self, _url: &str, _body: &str, _api_key: &str) -> Result<String, LlmError> {
|
||||
self.responses
|
||||
.borrow_mut()
|
||||
.pop()
|
||||
.unwrap_or(Err(LlmError::Http("no more mock responses".into())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_body_includes_system_when_provided() {
|
||||
let p = ChatParams {
|
||||
endpoint: "http://x/api/chat",
|
||||
model: "qwen",
|
||||
api_key: "",
|
||||
system_prompt: "be helpful",
|
||||
prompt: "hello",
|
||||
};
|
||||
let body = build_ollama_body(&p);
|
||||
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let messages = v["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[0]["content"], "be helpful");
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
assert_eq!(messages[1]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_body_omits_system_when_empty() {
|
||||
let p = ChatParams {
|
||||
endpoint: "http://x/api/chat",
|
||||
model: "qwen",
|
||||
api_key: "",
|
||||
system_prompt: "",
|
||||
prompt: "hello",
|
||||
};
|
||||
let body = build_ollama_body(&p);
|
||||
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let messages = v["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_content_pulls_message_content() {
|
||||
let body = r#"{"message":{"role":"assistant","content":"hi there"},"done":true}"#;
|
||||
assert_eq!(extract_ollama_content(body).unwrap(), "hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_content_errors_on_missing_field() {
|
||||
let body = r#"{"done":true}"#;
|
||||
assert!(matches!(
|
||||
extract_ollama_content(body),
|
||||
Err(LlmError::Decode(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_show_url_swaps_chat_suffix() {
|
||||
assert_eq!(
|
||||
derive_show_url("http://localhost:11434/api/chat").as_deref(),
|
||||
Some("http://localhost:11434/api/show")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_show_url_returns_none_for_non_ollama() {
|
||||
assert!(derive_show_url("https://api.openai.com/v1/chat/completions").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_show_digest_finds_top_level() {
|
||||
let body = r#"{"digest":"sha256:abc"}"#;
|
||||
assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_show_digest_falls_back_to_nested() {
|
||||
let body = r#"{"details":{"digest":"sha256:nested"}}"#;
|
||||
assert_eq!(extract_show_digest(body).as_deref(), Some("sha256:nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_with_identity_records_digest_when_show_responds() {
|
||||
let canned_chat = r#"{"message":{"content":"the response"},"done":true}"#.to_string();
|
||||
let canned_show = r#"{"digest":"sha256:deadbeef"}"#.to_string();
|
||||
let client = MockClient::new(vec![Ok(canned_show), Ok(canned_chat)]);
|
||||
let p = ChatParams {
|
||||
endpoint: "http://localhost:11434/api/chat",
|
||||
model: "qwen",
|
||||
api_key: "",
|
||||
system_prompt: "",
|
||||
prompt: "hi",
|
||||
};
|
||||
let result = chat_with_identity(&client, &p).unwrap();
|
||||
assert_eq!(result.response, "the response");
|
||||
assert_eq!(result.model_digest.as_deref(), Some("sha256:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_with_identity_swallows_show_failure() {
|
||||
let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string();
|
||||
let client = MockClient::new(vec![Err(LlmError::Status(500)), Ok(canned_chat)]);
|
||||
let p = ChatParams {
|
||||
endpoint: "http://localhost:11434/api/chat",
|
||||
model: "qwen",
|
||||
api_key: "",
|
||||
system_prompt: "",
|
||||
prompt: "hi",
|
||||
};
|
||||
let result = chat_with_identity(&client, &p).unwrap();
|
||||
assert_eq!(result.response, "ok");
|
||||
assert_eq!(result.model_digest, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_with_identity_skips_probe_for_non_ollama() {
|
||||
let canned_chat = r#"{"message":{"content":"ok"}}"#.to_string();
|
||||
let client = MockClient::new(vec![Ok(canned_chat)]);
|
||||
let p = ChatParams {
|
||||
endpoint: "https://api.openai.com/v1/chat/completions",
|
||||
model: "gpt",
|
||||
api_key: "k",
|
||||
system_prompt: "",
|
||||
prompt: "hi",
|
||||
};
|
||||
let result = chat_with_identity(&client, &p).unwrap();
|
||||
assert_eq!(result.response, "ok");
|
||||
assert_eq!(result.model_digest, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_is_rejected() {
|
||||
let client = MockClient::new(vec![]);
|
||||
let p = ChatParams {
|
||||
endpoint: "http://x/api/chat",
|
||||
model: "x",
|
||||
api_key: "",
|
||||
system_prompt: "",
|
||||
prompt: "",
|
||||
};
|
||||
assert!(matches!(
|
||||
chat_with_identity(&client, &p),
|
||||
Err(LlmError::MissingInput("prompt"))
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue