dk-runner 0.2.85

dkod verification runner — CI/CD pipeline execution
Documentation
use std::time::Duration;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::parse::parse_review_response;
use super::prompt::build_review_prompt;
use super::provider::{ReviewProvider, ReviewRequest, ReviewResponse};

pub struct OpenRouterReviewProvider {
    client: reqwest::Client,
    api_key: String,
    model: String,
    max_tokens: usize,
    base_url: String,
}

impl OpenRouterReviewProvider {
    pub fn new(
        api_key: String,
        model: Option<String>,
        max_tokens: Option<usize>,
        base_url: Option<String>,
    ) -> Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(120))
            .build()?;
        Ok(Self {
            client,
            api_key,
            model: model.unwrap_or_else(|| "anthropic/claude-opus-4.7".to_string()),
            max_tokens: max_tokens.unwrap_or(4096),
            base_url: base_url.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()),
        })
    }

    pub fn from_env() -> Option<Self> {
        let api_key = std::env::var("DKOD_OPENROUTER_API_KEY").ok()?;
        let model = std::env::var("DKOD_REVIEW_MODEL").ok();
        let base_url = std::env::var("DKOD_OPENROUTER_BASE_URL").ok();
        Self::new(api_key, model, None, base_url).ok()
    }
}

#[derive(Serialize)]
struct ChatRequest {
    model: String,
    max_tokens: usize,
    messages: Vec<ChatMessage>,
}

#[derive(Serialize)]
struct ChatMessage {
    role: String,
    content: String,
}

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
}

#[derive(Deserialize)]
struct Choice {
    message: ResponseMessage,
}

#[derive(Deserialize)]
struct ResponseMessage {
    content: String,
}

#[async_trait::async_trait]
impl ReviewProvider for OpenRouterReviewProvider {
    fn name(&self) -> &str {
        "openrouter"
    }

    async fn review(&self, request: ReviewRequest) -> Result<ReviewResponse> {
        let prompt = build_review_prompt(&request);
        let resp = self
            .client
            .post(format!("{}/chat/completions", self.base_url))
            .bearer_auth(&self.api_key)
            .header("HTTP-Referer", "https://dkod.io")
            .header("X-Title", "dkod code review")
            .json(&ChatRequest {
                model: self.model.clone(),
                max_tokens: self.max_tokens,
                messages: vec![ChatMessage {
                    role: "user".to_string(),
                    content: prompt,
                }],
            })
            .send()
            .await
            .context("Failed to call OpenRouter API")?;
        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            anyhow::bail!("OpenRouter API returned {status}: {body}");
        }
        let api_resp: ChatResponse = resp
            .json()
            .await
            .context("Failed to parse OpenRouter API response")?;
        let text = api_resp
            .choices
            .into_iter()
            .next()
            .map(|c| c.message.content)
            .unwrap_or_default();
        parse_review_response(&text)
    }
}

#[cfg(test)]
mod tests {
    use super::OpenRouterReviewProvider;
    use crate::steps::agent_review::provider::ReviewProvider;

    fn clear() {
        for k in ["DKOD_OPENROUTER_API_KEY", "DKOD_REVIEW_MODEL", "DKOD_OPENROUTER_BASE_URL"] {
            std::env::remove_var(k);
        }
    }

    #[serial_test::serial]
    #[tokio::test]
    async fn from_env_returns_none_without_key() {
        clear();
        assert!(OpenRouterReviewProvider::from_env().is_none());
    }

    #[serial_test::serial]
    #[tokio::test]
    async fn from_env_builds_provider_with_key() {
        clear();
        std::env::set_var("DKOD_OPENROUTER_API_KEY", "sk-test");
        let p = OpenRouterReviewProvider::from_env().unwrap();
        assert_eq!(p.name(), "openrouter");
        clear();
    }
}