//! Anthropic API proxy — thin adapter over `service::anthropic`. use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::service::anthropic::{self as svc, ModelSummary}; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize, Object)] struct ApiKeyPayload { api_key: String, } #[derive(Tags)] enum AnthropicTags { Anthropic, } pub struct AnthropicApi { ctx: Arc, } impl AnthropicApi { pub fn new(ctx: Arc) -> Self { Self { ctx } } } #[cfg(test)] impl From> for AnthropicApi { fn from(ctx: Arc) -> Self { Self::new(ctx) } } #[OpenApi(tag = "AnthropicTags::Anthropic")] impl AnthropicApi { /// Check whether an Anthropic API key is stored. /// /// Returns `true` if a non-empty key is present, otherwise `false`. #[oai(path = "/anthropic/key/exists", method = "get")] async fn get_anthropic_api_key_exists(&self) -> OpenApiResult> { let exists = svc::get_api_key_exists(self.ctx.store.as_ref()) .map_err(|e| bad_request(e.to_string()))?; Ok(Json(exists)) } /// Store or update the Anthropic API key used for requests. /// /// Returns `true` when the key is saved successfully. #[oai(path = "/anthropic/key", method = "post")] async fn set_anthropic_api_key( &self, payload: Json, ) -> OpenApiResult> { svc::set_api_key(self.ctx.store.as_ref(), payload.0.api_key) .map_err(|e| bad_request(e.to_string()))?; Ok(Json(true)) } /// List available Anthropic models. #[oai(path = "/anthropic/models", method = "get")] async fn list_anthropic_models(&self) -> OpenApiResult>> { let models = svc::list_models(self.ctx.store.as_ref()) .await .map_err(|e| bad_request(e.to_string()))?; Ok(Json(models)) } } #[cfg(test)] impl AnthropicApi { /// List models from an injectable URL (used in tests to avoid real network calls). async fn list_anthropic_models_from( &self, url: &str, ) -> OpenApiResult>> { let models = svc::list_models_from(self.ctx.store.as_ref(), url) .await .map_err(|e| bad_request(e.to_string()))?; Ok(Json(models)) } } // Private helper retained for backward compatibility with tests that call it directly. #[cfg(test)] fn get_anthropic_api_key(ctx: &AppContext) -> Result { svc::get_api_key(ctx.store.as_ref()).map_err(|e| e.to_string()) } // Private types retained so existing tests that deserialise them directly continue to compile. #[cfg(test)] #[derive(serde::Deserialize)] struct AnthropicModelsResponse { data: Vec, } #[cfg(test)] #[derive(serde::Deserialize)] struct AnthropicModelInfo { id: String, context_window: u64, } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use crate::http::test_helpers::{make_api, test_ctx}; use crate::store::StoreOps; const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; use serde_json::json; use tempfile::TempDir; // -- get_anthropic_api_key (private helper) -- #[test] fn get_api_key_returns_err_when_not_set() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); let result = get_anthropic_api_key(&ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); } #[test] fn get_api_key_returns_err_when_empty() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("")); let result = get_anthropic_api_key(&ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } #[test] fn get_api_key_returns_err_when_not_string() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(12345)); let result = get_anthropic_api_key(&ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("not a string")); } #[test] fn get_api_key_returns_key_when_set() { let dir = TempDir::new().unwrap(); let ctx = test_ctx(dir.path()); ctx.store .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); let result = get_anthropic_api_key(&ctx); assert_eq!(result.unwrap(), "sk-ant-test123"); } // -- get_anthropic_api_key_exists endpoint -- #[tokio::test] async fn key_exists_returns_false_when_not_set() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api.get_anthropic_api_key_exists().await.unwrap(); assert!(!result.0); } #[tokio::test] async fn key_exists_returns_true_when_set() { let dir = TempDir::new().unwrap(); let ctx = AppContext::new_test(dir.path().to_path_buf()); ctx.store .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); let api = AnthropicApi::new(Arc::new(ctx)); let result = api.get_anthropic_api_key_exists().await.unwrap(); assert!(result.0); } // -- set_anthropic_api_key endpoint -- #[tokio::test] async fn set_api_key_returns_true() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let payload = Json(ApiKeyPayload { api_key: "sk-ant-test123".to_string(), }); let result = api.set_anthropic_api_key(payload).await.unwrap(); assert!(result.0); } #[tokio::test] async fn set_then_exists_returns_true() { let dir = TempDir::new().unwrap(); let ctx = Arc::new(AppContext::new_test(dir.path().to_path_buf())); let api = AnthropicApi::new(ctx); api.set_anthropic_api_key(Json(ApiKeyPayload { api_key: "sk-ant-test123".to_string(), })) .await .unwrap(); let result = api.get_anthropic_api_key_exists().await.unwrap(); assert!(result.0); } // -- list_anthropic_models endpoint -- #[tokio::test] async fn list_models_fails_when_no_key() { let dir = TempDir::new().unwrap(); let api = make_api::(&dir); let result = api.list_anthropic_models().await; assert!(result.is_err()); } #[tokio::test] async fn list_models_fails_with_invalid_header_value() { let dir = TempDir::new().unwrap(); let ctx = AppContext::new_test(dir.path().to_path_buf()); // A header value containing a newline is invalid ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("bad\nvalue")); let api = AnthropicApi::new(Arc::new(ctx)); let result = api.list_anthropic_models_from("http://127.0.0.1:1").await; assert!(result.is_err()); } #[tokio::test] async fn list_models_fails_when_server_unreachable() { let dir = TempDir::new().unwrap(); let ctx = AppContext::new_test(dir.path().to_path_buf()); ctx.store .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); let api = AnthropicApi::new(Arc::new(ctx)); // Port 1 is reserved and should immediately refuse the connection let result = api.list_anthropic_models_from("http://127.0.0.1:1").await; assert!(result.is_err()); } #[test] fn new_creates_api_instance() { let dir = TempDir::new().unwrap(); let _api = make_api::(&dir); } #[test] fn anthropic_model_info_deserializes_context_window() { let json = json!({ "id": "claude-opus-4-5", "context_window": 200000 }); let info: AnthropicModelInfo = serde_json::from_value(json).unwrap(); assert_eq!(info.id, "claude-opus-4-5"); assert_eq!(info.context_window, 200000); } #[test] fn anthropic_models_response_deserializes_multiple_models() { let json = json!({ "data": [ { "id": "claude-opus-4-5", "context_window": 200000 }, { "id": "claude-haiku-4-5-20251001", "context_window": 100000 } ] }); let response: AnthropicModelsResponse = serde_json::from_value(json).unwrap(); assert_eq!(response.data.len(), 2); assert_eq!(response.data[0].context_window, 200000); assert_eq!(response.data[1].context_window, 100000); } }