Files
huskies/server/src/http/anthropic.rs
T
2026-04-29 10:47:18 +00:00

269 lines
8.7 KiB
Rust

//! 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,
}
/// OpenAPI endpoint group for Anthropic API key and model operations.
pub struct AnthropicApi {
ctx: Arc<AppContext>,
}
impl AnthropicApi {
/// Create a new `AnthropicApi` bound to the given application context.
pub fn new(ctx: Arc<AppContext>) -> Self {
Self { ctx }
}
}
#[cfg(test)]
impl From<Arc<AppContext>> for AnthropicApi {
fn from(ctx: Arc<AppContext>) -> 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<Json<bool>> {
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<ApiKeyPayload>,
) -> OpenApiResult<Json<bool>> {
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<Json<Vec<ModelSummary>>> {
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<Json<Vec<ModelSummary>>> {
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<String, String> {
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<AnthropicModelInfo>,
}
#[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::<AnthropicApi>(&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::<AnthropicApi>(&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::<AnthropicApi>(&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::<AnthropicApi>(&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);
}
}