Skip to content

Commit 5166a6c

Browse files
haasonsaasclaude
andcommitted
Improve code quality, security, and test coverage
- Extract shared adapter retry/validation logic into adapters/common.rs, eliminating 80+ lines of duplication across OpenAI/Anthropic/Ollama - Improve API error messages with actionable hints (check API key, rate limited, server error) - Use proper URL parsing for local endpoint detection instead of naive string matching - Add input validation: branch names, base_url format, temperature (0.0-2.0), max_tokens (1-128000), strictness with warnings - Reduce unnecessary clones in sort operations and adapter factory - Update reqwest 0.11->0.12, git2 0.18->0.19 - Reduce tokio features from "full" to only what's needed - Add 102 new tests (364->466): adapter tests, validation tests, common utility tests - Convert 19 stale BUG comments to Regression test markers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f86f16d commit 5166a6c

23 files changed

+1954
-446
lines changed

Cargo.lock

Lines changed: 266 additions & 209 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,26 @@ categories = ["development-tools", "command-line-utilities"]
1111

1212
[dependencies]
1313
clap = { version = "4.4", features = ["derive"] }
14-
tokio = { version = "1.35", features = ["full"] }
14+
tokio = { version = "1.35", features = ["macros", "rt-multi-thread", "fs", "time"] }
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
1717
serde_yaml = "0.9"
1818
anyhow = "1.0"
1919
thiserror = "1.0"
2020
tracing = "0.1"
2121
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
22-
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
22+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
2323
async-trait = "0.1"
2424
similar = "2.4"
25-
git2 = { version = "0.18", default-features = false }
25+
git2 = { version = "0.19", default-features = false }
2626
once_cell = "1.19"
2727
regex = "1.10"
2828
dirs = "5.0"
2929
chrono = "0.4"
3030
glob = "0.3"
3131
ignore = "0.4"
3232
shell-words = "1.1"
33+
url = "2.5"
3334

3435
[dev-dependencies]
3536
tempfile = "3.8"

src/adapters/anthropic.rs

Lines changed: 271 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
use crate::adapters::common;
12
use crate::adapters::llm::{LLMAdapter, LLMRequest, LLMResponse, ModelConfig, Usage};
23
use anyhow::{Context, Result};
34
use async_trait::async_trait;
4-
use reqwest::{Client, StatusCode};
5+
use reqwest::Client;
56
use serde::{Deserialize, Serialize};
6-
use std::time::Duration;
7-
use tokio::time::sleep;
87

98
pub struct AnthropicAdapter {
109
client: Client,
@@ -55,7 +54,7 @@ impl AnthropicAdapter {
5554
.clone()
5655
.unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
5756

58-
let is_local = is_local_endpoint(&base_url);
57+
let is_local = common::is_local_endpoint(&base_url);
5958

6059
let api_key = config.api_key.clone()
6160
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
@@ -74,41 +73,6 @@ impl AnthropicAdapter {
7473
})
7574
}
7675

77-
async fn send_with_retry<F>(&self, mut make_request: F) -> Result<reqwest::Response>
78-
where
79-
F: FnMut() -> reqwest::RequestBuilder,
80-
{
81-
const MAX_RETRIES: usize = 2;
82-
const BASE_DELAY_MS: u64 = 250;
83-
84-
for attempt in 0..=MAX_RETRIES {
85-
match make_request().send().await {
86-
Ok(response) => {
87-
if response.status().is_success() {
88-
return Ok(response);
89-
}
90-
91-
let status = response.status();
92-
let body = response.text().await.unwrap_or_default();
93-
if is_retryable_status(status) && attempt < MAX_RETRIES {
94-
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
95-
continue;
96-
}
97-
98-
anyhow::bail!("Anthropic API error ({}): {}", status, body);
99-
}
100-
Err(err) => {
101-
if attempt < MAX_RETRIES {
102-
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
103-
continue;
104-
}
105-
return Err(err.into());
106-
}
107-
}
108-
}
109-
110-
anyhow::bail!("Anthropic request failed after retries");
111-
}
11276
}
11377

11478
#[async_trait]
@@ -128,18 +92,17 @@ impl LLMAdapter for AnthropicAdapter {
12892
};
12993

13094
let url = format!("{}/messages", self.base_url);
131-
let response = self
132-
.send_with_retry(|| {
133-
self.client
134-
.post(&url)
135-
.header("x-api-key", &self.api_key)
136-
.header("anthropic-version", "2023-06-01")
137-
.header("anthropic-beta", "messages-2023-12-15")
138-
.header("Content-Type", "application/json")
139-
.json(&anthropic_request)
140-
})
141-
.await
142-
.context("Failed to send request to Anthropic")?;
95+
let response = common::send_with_retry("Anthropic", || {
96+
self.client
97+
.post(&url)
98+
.header("x-api-key", &self.api_key)
99+
.header("anthropic-version", "2023-06-01")
100+
.header("anthropic-beta", "messages-2023-12-15")
101+
.header("Content-Type", "application/json")
102+
.json(&anthropic_request)
103+
})
104+
.await
105+
.context("Failed to send request to Anthropic")?;
143106

144107
let anthropic_response: AnthropicResponse = response
145108
.json()
@@ -176,12 +139,262 @@ impl LLMAdapter for AnthropicAdapter {
176139
}
177140
}
178141

179-
fn is_retryable_status(status: StatusCode) -> bool {
180-
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
181-
}
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
use crate::adapters::llm::{LLMAdapter, LLMRequest, ModelConfig};
146+
147+
fn test_config(base_url: &str) -> ModelConfig {
148+
ModelConfig {
149+
model_name: "claude-3-5-sonnet-20241022".to_string(),
150+
api_key: Some("test-key".to_string()),
151+
base_url: Some(base_url.to_string()),
152+
temperature: 0.2,
153+
max_tokens: 100,
154+
openai_use_responses: None,
155+
adapter_override: None,
156+
}
157+
}
158+
159+
fn test_request() -> LLMRequest {
160+
LLMRequest {
161+
system_prompt: "system".to_string(),
162+
user_prompt: "user".to_string(),
163+
temperature: None,
164+
max_tokens: None,
165+
}
166+
}
167+
168+
#[tokio::test]
169+
async fn test_successful_completion() {
170+
let mut server = mockito::Server::new_async().await;
171+
let mock = server
172+
.mock("POST", "/messages")
173+
.with_status(200)
174+
.with_header("content-type", "application/json")
175+
.with_body(
176+
r#"{
177+
"content": [{"type": "text", "text": "test response"}],
178+
"model": "claude-3-5-sonnet-20241022",
179+
"usage": {"input_tokens": 10, "output_tokens": 5}
180+
}"#,
181+
)
182+
.create_async()
183+
.await;
184+
185+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
186+
let result = adapter.complete(test_request()).await;
187+
188+
assert!(result.is_ok());
189+
let response = result.unwrap();
190+
assert_eq!(response.content, "test response");
191+
assert_eq!(response.model, "claude-3-5-sonnet-20241022");
192+
let usage = response.usage.unwrap();
193+
assert_eq!(usage.prompt_tokens, 10);
194+
assert_eq!(usage.completion_tokens, 5);
195+
assert_eq!(usage.total_tokens, 15);
196+
mock.assert_async().await;
197+
}
198+
199+
#[tokio::test]
200+
async fn test_api_error_non_retryable_401() {
201+
let mut server = mockito::Server::new_async().await;
202+
let mock = server
203+
.mock("POST", "/messages")
204+
.with_status(401)
205+
.with_body("Unauthorized")
206+
.expect(1)
207+
.create_async()
208+
.await;
209+
210+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
211+
let result = adapter.complete(test_request()).await;
212+
213+
assert!(result.is_err());
214+
let err_msg = format!("{:#}", result.unwrap_err());
215+
assert!(
216+
err_msg.contains("401") || err_msg.contains("Unauthorized"),
217+
"Error should mention 401 or Unauthorized, got: {}",
218+
err_msg
219+
);
220+
mock.assert_async().await;
221+
}
222+
223+
#[tokio::test]
224+
async fn test_retryable_error_429_all_fail() {
225+
let mut server = mockito::Server::new_async().await;
226+
let mock = server
227+
.mock("POST", "/messages")
228+
.with_status(429)
229+
.with_body("Rate limited")
230+
.expect(3)
231+
.create_async()
232+
.await;
233+
234+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
235+
let result = adapter.complete(test_request()).await;
236+
237+
assert!(result.is_err());
238+
mock.assert_async().await;
239+
}
240+
241+
#[tokio::test]
242+
async fn test_retryable_error_500_all_fail() {
243+
let mut server = mockito::Server::new_async().await;
244+
let mock = server
245+
.mock("POST", "/messages")
246+
.with_status(500)
247+
.with_body("Internal Server Error")
248+
.expect(3)
249+
.create_async()
250+
.await;
182251

183-
fn is_local_endpoint(url: &str) -> bool {
184-
url.contains("localhost") || url.contains("127.0.0.1") || url.contains("0.0.0.0")
185-
|| url.contains("[::1]")
186-
|| (!url.contains("openai.com") && !url.contains("anthropic.com"))
252+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
253+
let result = adapter.complete(test_request()).await;
254+
255+
assert!(result.is_err());
256+
mock.assert_async().await;
257+
}
258+
259+
#[tokio::test]
260+
async fn test_request_includes_anthropic_headers() {
261+
let mut server = mockito::Server::new_async().await;
262+
let mock = server
263+
.mock("POST", "/messages")
264+
.match_header("x-api-key", "test-key")
265+
.match_header("anthropic-version", "2023-06-01")
266+
.with_status(200)
267+
.with_header("content-type", "application/json")
268+
.with_body(
269+
r#"{
270+
"content": [{"type": "text", "text": "ok"}],
271+
"model": "claude-3-5-sonnet-20241022",
272+
"usage": {"input_tokens": 1, "output_tokens": 1}
273+
}"#,
274+
)
275+
.create_async()
276+
.await;
277+
278+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
279+
let result = adapter.complete(test_request()).await;
280+
281+
assert!(result.is_ok());
282+
mock.assert_async().await;
283+
}
284+
285+
#[tokio::test]
286+
async fn test_unsupported_content_type() {
287+
let mut server = mockito::Server::new_async().await;
288+
let _mock = server
289+
.mock("POST", "/messages")
290+
.with_status(200)
291+
.with_header("content-type", "application/json")
292+
.with_body(
293+
r#"{
294+
"content": [{"type": "image", "text": "ignored"}],
295+
"model": "claude-3-5-sonnet-20241022",
296+
"usage": {"input_tokens": 1, "output_tokens": 1}
297+
}"#,
298+
)
299+
.create_async()
300+
.await;
301+
302+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
303+
let result = adapter.complete(test_request()).await;
304+
305+
assert!(result.is_ok());
306+
let response = result.unwrap();
307+
assert!(
308+
response.content.contains("Unsupported content type"),
309+
"Expected 'Unsupported content type' in response, got: {}",
310+
response.content
311+
);
312+
}
313+
314+
#[tokio::test]
315+
async fn test_empty_content_array() {
316+
let mut server = mockito::Server::new_async().await;
317+
let _mock = server
318+
.mock("POST", "/messages")
319+
.with_status(200)
320+
.with_header("content-type", "application/json")
321+
.with_body(
322+
r#"{
323+
"content": [],
324+
"model": "claude-3-5-sonnet-20241022",
325+
"usage": {"input_tokens": 1, "output_tokens": 0}
326+
}"#,
327+
)
328+
.create_async()
329+
.await;
330+
331+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
332+
let result = adapter.complete(test_request()).await;
333+
334+
assert!(result.is_ok());
335+
let response = result.unwrap();
336+
assert_eq!(response.content, "");
337+
}
338+
339+
#[test]
340+
fn test_local_endpoint_no_api_key() {
341+
let config = ModelConfig {
342+
model_name: "claude-3-5-sonnet-20241022".to_string(),
343+
api_key: None,
344+
base_url: Some("http://localhost:8080".to_string()),
345+
..Default::default()
346+
};
347+
let adapter = AnthropicAdapter::new(config);
348+
assert!(adapter.is_ok());
349+
}
350+
351+
#[test]
352+
fn test_local_endpoint_127_0_0_1_no_api_key() {
353+
let config = ModelConfig {
354+
model_name: "claude-3-5-sonnet-20241022".to_string(),
355+
api_key: None,
356+
base_url: Some("http://127.0.0.1:8080".to_string()),
357+
..Default::default()
358+
};
359+
let adapter = AnthropicAdapter::new(config);
360+
assert!(adapter.is_ok());
361+
}
362+
363+
#[test]
364+
fn test_model_name() {
365+
let config = test_config("http://localhost:8080");
366+
let adapter = AnthropicAdapter::new(config).unwrap();
367+
assert_eq!(adapter._model_name(), "claude-3-5-sonnet-20241022");
368+
}
369+
370+
#[tokio::test]
371+
async fn test_custom_temperature_and_max_tokens_override() {
372+
let mut server = mockito::Server::new_async().await;
373+
let _mock = server
374+
.mock("POST", "/messages")
375+
.with_status(200)
376+
.with_header("content-type", "application/json")
377+
.with_body(
378+
r#"{
379+
"content": [{"type": "text", "text": "ok"}],
380+
"model": "claude-3-5-sonnet-20241022",
381+
"usage": {"input_tokens": 1, "output_tokens": 1}
382+
}"#,
383+
)
384+
.create_async()
385+
.await;
386+
387+
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
388+
let result = adapter
389+
.complete(LLMRequest {
390+
system_prompt: "s".to_string(),
391+
user_prompt: "u".to_string(),
392+
temperature: Some(0.9),
393+
max_tokens: Some(200),
394+
})
395+
.await;
396+
397+
assert!(result.is_ok());
398+
}
187399
}
400+

0 commit comments

Comments
 (0)