Skip to main content

dk_runner/steps/agent_review/
openrouter.rs

1use std::time::Duration;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use super::parse::parse_review_response;
5use super::prompt::build_review_prompt;
6use super::provider::{ReviewProvider, ReviewRequest, ReviewResponse};
7
8pub struct OpenRouterReviewProvider {
9    client: reqwest::Client,
10    api_key: String,
11    model: String,
12    max_tokens: usize,
13    base_url: String,
14}
15
16impl OpenRouterReviewProvider {
17    pub fn new(
18        api_key: String,
19        model: Option<String>,
20        max_tokens: Option<usize>,
21        base_url: Option<String>,
22    ) -> Result<Self> {
23        let client = reqwest::Client::builder()
24            .timeout(Duration::from_secs(120))
25            .build()?;
26        Ok(Self {
27            client,
28            api_key,
29            model: model.unwrap_or_else(|| "anthropic/claude-opus-4.7".to_string()),
30            max_tokens: max_tokens.unwrap_or(4096),
31            base_url: base_url.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()),
32        })
33    }
34
35    pub fn from_env() -> Option<Self> {
36        let api_key = std::env::var("DKOD_OPENROUTER_API_KEY").ok()?;
37        let model = std::env::var("DKOD_REVIEW_MODEL").ok();
38        let base_url = std::env::var("DKOD_OPENROUTER_BASE_URL").ok();
39        Self::new(api_key, model, None, base_url).ok()
40    }
41}
42
43#[derive(Serialize)]
44struct ChatRequest {
45    model: String,
46    max_tokens: usize,
47    messages: Vec<ChatMessage>,
48}
49
50#[derive(Serialize)]
51struct ChatMessage {
52    role: String,
53    content: String,
54}
55
56#[derive(Deserialize)]
57struct ChatResponse {
58    choices: Vec<Choice>,
59}
60
61#[derive(Deserialize)]
62struct Choice {
63    message: ResponseMessage,
64}
65
66#[derive(Deserialize)]
67struct ResponseMessage {
68    content: String,
69}
70
71#[async_trait::async_trait]
72impl ReviewProvider for OpenRouterReviewProvider {
73    fn name(&self) -> &str {
74        "openrouter"
75    }
76
77    async fn review(&self, request: ReviewRequest) -> Result<ReviewResponse> {
78        let prompt = build_review_prompt(&request);
79        let resp = self
80            .client
81            .post(format!("{}/chat/completions", self.base_url))
82            .bearer_auth(&self.api_key)
83            .header("HTTP-Referer", "https://dkod.io")
84            .header("X-Title", "dkod code review")
85            .json(&ChatRequest {
86                model: self.model.clone(),
87                max_tokens: self.max_tokens,
88                messages: vec![ChatMessage {
89                    role: "user".to_string(),
90                    content: prompt,
91                }],
92            })
93            .send()
94            .await
95            .context("Failed to call OpenRouter API")?;
96        if !resp.status().is_success() {
97            let status = resp.status();
98            let body = resp.text().await.unwrap_or_default();
99            anyhow::bail!("OpenRouter API returned {status}: {body}");
100        }
101        let api_resp: ChatResponse = resp
102            .json()
103            .await
104            .context("Failed to parse OpenRouter API response")?;
105        let text = api_resp
106            .choices
107            .into_iter()
108            .next()
109            .map(|c| c.message.content)
110            .unwrap_or_default();
111        parse_review_response(&text)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::OpenRouterReviewProvider;
118    use crate::steps::agent_review::provider::ReviewProvider;
119
120    fn clear() {
121        for k in ["DKOD_OPENROUTER_API_KEY", "DKOD_REVIEW_MODEL", "DKOD_OPENROUTER_BASE_URL"] {
122            std::env::remove_var(k);
123        }
124    }
125
126    #[serial_test::serial]
127    #[tokio::test]
128    async fn from_env_returns_none_without_key() {
129        clear();
130        assert!(OpenRouterReviewProvider::from_env().is_none());
131    }
132
133    #[serial_test::serial]
134    #[tokio::test]
135    async fn from_env_builds_provider_with_key() {
136        clear();
137        std::env::set_var("DKOD_OPENROUTER_API_KEY", "sk-test");
138        let p = OpenRouterReviewProvider::from_env().unwrap();
139        assert_eq!(p.name(), "openrouter");
140        clear();
141    }
142}