1+ use crate :: adapters:: common;
12use crate :: adapters:: llm:: { LLMAdapter , LLMRequest , LLMResponse , ModelConfig , Usage } ;
23use anyhow:: { Context , Result } ;
34use async_trait:: async_trait;
4- use reqwest:: { Client , StatusCode } ;
5+ use reqwest:: Client ;
56use serde:: { Deserialize , Serialize } ;
6- use std:: time:: Duration ;
7- use tokio:: time:: sleep;
87
98pub 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