opencodecommit 1.6.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 GenerateContentResponse {
    #[serde(default)]
    candidates: Vec<Candidate>,
}

#[derive(Debug, Deserialize)]
struct Candidate {
    #[serde(default)]
    content: Option<Content>,
}

#[derive(Debug, Deserialize)]
struct Content {
    #[serde(default)]
    parts: Vec<Part>,
}

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

pub fn exec(request: &ApiRequest) -> Result<ApiResponse> {
    let api_key = request
        .api_key
        .as_deref()
        .ok_or_else(|| Error::BackendExecution("Gemini API key is not configured".to_owned()))?;
    if request.model.trim().is_empty() {
        return Err(Error::BackendExecution(
            "Gemini API model is not configured".to_owned(),
        ));
    }

    let client = http_client(request.timeout_secs)?;
    let endpoint = gemini_endpoint(&request.endpoint, &request.model);
    let response = ensure_success(
        client
            .post(endpoint)
            .query(&[("key", api_key)])
            .json(&json!({
                "contents": [{"parts": [{"text": request.prompt}]}],
                "generationConfig": { "maxOutputTokens": request.max_tokens },
            }))
            .send()
            .map_err(|err| Error::BackendExecution(format!("Gemini request failed: {err}")))?,
        "Gemini",
    )?;

    let payload: GenerateContentResponse = response
        .json()
        .map_err(|err| Error::BackendExecution(format!("invalid Gemini response: {err}")))?;
    let text = payload
        .candidates
        .iter()
        .find_map(|candidate| {
            candidate.content.as_ref().map(|content| {
                content
                    .parts
                    .iter()
                    .map(|part| part.text.as_str())
                    .collect::<Vec<_>>()
                    .join("")
            })
        })
        .unwrap_or_default()
        .trim()
        .to_owned();

    if text.is_empty() {
        return Err(Error::BackendExecution(
            "Gemini backend returned empty response".to_owned(),
        ));
    }

    Ok(ApiResponse {
        text,
        model: request.model.clone(),
        usage: None,
    })
}

fn gemini_endpoint(endpoint: &str, model: &str) -> String {
    let trimmed = endpoint.trim().trim_end_matches('/');
    if trimmed.contains("{model}") {
        return trimmed.replace("{model}", model);
    }
    if trimmed.contains(":generateContent") {
        return trimmed.to_owned();
    }
    if trimmed.ends_with("/v1beta") || trimmed.ends_with("/v1") {
        return format!("{trimmed}/models/{model}:generateContent");
    }
    format!("{trimmed}/v1beta/models/{model}:generateContent")
}