brazen 0.0.4

A stateless, swiss-army-knife adapter for every LLM provider and protocol.
Documentation
//! REQUEST projection (providers §5.3): canonical → `POST {base_url}/api/chat`
//! body + non-auth headers. OpenAI-chat-shaped, but generation params nest under
//! `options` and tool-call `arguments` ride as a JSON **object** (not a string).
//! Pure; the `Authorization: Bearer` header is set later by `Auth`. The
//! `messages[]` projection lives in [`messages`]; this module owns the top-level
//! body assembly, the nested `options`, and the `tools[]` objects.

use serde_json::{json, Map, Value};

use crate::canonical::{CanonicalError, CanonicalRequest, ErrorKind, OutputFormat, Tool};
use crate::protocol::json::finish_body;
use crate::protocol::{ProviderCtx, WireRequest};

mod messages;

/// The request path appended to `base_url` (§5.3) — the one home for `/api/chat`,
/// read by both `encode` and the `Protocol::path` impl.
pub(super) const REQUEST_PATH: &str = "/api/chat";

/// Build the wire request (§5.3). Typed fields serialize first; `extra` folds in
/// only keys they did not set — the typed field is the single source of truth.
pub(super) fn encode(
    req: &CanonicalRequest,
    ctx: &ProviderCtx,
) -> Result<WireRequest, CanonicalError> {
    let mut body = Map::new();
    body.insert("model".into(), json!(ctx.model));
    body.insert("messages".into(), messages::messages_value(req)?);
    if !req.tools.is_empty() {
        body.insert("tools".into(), tools_value(&req.tools)?); // omit when empty
    }
    let options = options_value(req);
    if !options.is_empty() {
        body.insert("options".into(), Value::Object(options)); // generation params nest here
    }
    if req.reasoning.is_some() {
        // Ollama's `think` is a top-level bool with NO effort granularity, so any
        // effort collapses to ON (providers §6). A non-reasoning model opts out via
        // `unsupported_body_keys = ["reasoning"]` (config §4.1.1).
        body.insert("think".into(), json!(true));
    }
    body.insert("stream".into(), json!(req.stream.unwrap_or(false)));
    // `output` → the top-level `format` field (§5.3): `"json"` for plain JSON mode,
    // the raw schema OBJECT for the schema variant. `name`/`strict` have no Ollama
    // field → narrowed (providers §6). Before the `extra` fold, so the typed knob wins.
    match &req.output {
        None => {}
        Some(OutputFormat::Json) => {
            body.insert("format".into(), json!("json"));
        }
        Some(OutputFormat::JsonSchema { schema, .. }) => {
            body.insert("format".into(), schema.clone());
        }
    }
    for (k, v) in &req.extra {
        body.entry(k.clone()).or_insert_with(|| v.clone()); // typed fields win (§5.3)
    }
    Ok(finish_body(body, format!("{}{REQUEST_PATH}", ctx.base_url)))
}

/// `max_tokens`/`temperature`/`top_p`/`stop` → the nested `options` map (§5.3),
/// each omitted when absent so an empty `options` is dropped entirely.
fn options_value(req: &CanonicalRequest) -> Map<String, Value> {
    let mut options = Map::new();
    if let Some(n) = req.max_tokens {
        options.insert("num_predict".into(), json!(n)); // RENAME
    }
    if let Some(t) = req.temperature {
        options.insert("temperature".into(), json!(t));
    }
    if let Some(p) = req.top_p {
        options.insert("top_p".into(), json!(p));
    }
    if !req.stop.is_empty() {
        options.insert("stop".into(), json!(req.stop));
    }
    options
}

/// `tools[]` → OpenAI-chat-shaped function objects (§5.3); `description` omitted
/// when `None`, `parameters` carries the schema verbatim. A provider-typed tool
/// has no Ollama projection — fail fast with `ParseInput` (exit 64), never a drop.
fn tools_value(tools: &[Tool]) -> Result<Value, CanonicalError> {
    let mut out = Vec::new();
    for t in tools {
        let Tool::Custom {
            name,
            description,
            input_schema,
            .. // `strict` has no Ollama function field → narrowed (providers §6)
        } = t
        else {
            return Err(CanonicalError {
                kind: ErrorKind::ParseInput,
                message: "provider-typed tools are not projected for this dialect".into(),
                provider_detail: None,
                retry_after_seconds: None,
            });
        };
        let mut f = json!({"name": name, "parameters": input_schema});
        if let Some(d) = description {
            f["description"] = json!(d);
        }
        out.push(json!({"type": "function", "function": f}));
    }
    Ok(Value::Array(out))
}