solo-storage 0.11.5

Solo: SQLite + SQLCipher persistence layer
Documentation
// SPDX-License-Identifier: Apache-2.0

//! [`OllamaClient`] — a thin wrapper around [`super::openai::OpenAIClient`]
//! that surfaces `"ollama:<model>"` as the backend identity instead of
//! the bare model string.
//!
//! ## Why the wrapper exists
//!
//! Ollama exposes the OpenAI Chat Completions wire format on
//! `localhost:11434/v1`, so Solo's `OpenAIClient` works against it
//! unchanged — set `OPENAI_BASE_URL=http://localhost:11434/v1` and an
//! `OPENAI_MODEL=qwen2.5-coder:7b`, and `complete()` round-trips
//! through Ollama. Operationally fine, but the [`solo_core::LlmClient`]
//! `name()` method (consumed by tracing logs in `daemon.rs` /
//! `common.rs` AND by [`solo_core::Provenance::by`] when persisting
//! abstractions) returns just the model string. An operator reading
//! the log later sees `model="qwen2.5-coder:7b"` with no indication
//! the call hit Ollama instead of hosted OpenAI.
//!
//! [`OllamaClient`] wraps the underlying [`super::openai::OpenAIClient`]
//! and overrides `name()` to return `"ollama:qwen2.5-coder:7b"`. Every
//! other method delegates to the inner client — same retry policy,
//! same wire format, same timeout. The Steward sees no behaviour
//! change; only the identity surface differs.
//!
//! ## How wrapping happens
//!
//! [`super::openai::build_openai_client_from_env`] inspects
//! `OPENAI_BASE_URL` after constructing the `OpenAIClient`. If the
//! URL passes [`is_ollama_base_url`] (today: contains `:11434`, the
//! default Ollama port), the env-builder wraps the client in
//! [`OllamaClient`] before boxing as `Arc<dyn LlmClient>`. Operators
//! who run Ollama on a non-default port lose the prefix but keep
//! identical behaviour — the heuristic is intentionally narrow to
//! avoid mis-tagging non-Ollama backends (LM Studio on `:1234`,
//! anything else on `:11434` would be unusual).
//!
//! ## Provenance impact
//!
//! `Provenance.by` is set from `LlmClient::name()` at write time
//! (`solo_steward::abstraction::abstract_cluster`). Wrapping changes
//! the stored value from `"qwen2.5-coder:7b"` to
//! `"ollama:qwen2.5-coder:7b"` for new abstractions / triples. This
//! is a CHANGE to the data shape but is forward-looking — operators
//! can later filter abstractions by backend ("show me everything
//! Ollama produced") which is more informative than the bare model
//! name. Historical rows produced before this commit landed retain
//! the un-prefixed name.

use std::sync::Arc;

use async_trait::async_trait;
use solo_core::{LlmClient, Message, Result};

use super::openai::OpenAIClient;

/// Wrap an [`OpenAIClient`] so its `LlmClient::name()` returns
/// `"ollama:<model>"` instead of just `"<model>"`. Every other
/// method delegates to the inner client unchanged.
///
/// Prefer [`OllamaClient::from_arc`] when you already have an
/// `Arc<OpenAIClient>` to share.
pub struct OllamaClient {
    inner: Arc<OpenAIClient>,
    /// Precomputed `"ollama:<model>"` so `name()` can return `&str`
    /// without allocating per call.
    display_name: String,
}

impl OllamaClient {
    /// Wrap a freshly-constructed [`OpenAIClient`].
    pub fn wrap(inner: OpenAIClient) -> Self {
        Self::from_arc(Arc::new(inner))
    }

    /// Wrap an `Arc<OpenAIClient>`. Useful when the same underlying
    /// client is shared by multiple call sites (rare today; the
    /// env-builder always constructs a fresh client).
    pub fn from_arc(inner: Arc<OpenAIClient>) -> Self {
        let display_name = format!("ollama:{}", inner.model());
        Self {
            inner,
            display_name,
        }
    }

    /// Borrow the wrapped client. Useful for tests that want to
    /// assert on the inner state (e.g. base_url) without going
    /// through `LlmClient` indirection.
    pub fn inner(&self) -> &OpenAIClient {
        &self.inner
    }
}

#[async_trait]
impl LlmClient for OllamaClient {
    fn name(&self) -> &str {
        &self.display_name
    }

    async fn complete(&self, messages: &[Message]) -> Result<Message> {
        self.inner.complete(messages).await
    }
}

/// Heuristic: a base URL pointing at port `11434` (Ollama's default)
/// is treated as Ollama. Used by the env-builder to decide whether
/// to wrap the constructed [`OpenAIClient`] in [`OllamaClient`].
///
/// False negatives: an operator running Ollama on a custom port
/// (e.g. behind a reverse proxy on `:8080/ollama/v1`) would not get
/// the `"ollama:"` prefix. They keep the bare model name in
/// `name()` and provenance — same behaviour as v0.3.7. Acceptable
/// for a cosmetic surface; the cost of false positives (mis-tagging
/// LM Studio etc.) is higher than the cost of missing custom-port
/// Ollama.
///
/// False positives: anything else listening on `:11434` would get
/// mis-tagged as Ollama. Unlikely in practice — the port is
/// well-known and reserved.
pub fn is_ollama_base_url(url: &str) -> bool {
    // Match `:11434` as a substring. Covers
    // `http://localhost:11434/v1`, `http://127.0.0.1:11434`,
    // `http://my-ollama-host:11434/v1`, etc. Doesn't match URLs that
    // include `11434` elsewhere (e.g. as a path component) because
    // the `:` prefix is meaningful.
    url.contains(":11434")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_default_ollama_url() {
        assert!(is_ollama_base_url("http://localhost:11434/v1"));
        assert!(is_ollama_base_url("http://127.0.0.1:11434/v1"));
        assert!(is_ollama_base_url("http://localhost:11434"));
        assert!(is_ollama_base_url(
            "http://my-remote-ollama.example.com:11434/v1"
        ));
    }

    #[test]
    fn rejects_other_endpoints() {
        // Hosted OpenAI
        assert!(!is_ollama_base_url("https://api.openai.com/v1"));
        // LM Studio (different default port)
        assert!(!is_ollama_base_url("http://localhost:1234/v1"));
        // Together / Groq / Mistral hosted
        assert!(!is_ollama_base_url("https://api.together.xyz/v1"));
        assert!(!is_ollama_base_url("https://api.groq.com/openai/v1"));
        // 11434 as path component (unlikely but pathological)
        assert!(!is_ollama_base_url("https://api.example.com/v1/11434"));
    }

    #[test]
    fn wrap_produces_ollama_prefixed_display_name() {
        let inner =
            OpenAIClient::new("dummy-key", "qwen2.5-coder:7b").unwrap();
        let wrapped = OllamaClient::wrap(inner);
        assert_eq!(wrapped.name(), "ollama:qwen2.5-coder:7b");
    }

    #[test]
    fn wrap_preserves_inner_for_introspection() {
        let inner = OpenAIClient::new("dummy-key", "phi4:14b")
            .unwrap()
            .with_base_url("http://localhost:11434/v1");
        let wrapped = OllamaClient::wrap(inner);
        assert_eq!(wrapped.inner().model(), "phi4:14b");
        assert_eq!(wrapped.inner().base_url(), "http://localhost:11434/v1");
        assert_eq!(wrapped.name(), "ollama:phi4:14b");
    }

    #[test]
    fn from_arc_constructor_works_for_shared_inner() {
        let inner =
            Arc::new(OpenAIClient::new("dummy-key", "llama3.3:8b").unwrap());
        let wrapped = OllamaClient::from_arc(inner.clone());
        assert_eq!(wrapped.name(), "ollama:llama3.3:8b");
        // Inner is shared — model() reflects the same source.
        assert_eq!(inner.model(), "llama3.3:8b");
    }
}