dk_runner/steps/agent_review/
openrouter.rs1use 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}