From 305ab1cecd25de07fc4938e295e6d4501f9d8488 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 11 Jul 2026 23:19:32 +0200 Subject: [PATCH] feat: OpenAI and Anthropic wire-format adapters via new api input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional input api: ollama|openai|anthropic (default ollama — existing flows unchanged). - openai: OpenAI Chat Completions format (/v1/chat/completions), Bearer auth — targets vLLM and compatible self-hosted servers. - anthropic: Messages API (/v1/messages), x-api-key + anthropic-version headers, top-level system field, mandatory max_tokens (fixed 4096). - Audit outputs unchanged: model_endpoint/model_name always set; model_digest stays Ollama-only (no digest API on openai/anthropic, field is left empty rather than fabricated). - api_key is only ever placed in auth headers; never in outputs, errors, or audit events (verified against the event log). - No streaming, no tool calls. Bump module + capability version to 0.2.0. Wire-format unit tests for request serialization and response parsing against fixed JSON fixtures; live smoke green on the ollama path and on the openai path against an OpenAI-compatible local endpoint. Signed-off-by: flemming-it --- Cargo.lock | 2 +- Cargo.toml | 2 +- MODULE.de.md | 80 ++++++-- MODULE.md | 76 ++++++-- README.md | 10 +- module.wasm | Bin 302110 -> 312033 bytes module.yaml | 39 +++- src/lib.rs | 78 +++++--- src/llm.rs | 511 +++++++++++++++++++++++++++++++++++++++++++++------ 9 files changed, 662 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45c991c..462390b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,7 @@ checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" [[package]] name = "llm_chat" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chain-module-sdk", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9be0a66..6a761ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "llm_chat" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "Apache-2.0" publish = false diff --git a/MODULE.de.md b/MODULE.de.md index 4d12be6..ab71365 100644 --- a/MODULE.de.md +++ b/MODULE.de.md @@ -1,23 +1,35 @@ # llm.chat -Generischer Ollama-kompatibler LLM-Chat-Adapter. Das -Baustein-Modul für jeden Flow, der eine LLM-Einzelantwort -braucht — klassifizieren, Felder extrahieren, umformulieren, -entscheiden — ohne einen eigenen HTTP-Client zu bauen. +Generischer LLM-Chat-Adapter. Das Baustein-Modul für jeden +Flow, der eine LLM-Einzelantwort braucht — klassifizieren, +Felder extrahieren, umformulieren, entscheiden — ohne einen +eigenen HTTP-Client zu bauen. Spricht drei Wire-Formate, +wählbar über die optionale Eingabe `api`: Ollama (Default), +OpenAI Chat Completions (OpenAI, vLLM, kompatible Server) und +die Anthropic Messages API. ## Capability -- `llm.chat@0.1.0` +- `llm.chat@0.2.0` ## Eingaben | Name | Typ | Beschreibung | | ---------------- | ---- | ------------------------------------------------------------------------- | | `prompt` | text | Der User-Prompt. | -| `endpoint` | text | Ollama-förmiger `/api/chat`-URL (z.B. `http://localhost:11434/api/chat`). | -| `model` | text | Modell-ID (z.B. `qwen2.5:14b`, `llama3.1:8b`). | -| `api_key` | text | Optionaler Bearer-Token für Cloud-Endpunkte. | +| `endpoint` | text | Chat-Endpunkt-URL passend zur gewählten `api` (siehe unten). | +| `model` | text | Modell-ID (z. B. `qwen2.5:14b`, `claude-fable-5`). | +| `api_key` | text | Optionaler API-Key. Erscheint nie in Ausgaben, Logs oder Events. | | `system_prompt` | text | Optionale System-Nachricht. Leer = Default des Modells. | +| `api` | text | Optionales Wire-Format: `ollama` (Default), `openai`, `anthropic`. | + +### Wire-Formate (`api`) + +| `api` | Endpunkt-Form | Auth-Header | Hinweise | +| ----------- | ----------------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| `ollama` | `http://localhost:11434/api/chat` | `Authorization: Bearer` (optional) | Verhalten wie v0.1.0, unverändert. Modell-Digest-Probe via `/api/show`. | +| `openai` | `https://.../v1/chat/completions` | `Authorization: Bearer` | OpenAI-Chat-Completions-Format — sprechen auch vLLM und die meisten selbst gehosteten Inferenz-Server. | +| `anthropic` | `https://api.anthropic.com/v1/messages` | `x-api-key` + `anthropic-version` | Messages API: `system` ist Top-Level-Feld; `max_tokens` ist Pflicht und steht fest auf 4096. | ## Ausgaben @@ -26,7 +38,7 @@ entscheiden — ohne einen eigenen HTTP-Client zu bauen. | `response` | text | Die Antwort des Assistants als Plain-Text. | | `model_endpoint` | text | URL, gegen die die Antwort erzeugt wurde. | | `model_name` | text | Modell-ID wie an die LLM-API gesendet. | -| `model_digest` | text | SHA-256-Digest des bedienenden Ollama-Modells. Leer bei Cloud-APIs. | +| `model_digest` | text | SHA-256-Digest des bedienenden Ollama-Modells. Leer bei `openai`/`anthropic` — diese APIs bieten keinen Digest; das Feld bleibt leer statt erfunden. | Zusammen beantworten die drei `model_*`-Ausgaben die Audit- Frage: „Welches genaue Modell hat diese Antwort erzeugt?" — @@ -43,8 +55,8 @@ permissions: - "net: api.anthropic.com" ``` -Loopback (lokales Ollama) per Default. Cloud-Endpunkte brauchen -einen Operator-Policy-Override in +Loopback (lokales Ollama / vLLM) per Default. Cloud-Endpunkte +brauchen einen Operator-Policy-Override in `~/.chain/config.yaml#security.max_permissions`. ## Warum dieses Modul statt Inline-HTTP @@ -61,23 +73,24 @@ Drei Gründe: `net:`-Liste. Ein hausgemachtes HTTP-Modul würde seine Endpunkte entweder verstecken oder bei jeder Installation eine frische Review-Oberfläche schaffen. -3. **Endpunkt-Portabilität.** Endpunkt + Modell sind Flow- - Inputs, keine Compile-Time-Konstanten. Derselbe Flow läuft - in der Entwicklung gegen `localhost:11434` und in Produktion - gegen ein Inferenz-Cluster — nur die Eingabe wechselt. +3. **Endpunkt-Portabilität.** Endpunkt, Modell und Wire-Format + sind Flow-Inputs, keine Compile-Time-Konstanten. Derselbe + Flow läuft in der Entwicklung gegen `localhost:11434` und + in Produktion gegen ein vLLM-Cluster — nur die Eingaben + wechseln. -## Grenzen in v0.1.0 +## Grenzen in v0.2.0 - Kein Streaming. Die ganze Antwort wird gepuffert, bevor der Output-Step feuert. - Keine Tool-Call- / Function-Call-Oberfläche. Ein Flow, der Tool-Use braucht, kombiniert mehrere `llm.chat`-Schritte mit Prompt-Engineering oder nutzt MCP via Bridge. -- Cloud-Provider-Adapter für OpenAI und Anthropic kommen erst, - wenn ein Flow sie braucht. Heute ist das Ollama-Wire-Format - das einzige Ziel. +- Anthropic-`max_tokens` steht fest auf 4096 (die API verlangt + das Feld; eine konfigurierbare Eingabe kommt, sobald ein + Flow sie braucht). -## Beispiel-Flow +## Beispiel-Flows ```yaml name: classify-incoming @@ -98,6 +111,33 @@ outputs: audit_model: $classify.model_digest ``` +Gegen einen vLLM-Server (OpenAI-kompatibel): + +```yaml +steps: + - id: classify + use: llm.chat@^0 + with: + api: "openai" + endpoint: "http://inference.internal:8000/v1/chat/completions" + model: "meta-llama/Llama-3.1-8B-Instruct" + prompt: $inputs.text +``` + +Gegen die Anthropic Messages API: + +```yaml +steps: + - id: classify + use: llm.chat@^0 + with: + api: "anthropic" + endpoint: "https://api.anthropic.com/v1/messages" + model: "claude-fable-5" + api_key: $inputs.anthropic_key + prompt: $inputs.text +``` + ## Build ```bash diff --git a/MODULE.md b/MODULE.md index 49460ad..cb8c167 100644 --- a/MODULE.md +++ b/MODULE.md @@ -1,23 +1,35 @@ # llm.chat -Generic Ollama-compatible LLM chat adapter. The building-block -module any flow uses when it needs a one-shot LLM completion — -classify, extract-fields, rewrite, decide — instead of rolling -its own HTTP client. +Generic LLM chat adapter. The building-block module any flow +uses when it needs a one-shot LLM completion — classify, +extract-fields, rewrite, decide — instead of rolling its own +HTTP client. Speaks three wire formats, selected by the +optional `api` input: Ollama (default), OpenAI Chat +Completions (OpenAI, vLLM, compatible servers), and the +Anthropic Messages API. ## Capability -- `llm.chat@0.1.0` +- `llm.chat@0.2.0` ## Inputs | Name | Type | Description | | ---------------- | ---- | --------------------------------------------------------------- | | `prompt` | text | The user-facing prompt. | -| `endpoint` | text | Ollama-shaped `/api/chat` URL (e.g. `http://localhost:11434/api/chat`). | -| `model` | text | Model identifier (e.g. `qwen2.5:14b`, `llama3.1:8b`). | -| `api_key` | text | Optional bearer token for cloud-hosted endpoints. | +| `endpoint` | text | Chat endpoint URL matching the selected `api` (see below). | +| `model` | text | Model identifier (e.g. `qwen2.5:14b`, `claude-fable-5`). | +| `api_key` | text | Optional API key. Never appears in outputs, logs, or events. | | `system_prompt` | text | Optional system message. Empty = use the model's default. | +| `api` | text | Optional wire format: `ollama` (default), `openai`, `anthropic`. | + +### Wire formats (`api`) + +| `api` | Endpoint shape | Auth header | Notes | +| ----------- | --------------------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| `ollama` | `http://localhost:11434/api/chat` | `Authorization: Bearer` (optional) | v0.1.0 behavior, unchanged. Model-digest probe via `/api/show`. | +| `openai` | `https://.../v1/chat/completions` | `Authorization: Bearer` | OpenAI Chat Completions format — also spoken by vLLM and most self-hosted inference servers. | +| `anthropic` | `https://api.anthropic.com/v1/messages` | `x-api-key` + `anthropic-version` | Messages API: `system` is a top-level field; `max_tokens` is mandatory and defaults to 4096. | ## Outputs @@ -26,7 +38,7 @@ its own HTTP client. | `response` | text | The assistant's plain-text reply. | | `model_endpoint` | text | The endpoint the response was generated against. | | `model_name` | text | The model identifier as supplied to the LLM API. | -| `model_digest` | text | SHA-256 digest of the served Ollama model. Empty for cloud APIs. | +| `model_digest` | text | SHA-256 digest of the served Ollama model. Empty for `openai`/`anthropic` — those APIs expose no digest; the field is left empty rather than fabricated. | Together the three `model_*` outputs answer the audit question: "which exact model produced this response?" — that @@ -43,7 +55,7 @@ permissions: - "net: api.anthropic.com" ``` -Loopback (local Ollama) by default. Cloud endpoints require an +Loopback (local Ollama / vLLM) by default. Cloud endpoints require an operator-policy override in `~/.chain/config.yaml#security.max_permissions`. ## Why this module instead of inline HTTP @@ -59,23 +71,22 @@ Three reasons: the installed modules + its declared `net:` list. A home-grown HTTP module would either hide its endpoints or be a fresh review surface every time. -3. **Endpoint portability.** The endpoint + model are flow - inputs, not compile-time constants. The same flow runs - against `localhost:11434` in dev and a production - inference cluster in prod just by swapping the input. +3. **Endpoint portability.** The endpoint, model, and wire + format are flow inputs, not compile-time constants. The + same flow runs against `localhost:11434` in dev and a + production vLLM cluster in prod just by swapping inputs. -## Limits in v0.1.0 +## Limits in v0.2.0 - No streaming. The whole reply is buffered before the output step fires. - No tool-call / function-call surface. A flow needing tool use composes multiple `llm.chat` steps with prompt engineering, or uses MCP via the bridge. -- Cloud-provider adapters for OpenAI and Anthropic are - deferred until a flow actually needs them. Today the - Ollama wire-format is the only target. +- Anthropic `max_tokens` is fixed at 4096 (the API requires + the field; a configurable input lands when a flow needs it). -## Example flow +## Example flows ```yaml name: classify-incoming @@ -96,6 +107,33 @@ outputs: audit_model: $classify.model_digest ``` +Against a vLLM (OpenAI-compatible) server: + +```yaml +steps: + - id: classify + use: llm.chat@^0 + with: + api: "openai" + endpoint: "http://inference.internal:8000/v1/chat/completions" + model: "meta-llama/Llama-3.1-8B-Instruct" + prompt: $inputs.text +``` + +Against the Anthropic Messages API: + +```yaml +steps: + - id: classify + use: llm.chat@^0 + with: + api: "anthropic" + endpoint: "https://api.anthropic.com/v1/messages" + model: "claude-fable-5" + api_key: $inputs.anthropic_key + prompt: $inputs.text +``` + ## Build ```bash diff --git a/README.md b/README.md index 275de8d..5e55757 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # llm-chat F∆I module providing the `llm.chat` capability — a generic -Ollama-compatible LLM chat adapter. +LLM chat adapter speaking Ollama (default), OpenAI Chat +Completions (OpenAI, vLLM, compatible servers), and the +Anthropic Messages API, selected via the optional `api` input. ## Capability | Field | Value | |-------|-------| -| Capability | `llm.chat@0.1.0` | -| Inputs | `prompt: text`, `endpoint: text`, `model: text`, `api_key: text` (opt), `system_prompt: text` (opt) | +| Capability | `llm.chat@0.2.0` | +| Inputs | `prompt: text`, `endpoint: text`, `model: text`, `api_key: text` (opt), `system_prompt: text` (opt), `api: text` (opt: `ollama`\|`openai`\|`anthropic`) | | Outputs | `response: text`, `model_endpoint: text`, `model_name: text`, `model_digest: text` | | Permissions | `net: localhost`, `net: 127.0.0.1`, `net: api.openai.com`, `net: api.anthropic.com` | | Status (in store index) | `alpha` | @@ -41,7 +43,7 @@ cargo build --release --target wasm32-wasip2 ## Test ```bash -cargo test # 11 tests, all host-side +cargo test # wire-format unit tests, all host-side cargo build --release --target wasm32-wasip2 ``` diff --git a/module.wasm b/module.wasm index cca97496ba1b52204c995ad28e04b0ab0d31ba27..353c54731c79ce1d939e1f6a9a9b3911647e9f50 100644 GIT binary patch delta 74315 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 QMEIawKEdM58YBDv0Ad1AZ~y=R delta 64919 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 Result { .get("system_prompt") .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 client = make_client(); let params = crate::llm::ChatParams { + api, endpoint: &endpoint, model: &model, api_key: &api_key, @@ -35,7 +44,7 @@ pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { 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("response", result.response) @@ -53,29 +62,50 @@ fn payload_text(p: &Payload) -> Option { /// Turn a transport/protocol error into a message that names the /// likely cause and the fix, instead of a raw `ConnectionRefused`. -/// The endpoint is Ollama-shaped by default, so a connect failure -/// almost always means Ollama isn't running or the model isn't -/// pulled. -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) => ModuleError::invalid_input(format!( - "missing required input '{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)" )), } } @@ -100,7 +130,7 @@ impl crate::llm::LlmClient for HostStubClient { &self, _url: &str, _body: &str, - _api_key: &str, + _headers: &[(&'static str, String)], ) -> Result { Err(crate::llm::LlmError::Http( "LLM HTTP path is unavailable on the host build; only wasm32 supports outbound HTTP" @@ -118,14 +148,14 @@ impl crate::llm::LlmClient for WakiClient { &self, url: &str, body: &str, - api_key: &str, + headers: &[(&'static str, String)], ) -> Result { 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() diff --git a/src/llm.rs b/src/llm.rs index e09707f..90565f5 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -1,35 +1,84 @@ -//! Ollama-shaped chat client. +//! Multi-API chat client: Ollama (default), OpenAI, Anthropic. //! -//! 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. +//! The wire format is selected via the optional `api` input: +//! +//! - `ollama` (default) — Ollama `/api/chat`; unchanged v0.1.0 +//! behavior including the best-effort model-digest probe. +//! - `openai` — OpenAI Chat Completions wire format +//! (`/v1/chat/completions`), the de-facto standard implemented +//! by vLLM and most self-hosted inference servers. Bearer auth. +//! - `anthropic` — Anthropic Messages API (`/v1/messages`), +//! `x-api-key` + `anthropic-version` headers, top-level +//! `system` field, mandatory `max_tokens`. +//! +//! No streaming, no tool calls — one prompt in, one completion out. //! //! All HTTP I/O lives behind a `LlmClient` trait so unit tests can -//! exercise prompt building, response parsing, and the digest +//! exercise request building, response parsing, and the digest //! probe on the host without making real network calls. use serde::Serialize; +/// Anthropic's Messages API requires `max_tokens`. This default is +/// large enough for document-processing completions while staying +/// well below every current model's output cap. +const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 4096; + +/// Pinned Messages API version header. Anthropic keeps old +/// versions working; bump deliberately, never implicitly. +const ANTHROPIC_VERSION: &str = "2023-06-01"; + #[allow(dead_code)] #[derive(Debug, thiserror::Error)] pub enum LlmError { #[error("missing required input '{0}'")] MissingInput(&'static str), + #[error("unsupported api '{0}' (expected: ollama, openai, anthropic)")] + UnsupportedApi(String), #[error("http error: {0}")] Http(String), #[error("non-success status: {0}")] Status(u16), - #[error("response body could not be parsed as Ollama schema: {0}")] + #[error("response body could not be parsed as the expected schema: {0}")] Decode(String), } +/// Which wire format to speak. Selected by the `api` input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Api { + Ollama, + Openai, + Anthropic, +} + +impl Api { + /// Parse the `api` input. Empty string means the input was not + /// provided and falls back to Ollama (v0.1.0 behavior). + pub fn parse(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "ollama" => Ok(Api::Ollama), + "openai" => Ok(Api::Openai), + "anthropic" => Ok(Api::Anthropic), + other => Err(LlmError::UnsupportedApi(other.to_string())), + } + } +} + pub trait LlmClient { - fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result; + /// POST `body` as JSON to `url` with the given extra headers. + /// `Content-Type: application/json` is implied. Header VALUES + /// may carry credentials — implementations must never log them. + fn post_json( + &self, + url: &str, + body: &str, + headers: &[(&'static str, String)], + ) -> Result; } #[derive(Debug, Clone)] pub struct ChatParams<'a> { + pub api: Api, pub endpoint: &'a str, pub model: &'a str, pub api_key: &'a str, @@ -37,8 +86,29 @@ pub struct ChatParams<'a> { pub prompt: &'a str, } +/// Auth/protocol headers for the selected API. The api_key is only +/// ever placed into a header value here; it must not appear in any +/// error, output, or log line. +pub fn build_headers(p: &ChatParams) -> Vec<(&'static str, String)> { + let mut headers = Vec::with_capacity(2); + match p.api { + Api::Ollama | Api::Openai => { + if !p.api_key.is_empty() { + headers.push(("Authorization", format!("Bearer {}", p.api_key))); + } + } + Api::Anthropic => { + if !p.api_key.is_empty() { + headers.push(("x-api-key", p.api_key.to_string())); + } + headers.push(("anthropic-version", ANTHROPIC_VERSION.to_string())); + } + } + headers +} + #[derive(Serialize)] -struct OllamaMessage<'a> { +struct ChatMessage<'a> { role: &'a str, content: &'a str, } @@ -46,33 +116,86 @@ struct OllamaMessage<'a> { #[derive(Serialize)] struct OllamaRequest<'a> { model: &'a str, - messages: Vec>, + messages: Vec>, 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 { +#[derive(Serialize)] +struct OpenAiRequest<'a> { + model: &'a str, + messages: Vec>, + stream: bool, +} + +#[derive(Serialize)] +struct AnthropicRequest<'a> { + model: &'a str, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option<&'a str>, + messages: Vec>, +} + +/// System-then-user message list shared by the Ollama and OpenAI +/// wire formats. The system message is omitted when empty so a +/// deployment can use the model's built-in system prompt. +fn build_messages<'a>(p: &ChatParams<'a>) -> Vec> { let mut messages = Vec::with_capacity(2); if !p.system_prompt.is_empty() { - messages.push(OllamaMessage { + messages.push(ChatMessage { role: "system", content: p.system_prompt, }); } - messages.push(OllamaMessage { + messages.push(ChatMessage { role: "user", content: p.prompt, }); + messages +} + +/// Build the request body for an Ollama `/api/chat` invocation. +pub fn build_ollama_body(p: &ChatParams) -> String { let req = OllamaRequest { model: p.model, - messages, + messages: build_messages(p), stream: false, }; serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}")) } +/// Build the request body for an OpenAI-compatible +/// `/v1/chat/completions` invocation (OpenAI, vLLM, most +/// self-hosted inference servers). +pub fn build_openai_body(p: &ChatParams) -> String { + let req = OpenAiRequest { + model: p.model, + messages: build_messages(p), + stream: false, + }; + serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}")) +} + +/// Build the request body for an Anthropic `/v1/messages` +/// invocation. `system` is a top-level field (not a message); +/// `max_tokens` is mandatory in the Messages API. +pub fn build_anthropic_body(p: &ChatParams) -> String { + let req = AnthropicRequest { + model: p.model, + max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, + system: if p.system_prompt.is_empty() { + None + } else { + Some(p.system_prompt) + }, + messages: vec![ChatMessage { + role: "user", + content: p.prompt, + }], + }; + 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 { @@ -85,16 +208,44 @@ pub fn extract_ollama_content(body: &str) -> Result { .ok_or_else(|| LlmError::Decode("missing message.content".into())) } +/// Extract the assistant text from an OpenAI Chat Completions +/// response: `choices[0].message.content`. +pub fn extract_openai_content(body: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?; + v.get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.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 choices[0].message.content".into())) +} + +/// Extract the assistant text from an Anthropic Messages +/// response: `content[0].text`. +pub fn extract_anthropic_content(body: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?; + v.get("content") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| LlmError::Decode("missing content[0].text".into())) +} + #[derive(Debug, Clone)] pub struct ChatWithIdentity { pub response: String, pub model_digest: Option, } -/// 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. +/// Run one chat call in the selected wire format AND, for Ollama, +/// probe the model digest. The probe is best-effort — non-Ollama +/// APIs expose no digest endpoint, so `model_digest = None` there +/// (documented empty, never invented); transient probe failures on +/// Ollama also yield `None` rather than failing the whole call. pub fn chat_with_identity( client: &C, p: &ChatParams, @@ -108,10 +259,22 @@ pub fn chat_with_identity( 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); + let body = match p.api { + Api::Ollama => build_ollama_body(p), + Api::Openai => build_openai_body(p), + Api::Anthropic => build_anthropic_body(p), + }; + let headers = build_headers(p); + let response_body = client.post_json(p.endpoint, &body, &headers)?; + let response = match p.api { + Api::Ollama => extract_ollama_content(&response_body)?, + Api::Openai => extract_openai_content(&response_body)?, + Api::Anthropic => extract_anthropic_content(&response_body)?, + }; + let model_digest = match p.api { + Api::Ollama => probe_model_digest(client, p), + Api::Openai | Api::Anthropic => None, + }; Ok(ChatWithIdentity { response, model_digest, @@ -121,7 +284,8 @@ pub fn chat_with_identity( fn probe_model_digest(client: &C, p: &ChatParams) -> Option { 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()?; + let headers = build_headers(p); + let response = client.post_json(&show_url, &body, &headers).ok()?; extract_show_digest(&response) } @@ -160,20 +324,39 @@ mod tests { use super::*; use std::cell::RefCell; + /// (url, body, headers) of one recorded call. + type RecordedCall = (String, String, Vec<(String, String)>); + struct MockClient { responses: RefCell>>, + /// Every call, in call order. + calls: RefCell>, } impl MockClient { fn new(responses: Vec>) -> Self { Self { responses: RefCell::new(responses), + calls: RefCell::new(Vec::new()), } } } impl LlmClient for MockClient { - fn post_json(&self, _url: &str, _body: &str, _api_key: &str) -> Result { + fn post_json( + &self, + url: &str, + body: &str, + headers: &[(&'static str, String)], + ) -> Result { + self.calls.borrow_mut().push(( + url.to_string(), + body.to_string(), + headers + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + )); self.responses .borrow_mut() .pop() @@ -181,14 +364,42 @@ mod tests { } } - #[test] - fn ollama_body_includes_system_when_provided() { - let p = ChatParams { + fn params<'a>(api: Api) -> ChatParams<'a> { + ChatParams { + api, endpoint: "http://x/api/chat", model: "qwen", api_key: "", - system_prompt: "be helpful", + system_prompt: "", prompt: "hello", + } + } + + // ---------------- api input parsing ---------------- + + #[test] + fn api_parse_defaults_to_ollama() { + assert_eq!(Api::parse("").unwrap(), Api::Ollama); + assert_eq!(Api::parse("ollama").unwrap(), Api::Ollama); + assert_eq!(Api::parse(" OpenAI ").unwrap(), Api::Openai); + assert_eq!(Api::parse("anthropic").unwrap(), Api::Anthropic); + } + + #[test] + fn api_parse_rejects_unknown() { + assert!(matches!( + Api::parse("gemini"), + Err(LlmError::UnsupportedApi(_)) + )); + } + + // ---------------- Ollama wire format (regression) ---------------- + + #[test] + fn ollama_body_includes_system_when_provided() { + let p = ChatParams { + system_prompt: "be helpful", + ..params(Api::Ollama) }; let body = build_ollama_body(&p); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); @@ -198,18 +409,12 @@ mod tests { assert_eq!(messages[0]["content"], "be helpful"); assert_eq!(messages[1]["role"], "user"); assert_eq!(messages[1]["content"], "hello"); + assert_eq!(v["stream"], false); } #[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 body = build_ollama_body(¶ms(Api::Ollama)); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); let messages = v["messages"].as_array().unwrap(); assert_eq!(messages.len(), 1); @@ -231,6 +436,208 @@ mod tests { )); } + #[test] + fn ollama_bearer_header_only_with_key() { + assert!(build_headers(¶ms(Api::Ollama)).is_empty()); + let p = ChatParams { + api_key: "sk-test", + ..params(Api::Ollama) + }; + assert_eq!( + build_headers(&p), + vec![("Authorization", "Bearer sk-test".to_string())] + ); + } + + // ---------------- OpenAI wire format ---------------- + + #[test] + fn openai_body_matches_chat_completions_format() { + let p = ChatParams { + api: Api::Openai, + endpoint: "http://vllm:8000/v1/chat/completions", + model: "meta-llama/Llama-3.1-8B-Instruct", + api_key: "sk-x", + system_prompt: "be terse", + prompt: "hello", + }; + let body = build_openai_body(&p); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["model"], "meta-llama/Llama-3.1-8B-Instruct"); + assert_eq!(v["stream"], false); + let messages = v["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[0]["content"], "be terse"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "hello"); + // No Anthropic-only fields leak into the OpenAI body. + assert!(v.get("max_tokens").is_none()); + assert!(v.get("system").is_none()); + } + + #[test] + fn openai_response_parses_fixture() { + // Shape as returned by OpenAI / vLLM /v1/chat/completions. + let body = r#"{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1719000000, + "model": "meta-llama/Llama-3.1-8B-Instruct", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hi from vLLM"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13} + }"#; + assert_eq!(extract_openai_content(body).unwrap(), "Hi from vLLM"); + } + + #[test] + fn openai_response_errors_on_missing_choices() { + assert!(matches!( + extract_openai_content(r#"{"object":"chat.completion","choices":[]}"#), + Err(LlmError::Decode(_)) + )); + assert!(matches!( + extract_openai_content(r#"{"error":{"message":"invalid key"}}"#), + Err(LlmError::Decode(_)) + )); + } + + #[test] + fn openai_uses_bearer_auth() { + let p = ChatParams { + api_key: "sk-test", + ..params(Api::Openai) + }; + assert_eq!( + build_headers(&p), + vec![("Authorization", "Bearer sk-test".to_string())] + ); + } + + #[test] + fn openai_chat_returns_content_and_no_digest() { + let canned = r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#; + let client = MockClient::new(vec![Ok(canned.to_string())]); + let p = ChatParams { + api: Api::Openai, + endpoint: "http://vllm:8000/v1/chat/completions", + model: "m", + api_key: "sk", + system_prompt: "", + prompt: "hi", + }; + let result = chat_with_identity(&client, &p).unwrap(); + assert_eq!(result.response, "ok"); + // No digest API on OpenAI-compatible endpoints — documented + // empty, and exactly one HTTP call (no probe). + assert_eq!(result.model_digest, None); + assert_eq!(client.calls.borrow().len(), 1); + } + + // ---------------- Anthropic wire format ---------------- + + #[test] + fn anthropic_body_matches_messages_format() { + let p = ChatParams { + api: Api::Anthropic, + endpoint: "https://api.anthropic.com/v1/messages", + model: "claude-fable-5", + api_key: "sk-ant", + system_prompt: "be terse", + prompt: "hello", + }; + let body = build_anthropic_body(&p); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["model"], "claude-fable-5"); + // max_tokens is mandatory in the Messages API. + assert_eq!(v["max_tokens"], 4096); + // system is a top-level field, never a message. + assert_eq!(v["system"], "be terse"); + let messages = v["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"], "hello"); + // No OpenAI/Ollama-only fields leak in. + assert!(v.get("stream").is_none()); + } + + #[test] + fn anthropic_body_omits_system_when_empty() { + let body = build_anthropic_body(¶ms(Api::Anthropic)); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(v.get("system").is_none()); + assert_eq!(v["max_tokens"], 4096); + } + + #[test] + fn anthropic_response_parses_fixture() { + // Shape as returned by the Anthropic Messages API. + let body = r#"{ + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "model": "claude-fable-5", + "content": [{"type": "text", "text": "Hi from Claude"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5} + }"#; + assert_eq!(extract_anthropic_content(body).unwrap(), "Hi from Claude"); + } + + #[test] + fn anthropic_response_errors_on_missing_content() { + assert!(matches!( + extract_anthropic_content(r#"{"type":"message","content":[]}"#), + Err(LlmError::Decode(_)) + )); + assert!(matches!( + extract_anthropic_content(r#"{"type":"error","error":{"message":"x"}}"#), + Err(LlmError::Decode(_)) + )); + } + + #[test] + fn anthropic_uses_x_api_key_and_version_headers() { + let p = ChatParams { + api_key: "sk-ant-test", + ..params(Api::Anthropic) + }; + let headers = build_headers(&p); + assert_eq!( + headers, + vec![ + ("x-api-key", "sk-ant-test".to_string()), + ("anthropic-version", "2023-06-01".to_string()), + ] + ); + // Never Bearer auth on the Anthropic path. + assert!(headers.iter().all(|(k, _)| *k != "Authorization")); + } + + #[test] + fn anthropic_chat_returns_content_and_no_digest() { + let canned = r#"{"content":[{"type":"text","text":"ok"}]}"#; + let client = MockClient::new(vec![Ok(canned.to_string())]); + let p = ChatParams { + api: Api::Anthropic, + endpoint: "https://api.anthropic.com/v1/messages", + model: "claude-fable-5", + api_key: "sk-ant", + system_prompt: "", + prompt: "hi", + }; + let result = chat_with_identity(&client, &p).unwrap(); + assert_eq!(result.response, "ok"); + assert_eq!(result.model_digest, None); + assert_eq!(client.calls.borrow().len(), 1); + } + + // ---------------- digest probe (Ollama only) ---------------- + #[test] fn derive_show_url_swaps_chat_suffix() { assert_eq!( @@ -263,10 +670,7 @@ mod tests { 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", + ..params(Api::Ollama) }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "the response"); @@ -279,10 +683,7 @@ mod tests { 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", + ..params(Api::Ollama) }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "ok"); @@ -290,30 +691,26 @@ mod tests { } #[test] - fn chat_with_identity_skips_probe_for_non_ollama() { + fn chat_with_identity_skips_probe_for_non_ollama_url() { 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", + endpoint: "https://example.com/proxy/chat", + ..params(Api::Ollama) }; let result = chat_with_identity(&client, &p).unwrap(); assert_eq!(result.response, "ok"); assert_eq!(result.model_digest, None); } + // ---------------- input guards ---------------- + #[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: "", + ..params(Api::Ollama) }; assert!(matches!( chat_with_identity(&client, &p),