feat: add ProxyClient with upstream forwarding and reply caching
This commit is contained in:
Generated
+1
@@ -1314,6 +1314,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::cache::CacheStore;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::types::ChatCompletionRequest;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProxyClient {
|
||||||
|
client: Client,
|
||||||
|
config: Config,
|
||||||
|
cache: CacheStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ProxyResponse {
|
||||||
|
Buffered(Bytes),
|
||||||
|
Streamed(reqwest::Response),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyClient {
|
||||||
|
pub fn new(config: Config, cache: CacheStore) -> Result<Self> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.build()
|
||||||
|
.context("failed to build reqwest client")?;
|
||||||
|
Ok(Self { client, config, cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forward_request(&self, req: ChatCompletionRequest) -> Result<ProxyResponse> {
|
||||||
|
self.cache.cache_messages(&req).await?;
|
||||||
|
|
||||||
|
let is_streaming = req.stream.unwrap_or(false);
|
||||||
|
let url = format!("{}/v1/chat/completions", self.config.upstream_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
debug!("Forwarding to upstream: {}", url);
|
||||||
|
|
||||||
|
let http_resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&self.config.upstream_api_key)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("upstream request failed")?;
|
||||||
|
|
||||||
|
if !http_resp.status().is_success() {
|
||||||
|
let status = http_resp.status();
|
||||||
|
let body = http_resp.bytes().await.unwrap_or_default();
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"upstream returned {}: {}",
|
||||||
|
status,
|
||||||
|
String::from_utf8_lossy(&body)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_streaming {
|
||||||
|
return Ok(ProxyResponse::Streamed(http_resp));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_bytes = http_resp.bytes().await.context("failed to read upstream body")?;
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||||
|
if let Some(content) = json
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|c| c.get(0))
|
||||||
|
.and_then(|c| c.get("message"))
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
|
if let Err(e) = self.cache.cache_assistant_reply(content).await {
|
||||||
|
warn!("Failed to cache assistant reply: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProxyResponse::Buffered(body_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forward_get(&self, path: &str) -> Result<Bytes> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.config.upstream_url.trim_end_matches('/'),
|
||||||
|
path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&self.config.upstream_api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("upstream GET failed")?;
|
||||||
|
let bytes = resp.bytes().await.context("failed to read upstream GET body")?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_construction() {
|
||||||
|
let base = "https://api.openai.com/";
|
||||||
|
let url = format!("{}/v1/chat/completions", base.trim_end_matches('/'));
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_construction_no_trailing_slash() {
|
||||||
|
let base = "https://api.openai.com";
|
||||||
|
let url = format!("{}/v1/chat/completions", base.trim_end_matches('/'));
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_path_construction() {
|
||||||
|
let base = "https://api.openai.com";
|
||||||
|
let path = "/v1/models";
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}",
|
||||||
|
base.trim_end_matches('/'),
|
||||||
|
path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1/models");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assistant_content_extraction_from_response() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": "chatcmpl-abc",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1699000000,
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "The answer is 42."
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = json
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|c| c.get(0))
|
||||||
|
.and_then(|c| c.get("message"))
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_str());
|
||||||
|
|
||||||
|
assert_eq!(content, Some("The answer is 42."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assistant_content_extraction_null_content() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": null,
|
||||||
|
"tool_calls": []
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = json
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|c| c.get(0))
|
||||||
|
.and_then(|c| c.get("message"))
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_str());
|
||||||
|
|
||||||
|
assert!(content.is_none(), "null content should not be cached");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user