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

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:
flemming-it 2026-05-03 23:17:36 +02:00
commit 4c10a5ff3d
11 changed files with 1419 additions and 0 deletions

113
src/lib.rs Normal file
View 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, &params)
.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
View 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"))
));
}
}