feat: selectable wire format — ollama (default), openai (vLLM etc.), anthropic
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:
parent
47db969564
commit
c54c14b28a
6 changed files with 567 additions and 93 deletions
82
src/lib.rs
82
src/lib.rs
|
|
@ -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, ¶ms)
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue