huskies: merge 608_story_extract_io_and_anthropic_services
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
//! Anthropic I/O — the ONLY place in `service/anthropic/` that may perform
|
||||
//! network requests or store operations.
|
||||
//!
|
||||
//! Every function here is a thin adapter that converts lower-level errors
|
||||
//! into the typed [`super::Error`] variants. No business logic or branching
|
||||
//! lives here; that belongs in `mod.rs`.
|
||||
|
||||
use super::{Error, ModelSummary, ModelsResponse};
|
||||
use crate::store::StoreOps;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Store key for the Anthropic API key — shared with `llm::chat`.
|
||||
pub(crate) const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key";
|
||||
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
/// Return whether a non-empty API key is stored.
|
||||
pub(super) fn api_key_exists(store: &dyn StoreOps) -> bool {
|
||||
match store.get(KEY_ANTHROPIC_API_KEY) {
|
||||
Some(value) => value.as_str().map(|k| !k.is_empty()).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the stored API key, returning a typed error when absent or invalid.
|
||||
pub(super) fn get_api_key(store: &dyn StoreOps) -> Result<String, Error> {
|
||||
match store.get(KEY_ANTHROPIC_API_KEY) {
|
||||
Some(value) => {
|
||||
if let Some(key) = value.as_str() {
|
||||
if key.is_empty() {
|
||||
Err(Error::Validation(
|
||||
"Anthropic API key is empty. Please set your API key.".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(key.to_string())
|
||||
}
|
||||
} else {
|
||||
Err(Error::Validation(
|
||||
"Stored API key is not a string".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Err(Error::Validation(
|
||||
"Anthropic API key not found. Please set your API key.".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a new API key to the store.
|
||||
pub(super) fn save_api_key(store: &dyn StoreOps, api_key: &str) -> Result<(), String> {
|
||||
store.set(KEY_ANTHROPIC_API_KEY, serde_json::json!(api_key));
|
||||
store.save()
|
||||
}
|
||||
|
||||
/// Fetch models from the Anthropic API at `url`.
|
||||
pub(super) async fn fetch_models(api_key: &str, url: &str) -> Result<Vec<ModelSummary>, Error> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
HeaderValue::from_str(api_key)
|
||||
.map_err(|e| Error::Validation(format!("Invalid API key header value: {e}")))?,
|
||||
);
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
HeaderValue::from_static(ANTHROPIC_VERSION),
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::UpstreamApi(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(Error::UpstreamApi(format!(
|
||||
"Anthropic API error {status}: {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.json::<ModelsResponse>()
|
||||
.await
|
||||
.map_err(|e| Error::Internal(format!("Failed to parse response: {e}")))?;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| ModelSummary {
|
||||
id: m.id,
|
||||
context_window: m.context_window,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Anthropic service — public API for Anthropic API-key management and model listing.
|
||||
//!
|
||||
//! Exposes functions to check, store, and use the Anthropic API key, and to
|
||||
//! list available models. HTTP handlers call these functions instead of
|
||||
//! talking to `llm::chat` or making HTTP requests directly.
|
||||
//!
|
||||
//! Conventions: `docs/architecture/service-modules.md`
|
||||
|
||||
pub(super) mod io;
|
||||
|
||||
use crate::store::StoreOps;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Typed errors returned by `service::anthropic` functions.
|
||||
///
|
||||
/// HTTP handlers map these to status codes:
|
||||
/// - [`Error::Validation`] → 400 Bad Request
|
||||
/// - [`Error::UpstreamApi`] → 502 Bad Gateway (or 400 for invalid keys)
|
||||
/// - [`Error::Internal`] → 500 Internal Server Error
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// The request was invalid (e.g. missing, empty, or malformed API key).
|
||||
Validation(String),
|
||||
/// The upstream Anthropic API returned an error or was unreachable.
|
||||
UpstreamApi(String),
|
||||
/// An internal error occurred (JSON parse failure, store I/O error, etc.).
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Validation(msg) => write!(f, "Validation error: {msg}"),
|
||||
Self::UpstreamApi(msg) => write!(f, "Upstream API error: {msg}"),
|
||||
Self::Internal(msg) => write!(f, "Internal error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A summary of an Anthropic model as returned by the `/v1/models` endpoint.
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, poem_openapi::Object)]
|
||||
pub struct ModelSummary {
|
||||
pub id: String,
|
||||
pub context_window: u64,
|
||||
}
|
||||
|
||||
/// Raw response shape from the Anthropic `/v1/models` endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ModelsResponse {
|
||||
pub data: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
/// A single model entry in the Anthropic API response.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ModelInfo {
|
||||
pub id: String,
|
||||
pub context_window: u64,
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return whether a non-empty Anthropic API key is currently stored.
|
||||
pub fn get_api_key_exists(store: &dyn StoreOps) -> Result<bool, Error> {
|
||||
Ok(io::api_key_exists(store))
|
||||
}
|
||||
|
||||
/// Read the stored Anthropic API key.
|
||||
///
|
||||
/// Returns [`Error::Validation`] when the key is absent, empty, or not a string.
|
||||
pub fn get_api_key(store: &dyn StoreOps) -> Result<String, Error> {
|
||||
io::get_api_key(store)
|
||||
}
|
||||
|
||||
/// Store or replace the Anthropic API key.
|
||||
pub fn set_api_key(store: &dyn StoreOps, api_key: String) -> Result<(), Error> {
|
||||
io::save_api_key(store, &api_key).map_err(Error::Internal)
|
||||
}
|
||||
|
||||
/// List available Anthropic models from the production endpoint.
|
||||
pub async fn list_models(store: &dyn StoreOps) -> Result<Vec<ModelSummary>, Error> {
|
||||
list_models_from(store, ANTHROPIC_MODELS_URL).await
|
||||
}
|
||||
|
||||
/// List available Anthropic models from `url` (injectable for tests).
|
||||
pub async fn list_models_from(store: &dyn StoreOps, url: &str) -> Result<Vec<ModelSummary>, Error> {
|
||||
let api_key = get_api_key(store)?;
|
||||
io::fetch_models(&api_key, url).await
|
||||
}
|
||||
|
||||
/// Parse a raw JSON string from the Anthropic `/v1/models` endpoint into model summaries.
|
||||
///
|
||||
/// Pure function for unit testing; production code uses [`list_models`].
|
||||
#[cfg(test)]
|
||||
pub fn parse_models_response(json: &str) -> Result<Vec<ModelSummary>, Error> {
|
||||
let response: ModelsResponse = serde_json::from_str(json)
|
||||
.map_err(|e| Error::Internal(format!("Failed to parse models response: {e}")))?;
|
||||
Ok(response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| ModelSummary {
|
||||
id: m.id,
|
||||
context_window: m.context_window,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Pure unit tests for response parsing — no tempdir, no network.
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_parses_single_model() {
|
||||
let json = r#"{"data":[{"id":"claude-opus-4-5","context_window":200000}]}"#;
|
||||
let models = parse_models_response(json).unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id, "claude-opus-4-5");
|
||||
assert_eq!(models[0].context_window, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_parses_multiple_models() {
|
||||
let json = r#"{"data":[
|
||||
{"id":"claude-opus-4-5","context_window":200000},
|
||||
{"id":"claude-haiku-4-5-20251001","context_window":100000}
|
||||
]}"#;
|
||||
let models = parse_models_response(json).unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].id, "claude-opus-4-5");
|
||||
assert_eq!(models[1].context_window, 100000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_returns_empty_for_empty_data() {
|
||||
let json = r#"{"data":[]}"#;
|
||||
let models = parse_models_response(json).unwrap();
|
||||
assert!(models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_returns_internal_error_for_invalid_json() {
|
||||
let result = parse_models_response("not json at all");
|
||||
assert!(matches!(result, Err(Error::Internal(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_returns_error_for_missing_data_field() {
|
||||
let result = parse_models_response(r#"{"wrong_field":[]}"#);
|
||||
assert!(matches!(result, Err(Error::Internal(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_validation() {
|
||||
let e = Error::Validation("no key".to_string());
|
||||
assert!(e.to_string().contains("no key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_upstream_api() {
|
||||
let e = Error::UpstreamApi("500 Server Error".to_string());
|
||||
assert!(e.to_string().contains("500 Server Error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_internal() {
|
||||
let e = Error::Internal("parse failed".to_string());
|
||||
assert!(e.to_string().contains("parse failed"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user