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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! File I/O — the ONLY place in `service/file_io/` that may perform
|
||||
//! filesystem reads, writes, shell execution, or other side effects.
|
||||
//!
|
||||
//! Every function here is a thin adapter that converts lower-level
|
||||
//! `String` errors into the typed [`super::Error`] variants.
|
||||
|
||||
use super::Error;
|
||||
use crate::io::fs::FileEntry;
|
||||
use crate::io::search::SearchResult;
|
||||
use crate::io::shell::CommandOutput;
|
||||
use crate::state::SessionState;
|
||||
|
||||
pub(super) async fn read_file(path: String, state: &SessionState) -> Result<String, Error> {
|
||||
crate::io::fs::read_file(path, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn write_file(
|
||||
path: String,
|
||||
content: String,
|
||||
state: &SessionState,
|
||||
) -> Result<(), Error> {
|
||||
crate::io::fs::write_file(path, content, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_directory(
|
||||
path: String,
|
||||
state: &SessionState,
|
||||
) -> Result<Vec<FileEntry>, Error> {
|
||||
crate::io::fs::list_directory(path, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, Error> {
|
||||
crate::io::fs::list_directory_absolute(path)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn create_directory_absolute(path: String) -> Result<(), Error> {
|
||||
crate::io::fs::create_directory_absolute(path)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn get_home_directory() -> Result<String, Error> {
|
||||
crate::io::fs::get_home_directory().map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_project_files(state: &SessionState) -> Result<Vec<String>, Error> {
|
||||
crate::io::fs::list_project_files(state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn search_files(
|
||||
query: String,
|
||||
state: &SessionState,
|
||||
) -> Result<Vec<SearchResult>, Error> {
|
||||
crate::io::search::search_files(query, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn exec_shell(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
state: &SessionState,
|
||||
) -> Result<CommandOutput, Error> {
|
||||
crate::io::shell::exec_shell(command, args, state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.contains("not in the allowlist") {
|
||||
Error::Validation(e)
|
||||
} else {
|
||||
Error::Filesystem(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! File I/O service — public API for filesystem and shell operations.
|
||||
//!
|
||||
//! Exposes functions for reading, writing, and listing files scoped to the
|
||||
//! active project root, plus utilities for absolute-path and shell operations.
|
||||
//! HTTP handlers call these functions instead of touching `io::fs` directly.
|
||||
//!
|
||||
//! Conventions: `docs/architecture/service-modules.md`
|
||||
|
||||
pub(super) mod io;
|
||||
|
||||
use crate::state::SessionState;
|
||||
|
||||
/// Re-export the canonical filesystem entry type so HTTP handlers don't need
|
||||
/// to import from `io::fs` directly.
|
||||
pub use crate::io::fs::FileEntry;
|
||||
/// Re-export the search result type.
|
||||
pub use crate::io::search::SearchResult;
|
||||
/// Re-export the shell output type.
|
||||
pub use crate::io::shell::CommandOutput;
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Typed errors returned by `service::file_io` functions.
|
||||
///
|
||||
/// HTTP handlers map these to status codes:
|
||||
/// - [`Error::Validation`] → 400 Bad Request
|
||||
/// - [`Error::Filesystem`] → 400 Bad Request (or 404 when appropriate)
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// The request was invalid (e.g. path traversal attempt, command not allowlisted).
|
||||
Validation(String),
|
||||
/// A filesystem or shell operation failed (file not found, permission denied, etc.).
|
||||
Filesystem(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::Filesystem(msg) => write!(f, "Filesystem error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path validation ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate a relative path, rejecting directory traversal attempts.
|
||||
///
|
||||
/// Returns [`Error::Validation`] when the path contains `..`.
|
||||
pub fn validate_path(path: &str) -> Result<(), Error> {
|
||||
if path.contains("..") {
|
||||
return Err(Error::Validation(
|
||||
"Security Violation: Directory traversal ('..') is not allowed.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a file from the project root.
|
||||
pub async fn read_file(path: String, state: &SessionState) -> Result<String, Error> {
|
||||
validate_path(&path)?;
|
||||
io::read_file(path, state).await
|
||||
}
|
||||
|
||||
/// Write a file to the project root, creating parent directories as needed.
|
||||
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), Error> {
|
||||
validate_path(&path)?;
|
||||
io::write_file(path, content, state).await
|
||||
}
|
||||
|
||||
/// List directory entries at a project-relative path.
|
||||
pub async fn list_directory(path: String, state: &SessionState) -> Result<Vec<FileEntry>, Error> {
|
||||
io::list_directory(path, state).await
|
||||
}
|
||||
|
||||
/// List directory entries at an absolute path (not scoped to the project root).
|
||||
pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, Error> {
|
||||
io::list_directory_absolute(path).await
|
||||
}
|
||||
|
||||
/// Create a directory (and all parents) at an absolute path.
|
||||
pub async fn create_directory_absolute(path: String) -> Result<(), Error> {
|
||||
io::create_directory_absolute(path).await
|
||||
}
|
||||
|
||||
/// Return the current user's home directory path.
|
||||
pub fn get_home_directory() -> Result<String, Error> {
|
||||
io::get_home_directory()
|
||||
}
|
||||
|
||||
/// List all files in the project recursively, respecting `.gitignore`.
|
||||
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, Error> {
|
||||
io::list_project_files(state).await
|
||||
}
|
||||
|
||||
/// Search the project for files whose contents contain `query`.
|
||||
pub async fn search_files(query: String, state: &SessionState) -> Result<Vec<SearchResult>, Error> {
|
||||
io::search_files(query, state).await
|
||||
}
|
||||
|
||||
/// Execute an allowlisted shell command in the project root directory.
|
||||
pub async fn exec_shell(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
state: &SessionState,
|
||||
) -> Result<CommandOutput, Error> {
|
||||
io::exec_shell(command, args, state).await
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Pure unit tests for path validation and sanitisation — no tempdir, no network.
|
||||
|
||||
#[test]
|
||||
fn validate_path_accepts_simple_relative_path() {
|
||||
assert!(validate_path("src/main.rs").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_accepts_dot_path() {
|
||||
assert!(validate_path(".").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_accepts_root_relative() {
|
||||
assert!(validate_path("subdir/file.txt").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_rejects_parent_traversal() {
|
||||
let result = validate_path("../etc/passwd");
|
||||
assert!(matches!(result, Err(Error::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_rejects_embedded_traversal() {
|
||||
let result = validate_path("src/../../../etc/passwd");
|
||||
assert!(matches!(result, Err(Error::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_rejects_double_dot_only() {
|
||||
let result = validate_path("..");
|
||||
assert!(matches!(result, Err(Error::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_accepts_file_with_single_dots_in_name() {
|
||||
// Filenames like "config.dev.toml" have single dots — must be accepted.
|
||||
assert!(validate_path("config.dev.toml").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_rejects_traversal_with_url_encoding_lookalike() {
|
||||
// A literal ".." sequence anywhere in the string is rejected.
|
||||
let result = validate_path("valid/..hidden");
|
||||
assert!(matches!(result, Err(Error::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_validation() {
|
||||
let e = Error::Validation("bad path".to_string());
|
||||
assert!(e.to_string().contains("bad path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_filesystem() {
|
||||
let e = Error::Filesystem("file not found".to_string());
|
||||
assert!(e.to_string().contains("file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_filesystem_contains_message() {
|
||||
let e = Error::Filesystem("task panic".to_string());
|
||||
assert!(e.to_string().contains("task panic"));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
//! - `io.rs` is the only file that performs side effects
|
||||
//! - Topic-named pure files contain branching logic with no I/O
|
||||
pub mod agents;
|
||||
pub mod anthropic;
|
||||
pub mod bot_command;
|
||||
pub mod events;
|
||||
pub mod file_io;
|
||||
pub mod health;
|
||||
pub mod project;
|
||||
pub mod ws;
|
||||
|
||||
Reference in New Issue
Block a user