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
+46 -98
View File
@@ -1,50 +1,10 @@
//! Anthropic API proxy — forwards model listing and key-validation requests to Anthropic.
//! Anthropic API proxy — thin adapter over `service::anthropic`.
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::llm::chat;
use crate::store::StoreOps;
use crate::service::anthropic::{self as svc, ModelSummary};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::sync::Arc;
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key";
#[derive(Deserialize)]
struct AnthropicModelsResponse {
data: Vec<AnthropicModelInfo>,
}
#[derive(Deserialize)]
struct AnthropicModelInfo {
id: String,
context_window: u64,
}
#[derive(Serialize, Object)]
struct AnthropicModelSummary {
id: String,
context_window: u64,
}
fn get_anthropic_api_key(ctx: &AppContext) -> Result<String, String> {
match ctx.store.get(KEY_ANTHROPIC_API_KEY) {
Some(value) => {
if let Some(key) = value.as_str() {
if key.is_empty() {
Err("Anthropic API key is empty. Please set your API key.".to_string())
} else {
Ok(key.to_string())
}
} else {
Err("Stored API key is not a string".to_string())
}
}
None => Err("Anthropic API key not found. Please set your API key.".to_string()),
}
}
#[derive(Deserialize, Object)]
struct ApiKeyPayload {
api_key: String,
@@ -79,8 +39,8 @@ impl AnthropicApi {
/// 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 =
chat::get_anthropic_api_key_exists(self.ctx.store.as_ref()).map_err(bad_request)?;
let exists = svc::get_api_key_exists(self.ctx.store.as_ref())
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(exists))
}
@@ -92,74 +52,62 @@ impl AnthropicApi {
&self,
payload: Json<ApiKeyPayload>,
) -> OpenApiResult<Json<bool>> {
chat::set_anthropic_api_key(self.ctx.store.as_ref(), payload.0.api_key)
.map_err(bad_request)?;
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<AnthropicModelSummary>>> {
self.list_anthropic_models_from(ANTHROPIC_MODELS_URL).await
}
}
impl AnthropicApi {
async fn list_anthropic_models_from(
&self,
url: &str,
) -> OpenApiResult<Json<Vec<AnthropicModelSummary>>> {
let api_key = get_anthropic_api_key(self.ctx.as_ref()).map_err(bad_request)?;
let client = reqwest::Client::new();
let mut headers = HeaderMap::new();
headers.insert(
"x-api-key",
HeaderValue::from_str(&api_key).map_err(|e| bad_request(e.to_string()))?,
);
headers.insert(
"anthropic-version",
HeaderValue::from_static(ANTHROPIC_VERSION),
);
let response = client
.get(url)
.headers(headers)
.send()
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()))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(bad_request(format!(
"Anthropic API error {status}: {error_text}"
)));
}
let body = response
.json::<AnthropicModelsResponse>()
.await
.map_err(|e| bad_request(e.to_string()))?;
let models = body
.data
.into_iter()
.map(|m| AnthropicModelSummary {
id: m.id,
context_window: m.context_window,
})
.collect();
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;
+24 -25
View File
@@ -1,6 +1,6 @@
//! HTTP I/O endpoints — REST API for file and directory operations.
//! HTTP I/O endpoints — thin adapters over `service::file_io`.
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::fs as io_fs;
use crate::service::file_io::{self as svc, FileEntry};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize;
use std::sync::Arc;
@@ -46,18 +46,18 @@ impl IoApi {
/// Read a file from the currently open project and return its contents.
#[oai(path = "/io/fs/read", method = "post")]
async fn read_file(&self, payload: Json<FilePathPayload>) -> OpenApiResult<Json<String>> {
let content = io_fs::read_file(payload.0.path, &self.ctx.state)
let content = svc::read_file(payload.0.path, &self.ctx.state)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(content))
}
/// Write a file to the currently open project, creating parent directories if needed.
#[oai(path = "/io/fs/write", method = "post")]
async fn write_file(&self, payload: Json<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
io_fs::write_file(payload.0.path, payload.0.content, &self.ctx.state)
svc::write_file(payload.0.path, payload.0.content, &self.ctx.state)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(true))
}
@@ -66,10 +66,10 @@ impl IoApi {
async fn list_directory(
&self,
payload: Json<FilePathPayload>,
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
let entries = io_fs::list_directory(payload.0.path, &self.ctx.state)
) -> OpenApiResult<Json<Vec<FileEntry>>> {
let entries = svc::list_directory(payload.0.path, &self.ctx.state)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(entries))
}
@@ -78,10 +78,10 @@ impl IoApi {
async fn list_directory_absolute(
&self,
payload: Json<FilePathPayload>,
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
let entries = io_fs::list_directory_absolute(payload.0.path)
) -> OpenApiResult<Json<Vec<FileEntry>>> {
let entries = svc::list_directory_absolute(payload.0.path)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(entries))
}
@@ -91,25 +91,25 @@ impl IoApi {
&self,
payload: Json<CreateDirectoryPayload>,
) -> OpenApiResult<Json<bool>> {
io_fs::create_directory_absolute(payload.0.path)
svc::create_directory_absolute(payload.0.path)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(true))
}
/// Get the user's home directory.
#[oai(path = "/io/fs/home", method = "get")]
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {
let home = io_fs::get_home_directory().map_err(bad_request)?;
let home = svc::get_home_directory().map_err(|e| bad_request(e.to_string()))?;
Ok(Json(home))
}
/// List all files in the project recursively, respecting .gitignore.
#[oai(path = "/io/fs/files", method = "get")]
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
let files = io_fs::list_project_files(&self.ctx.state)
let files = svc::list_project_files(&self.ctx.state)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(files))
}
@@ -118,10 +118,10 @@ impl IoApi {
async fn search_files(
&self,
payload: Json<SearchPayload>,
) -> OpenApiResult<Json<Vec<crate::io::search::SearchResult>>> {
let results = crate::io::search::search_files(payload.0.query, &self.ctx.state)
) -> OpenApiResult<Json<Vec<crate::service::file_io::SearchResult>>> {
let results = svc::search_files(payload.0.query, &self.ctx.state)
.await
.map_err(bad_request)?;
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(results))
}
@@ -130,11 +130,10 @@ impl IoApi {
async fn exec_shell(
&self,
payload: Json<ExecShellPayload>,
) -> OpenApiResult<Json<crate::io::shell::CommandOutput>> {
let output =
crate::io::shell::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
.await
.map_err(bad_request)?;
) -> OpenApiResult<Json<crate::service::file_io::CommandOutput>> {
let output = svc::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
.await
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(output))
}
}