opencodecommit 1.5.0

AI-powered git commit message generator that delegates to terminal AI agents
Documentation
use serde::Deserialize;
use serde_json::json;

use super::{ApiRequest, ApiResponse, ensure_success, http_client};
use crate::{Error, Result};

#[derive(Debug, Deserialize)]
struct TagsResponse {
    #[serde(default)]
    models: Vec<ModelEntry>,
}

#[derive(Debug, Deserialize)]
struct ModelEntry {
    #[serde(default)]
    name: String,
}

#[derive(Debug, Deserialize)]
struct GenerateResponse {
    #[serde(default)]
    response: String,
    #[serde(default)]
    model: String,
}

pub fn exec(request: &ApiRequest) -> Result<ApiResponse> {
    let client = http_client(request.timeout_secs)?;
    let model = if request.model.trim().is_empty() {
        detect_model(&request.endpoint, request.timeout_secs)?
    } else {
        request.model.trim().to_owned()
    };
    let endpoint = format!("{}/api/generate", base_endpoint(&request.endpoint));
    let response = ensure_success(
        client
            .post(endpoint)
            .json(&json!({
                "model": model,
                "prompt": request.prompt,
                "stream": false,
            }))
            .send()
            .map_err(|err| Error::BackendExecution(format!("Ollama request failed: {err}")))?,
        "Ollama",
    )?;

    let payload: GenerateResponse = response
        .json()
        .map_err(|err| Error::BackendExecution(format!("invalid Ollama response: {err}")))?;
    let text = payload.response.trim().to_owned();
    if text.is_empty() {
        return Err(Error::BackendExecution(
            "Ollama backend returned empty response".to_owned(),
        ));
    }

    Ok(ApiResponse {
        text,
        model: if payload.model.is_empty() {
            model
        } else {
            payload.model
        },
        usage: None,
    })
}

pub fn detect_model(endpoint: &str, timeout_secs: u64) -> Result<String> {
    let client = http_client(timeout_secs.min(2))?;
    let response = ensure_success(
        client
            .get(format!("{}/api/tags", base_endpoint(endpoint)))
            .send()
            .map_err(|err| Error::BackendExecution(format!("Ollama model lookup failed: {err}")))?,
        "Ollama",
    )?;
    let payload: TagsResponse = response
        .json()
        .map_err(|err| Error::BackendExecution(format!("invalid Ollama model list: {err}")))?;

    let mut names: Vec<String> = payload
        .models
        .into_iter()
        .filter_map(|entry| (!entry.name.trim().is_empty()).then_some(entry.name))
        .collect();
    names.sort();
    names
        .into_iter()
        .next()
        .ok_or_else(|| Error::BackendExecution("no Ollama models available".to_owned()))
}

fn base_endpoint(endpoint: &str) -> String {
    let trimmed = endpoint.trim().trim_end_matches('/');
    for suffix in ["/api/generate", "/api/tags"] {
        if let Some(base) = trimmed.strip_suffix(suffix) {
            return base.to_owned();
        }
    }
    trimmed.to_owned()
}