feat: add Middleware trait, Pipeline, StructuredOutputMiddleware
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod cache;
|
||||||
|
pub mod config;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod proxy;
|
||||||
|
pub mod router;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod types;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::types::{ChatCompletionRequest, Message, MessageContent, ResponseFormat};
|
||||||
|
|
||||||
|
pub trait Middleware: Send + Sync {
|
||||||
|
fn pre_send(&self, _req: &mut ChatCompletionRequest, _auth: &mut String) {}
|
||||||
|
fn post_receive(&self, _req: &ChatCompletionRequest, _resp: &mut Value) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Pipeline {
|
||||||
|
middlewares: Vec<Box<dyn Middleware>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { middlewares: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add<M: Middleware + 'static>(mut self, m: M) -> Self {
|
||||||
|
self.middlewares.push(Box::new(m));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_pre(&self, req: &mut ChatCompletionRequest, auth: &mut String) {
|
||||||
|
for mw in &self.middlewares {
|
||||||
|
mw.pre_send(req, auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_post(&self, req: &ChatCompletionRequest, resp: &mut Value) {
|
||||||
|
for mw in &self.middlewares {
|
||||||
|
mw.post_receive(req, resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injects a JSON schema formatting system prompt for models that do not
|
||||||
|
/// natively support `response_format: { type: "json_schema", ... }`.
|
||||||
|
/// Models in `native_models` are left untouched.
|
||||||
|
pub struct StructuredOutputMiddleware {
|
||||||
|
native_models: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StructuredOutputMiddleware {
|
||||||
|
pub fn new(native_models: Vec<String>) -> Self {
|
||||||
|
Self { native_models }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_native(&self, model: &str) -> bool {
|
||||||
|
self.native_models.iter().any(|n| model.contains(n.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Middleware for StructuredOutputMiddleware {
|
||||||
|
fn pre_send(&self, req: &mut ChatCompletionRequest, _auth: &mut String) {
|
||||||
|
let json_schema = match &req.response_format {
|
||||||
|
Some(ResponseFormat { format_type, json_schema: Some(schema) })
|
||||||
|
if format_type == "json_schema" || format_type == "json_object" =>
|
||||||
|
schema.clone(),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.is_native(&req.model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema_str = serde_json::to_string_pretty(&json_schema).unwrap_or_default();
|
||||||
|
let instruction = format!(
|
||||||
|
"You MUST respond with valid JSON only, conforming exactly to this JSON schema:\n\n```json\n{}\n```\n\nDo NOT include any text before or after the JSON. Output ONLY the JSON object.",
|
||||||
|
schema_str
|
||||||
|
);
|
||||||
|
|
||||||
|
let inject_pos = req.messages.iter()
|
||||||
|
.rposition(|m| matches!(m, Message::User { .. }))
|
||||||
|
.unwrap_or(req.messages.len());
|
||||||
|
|
||||||
|
req.messages.insert(inject_pos, Message::System {
|
||||||
|
content: MessageContent::Text(instruction),
|
||||||
|
name: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.response_format = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
fn make_req(model: &str, messages: Vec<Message>, rf: Option<ResponseFormat>) -> ChatCompletionRequest {
|
||||||
|
ChatCompletionRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages,
|
||||||
|
stream: Some(false),
|
||||||
|
temperature: None, top_p: None, n: None,
|
||||||
|
max_tokens: None, max_completion_tokens: None, stop: None,
|
||||||
|
presence_penalty: None, frequency_penalty: None, logit_bias: None,
|
||||||
|
user: None, seed: None, logprobs: None, top_logprobs: None,
|
||||||
|
response_format: rf,
|
||||||
|
tools: None, tool_choice: None, parallel_tool_calls: None,
|
||||||
|
functions: None, function_call: None, service_tier: None,
|
||||||
|
modalities: None, audio: None, reasoning_effort: None,
|
||||||
|
metadata: None, store: None, prediction: None,
|
||||||
|
stream_options: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppendTextMiddleware(String);
|
||||||
|
|
||||||
|
impl Middleware for AppendTextMiddleware {
|
||||||
|
fn pre_send(&self, req: &mut ChatCompletionRequest, _auth: &mut String) {
|
||||||
|
if let Some(Message::System { content, .. }) = req.messages.first_mut() {
|
||||||
|
if let MessageContent::Text(t) = content {
|
||||||
|
t.push_str(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipeline_applies_middleware_in_order() {
|
||||||
|
let mut req = make_req(
|
||||||
|
"gpt-4o",
|
||||||
|
vec![Message::System {
|
||||||
|
content: MessageContent::Text("hello".into()),
|
||||||
|
name: None,
|
||||||
|
}],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut auth = "Bearer test".to_string();
|
||||||
|
|
||||||
|
let pipeline = Pipeline::new()
|
||||||
|
.add(AppendTextMiddleware(" world".into()))
|
||||||
|
.add(AppendTextMiddleware("!!!".into()));
|
||||||
|
|
||||||
|
pipeline.run_pre(&mut req, &mut auth);
|
||||||
|
|
||||||
|
let content = match &req.messages[0] {
|
||||||
|
Message::System { content: MessageContent::Text(t), .. } => t.as_str(),
|
||||||
|
_ => panic!("expected system text"),
|
||||||
|
};
|
||||||
|
assert_eq!(content, "hello world!!!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_pipeline_is_noop() {
|
||||||
|
let mut req = make_req(
|
||||||
|
"gpt-4o",
|
||||||
|
vec![Message::User { content: MessageContent::Text("hi".into()), name: None }],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut auth = "Bearer test".to_string();
|
||||||
|
|
||||||
|
let pipeline = Pipeline::new();
|
||||||
|
pipeline.run_pre(&mut req, &mut auth);
|
||||||
|
assert_eq!(req.messages.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_structured_output_injects_prompt_for_non_native_model() {
|
||||||
|
let schema = serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}});
|
||||||
|
let m = StructuredOutputMiddleware::new(vec!["gpt-4o".to_string()]);
|
||||||
|
|
||||||
|
let mut req = make_req(
|
||||||
|
"dummy-model",
|
||||||
|
vec![
|
||||||
|
Message::System { content: MessageContent::Text("be helpful".into()), name: None },
|
||||||
|
Message::User { content: MessageContent::Text("hi".into()), name: None },
|
||||||
|
],
|
||||||
|
Some(ResponseFormat {
|
||||||
|
format_type: "json_schema".into(),
|
||||||
|
json_schema: Some(schema),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut auth = "Bearer test".to_string();
|
||||||
|
|
||||||
|
m.pre_send(&mut req, &mut auth);
|
||||||
|
|
||||||
|
assert_eq!(req.messages.len(), 3, "should have 3 messages");
|
||||||
|
// Injected before the last User message (index 1), so injected is at index 1
|
||||||
|
let injected = match &req.messages[1] {
|
||||||
|
Message::System { content: MessageContent::Text(t), .. } => t.clone(),
|
||||||
|
_ => panic!("expected injected system message at index 1, got: {:?}", &req.messages[1]),
|
||||||
|
};
|
||||||
|
assert!(injected.contains("JSON schema"), "injected prompt missing 'JSON schema': {}", injected);
|
||||||
|
assert!(injected.contains("name"), "injected prompt should reference schema fields: {}", injected);
|
||||||
|
assert!(req.response_format.is_none(), "response_format should be stripped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_structured_output_skips_native_model() {
|
||||||
|
let schema = serde_json::json!({"type": "object"});
|
||||||
|
let m = StructuredOutputMiddleware::new(vec!["gpt-4o".to_string()]);
|
||||||
|
|
||||||
|
let mut req = make_req(
|
||||||
|
"gpt-4o",
|
||||||
|
vec![Message::User { content: MessageContent::Text("hi".into()), name: None }],
|
||||||
|
Some(ResponseFormat {
|
||||||
|
format_type: "json_schema".into(),
|
||||||
|
json_schema: Some(schema),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut auth = "Bearer test".to_string();
|
||||||
|
|
||||||
|
m.pre_send(&mut req, &mut auth);
|
||||||
|
|
||||||
|
assert_eq!(req.messages.len(), 1, "should not inject for native models");
|
||||||
|
assert!(req.response_format.is_some(), "should keep response_format for native models");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_structured_output_noops_when_no_response_format() {
|
||||||
|
let m = StructuredOutputMiddleware::new(vec![]);
|
||||||
|
|
||||||
|
let mut req = make_req(
|
||||||
|
"dummy-model",
|
||||||
|
vec![Message::User { content: MessageContent::Text("hi".into()), name: None }],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let orig_count = req.messages.len();
|
||||||
|
let mut auth = "Bearer test".to_string();
|
||||||
|
|
||||||
|
m.pre_send(&mut req, &mut auth);
|
||||||
|
assert_eq!(req.messages.len(), orig_count, "no injection when no response_format");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_post_receive_default_is_noop() {
|
||||||
|
let m = StructuredOutputMiddleware::new(vec![]);
|
||||||
|
let req = make_req("dummy", vec![], None);
|
||||||
|
let mut resp = serde_json::json!({"choices": [{"message": {"content": "test"}}]});
|
||||||
|
let original = resp.clone();
|
||||||
|
m.post_receive(&req, &mut resp);
|
||||||
|
assert_eq!(resp, original);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user