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;