feat: api accepts 'vllm' as a first-class value
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s
sign-bundle / sign (push) Failing after 1m48s

'openai' reads as the cloud company — an operator running
self-hosted vLLM should be able to write what they mean. The new
value speaks the identical OpenAI wire (a guard test asserts the
emitted request stays byte-identical to api: openai, so the alias
can never drift into a dialect) but yields vLLM-specific error
hints (vllm serve, port 8000) instead of generic OpenAI prose.
Manifest + docs name the value in DE and EN.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-08-20 17:13:42 +02:00
parent 6efdb8a4f7
commit 4d6acab6a1
6 changed files with 61 additions and 19 deletions

2
Cargo.lock generated
View file

@ -265,7 +265,7 @@ dependencies = [
[[package]] [[package]]
name = "text_translate" name = "text_translate"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"chain-module-sdk", "chain-module-sdk",
"serde", "serde",

View file

@ -17,7 +17,7 @@ audit-taugliche Modell-Herkunfts-Felder.
| `target_language` | text | Zielsprache in einfachem Englisch (z.B. `German`, `French`, `ja-JP`). | | `target_language` | text | Zielsprache in einfachem Englisch (z.B. `German`, `French`, `ja-JP`). |
| `source_language` | text | Optionaler Hinweis auf die Ausgangssprache. Leer = Modell erkennt selbst. | | `source_language` | text | Optionaler Hinweis auf die Ausgangssprache. Leer = Modell erkennt selbst. |
| `endpoint` | text | Chat-Endpunkt passend zum gewählten `api` (Ollama `/api/chat`, OpenAI-kompatibel `/v1/chat/completions` — vLLM u. a., Anthropic `/v1/messages`). | | `endpoint` | text | Chat-Endpunkt passend zum gewählten `api` (Ollama `/api/chat`, OpenAI-kompatibel `/v1/chat/completions` — vLLM u. a., Anthropic `/v1/messages`). |
| `api` | text | Optionales Wire-Format: `ollama` (Default), `openai`, `anthropic`. | | `api` | text | Optionales Wire-Format: `ollama` (Default), `vllm`, `openai`, `anthropic` (`vllm` = OpenAI-Wire mit vLLM-spezifischen Hinweisen). |
| `model` | text | Modell-ID am Endpunkt (z.B. `qwen2.5:14b`). | | `model` | text | Modell-ID am Endpunkt (z.B. `qwen2.5:14b`). |
| `api_key` | text | Optionaler Bearer-Token für Cloud-Endpunkte. | | `api_key` | text | Optionaler Bearer-Token für Cloud-Endpunkte. |

View file

@ -17,7 +17,7 @@ model-provenance fields.
| `target_language` | text | Target language in plain English (e.g. `German`, `French`, `ja-JP`). | | `target_language` | text | Target language in plain English (e.g. `German`, `French`, `ja-JP`). |
| `source_language` | text | Optional source-language hint. Empty = let the model auto-detect. | | `source_language` | text | Optional source-language hint. Empty = let the model auto-detect. |
| `endpoint` | text | Chat endpoint URL matching the selected `api` (Ollama `/api/chat`, OpenAI-compatible `/v1/chat/completions` — vLLM etc., Anthropic `/v1/messages`). | | `endpoint` | text | Chat endpoint URL matching the selected `api` (Ollama `/api/chat`, OpenAI-compatible `/v1/chat/completions` — vLLM etc., Anthropic `/v1/messages`). |
| `api` | text | Optional wire format: `ollama` (default), `openai`, `anthropic`. | | `api` | text | Optional wire format: `ollama` (default), `vllm`, `openai`, `anthropic` (`vllm` = the OpenAI wire with vLLM-specific hints). |
| `model` | text | Model identifier the endpoint serves (e.g. `qwen2.5:14b`). | | `model` | text | Model identifier the endpoint serves (e.g. `qwen2.5:14b`). |
| `api_key` | text | Optional bearer token for cloud-hosted endpoints. | | `api_key` | text | Optional bearer token for cloud-hosted endpoints. |

View file

@ -49,13 +49,20 @@ inputs:
type: text type: text
description: description:
en: | en: |
Optional wire format: "ollama" (default), "openai" Optional wire format: "ollama" (default), "vllm"
(OpenAI-compatible servers such as vLLM or LM Studio), (self-hosted vLLM), "openai" (OpenAI or other
or "anthropic" (Messages API). Empty = ollama. OpenAI-compatible servers such as LM Studio), or
"anthropic" (Messages API). vllm and openai speak the
same wire; the separate value exists so you can write
what you mean and get vLLM-specific hints. Empty = ollama.
de: | de: |
Optionales Wire-Format: "ollama" (Default), "openai" Optionales Wire-Format: "ollama" (Default), "vllm"
(OpenAI-kompatible Server wie vLLM oder LM Studio) (selbst gehostetes vLLM), "openai" (OpenAI oder andere
oder "anthropic" (Messages API). Leer = ollama. OpenAI-kompatible Server wie LM Studio) oder "anthropic"
(Messages API). vllm und openai sprechen dieselbe Wire;
der eigene Wert existiert, damit Du schreibst, was Du
meinst, und vLLM-spezifische Hinweise bekommst.
Leer = ollama.
model: model:
type: text type: text
description: description:

View file

@ -6,9 +6,9 @@
//! fields, same as `llm.chat`. //! fields, same as `llm.chat`.
//! //!
//! The wire format is selected by the optional `api` input: //! The wire format is selected by the optional `api` input:
//! `ollama` (default, unchanged v0.1.x behavior), `openai` //! `ollama` (default, unchanged v0.1.x behavior), `vllm`
//! (OpenAI-compatible `/v1/chat/completions` — vLLM, LM Studio, //! (self-hosted vLLM), `openai` (any other OpenAI-compatible
//! LiteLLM, cloud OpenAI), or `anthropic` (Messages API). The //! server), or `anthropic` (Messages API). The
//! client logic in `llm.rs` is kept in lockstep with `llm.chat`. //! client logic in `llm.rs` is kept in lockstep with `llm.chat`.
mod llm; mod llm;
@ -83,7 +83,12 @@ fn llm_error_to_module_error(
)), )),
(Api::Openai, LlmError::Http(detail)) => ModuleError::internal(format!( (Api::Openai, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Expected an \ "LLM endpoint {endpoint} not reachable ({detail}). Expected an \
OpenAI-compatible server (OpenAI, vLLM, ...) at a /v1/chat/completions URL." OpenAI-compatible server at a /v1/chat/completions URL."
)),
(Api::Vllm, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Is vLLM running? \
Start it with `vllm serve {model}`, then point `endpoint` at \
http://<host>:8000/v1/chat/completions."
)), )),
(Api::Anthropic, LlmError::Http(detail)) => ModuleError::internal(format!( (Api::Anthropic, LlmError::Http(detail)) => ModuleError::internal(format!(
"LLM endpoint {endpoint} not reachable ({detail}). Expected the \ "LLM endpoint {endpoint} not reachable ({detail}). Expected the \
@ -107,7 +112,7 @@ fn llm_error_to_module_error(
ModuleError::invalid_input(format!("missing required input '{name}'")) ModuleError::invalid_input(format!("missing required input '{name}'"))
} }
(_, LlmError::UnsupportedApi(raw)) => ModuleError::invalid_input(format!( (_, LlmError::UnsupportedApi(raw)) => ModuleError::invalid_input(format!(
"unsupported api '{raw}' (expected: ollama, openai, anthropic)" "unsupported api '{raw}' (expected: ollama, openai, vllm, anthropic)"
)), )),
} }
} }

View file

@ -33,7 +33,7 @@ const ANTHROPIC_VERSION: &str = "2023-06-01";
pub enum LlmError { pub enum LlmError {
#[error("missing required input '{0}'")] #[error("missing required input '{0}'")]
MissingInput(&'static str), MissingInput(&'static str),
#[error("unsupported api '{0}' (expected: ollama, openai, anthropic)")] #[error("unsupported api '{0}' (expected: ollama, openai, vllm, anthropic)")]
UnsupportedApi(String), UnsupportedApi(String),
#[error("http error: {0}")] #[error("http error: {0}")]
Http(String), Http(String),
@ -48,6 +48,13 @@ pub enum LlmError {
pub enum Api { pub enum Api {
Ollama, Ollama,
Openai, Openai,
/// vLLM speaks the OpenAI wire format verbatim; it is a
/// separate variant ONLY so operators can write what they
/// mean (`api: vllm`) and get vLLM-specific error hints —
/// "openai" would read as the cloud company, not a
/// self-hosted server. Same rationale as the hub's
/// SystemLlmProvider vocabulary.
Vllm,
Anthropic, Anthropic,
} }
@ -58,6 +65,7 @@ impl Api {
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"" | "ollama" => Ok(Api::Ollama), "" | "ollama" => Ok(Api::Ollama),
"openai" => Ok(Api::Openai), "openai" => Ok(Api::Openai),
"vllm" => Ok(Api::Vllm),
"anthropic" => Ok(Api::Anthropic), "anthropic" => Ok(Api::Anthropic),
other => Err(LlmError::UnsupportedApi(other.to_string())), other => Err(LlmError::UnsupportedApi(other.to_string())),
} }
@ -92,7 +100,7 @@ pub struct ChatParams<'a> {
pub fn build_headers(p: &ChatParams) -> Vec<(&'static str, String)> { pub fn build_headers(p: &ChatParams) -> Vec<(&'static str, String)> {
let mut headers = Vec::with_capacity(2); let mut headers = Vec::with_capacity(2);
match p.api { match p.api {
Api::Ollama | Api::Openai => { Api::Ollama | Api::Openai | Api::Vllm => {
if !p.api_key.is_empty() { if !p.api_key.is_empty() {
headers.push(("Authorization", format!("Bearer {}", p.api_key))); headers.push(("Authorization", format!("Bearer {}", p.api_key)));
} }
@ -261,19 +269,19 @@ pub fn chat_with_identity<C: LlmClient>(
} }
let body = match p.api { let body = match p.api {
Api::Ollama => build_ollama_body(p), Api::Ollama => build_ollama_body(p),
Api::Openai => build_openai_body(p), Api::Openai | Api::Vllm => build_openai_body(p),
Api::Anthropic => build_anthropic_body(p), Api::Anthropic => build_anthropic_body(p),
}; };
let headers = build_headers(p); let headers = build_headers(p);
let response_body = client.post_json(p.endpoint, &body, &headers)?; let response_body = client.post_json(p.endpoint, &body, &headers)?;
let response = match p.api { let response = match p.api {
Api::Ollama => extract_ollama_content(&response_body)?, Api::Ollama => extract_ollama_content(&response_body)?,
Api::Openai => extract_openai_content(&response_body)?, Api::Openai | Api::Vllm => extract_openai_content(&response_body)?,
Api::Anthropic => extract_anthropic_content(&response_body)?, Api::Anthropic => extract_anthropic_content(&response_body)?,
}; };
let model_digest = match p.api { let model_digest = match p.api {
Api::Ollama => probe_model_digest(client, p), Api::Ollama => probe_model_digest(client, p),
Api::Openai | Api::Anthropic => None, Api::Openai | Api::Vllm | Api::Anthropic => None,
}; };
Ok(ChatWithIdentity { Ok(ChatWithIdentity {
response, response,
@ -717,4 +725,26 @@ mod tests {
Err(LlmError::MissingInput("prompt")) Err(LlmError::MissingInput("prompt"))
)); ));
} }
/// Guard: `api: vllm` exists ONLY for operator clarity — the
/// request it emits (URL, body, headers) must stay identical to
/// `api: openai`. If the two ever diverge, that is a new wire
/// dialect and needs its own deliberate design, not drift.
#[test]
fn vllm_is_the_openai_wire_verbatim() {
assert_eq!(Api::parse("vllm").unwrap(), Api::Vllm);
let openai_reply = r#"{"choices":[{"message":{"role":"assistant","content":"x"}}]}"#;
let recorded: Vec<RecordedCall> = [Api::Vllm, Api::Openai]
.into_iter()
.map(|api| {
let client = MockClient::new(vec![Ok(openai_reply.to_string())]);
chat_with_identity(&client, &params(api)).unwrap();
client.calls.into_inner().pop().unwrap()
})
.collect();
assert_eq!(
recorded[0], recorded[1],
"vllm request drifted from openai"
);
}
} }