huskies: merge 608_story_extract_io_and_anthropic_services

This commit is contained in:
dave
2026-04-24 15:50:26 +00:00
parent aba3120388
commit 65c896f07f
8 changed files with 617 additions and 291 deletions
+100
View File
@@ -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())
}
+178
View File
@@ -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"));
}
}