feat: selectable wire format — ollama (default), openai (vLLM etc.), anthropic
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
sign-bundle / sign (push) Failing after 1m51s

Port the multi-API client from llm.chat (llm.rs kept in lockstep):
the optional api input selects the dialect, default stays the
unchanged v0.1.x Ollama behavior. openai covers vLLM, LM Studio,
LiteLLM and cloud OpenAI; api-specific error hints; api_key sent
as Bearer (ollama/openai) or x-api-key (anthropic). model_digest
stays an Ollama-only best-effort probe and is documented as such.

Proven end-to-end against a hermetic OpenAI-wire fake (request
shape validated, response parsed) via a hub flow run.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-08-20 16:32:59 +02:00
parent 47db969564
commit c54c14b28a
6 changed files with 567 additions and 93 deletions

View file

@ -1,12 +1,15 @@
//! `text.translate` — Ollama-backed text translation.
//! `text.translate` — LLM-backed text translation.
//!
//! Sends `text` plus a target-language directive to an Ollama
//! `/api/chat` endpoint and returns the translated text. Reports
//! Sends `text` plus a target-language directive to an LLM chat
//! endpoint and returns the translated text. Reports
//! `model_endpoint`, `model_name`, and `model_digest` as audit
//! fields, same as `llm.chat`.
//!
//! v0.1.0 targets Ollama only. Cloud-provider adapters (OpenAI,
//! Anthropic) follow when a flow needs them.
//! The wire format is selected by the optional `api` input:
//! `ollama` (default, unchanged v0.1.x behavior), `openai`
//! (OpenAI-compatible `/v1/chat/completions` — vLLM, LM Studio,
//! LiteLLM, cloud OpenAI), or `anthropic` (Messages API). The
//! client logic in `llm.rs` is kept in lockstep with `llm.chat`.
mod llm;
@ -32,11 +35,15 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
.get("source_language")
.and_then(payload_text)
.unwrap_or_default();
let api_raw = inputs.get("api").and_then(payload_text).unwrap_or_default();
let api =
crate::llm::Api::parse(&api_raw).map_err(|e| ModuleError::invalid_input(e.to_string()))?;
let prompt = build_prompt(&source_language, &target_language, &text);
let client = make_client();
let params = crate::llm::ChatParams {
api,
endpoint: &endpoint,
model: &model,
api_key: &api_key,
@ -44,7 +51,7 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
prompt: &prompt,
};
let result = crate::llm::chat_with_identity(&client, &params)
.map_err(|e| llm_error_to_module_error(e, &endpoint, &model))?;
.map_err(|e| llm_error_to_module_error(e, api, &endpoint, &model))?;
Ok(Outputs::new()
.with_text("translation", result.response)
@ -57,27 +64,51 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
/// Turn a transport/protocol error into a message that names the
/// likely cause and the fix, instead of a raw `ConnectionRefused`.
fn llm_error_to_module_error(e: crate::llm::LlmError, endpoint: &str, model: &str) -> ModuleError {
use crate::llm::LlmError;
match e {
LlmError::Http(detail) => ModuleError::internal(format!(
/// The hints are api-specific: an Ollama connect failure almost
/// always means Ollama isn't running or the model isn't pulled,
/// while cloud/vLLM failures are usually endpoint or key issues.
/// The message must never contain the api_key.
fn llm_error_to_module_error(
e: crate::llm::LlmError,
api: crate::llm::Api,
endpoint: &str,
model: &str,
) -> ModuleError {
use crate::llm::{Api, LlmError};
match (api, e) {
(Api::Ollama, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Is Ollama running? \
Start it with `ollama serve`, then pull the model with `ollama pull {model}`. \
If the LLM runs elsewhere, set the `endpoint` input to its /api URL."
)),
LlmError::Status(404) => ModuleError::internal(format!(
(Api::Openai, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Expected an \
OpenAI-compatible server (OpenAI, vLLM, ...) at a /v1/chat/completions URL."
)),
(Api::Anthropic, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Expected the \
Anthropic Messages API at a /v1/messages URL."
)),
(Api::Ollama, LlmError::Status(404)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} returned 404 for model '{model}' — the model is \
likely not pulled. Run `ollama pull {model}` (or check the model name)."
)),
LlmError::Status(code) => ModuleError::internal(format!(
(_, LlmError::Status(401)) | (_, LlmError::Status(403)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} rejected the request as unauthorized — check the \
`api_key` input."
)),
(_, LlmError::Status(code)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} returned HTTP {code} for model '{model}'."
)),
LlmError::Decode(detail) => ModuleError::internal(format!(
"LLM response from {endpoint} was not valid Ollama JSON: {detail}"
(_, LlmError::Decode(detail)) => ModuleError::internal(format!(
"LLM response from {endpoint} did not match the expected schema: {detail}"
)),
LlmError::MissingInput(name) => {
(_, LlmError::MissingInput(name)) => {
ModuleError::invalid_input(format!("missing required input '{name}'"))
}
(_, LlmError::UnsupportedApi(raw)) => ModuleError::invalid_input(format!(
"unsupported api '{raw}' (expected: ollama, openai, anthropic)"
)),
}
}
@ -120,7 +151,7 @@ impl crate::llm::LlmClient for HostStubClient {
&self,
_url: &str,
_body: &str,
_api_key: &str,
_headers: &[(&'static str, String)],
) -> Result<String, crate::llm::LlmError> {
Err(crate::llm::LlmError::Http(
"LLM HTTP path is unavailable on the host build; only wasm32 supports outbound HTTP"
@ -138,14 +169,14 @@ impl crate::llm::LlmClient for WakiClient {
&self,
url: &str,
body: &str,
api_key: &str,
headers: &[(&'static str, String)],
) -> 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}"));
for (name, value) in headers {
request = request.header(*name, value);
}
let response = request
.send()
@ -179,4 +210,17 @@ mod tests {
let p = build_prompt("English", "French", "Hello");
assert!(p.contains("English text into French"));
}
#[test]
fn empty_api_input_defaults_to_ollama() {
assert_eq!(crate::llm::Api::parse("").unwrap(), crate::llm::Api::Ollama);
}
#[test]
fn openai_api_is_accepted_for_vllm_endpoints() {
assert_eq!(
crate::llm::Api::parse("openai").unwrap(),
crate::llm::Api::Openai
);
}
}