mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-22 00:00:50 +00:00
Merge feat/batch-engine-phase1 into main
Resolves conflicts: take HEAD (security audit) for mcp.rs imports, register_server_blocking error handling, and maybe_execute_tools loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
anyllm_translate = { path = "../translator", version = "0.2.0" }
|
||||
anyllm_client = { path = "../client", version = "0.2.0" }
|
||||
anyllm_batch_engine = { path = "../batch_engine", version = "0.2.0" }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "native-tls", "http2", "multipart"] }
|
||||
|
||||
@@ -77,39 +77,6 @@ pub fn init_db(conn: &Connection) -> rusqlite::Result<()> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
||||
|
||||
-- Note: batch file JSONL is stored directly in SQLite. For large batch files
|
||||
-- (>10MB), consider external blob storage. Current design prioritizes simplicity
|
||||
-- and single-binary deployment over storage efficiency.
|
||||
CREATE TABLE IF NOT EXISTS batch_file (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id TEXT NOT NULL UNIQUE,
|
||||
key_id INTEGER,
|
||||
purpose TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
byte_size INTEGER NOT NULL,
|
||||
line_count INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS batch_job (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT NOT NULL UNIQUE,
|
||||
key_id INTEGER,
|
||||
input_file_id TEXT NOT NULL,
|
||||
backend_batch_id TEXT,
|
||||
backend_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
request_counts_total INTEGER NOT NULL DEFAULT 0,
|
||||
request_counts_completed INTEGER NOT NULL DEFAULT 0,
|
||||
request_counts_failed INTEGER NOT NULL DEFAULT 0,
|
||||
output_file_id TEXT,
|
||||
error_file_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
expires_at TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
",
|
||||
)?;
|
||||
|
||||
|
||||
@@ -323,10 +323,7 @@ pub fn admin_router(shared: SharedState, token: Arc<String>) -> Router {
|
||||
"/admin/api/mcp-servers",
|
||||
get(list_mcp_servers).post(add_mcp_server),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/mcp-servers/{name}",
|
||||
delete(remove_mcp_server),
|
||||
)
|
||||
.route("/admin/api/mcp-servers/{name}", delete(remove_mcp_server))
|
||||
.with_state(shared.clone())
|
||||
// Innermost: CSRF check runs after auth succeeds.
|
||||
.layer(middleware::from_fn_with_state(shared.clone(), validate_csrf))
|
||||
@@ -1647,7 +1644,11 @@ async fn list_mcp_servers(State(shared): State<SharedState>) -> axum::response::
|
||||
return (StatusCode::OK, Json(serde_json::json!({"servers": []}))).into_response();
|
||||
};
|
||||
let servers = mgr.list_servers_blocking();
|
||||
(StatusCode::OK, Json(serde_json::json!({"servers": servers}))).into_response()
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"servers": servers})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// POST /admin/api/mcp-servers - Register an MCP server. Body: { name, url }.
|
||||
|
||||
@@ -122,8 +122,7 @@ impl BackendError {
|
||||
_ => {
|
||||
let status = self.api_error_status();
|
||||
let msg = self.api_error_message();
|
||||
infer_error_kind(status.unwrap_or(0), Some(msg.as_str()))
|
||||
.unwrap_or("unknown")
|
||||
infer_error_kind(status.unwrap_or(0), Some(msg.as_str())).unwrap_or("unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ pub(crate) async fn create_anthropic_batch(
|
||||
let model_clone = model.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
db::init_anthropic_batch_map_table(&conn)?;
|
||||
db::insert_anthropic_batch_map(&conn, &our_id, &oai_id)?;
|
||||
// Store model alongside mapping for result translation.
|
||||
conn.execute(
|
||||
@@ -185,7 +185,7 @@ pub(crate) async fn get_anthropic_batch(
|
||||
let batch_id_clone = batch_id.clone();
|
||||
let mapping = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
db::init_anthropic_batch_map_table(&conn)?;
|
||||
db::get_anthropic_batch_map(&conn, &batch_id_clone)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SQLite CRUD for batch_file and batch_job tables.
|
||||
// Anthropic-to-OpenAI batch ID mapping table.
|
||||
// The batch_file and batch_job tables are now managed by anyllm_batch_engine.
|
||||
|
||||
use super::{BatchJob, BatchStatus, RequestCounts};
|
||||
use crate::admin::db::now_iso8601;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Mapping from our Anthropic batch ID to the upstream OpenAI batch ID.
|
||||
@@ -12,7 +11,7 @@ pub struct AnthropicBatchMap {
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Create the anthropic_batch_map table if it doesn't exist.
|
||||
/// Create the anthropic_batch_map table if it doesn't exist (old schema for proxy use).
|
||||
pub fn init_anthropic_batch_map_table(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS anthropic_batch_map (
|
||||
@@ -73,385 +72,13 @@ pub fn set_anthropic_batch_output_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the batch_file and batch_job tables if they do not exist.
|
||||
pub fn init_batch_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS batch_file (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id TEXT NOT NULL UNIQUE,
|
||||
key_id INTEGER,
|
||||
purpose TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
byte_size INTEGER NOT NULL,
|
||||
line_count INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS batch_job (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT NOT NULL UNIQUE,
|
||||
key_id INTEGER,
|
||||
input_file_id TEXT NOT NULL,
|
||||
backend_batch_id TEXT,
|
||||
backend_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
request_counts_total INTEGER NOT NULL DEFAULT 0,
|
||||
request_counts_completed INTEGER NOT NULL DEFAULT 0,
|
||||
request_counts_failed INTEGER NOT NULL DEFAULT 0,
|
||||
output_file_id TEXT,
|
||||
error_file_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
expires_at TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
",
|
||||
)?;
|
||||
// Also create the Anthropic batch ID mapping table.
|
||||
init_anthropic_batch_map_table(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a new batch file record.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn insert_batch_file(
|
||||
conn: &Connection,
|
||||
file_id: &str,
|
||||
key_id: Option<i64>,
|
||||
purpose: &str,
|
||||
filename: Option<&str>,
|
||||
byte_size: i64,
|
||||
line_count: i64,
|
||||
content: &[u8],
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO batch_file (file_id, key_id, purpose, filename, byte_size, line_count, content, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
file_id,
|
||||
key_id,
|
||||
purpose,
|
||||
filename,
|
||||
byte_size,
|
||||
line_count,
|
||||
content,
|
||||
now_iso8601(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a batch file exists by file_id. Returns (byte_size, line_count, created_at) if found.
|
||||
pub fn get_batch_file_meta(
|
||||
conn: &Connection,
|
||||
file_id: &str,
|
||||
) -> rusqlite::Result<Option<(i64, i64, String)>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT byte_size, line_count, created_at FROM batch_file WHERE file_id = ?1")?;
|
||||
let mut rows = stmt.query_map(params![file_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
rows.next().transpose()
|
||||
}
|
||||
|
||||
/// Insert a new batch job record.
|
||||
pub fn insert_batch_job(
|
||||
conn: &Connection,
|
||||
batch_id: &str,
|
||||
key_id: Option<i64>,
|
||||
input_file_id: &str,
|
||||
backend_name: &str,
|
||||
line_count: i64,
|
||||
metadata: Option<&serde_json::Value>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let meta_str = metadata.map(|m| serde_json::to_string(m).unwrap_or_default());
|
||||
conn.execute(
|
||||
"INSERT INTO batch_job (batch_id, key_id, input_file_id, backend_name, status, request_counts_total, created_at, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
batch_id,
|
||||
key_id,
|
||||
input_file_id,
|
||||
backend_name,
|
||||
BatchStatus::Validating.as_str(),
|
||||
line_count,
|
||||
now_iso8601(),
|
||||
meta_str,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a single batch job by batch_id.
|
||||
pub fn get_batch_job(conn: &Connection, batch_id: &str) -> rusqlite::Result<Option<BatchJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT batch_id, input_file_id, backend_name, status,
|
||||
request_counts_total, request_counts_completed, request_counts_failed,
|
||||
output_file_id, error_file_id, created_at, completed_at, expires_at, metadata
|
||||
FROM batch_job WHERE batch_id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![batch_id], row_to_batch_job)?;
|
||||
rows.next().transpose()
|
||||
}
|
||||
|
||||
/// Update the status (and optional completion fields) of a batch job.
|
||||
pub fn update_batch_job_status(
|
||||
conn: &Connection,
|
||||
batch_id: &str,
|
||||
status: &BatchStatus,
|
||||
completed_count: Option<i64>,
|
||||
failed_count: Option<i64>,
|
||||
output_file_id: Option<&str>,
|
||||
error_file_id: Option<&str>,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let completed_at = if matches!(status, BatchStatus::Completed | BatchStatus::Failed) {
|
||||
Some(now_iso8601())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let changed = conn.execute(
|
||||
"UPDATE batch_job SET status = ?1,
|
||||
request_counts_completed = COALESCE(?2, request_counts_completed),
|
||||
request_counts_failed = COALESCE(?3, request_counts_failed),
|
||||
output_file_id = COALESCE(?4, output_file_id),
|
||||
error_file_id = COALESCE(?5, error_file_id),
|
||||
completed_at = COALESCE(?6, completed_at)
|
||||
WHERE batch_id = ?7",
|
||||
params![
|
||||
status.as_str(),
|
||||
completed_count,
|
||||
failed_count,
|
||||
output_file_id,
|
||||
error_file_id,
|
||||
completed_at,
|
||||
batch_id,
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// List batch jobs, optionally filtered by key_id, with cursor pagination.
|
||||
pub fn list_batch_jobs(
|
||||
conn: &Connection,
|
||||
key_id: Option<i64>,
|
||||
limit: u32,
|
||||
after: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<BatchJob>> {
|
||||
let mut sql = String::from(
|
||||
"SELECT batch_id, input_file_id, backend_name, status,
|
||||
request_counts_total, request_counts_completed, request_counts_failed,
|
||||
output_file_id, error_file_id, created_at, completed_at, expires_at, metadata
|
||||
FROM batch_job WHERE 1=1",
|
||||
);
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(kid) = key_id {
|
||||
sql.push_str(" AND key_id = ?");
|
||||
param_values.push(Box::new(kid));
|
||||
}
|
||||
if let Some(cursor) = after {
|
||||
sql.push_str(" AND batch_id < ?");
|
||||
param_values.push(Box::new(cursor.to_string()));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY id DESC LIMIT ?");
|
||||
param_values.push(Box::new(limit));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), row_to_batch_job)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Map a SQLite row to a BatchJob.
|
||||
fn row_to_batch_job(row: &rusqlite::Row) -> rusqlite::Result<BatchJob> {
|
||||
let status_str: String = row.get(3)?;
|
||||
let created_at_str: String = row.get(9)?;
|
||||
let metadata_str: Option<String> = row.get(12)?;
|
||||
|
||||
Ok(BatchJob {
|
||||
id: row.get(0)?,
|
||||
object: "batch".to_string(),
|
||||
endpoint: "/v1/chat/completions".to_string(),
|
||||
status: BatchStatus::from_str_status(&status_str),
|
||||
input_file_id: row.get(1)?,
|
||||
completion_window: "24h".to_string(),
|
||||
created_at: iso8601_to_epoch(&created_at_str),
|
||||
request_counts: RequestCounts {
|
||||
total: row.get(4)?,
|
||||
completed: row.get(5)?,
|
||||
failed: row.get(6)?,
|
||||
},
|
||||
metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
|
||||
output_file_id: row.get(7)?,
|
||||
error_file_id: row.get(8)?,
|
||||
completed_at: row
|
||||
.get::<_, Option<String>>(10)?
|
||||
.map(|s| iso8601_to_epoch(&s)),
|
||||
expires_at: row
|
||||
.get::<_, Option<String>>(11)?
|
||||
.map(|s| iso8601_to_epoch(&s)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Approximate conversion from ISO 8601 string to unix epoch seconds.
|
||||
/// Falls back to 0 on parse failure (non-critical metadata field).
|
||||
fn iso8601_to_epoch(s: &str) -> i64 {
|
||||
// Parse "YYYY-MM-DDTHH:MM:SSZ" manually (no chrono dependency).
|
||||
let parts: Vec<&str> = s.split('T').collect();
|
||||
if parts.len() != 2 {
|
||||
return 0;
|
||||
}
|
||||
let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
|
||||
let time_str = parts[1].trim_end_matches('Z');
|
||||
let time_parts: Vec<u64> = time_str.split(':').filter_map(|p| p.parse().ok()).collect();
|
||||
|
||||
if date_parts.len() != 3 || time_parts.len() != 3 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let (y, m, d) = (date_parts[0], date_parts[1], date_parts[2]);
|
||||
let (hh, mm, ss) = (time_parts[0], time_parts[1], time_parts[2]);
|
||||
|
||||
// Days from epoch using the inverse of the Howard Hinnant algorithm.
|
||||
let y_adj = if m <= 2 { y - 1 } else { y };
|
||||
let era = y_adj / 400;
|
||||
let yoe = y_adj - era * 400;
|
||||
let m_adj = if m > 2 { m - 3 } else { m + 9 };
|
||||
let doy = (153 * m_adj + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
|
||||
(days * 86400 + hh * 3600 + mm * 60 + ss) as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::admin::db::init_db;
|
||||
|
||||
fn test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
init_db(&conn).unwrap();
|
||||
init_batch_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_batch_file() {
|
||||
let conn = test_db();
|
||||
insert_batch_file(
|
||||
&conn,
|
||||
"file-abc123",
|
||||
None,
|
||||
"batch",
|
||||
Some("test.jsonl"),
|
||||
1024,
|
||||
10,
|
||||
b"test content",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let meta = get_batch_file_meta(&conn, "file-abc123").unwrap();
|
||||
assert!(meta.is_some());
|
||||
let (size, count, _created) = meta.unwrap();
|
||||
assert_eq!(size, 1024);
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_batch_job() {
|
||||
let conn = test_db();
|
||||
insert_batch_file(&conn, "file-input1", None, "batch", None, 512, 5, b"data").unwrap();
|
||||
|
||||
insert_batch_job(&conn, "batch-job1", None, "file-input1", "openai", 5, None).unwrap();
|
||||
|
||||
let job = get_batch_job(&conn, "batch-job1").unwrap();
|
||||
assert!(job.is_some());
|
||||
let job = job.unwrap();
|
||||
assert_eq!(job.id, "batch-job1");
|
||||
assert_eq!(job.status, BatchStatus::Validating);
|
||||
assert_eq!(job.request_counts.total, 5);
|
||||
assert_eq!(job.input_file_id, "file-input1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_batch_job_status_works() {
|
||||
let conn = test_db();
|
||||
insert_batch_file(&conn, "file-u1", None, "batch", None, 100, 2, b"d").unwrap();
|
||||
insert_batch_job(&conn, "batch-u1", None, "file-u1", "openai", 2, None).unwrap();
|
||||
|
||||
let ok = update_batch_job_status(
|
||||
&conn,
|
||||
"batch-u1",
|
||||
&BatchStatus::Completed,
|
||||
Some(2),
|
||||
Some(0),
|
||||
Some("file-out1"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
|
||||
let job = get_batch_job(&conn, "batch-u1").unwrap().unwrap();
|
||||
assert_eq!(job.status, BatchStatus::Completed);
|
||||
assert_eq!(job.request_counts.completed, 2);
|
||||
assert!(job.output_file_id.is_some());
|
||||
assert!(job.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_batch_jobs_with_pagination() {
|
||||
let conn = test_db();
|
||||
insert_batch_file(&conn, "file-l1", None, "batch", None, 10, 1, b"d").unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
insert_batch_job(
|
||||
&conn,
|
||||
&format!("batch-l{i}"),
|
||||
Some(1),
|
||||
"file-l1",
|
||||
"openai",
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let all = list_batch_jobs(&conn, Some(1), 10, None).unwrap();
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
let page = list_batch_jobs(&conn, Some(1), 2, None).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
|
||||
// Different key_id returns nothing
|
||||
let empty = list_batch_jobs(&conn, Some(999), 10, None).unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_nonexistent_job() {
|
||||
let conn = test_db();
|
||||
let job = get_batch_job(&conn, "batch-nope").unwrap();
|
||||
assert!(job.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_round_trip() {
|
||||
// 2026-03-22T10:30:00Z
|
||||
let epoch = iso8601_to_epoch("2026-03-22T10:30:00Z");
|
||||
assert!(epoch > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_batch_map_round_trip() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
init_batch_tables(&conn).unwrap();
|
||||
init_anthropic_batch_map_table(&conn).unwrap();
|
||||
|
||||
insert_anthropic_batch_map(&conn, "msgbatch_our1", "batch_openai1").unwrap();
|
||||
|
||||
@@ -1,281 +1,11 @@
|
||||
// Batch processing types and JSONL validation.
|
||||
// Implements OpenAI-compatible batch file upload and job management.
|
||||
//! Batch API HTTP handlers. Types and logic live in anyllm_batch_engine.
|
||||
|
||||
pub mod anthropic_batch;
|
||||
pub mod db;
|
||||
pub mod openai_batch_client;
|
||||
pub mod routes;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Maximum number of lines in a JSONL batch file.
|
||||
const MAX_LINE_COUNT: usize = 50_000;
|
||||
|
||||
/// Maximum file size in bytes (100 MB).
|
||||
const MAX_FILE_SIZE: usize = 100 * 1024 * 1024;
|
||||
|
||||
/// Maximum length of a custom_id field.
|
||||
const MAX_CUSTOM_ID_LEN: usize = 64;
|
||||
|
||||
/// A batch input file stored in SQLite.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BatchFile {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub bytes: i64,
|
||||
pub created_at: i64,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
/// A batch processing job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BatchJob {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub endpoint: String,
|
||||
pub status: BatchStatus,
|
||||
pub input_file_id: String,
|
||||
pub completion_window: String,
|
||||
pub created_at: i64,
|
||||
pub request_counts: RequestCounts,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_file_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_file_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Counts of requests within a batch job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RequestCounts {
|
||||
pub total: i64,
|
||||
pub completed: i64,
|
||||
pub failed: i64,
|
||||
}
|
||||
|
||||
/// Batch job lifecycle status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BatchStatus {
|
||||
Validating,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Expired,
|
||||
Cancelling,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl BatchStatus {
|
||||
/// Convert from the string stored in SQLite.
|
||||
pub fn from_str_status(s: &str) -> Self {
|
||||
match s {
|
||||
"validating" => Self::Validating,
|
||||
"in_progress" => Self::InProgress,
|
||||
"completed" => Self::Completed,
|
||||
"failed" => Self::Failed,
|
||||
"expired" => Self::Expired,
|
||||
"cancelling" => Self::Cancelling,
|
||||
"cancelled" => Self::Cancelled,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to the string stored in SQLite.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Validating => "validating",
|
||||
Self::InProgress => "in_progress",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Expired => "expired",
|
||||
Self::Cancelling => "cancelling",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of JSONL validation: line count on success, error message on failure.
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedJsonl {
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
/// Validate a JSONL batch file.
|
||||
///
|
||||
/// Each line must be valid JSON with a unique `custom_id` (string, max 64 chars)
|
||||
/// and a `body` object containing a `model` field. Max 50,000 lines, 100 MB.
|
||||
///
|
||||
/// Takes a `BufRead` to read line-by-line without requiring a contiguous UTF-8
|
||||
/// string for the entire file.
|
||||
pub fn validate_jsonl(mut reader: impl std::io::BufRead) -> Result<ValidatedJsonl, String> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut line_count = 0usize;
|
||||
let mut raw_line_num = 0usize;
|
||||
let mut bytes_read = 0usize;
|
||||
let mut line_buf = String::new();
|
||||
|
||||
loop {
|
||||
line_buf.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line_buf)
|
||||
.map_err(|e| format!("Read error: {e}"))?;
|
||||
if n == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
raw_line_num += 1;
|
||||
bytes_read += n;
|
||||
if bytes_read > MAX_FILE_SIZE {
|
||||
return Err(format!(
|
||||
"File exceeds maximum size of {} bytes",
|
||||
MAX_FILE_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
let line = line_buf.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
line_count += 1;
|
||||
if line_count > MAX_LINE_COUNT {
|
||||
return Err(format!("File exceeds maximum of {MAX_LINE_COUNT} lines"));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Line {raw_line_num}: invalid JSON: {e}"))?;
|
||||
|
||||
let obj = parsed
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("Line {raw_line_num}: expected JSON object"))?;
|
||||
|
||||
let custom_id = obj
|
||||
.get("custom_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("Line {raw_line_num}: missing or non-string 'custom_id'"))?;
|
||||
|
||||
if custom_id.len() > MAX_CUSTOM_ID_LEN {
|
||||
return Err(format!(
|
||||
"Line {raw_line_num}: custom_id exceeds maximum length of {MAX_CUSTOM_ID_LEN} characters"
|
||||
));
|
||||
}
|
||||
|
||||
if !seen_ids.insert(custom_id.to_string()) {
|
||||
return Err(format!(
|
||||
"Line {raw_line_num}: duplicate custom_id '{custom_id}'"
|
||||
));
|
||||
}
|
||||
|
||||
let body = obj
|
||||
.get("body")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| format!("Line {raw_line_num}: missing or non-object 'body'"))?;
|
||||
|
||||
if !body.contains_key("model") {
|
||||
return Err(format!("Line {raw_line_num}: body missing 'model' field"));
|
||||
}
|
||||
}
|
||||
|
||||
if line_count == 0 {
|
||||
return Err("File is empty".to_string());
|
||||
}
|
||||
|
||||
Ok(ValidatedJsonl { line_count })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufReader, Cursor};
|
||||
|
||||
fn check(data: &str) -> Result<ValidatedJsonl, String> {
|
||||
validate_jsonl(BufReader::new(Cursor::new(data.as_bytes())))
|
||||
}
|
||||
|
||||
fn check_bytes(data: &[u8]) -> Result<ValidatedJsonl, String> {
|
||||
validate_jsonl(BufReader::new(Cursor::new(data)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_jsonl() {
|
||||
let data = r#"{"custom_id": "req-1", "body": {"model": "gpt-4o", "messages": []}}
|
||||
{"custom_id": "req-2", "body": {"model": "gpt-4o", "messages": []}}"#;
|
||||
let result = check(data);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().line_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_custom_id() {
|
||||
let data = r#"{"body": {"model": "gpt-4o"}}"#;
|
||||
let result = check(data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("custom_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_model() {
|
||||
let data = r#"{"custom_id": "req-1", "body": {"messages": []}}"#;
|
||||
let result = check(data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_custom_id() {
|
||||
let data = r#"{"custom_id": "req-1", "body": {"model": "gpt-4o"}}
|
||||
{"custom_id": "req-1", "body": {"model": "gpt-4o"}}"#;
|
||||
let result = check(data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_custom_id() {
|
||||
let long_id = "a".repeat(65);
|
||||
let data = format!(r#"{{"custom_id": "{long_id}", "body": {{"model": "gpt-4o"}}}}"#);
|
||||
let result = check(&data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("maximum length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file() {
|
||||
let result = check_bytes(b"");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_line() {
|
||||
let data = b"not json at all";
|
||||
let result = check_bytes(data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_skipped() {
|
||||
let data = r#"{"custom_id": "req-1", "body": {"model": "gpt-4o"}}
|
||||
|
||||
{"custom_id": "req-2", "body": {"model": "gpt-4o"}}"#;
|
||||
let result = check(data);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().line_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_reports_absolute_line_number_with_blank_lines() {
|
||||
// Blank line at position 1, bad JSON at position 2.
|
||||
// Should report "Line 2", not "Line 1".
|
||||
let data = "\n{\"custom_id\": \"ok\", \"body\": INVALID}";
|
||||
let err = check(data).unwrap_err();
|
||||
assert!(err.contains("Line 2"), "expected 'Line 2' in: {err}");
|
||||
}
|
||||
}
|
||||
// Re-export engine types for handler use.
|
||||
pub use anyllm_batch_engine::job::{BatchId, BatchJob, BatchStatus, RequestCounts};
|
||||
pub use anyllm_batch_engine::validation::{validate_jsonl, ValidatedJsonl};
|
||||
pub use anyllm_batch_engine::BatchEngine;
|
||||
|
||||
+214
-138
@@ -1,10 +1,9 @@
|
||||
// Axum handlers for batch file upload and job management.
|
||||
// POST /v1/files, POST /v1/batches, GET /v1/batches/{id}, GET /v1/batches
|
||||
|
||||
use super::db;
|
||||
use super::validate_jsonl;
|
||||
use crate::backend::BackendClient;
|
||||
use crate::server::routes::AppState;
|
||||
use anyllm_batch_engine::job::{BatchSubmission, ExecutionMode, SourceFormat, SubmissionItem};
|
||||
use anyllm_translate::anthropic;
|
||||
use anyllm_translate::mapping::errors_map::create_anthropic_error;
|
||||
use axum::{
|
||||
@@ -16,11 +15,9 @@ use serde::Deserialize;
|
||||
use std::io::{BufReader, Cursor};
|
||||
|
||||
/// POST /v1/files - Upload a JSONL batch file via multipart/form-data.
|
||||
///
|
||||
/// Expects fields: `purpose` (must be "batch") and `file` (the JSONL content).
|
||||
pub async fn upload_file(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
||||
let db = match state.shared.as_ref().map(|s| s.db.clone()) {
|
||||
Some(db) => db,
|
||||
let engine = match state.batch_engine.as_ref() {
|
||||
Some(e) => e.clone(),
|
||||
None => return service_unavailable("Batch storage not available"),
|
||||
};
|
||||
|
||||
@@ -31,9 +28,7 @@ pub async fn upload_file(State(state): State<AppState>, mut multipart: Multipart
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
match field_name.as_str() {
|
||||
"purpose" => {
|
||||
purpose = field.text().await.ok();
|
||||
}
|
||||
"purpose" => purpose = field.text().await.ok(),
|
||||
"file" => {
|
||||
filename = field.file_name().map(|s| s.to_string());
|
||||
file_data = field.bytes().await.ok();
|
||||
@@ -42,82 +37,61 @@ pub async fn upload_file(State(state): State<AppState>, mut multipart: Multipart
|
||||
}
|
||||
}
|
||||
|
||||
let purpose = match purpose.as_deref() {
|
||||
Some("batch") => "batch",
|
||||
match purpose.as_deref() {
|
||||
Some("batch") => {}
|
||||
Some(other) => {
|
||||
return bad_request(&format!(
|
||||
"Unsupported purpose: '{other}'. Only 'batch' is supported."
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return bad_request("Missing required field 'purpose'");
|
||||
}
|
||||
};
|
||||
None => return bad_request("Missing required field 'purpose'"),
|
||||
}
|
||||
|
||||
let data = match file_data {
|
||||
Some(d) if !d.is_empty() => d,
|
||||
_ => {
|
||||
return bad_request("Missing or empty 'file' field");
|
||||
}
|
||||
_ => return bad_request("Missing or empty 'file' field"),
|
||||
};
|
||||
|
||||
// Validate JSONL structure
|
||||
let validated = match validate_jsonl(BufReader::new(Cursor::new(data.as_ref()))) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return bad_request(&format!("Invalid JSONL: {e}"));
|
||||
}
|
||||
};
|
||||
let validated =
|
||||
match anyllm_batch_engine::validate_jsonl(BufReader::new(Cursor::new(data.as_ref()))) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return bad_request(&format!("Invalid JSONL: {e}")),
|
||||
};
|
||||
|
||||
let file_id = format!("file-{}", uuid::Uuid::new_v4());
|
||||
let byte_size = data.len() as i64;
|
||||
let line_count = validated.line_count as i64;
|
||||
|
||||
// Insert into SQLite on the blocking threadpool
|
||||
let file_id_clone = file_id.clone();
|
||||
let filename_clone = filename.clone();
|
||||
let data_ref = data.as_ref().to_vec();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
db::insert_batch_file(
|
||||
&conn,
|
||||
&file_id_clone,
|
||||
match engine
|
||||
.file_store
|
||||
.insert(
|
||||
&file_id,
|
||||
None,
|
||||
purpose,
|
||||
filename_clone.as_deref(),
|
||||
byte_size,
|
||||
filename.as_deref(),
|
||||
data.as_ref(),
|
||||
line_count,
|
||||
&data_ref,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let now_epoch = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
let file_obj = super::BatchFile {
|
||||
id: file_id,
|
||||
object: "file".to_string(),
|
||||
bytes: byte_size,
|
||||
created_at: now_epoch,
|
||||
filename,
|
||||
purpose: purpose.to_string(),
|
||||
};
|
||||
let file_obj = serde_json::json!({
|
||||
"id": file_id,
|
||||
"object": "file",
|
||||
"bytes": byte_size,
|
||||
"created_at": now_epoch,
|
||||
"filename": filename,
|
||||
"purpose": "batch",
|
||||
});
|
||||
(StatusCode::OK, Json(file_obj)).into_response()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to store batch file");
|
||||
internal_error("Failed to store file")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "spawn_blocking panicked");
|
||||
internal_error("Internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +115,6 @@ fn default_completion_window() -> String {
|
||||
}
|
||||
|
||||
/// POST /v1/batches - Create a new batch job.
|
||||
///
|
||||
/// Returns 501 for unsupported backends (vertex, gemini, anthropic, bedrock).
|
||||
pub async fn create_batch(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateBatchRequest>,
|
||||
@@ -155,75 +127,67 @@ pub async fn create_batch(
|
||||
));
|
||||
}
|
||||
|
||||
let db = match state.shared.as_ref().map(|s| s.db.clone()) {
|
||||
Some(db) => db,
|
||||
let engine = match state.batch_engine.as_ref() {
|
||||
Some(e) => e.clone(),
|
||||
None => return service_unavailable("Batch storage not available"),
|
||||
};
|
||||
|
||||
let input_file_id = req.input_file_id.clone();
|
||||
let batch_id = format!("batch-{}", uuid::Uuid::new_v4());
|
||||
let backend_name = state.backend_name.clone();
|
||||
let metadata = req.metadata.clone();
|
||||
// Read file content from file store.
|
||||
let content = match engine.file_store.get_content(&req.input_file_id).await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return bad_request(&format!("Input file '{}' not found", req.input_file_id)),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to read batch file content");
|
||||
return internal_error("Failed to read file");
|
||||
}
|
||||
};
|
||||
|
||||
// Verify input file exists and get line count
|
||||
let batch_id_clone = batch_id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
// Parse JSONL into submission items.
|
||||
let items: Vec<SubmissionItem> = match parse_jsonl_items(&content) {
|
||||
Ok(items) => items,
|
||||
Err(e) => return bad_request(&format!("Invalid JSONL: {e}")),
|
||||
};
|
||||
|
||||
let meta = db::get_batch_file_meta(&conn, &input_file_id)?;
|
||||
let (_byte_size, line_count, _created) = match meta {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let execution_mode = if is_openai_or_azure_backend(&state.backend) {
|
||||
ExecutionMode::Native {
|
||||
provider: state.backend_name.clone(),
|
||||
}
|
||||
} else {
|
||||
ExecutionMode::ProxyNative
|
||||
};
|
||||
|
||||
db::insert_batch_job(
|
||||
&conn,
|
||||
&batch_id_clone,
|
||||
None,
|
||||
&input_file_id,
|
||||
&backend_name,
|
||||
line_count,
|
||||
metadata.as_ref(),
|
||||
)?;
|
||||
let submission = BatchSubmission {
|
||||
items,
|
||||
execution_mode,
|
||||
input_file_id: req.input_file_id.clone(),
|
||||
key_id: None,
|
||||
webhook_url: None,
|
||||
metadata: req.metadata.clone(),
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
db::get_batch_job(&conn, &batch_id_clone)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(Some(job))) => (StatusCode::OK, Json(job)).into_response(),
|
||||
Ok(Ok(None)) => bad_request(&format!("Input file '{}' not found", req.input_file_id)),
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, "failed to create batch job");
|
||||
internal_error("Failed to create batch job")
|
||||
match engine.submit(submission).await {
|
||||
Ok(job) => (StatusCode::OK, Json(job_to_openai_response(&job))).into_response(),
|
||||
Err(anyllm_batch_engine::EngineError::FileNotFound(_)) => {
|
||||
bad_request(&format!("Input file '{}' not found", req.input_file_id))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "spawn_blocking panicked");
|
||||
internal_error("Internal error")
|
||||
tracing::error!(error = %e, "failed to create batch job");
|
||||
internal_error("Failed to create batch job")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /v1/batches/{batch_id} - Retrieve a batch job by ID.
|
||||
/// GET /v1/batches/{batch_id}
|
||||
pub async fn get_batch(State(state): State<AppState>, Path(batch_id): Path<String>) -> Response {
|
||||
let db = match state.shared.as_ref().map(|s| s.db.clone()) {
|
||||
Some(db) => db,
|
||||
let engine = match state.batch_engine.as_ref() {
|
||||
Some(e) => e.clone(),
|
||||
None => return service_unavailable("Batch storage not available"),
|
||||
};
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
db::get_batch_job(&conn, &batch_id)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(Some(job))) => (StatusCode::OK, Json(job)).into_response(),
|
||||
Ok(Ok(None)) => {
|
||||
match engine.get(&anyllm_batch_engine::BatchId(batch_id)).await {
|
||||
Ok(Some(job)) => (StatusCode::OK, Json(job_to_openai_response(&job))).into_response(),
|
||||
Ok(None) => {
|
||||
let err = create_anthropic_error(
|
||||
anthropic::ErrorType::NotFoundError,
|
||||
"Batch not found".to_string(),
|
||||
@@ -231,14 +195,10 @@ pub async fn get_batch(State(state): State<AppState>, Path(batch_id): Path<Strin
|
||||
);
|
||||
(StatusCode::NOT_FOUND, Json(err)).into_response()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to fetch batch job");
|
||||
internal_error("Failed to fetch batch job")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "spawn_blocking panicked");
|
||||
internal_error("Internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,50 +214,154 @@ fn default_limit() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
/// GET /v1/batches - List batch jobs with cursor pagination.
|
||||
/// GET /v1/batches
|
||||
pub async fn list_batches(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListBatchesQuery>,
|
||||
) -> Response {
|
||||
let db = match state.shared.as_ref().map(|s| s.db.clone()) {
|
||||
Some(db) => db,
|
||||
let engine = match state.batch_engine.as_ref() {
|
||||
Some(e) => e.clone(),
|
||||
None => return service_unavailable("Batch storage not available"),
|
||||
};
|
||||
|
||||
let limit = query.limit.min(100);
|
||||
let after = query.after.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||
db::init_batch_tables(&conn)?;
|
||||
db::list_batch_jobs(&conn, None, limit, after.as_deref())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(jobs)) => {
|
||||
match engine.list(None, query.after.as_deref(), limit).await {
|
||||
Ok(jobs) => {
|
||||
let has_more = jobs.len() as u32 == limit;
|
||||
let last_id = jobs.last().map(|j| j.id.clone());
|
||||
let first_id = jobs.first().map(|j| j.id.0.clone());
|
||||
let last_id = jobs.last().map(|j| j.id.0.clone());
|
||||
let data: Vec<serde_json::Value> = jobs.iter().map(job_to_openai_response).collect();
|
||||
let response = serde_json::json!({
|
||||
"object": "list",
|
||||
"data": jobs,
|
||||
"data": data,
|
||||
"has_more": has_more,
|
||||
"first_id": jobs.first().map(|j| &j.id),
|
||||
"first_id": first_id,
|
||||
"last_id": last_id,
|
||||
});
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to list batch jobs");
|
||||
internal_error("Failed to list batch jobs")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "spawn_blocking panicked");
|
||||
internal_error("Internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/batches/{batch_id}/cancel
|
||||
pub async fn cancel_batch(State(state): State<AppState>, Path(batch_id): Path<String>) -> Response {
|
||||
let Some(engine) = state.batch_engine.as_ref() else {
|
||||
return not_implemented("batch engine not available");
|
||||
};
|
||||
|
||||
let id = anyllm_batch_engine::BatchId(batch_id);
|
||||
match engine.cancel(&id).await {
|
||||
Ok(_) => match engine.get(&id).await {
|
||||
Ok(Some(job)) => (StatusCode::OK, Json(job_to_openai_response(&job))).into_response(),
|
||||
Ok(None) => not_found_response("batch not found"),
|
||||
Err(e) => internal_error(&e.to_string()),
|
||||
},
|
||||
Err(anyllm_batch_engine::EngineError::Queue(anyllm_batch_engine::QueueError::NotFound)) => {
|
||||
not_found_response("batch not found")
|
||||
}
|
||||
Err(e) => internal_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a BatchJob to an OpenAI-compatible batch response JSON.
|
||||
pub fn job_to_openai_response(job: &anyllm_batch_engine::BatchJob) -> serde_json::Value {
|
||||
let created_epoch = iso8601_to_epoch(&job.created_at);
|
||||
let completed_epoch = job.completed_at.as_deref().map(iso8601_to_epoch);
|
||||
|
||||
serde_json::json!({
|
||||
"id": job.id.0,
|
||||
"object": "batch",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"status": map_batch_status(&job.status),
|
||||
"input_file_id": job.input_file_id,
|
||||
"completion_window": "24h",
|
||||
"created_at": created_epoch,
|
||||
"request_counts": {
|
||||
"total": job.request_counts.total,
|
||||
"completed": job.request_counts.succeeded,
|
||||
"failed": job.request_counts.failed,
|
||||
},
|
||||
"metadata": job.metadata,
|
||||
"output_file_id": serde_json::Value::Null,
|
||||
"error_file_id": serde_json::Value::Null,
|
||||
"completed_at": completed_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map BatchEngine status to OpenAI batch status string.
|
||||
fn map_batch_status(status: &anyllm_batch_engine::BatchStatus) -> &'static str {
|
||||
match status {
|
||||
anyllm_batch_engine::BatchStatus::Queued => "validating",
|
||||
anyllm_batch_engine::BatchStatus::Processing => "in_progress",
|
||||
anyllm_batch_engine::BatchStatus::Completed => "completed",
|
||||
anyllm_batch_engine::BatchStatus::Failed => "failed",
|
||||
anyllm_batch_engine::BatchStatus::Cancelling => "cancelling",
|
||||
anyllm_batch_engine::BatchStatus::Cancelled => "cancelled",
|
||||
anyllm_batch_engine::BatchStatus::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse JSONL bytes into SubmissionItems.
|
||||
fn parse_jsonl_items(content: &[u8]) -> Result<Vec<SubmissionItem>, String> {
|
||||
let mut items = Vec::new();
|
||||
let text = std::str::from_utf8(content).map_err(|e| format!("Invalid UTF-8: {e}"))?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let obj = parsed.as_object().ok_or("Expected JSON object")?;
|
||||
let custom_id = obj
|
||||
.get("custom_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing custom_id")?
|
||||
.to_string();
|
||||
let body = obj.get("body").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
items.push(SubmissionItem {
|
||||
custom_id,
|
||||
model,
|
||||
body,
|
||||
source_format: SourceFormat::OpenAI,
|
||||
});
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn iso8601_to_epoch(s: &str) -> i64 {
|
||||
let parts: Vec<&str> = s.split('T').collect();
|
||||
if parts.len() != 2 {
|
||||
return 0;
|
||||
}
|
||||
let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
|
||||
let time_str = parts[1].trim_end_matches('Z');
|
||||
let time_parts: Vec<u64> = time_str.split(':').filter_map(|p| p.parse().ok()).collect();
|
||||
if date_parts.len() != 3 || time_parts.len() != 3 {
|
||||
return 0;
|
||||
}
|
||||
let (y, m, d) = (date_parts[0], date_parts[1], date_parts[2]);
|
||||
let (hh, mm, ss) = (time_parts[0], time_parts[1], time_parts[2]);
|
||||
let y_adj = if m <= 2 { y - 1 } else { y };
|
||||
let era = y_adj / 400;
|
||||
let yoe = y_adj - era * 400;
|
||||
let m_adj = if m > 2 { m - 3 } else { m + 9 };
|
||||
let doy = (153 * m_adj + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
(days * 86400 + hh * 3600 + mm * 60 + ss) as i64
|
||||
}
|
||||
|
||||
/// Check if the backend supports batch processing (OpenAI and Azure only).
|
||||
fn is_batch_supported(backend: &BackendClient) -> bool {
|
||||
matches!(
|
||||
@@ -306,6 +370,13 @@ fn is_batch_supported(backend: &BackendClient) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_openai_or_azure_backend(backend: &BackendClient) -> bool {
|
||||
matches!(
|
||||
backend,
|
||||
BackendClient::OpenAI(_) | BackendClient::AzureOpenAI(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> Response {
|
||||
let err = create_anthropic_error(
|
||||
anthropic::ErrorType::InvalidRequestError,
|
||||
@@ -333,3 +404,8 @@ fn internal_error(msg: &str) -> Response {
|
||||
let err = create_anthropic_error(anthropic::ErrorType::ApiError, msg.to_string(), None);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(err)).into_response()
|
||||
}
|
||||
|
||||
fn not_found_response(msg: &str) -> Response {
|
||||
let err = create_anthropic_error(anthropic::ErrorType::NotFoundError, msg.to_string(), None);
|
||||
(StatusCode::NOT_FOUND, Json(err)).into_response()
|
||||
}
|
||||
|
||||
@@ -154,9 +154,7 @@ impl ToolStartupConfig {
|
||||
/// Returns true when at least one tool-related section was present in the config.
|
||||
/// Used to decide whether to construct a ToolEngineState at all.
|
||||
pub fn has_any(&self) -> bool {
|
||||
self.tool_execution.is_some()
|
||||
|| self.builtin_tools.is_some()
|
||||
|| self.mcp_servers.is_some()
|
||||
self.tool_execution.is_some() || self.builtin_tools.is_some() || self.mcp_servers.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,9 +393,7 @@ impl SimpleConfig {
|
||||
crate::tools::LoopConfig {
|
||||
max_iterations: te.max_iterations.unwrap_or(1),
|
||||
tool_timeout: std::time::Duration::from_secs(te.tool_timeout_secs.unwrap_or(30)),
|
||||
total_timeout: std::time::Duration::from_secs(
|
||||
te.total_timeout_secs.unwrap_or(300),
|
||||
),
|
||||
total_timeout: std::time::Duration::from_secs(te.total_timeout_secs.unwrap_or(300)),
|
||||
max_tool_calls_per_turn: te.max_tool_calls_per_turn.unwrap_or(16),
|
||||
}
|
||||
} else {
|
||||
@@ -903,7 +899,10 @@ mcp_servers:
|
||||
assert_eq!(rule.timeout, Some(std::time::Duration::from_secs(10)));
|
||||
|
||||
// MCP glob rule
|
||||
assert_eq!(policy.resolve("mcp_github_search_repos"), PolicyAction::Allow);
|
||||
assert_eq!(
|
||||
policy.resolve("mcp_github_search_repos"),
|
||||
PolicyAction::Allow
|
||||
);
|
||||
|
||||
// Default loop config (no tool_execution section)
|
||||
assert_eq!(loop_config.max_iterations, 1);
|
||||
@@ -922,10 +921,7 @@ tool_execution:
|
||||
let config: SimpleConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let (_policy, loop_config) = config.build_tool_config();
|
||||
assert_eq!(loop_config.max_iterations, 5);
|
||||
assert_eq!(
|
||||
loop_config.tool_timeout,
|
||||
std::time::Duration::from_secs(45)
|
||||
);
|
||||
assert_eq!(loop_config.tool_timeout, std::time::Duration::from_secs(45));
|
||||
assert_eq!(
|
||||
loop_config.total_timeout,
|
||||
std::time::Duration::from_secs(300)
|
||||
|
||||
+119
-78
@@ -160,96 +160,97 @@ async fn main() {
|
||||
// Build tool engine state from config, if tool sections were present.
|
||||
// Only constructed when at least one of tool_execution / builtin_tools / mcp_servers
|
||||
// is present in the config file, to avoid overhead when tools are unused.
|
||||
let tool_engine_state: Option<Arc<routes::ToolEngineState>> =
|
||||
if let Some(tc) = load_result.tool_config.filter(|tc| tc.has_any()) {
|
||||
let simple_config_shell = config::simple::SimpleConfig {
|
||||
routing_strategy: None,
|
||||
listen_port: None,
|
||||
log_bodies: None,
|
||||
models: vec![],
|
||||
tool_execution: tc.tool_execution,
|
||||
builtin_tools: tc.builtin_tools,
|
||||
mcp_servers: tc.mcp_servers,
|
||||
};
|
||||
let (policy, loop_config) = simple_config_shell.build_tool_config();
|
||||
let tool_engine_state: Option<Arc<routes::ToolEngineState>> = if let Some(tc) =
|
||||
load_result.tool_config.filter(|tc| tc.has_any())
|
||||
{
|
||||
let simple_config_shell = config::simple::SimpleConfig {
|
||||
routing_strategy: None,
|
||||
listen_port: None,
|
||||
log_bodies: None,
|
||||
models: vec![],
|
||||
tool_execution: tc.tool_execution,
|
||||
builtin_tools: tc.builtin_tools,
|
||||
mcp_servers: tc.mcp_servers,
|
||||
};
|
||||
let (policy, loop_config) = simple_config_shell.build_tool_config();
|
||||
|
||||
let mut registry = tools::ToolRegistry::new();
|
||||
// Register built-in tools (gated behind the dangerous-builtin-tools feature).
|
||||
anyllm_proxy::tools::builtin::register_all(
|
||||
&mut registry,
|
||||
simple_config_shell.builtin_tools.as_ref(),
|
||||
);
|
||||
let mut registry = tools::ToolRegistry::new();
|
||||
// Register built-in tools (gated behind the dangerous-builtin-tools feature).
|
||||
anyllm_proxy::tools::builtin::register_all(
|
||||
&mut registry,
|
||||
simple_config_shell.builtin_tools.as_ref(),
|
||||
);
|
||||
|
||||
// Build MCP manager and discover tools from configured servers.
|
||||
let mcp_manager = if let Some(ref servers) = simple_config_shell.mcp_servers {
|
||||
let manager = Arc::new(tools::McpServerManager::new());
|
||||
for server_cfg in servers {
|
||||
// SSRF protection: skip servers with private/loopback URLs.
|
||||
if let Err(e) = crate::config::validate_base_url(&server_cfg.url) {
|
||||
tracing::error!(
|
||||
// Build MCP manager and discover tools from configured servers.
|
||||
let mcp_manager = if let Some(ref servers) = simple_config_shell.mcp_servers {
|
||||
let manager = Arc::new(tools::McpServerManager::new());
|
||||
for server_cfg in servers {
|
||||
// SSRF protection: skip servers with private/loopback URLs.
|
||||
if let Err(e) = crate::config::validate_base_url(&server_cfg.url) {
|
||||
tracing::error!(
|
||||
server = %server_cfg.name,
|
||||
url = %server_cfg.url,
|
||||
error = %e,
|
||||
"MCP server URL rejected (SSRF protection); skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match tools::McpServerManager::discover_tools(&server_cfg.url).await {
|
||||
Ok(discovered) => {
|
||||
tracing::info!(
|
||||
server = %server_cfg.name,
|
||||
url = %server_cfg.url,
|
||||
tools = discovered.len(),
|
||||
"MCP server connected and tools discovered"
|
||||
);
|
||||
if let Err(e) = manager.register_server_blocking(
|
||||
&server_cfg.name,
|
||||
&server_cfg.url,
|
||||
discovered,
|
||||
) {
|
||||
tracing::error!(
|
||||
server = %server_cfg.name,
|
||||
error = %e,
|
||||
"MCP server registration failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
server = %server_cfg.name,
|
||||
url = %server_cfg.url,
|
||||
error = %e,
|
||||
"MCP server URL rejected (SSRF protection); skipping"
|
||||
"MCP server unreachable at startup; tools from this server will be unavailable"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match tools::McpServerManager::discover_tools(&server_cfg.url).await {
|
||||
Ok(discovered) => {
|
||||
tracing::info!(
|
||||
server = %server_cfg.name,
|
||||
url = %server_cfg.url,
|
||||
tools = discovered.len(),
|
||||
"MCP server connected and tools discovered"
|
||||
);
|
||||
if let Err(e) = manager.register_server_blocking(
|
||||
&server_cfg.name,
|
||||
&server_cfg.url,
|
||||
discovered,
|
||||
) {
|
||||
tracing::error!(
|
||||
server = %server_cfg.name,
|
||||
error = %e,
|
||||
"MCP server registration failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
server = %server_cfg.name,
|
||||
url = %server_cfg.url,
|
||||
error = %e,
|
||||
"MCP server unreachable at startup; tools from this server will be unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Register all discovered MCP tools into the registry.
|
||||
tools::mcp::register_mcp_tools(&manager, &mut registry);
|
||||
Some(manager)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
registered_tools = registry.list_names().len(),
|
||||
mcp_servers = mcp_manager
|
||||
.as_ref()
|
||||
.map(|m| m.list_servers_blocking().len())
|
||||
.unwrap_or(0),
|
||||
"tool execution engine initialized"
|
||||
);
|
||||
|
||||
Some(Arc::new(routes::ToolEngineState {
|
||||
registry: Arc::new(registry),
|
||||
policy: Arc::new(policy),
|
||||
loop_config,
|
||||
mcp_manager,
|
||||
}))
|
||||
}
|
||||
// Register all discovered MCP tools into the registry.
|
||||
tools::mcp::register_mcp_tools(&manager, &mut registry);
|
||||
Some(manager)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
registered_tools = registry.list_names().len(),
|
||||
mcp_servers = mcp_manager
|
||||
.as_ref()
|
||||
.map(|m| m.list_servers_blocking().len())
|
||||
.unwrap_or(0),
|
||||
"tool execution engine initialized"
|
||||
);
|
||||
|
||||
Some(Arc::new(routes::ToolEngineState {
|
||||
registry: Arc::new(registry),
|
||||
policy: Arc::new(policy),
|
||||
loop_config,
|
||||
mcp_manager,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Admin web UI is opt-in: pass --webui or --admin to enable.
|
||||
// DISABLE_ADMIN=1 overrides the flag (useful in container/scripted environments).
|
||||
let flag_set = args.iter().any(|a| a == "--webui" || a == "--admin");
|
||||
@@ -543,12 +544,52 @@ async fn main() {
|
||||
None
|
||||
};
|
||||
|
||||
// Initialize batch engine with its own connection to the same DB file.
|
||||
// Only available when admin is enabled (requires a DB path).
|
||||
let batch_engine: Option<
|
||||
std::sync::Arc<
|
||||
anyllm_batch_engine::BatchEngine<
|
||||
anyllm_batch_engine::queue::sqlite::SqliteQueue,
|
||||
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
|
||||
>,
|
||||
>,
|
||||
> = if enable_admin {
|
||||
let db_path = std::env::var("ADMIN_DB_PATH").unwrap_or_else(|_| "admin.db".into());
|
||||
let batch_conn = rusqlite::Connection::open(&db_path)
|
||||
.expect("failed to open second SQLite connection for batch engine");
|
||||
anyllm_batch_engine::db::migrate_old_tables(&batch_conn)
|
||||
.expect("failed to migrate old batch tables");
|
||||
anyllm_batch_engine::db::init_batch_engine_tables(&batch_conn)
|
||||
.expect("failed to initialize batch engine tables");
|
||||
let batch_db = std::sync::Arc::new(tokio::sync::Mutex::new(batch_conn));
|
||||
let global_webhook_urls: Vec<String> = std::env::var("BATCH_WEBHOOK_URLS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Some(std::sync::Arc::new(anyllm_batch_engine::BatchEngine {
|
||||
queue: std::sync::Arc::new(anyllm_batch_engine::queue::sqlite::SqliteQueue::new(
|
||||
batch_db.clone(),
|
||||
)),
|
||||
file_store: anyllm_batch_engine::file_store::FileStore::new(batch_db.clone()),
|
||||
webhook_queue: std::sync::Arc::new(
|
||||
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue::new(batch_db),
|
||||
),
|
||||
global_webhook_urls,
|
||||
webhook_signing_secret: std::env::var("BATCH_WEBHOOK_SIGNING_SECRET").ok(),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build proxy router with optional shared admin state and tool engine.
|
||||
let app = routes::app_multi_with_shared(
|
||||
multi_config,
|
||||
admin_parts.as_ref().map(|(s, _, _)| s.clone()),
|
||||
model_router,
|
||||
tool_engine_state,
|
||||
batch_engine,
|
||||
);
|
||||
|
||||
// --- Start servers ---
|
||||
|
||||
@@ -258,11 +258,11 @@ pub(crate) async fn chat_completions(
|
||||
);
|
||||
oai_req.model = m;
|
||||
match c.chat_completion(&oai_req).await {
|
||||
Ok((resp, _, _)) => Ok(
|
||||
mapping::message_map::openai_to_anthropic_response(
|
||||
Ok((resp, _, _)) => {
|
||||
Ok(mapping::message_map::openai_to_anthropic_response(
|
||||
&resp, &om,
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("{e}")),
|
||||
}
|
||||
}
|
||||
@@ -583,21 +583,29 @@ async fn chat_completions_stream(
|
||||
for tc in tc_list {
|
||||
let idx = tc.index as usize;
|
||||
while accumulated_tool_calls.len() <= idx {
|
||||
accumulated_tool_calls.push((String::new(), String::new(), String::new()));
|
||||
accumulated_tool_calls.push((
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
));
|
||||
}
|
||||
if let Some(ref id) = tc.id {
|
||||
if !id.is_empty() {
|
||||
accumulated_tool_calls[idx].0 = id.clone();
|
||||
accumulated_tool_calls[idx].0 =
|
||||
id.clone();
|
||||
}
|
||||
}
|
||||
if let Some(ref func) = tc.function {
|
||||
if let Some(ref name) = func.name {
|
||||
if !name.is_empty() {
|
||||
accumulated_tool_calls[idx].1 = name.clone();
|
||||
accumulated_tool_calls[idx].1 =
|
||||
name.clone();
|
||||
}
|
||||
}
|
||||
if let Some(ref args) = func.arguments {
|
||||
accumulated_tool_calls[idx].2.push_str(args);
|
||||
accumulated_tool_calls[idx]
|
||||
.2
|
||||
.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,8 +715,7 @@ async fn chat_completions_stream(
|
||||
&engine.registry,
|
||||
&engine.policy,
|
||||
);
|
||||
let denied_results =
|
||||
crate::tools::execution::denied_tool_results(&denied);
|
||||
let denied_results = crate::tools::execution::denied_tool_results(&denied);
|
||||
|
||||
if !auto_exec.is_empty() || !denied_results.is_empty() {
|
||||
let mut results = crate::tools::execution::execute_tool_calls(
|
||||
@@ -734,14 +741,14 @@ async fn chat_completions_stream(
|
||||
.collect();
|
||||
|
||||
let mut follow_up_req = anthropic_req_for_tools;
|
||||
follow_up_req
|
||||
.messages
|
||||
.push(anyllm_translate::anthropic::InputMessage {
|
||||
follow_up_req.messages.push(
|
||||
anyllm_translate::anthropic::InputMessage {
|
||||
role: anyllm_translate::anthropic::Role::Assistant,
|
||||
content: anyllm_translate::anthropic::Content::Blocks(
|
||||
assistant_content,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
follow_up_req.messages.push(
|
||||
crate::tools::execution::tool_results_to_user_message(&results),
|
||||
);
|
||||
@@ -781,25 +788,18 @@ async fn chat_completions_stream(
|
||||
let mut follow_buffer = BytesMut::new();
|
||||
let mut follow_search_from: usize = 0;
|
||||
|
||||
while let Some(chunk_result) =
|
||||
follow_byte_stream.next().await
|
||||
{
|
||||
while let Some(chunk_result) = follow_byte_stream.next().await {
|
||||
let bytes = match chunk_result {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"follow-up stream read error: {e}"
|
||||
);
|
||||
tracing::error!("follow-up stream read error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
follow_buffer.extend_from_slice(&bytes);
|
||||
|
||||
while let Some((pos, delim_len)) =
|
||||
find_double_newline(
|
||||
&follow_buffer,
|
||||
follow_search_from,
|
||||
)
|
||||
find_double_newline(&follow_buffer, follow_search_from)
|
||||
{
|
||||
if let Ok(frame_str) =
|
||||
std::str::from_utf8(&follow_buffer[..pos])
|
||||
@@ -817,13 +817,11 @@ async fn chat_completions_stream(
|
||||
>(
|
||||
json_str
|
||||
) {
|
||||
let events =
|
||||
follow_stream_translator
|
||||
.process_chunk(&chunk);
|
||||
let events = follow_stream_translator
|
||||
.process_chunk(&chunk);
|
||||
for event in &events {
|
||||
let oai_chunks =
|
||||
follow_translator
|
||||
.process_event(event);
|
||||
let oai_chunks = follow_translator
|
||||
.process_event(event);
|
||||
for oai_chunk in &oai_chunks {
|
||||
if let Ok(json) =
|
||||
serde_json::to_string(
|
||||
@@ -847,23 +845,18 @@ async fn chat_completions_stream(
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ =
|
||||
follow_buffer.split_to(pos + delim_len);
|
||||
let _ = follow_buffer.split_to(pos + delim_len);
|
||||
follow_search_from = 0;
|
||||
}
|
||||
follow_search_from =
|
||||
follow_buffer.len().saturating_sub(3);
|
||||
follow_search_from = follow_buffer.len().saturating_sub(3);
|
||||
}
|
||||
|
||||
// Emit finish events for the follow-up stream.
|
||||
let follow_finish = follow_stream_translator.finish();
|
||||
for event in &follow_finish {
|
||||
let oai_chunks =
|
||||
follow_translator.process_event(event);
|
||||
let oai_chunks = follow_translator.process_event(event);
|
||||
for oai_chunk in &oai_chunks {
|
||||
if let Ok(json) =
|
||||
serde_json::to_string(oai_chunk)
|
||||
{
|
||||
if let Ok(json) = serde_json::to_string(oai_chunk) {
|
||||
let _ = tx
|
||||
.send(Ok(format!("data: {}\n\n", json)))
|
||||
.await;
|
||||
|
||||
@@ -107,6 +107,15 @@ pub struct AppState {
|
||||
pub all_backends: Option<Arc<HashMap<String, AppState>>>,
|
||||
/// Tool execution engine state. None when tool execution is not configured.
|
||||
pub tool_engine: Option<Arc<ToolEngineState>>,
|
||||
/// Batch orchestration engine. None in test configs that don't need batch.
|
||||
pub batch_engine: Option<
|
||||
Arc<
|
||||
anyllm_batch_engine::BatchEngine<
|
||||
anyllm_batch_engine::queue::sqlite::SqliteQueue,
|
||||
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -207,7 +216,7 @@ pub fn app(config: Config) -> Router {
|
||||
/// Build the axum router from multi-backend configuration.
|
||||
/// Creates nested sub-routers for each configured backend.
|
||||
pub fn app_multi(config: MultiConfig) -> Router {
|
||||
app_multi_with_shared(config, None, None, None)
|
||||
app_multi_with_shared(config, None, None, None, None)
|
||||
}
|
||||
|
||||
/// Build the axum router with optional shared admin state and model router.
|
||||
@@ -216,6 +225,14 @@ pub fn app_multi_with_shared(
|
||||
shared: Option<SharedState>,
|
||||
model_router: Option<Arc<RwLock<crate::config::model_router::ModelRouter>>>,
|
||||
tool_engine: Option<Arc<ToolEngineState>>,
|
||||
batch_engine: Option<
|
||||
Arc<
|
||||
anyllm_batch_engine::BatchEngine<
|
||||
anyllm_batch_engine::queue::sqlite::SqliteQueue,
|
||||
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
) -> Router {
|
||||
let mut backend_metrics: HashMap<String, Metrics> = HashMap::new();
|
||||
let mut router = Router::new();
|
||||
@@ -269,6 +286,7 @@ pub fn app_multi_with_shared(
|
||||
// all_backends is set after the loop (needs all states built first).
|
||||
all_backends: None,
|
||||
tool_engine: tool_engine.clone(),
|
||||
batch_engine: batch_engine.clone(),
|
||||
};
|
||||
let sub = backend_router(state.clone(), mode);
|
||||
backend_states.insert(name.clone(), (state, mode));
|
||||
@@ -393,6 +411,10 @@ fn backend_router(state: AppState, mode: HandlerMode) -> Router<GlobalState> {
|
||||
.route(
|
||||
"/v1/batches/{batch_id}",
|
||||
get(crate::batch::routes::get_batch),
|
||||
)
|
||||
.route(
|
||||
"/v1/batches/{batch_id}/cancel",
|
||||
post(crate::batch::routes::cancel_batch),
|
||||
);
|
||||
|
||||
let api_routes = match mode {
|
||||
|
||||
@@ -52,21 +52,15 @@ impl Tool for BashTool {
|
||||
.stderr(Stdio::piped())
|
||||
.output();
|
||||
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(TIMEOUT_SECS),
|
||||
fut,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(e)) => return Err(format!("Failed to spawn process: {}", e)),
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Command timed out after {} seconds",
|
||||
TIMEOUT_SECS
|
||||
))
|
||||
}
|
||||
};
|
||||
let output =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(TIMEOUT_SECS), fut).await
|
||||
{
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(e)) => return Err(format!("Failed to spawn process: {}", e)),
|
||||
Err(_) => {
|
||||
return Err(format!("Command timed out after {} seconds", TIMEOUT_SECS))
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::task::JoinSet;
|
||||
|
||||
use crate::tools::policy::{PolicyAction, ToolExecutionPolicy};
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::trace::{ToolOutcome};
|
||||
use crate::tools::trace::ToolOutcome;
|
||||
|
||||
/// A tool call extracted from an LLM response.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -113,11 +113,8 @@ pub async fn execute_tool_calls(
|
||||
let input = call.input.clone();
|
||||
|
||||
join_set.spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
timeout,
|
||||
execute_single(®istry, &name, input),
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
tokio::time::timeout(timeout, execute_single(®istry, &name, input)).await;
|
||||
|
||||
let outcome = match result {
|
||||
Ok(Ok(value)) => ToolOutcome::Success(value),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use anyllm_client::http::{build_http_client, HttpClientConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// An MCP tool definition discovered from a server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -109,7 +109,11 @@ impl McpServerManager {
|
||||
}
|
||||
|
||||
pub fn find_server_for_tool_blocking(&self, prefixed_name: &str) -> Option<String> {
|
||||
self.tool_to_server.read().unwrap().get(prefixed_name).cloned()
|
||||
self.tool_to_server
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(prefixed_name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn as_anthropic_tools_blocking(&self) -> Vec<anyllm_translate::anthropic::Tool> {
|
||||
@@ -201,49 +205,51 @@ impl McpServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_tools_impl(client: &reqwest::Client, url: &str) -> Result<Vec<McpToolDef>, String> {
|
||||
let rpc_request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
});
|
||||
async fn discover_tools_impl(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<Vec<McpToolDef>, String> {
|
||||
let rpc_request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&rpc_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("MCP discovery failed for '{}': {}", url, e))?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&rpc_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("MCP discovery failed for '{}': {}", url, e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"MCP discovery returned status {} for '{}'",
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"MCP discovery returned status {} for '{}'",
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("MCP discovery parse error: {}", e))?;
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("MCP discovery parse error: {}", e))?;
|
||||
|
||||
if let Some(error) = body.get("error") {
|
||||
let msg = error
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Err(format!("MCP discovery error: {}", msg));
|
||||
}
|
||||
if let Some(error) = body.get("error") {
|
||||
let msg = error
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Err(format!("MCP discovery error: {}", msg));
|
||||
}
|
||||
|
||||
let tools_value = body
|
||||
.get("result")
|
||||
.and_then(|r| r.get("tools"))
|
||||
.ok_or_else(|| "MCP response missing result.tools".to_string())?;
|
||||
let tools_value = body
|
||||
.get("result")
|
||||
.and_then(|r| r.get("tools"))
|
||||
.ok_or_else(|| "MCP response missing result.tools".to_string())?;
|
||||
|
||||
serde_json::from_value(tools_value.clone())
|
||||
.map_err(|e| format!("MCP tools parse error: {}", e))
|
||||
serde_json::from_value(tools_value.clone()).map_err(|e| format!("MCP tools parse error: {}", e))
|
||||
}
|
||||
|
||||
impl Default for McpServerManager {
|
||||
@@ -284,7 +290,10 @@ impl crate::tools::registry::Tool for McpToolAdapter {
|
||||
}
|
||||
|
||||
/// Register all MCP tools from the manager into a ToolRegistry.
|
||||
pub fn register_mcp_tools(manager: &Arc<McpServerManager>, registry: &mut crate::tools::ToolRegistry) {
|
||||
pub fn register_mcp_tools(
|
||||
manager: &Arc<McpServerManager>,
|
||||
registry: &mut crate::tools::ToolRegistry,
|
||||
) {
|
||||
let servers = manager.list_servers_blocking();
|
||||
for server in &servers {
|
||||
for tool in &server.tools {
|
||||
@@ -340,7 +349,9 @@ mod tests {
|
||||
mgr.find_server_for_tool_blocking("mcp_github_create_issue"),
|
||||
Some("github".to_string())
|
||||
);
|
||||
assert!(mgr.find_server_for_tool_blocking("mcp_slack_send").is_none());
|
||||
assert!(mgr
|
||||
.find_server_for_tool_blocking("mcp_slack_send")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,20 +24,37 @@ fn test_config() -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_test_batch_engine() -> std::sync::Arc<
|
||||
anyllm_batch_engine::BatchEngine<
|
||||
anyllm_batch_engine::queue::sqlite::SqliteQueue,
|
||||
anyllm_batch_engine::webhook::sqlite::SqliteWebhookQueue,
|
||||
>,
|
||||
> {
|
||||
use anyllm_batch_engine::{
|
||||
db::init_batch_engine_tables, file_store::FileStore, queue::sqlite::SqliteQueue,
|
||||
webhook::sqlite::SqliteWebhookQueue, BatchEngine,
|
||||
};
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
init_batch_engine_tables(&conn).unwrap();
|
||||
let db = std::sync::Arc::new(tokio::sync::Mutex::new(conn));
|
||||
std::sync::Arc::new(BatchEngine {
|
||||
queue: std::sync::Arc::new(SqliteQueue::new(db.clone())),
|
||||
file_store: FileStore::new(db.clone()),
|
||||
webhook_queue: std::sync::Arc::new(SqliteWebhookQueue::new(db)),
|
||||
global_webhook_urls: vec![],
|
||||
webhook_signing_secret: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a test server with SharedState (needed for batch DB access).
|
||||
async fn spawn_test_server_with_shared() -> String {
|
||||
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
||||
let config = test_config();
|
||||
let multi = MultiConfig::from_single_config(&config);
|
||||
let shared = admin::state::SharedState::new_for_test();
|
||||
let engine = make_test_batch_engine().await;
|
||||
|
||||
// Initialize batch tables in the test DB
|
||||
{
|
||||
let conn = shared.db.lock().unwrap();
|
||||
anyllm_proxy::batch::db::init_batch_tables(&conn).unwrap();
|
||||
}
|
||||
|
||||
let app = routes::app_multi_with_shared(multi, Some(shared), None, None);
|
||||
let app = routes::app_multi_with_shared(multi, Some(shared), None, None, Some(engine));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
@@ -94,7 +111,8 @@ async fn upload_file_and_create_batch() {
|
||||
|
||||
let batch_obj: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(batch_obj["object"], "batch");
|
||||
assert!(batch_obj["id"].as_str().unwrap().starts_with("batch-"));
|
||||
// Engine generates batch IDs (e.g. "batch_<uuid>")
|
||||
assert!(batch_obj["id"].as_str().unwrap().starts_with("batch"));
|
||||
assert_eq!(batch_obj["status"], "validating");
|
||||
assert_eq!(batch_obj["input_file_id"], file_id);
|
||||
assert_eq!(batch_obj["request_counts"]["total"], 2);
|
||||
@@ -200,11 +218,8 @@ async fn unsupported_backend_returns_501() {
|
||||
|
||||
let multi = MultiConfig::from_single_config(&config);
|
||||
let shared = admin::state::SharedState::new_for_test();
|
||||
{
|
||||
let conn = shared.db.lock().unwrap();
|
||||
anyllm_proxy::batch::db::init_batch_tables(&conn).unwrap();
|
||||
}
|
||||
let app = routes::app_multi_with_shared(multi, Some(shared), None, None);
|
||||
let engine = make_test_batch_engine().await;
|
||||
let app = routes::app_multi_with_shared(multi, Some(shared), None, None, Some(engine));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
@@ -225,6 +240,62 @@ async fn unsupported_backend_returns_501() {
|
||||
assert_eq!(resp.status(), 501);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_queued_batch() {
|
||||
let base = spawn_test_server_with_shared().await;
|
||||
let client = Client::new();
|
||||
|
||||
// Upload a file first
|
||||
let form = multipart::Form::new().text("purpose", "batch").part(
|
||||
"file",
|
||||
multipart::Part::bytes(valid_jsonl().as_bytes().to_vec())
|
||||
.file_name("test.jsonl")
|
||||
.mime_str("application/jsonl")
|
||||
.unwrap(),
|
||||
);
|
||||
let resp = client
|
||||
.post(format!("{base}/v1/files"))
|
||||
.header("x-api-key", "test")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let file_obj: serde_json::Value = resp.json().await.unwrap();
|
||||
let file_id = file_obj["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Create a batch
|
||||
let resp = client
|
||||
.post(format!("{base}/v1/batches"))
|
||||
.header("x-api-key", "test")
|
||||
.json(&serde_json::json!({
|
||||
"input_file_id": file_id,
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let batch_obj: serde_json::Value = resp.json().await.unwrap();
|
||||
let batch_id = batch_obj["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Cancel the batch
|
||||
let resp = client
|
||||
.post(format!("{base}/v1/batches/{batch_id}/cancel"))
|
||||
.header("x-api-key", "test")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let cancelled: serde_json::Value = resp.json().await.unwrap();
|
||||
let status = cancelled["status"].as_str().unwrap();
|
||||
assert!(
|
||||
status == "cancelling" || status == "cancelled",
|
||||
"expected cancelling or cancelled, got {status}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_batch_rejects_empty_requests() {
|
||||
let base = spawn_test_server_with_shared().await;
|
||||
|
||||
@@ -7,14 +7,14 @@ use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyllm_proxy::tools::{
|
||||
PolicyAction, PolicyRule, Tool, ToolCall, ToolExecutionPolicy, ToolRegistry, ToolResult,
|
||||
};
|
||||
use anyllm_proxy::tools::execution::{
|
||||
denied_tool_results, execute_tool_calls, extract_tool_calls, is_duplicate, maybe_execute_tools,
|
||||
partition_tool_calls, tool_results_to_user_message, LoopConfig, ToolEngineState,
|
||||
};
|
||||
use anyllm_proxy::tools::trace::ToolOutcome;
|
||||
use anyllm_proxy::tools::{
|
||||
PolicyAction, PolicyRule, Tool, ToolCall, ToolExecutionPolicy, ToolRegistry, ToolResult,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test tool: uppercases the "text" field of the input
|
||||
@@ -231,14 +231,12 @@ fn tool_results_error_outcome_sets_is_error_true() {
|
||||
}];
|
||||
let msg = tool_results_to_user_message(&results);
|
||||
match &msg.content {
|
||||
anyllm_translate::anthropic::Content::Blocks(blocks) => {
|
||||
match &blocks[0] {
|
||||
anyllm_translate::anthropic::ContentBlock::ToolResult { is_error, .. } => {
|
||||
assert_eq!(*is_error, Some(true));
|
||||
}
|
||||
other => panic!("expected ToolResult block, got {:?}", other),
|
||||
anyllm_translate::anthropic::Content::Blocks(blocks) => match &blocks[0] {
|
||||
anyllm_translate::anthropic::ContentBlock::ToolResult { is_error, .. } => {
|
||||
assert_eq!(*is_error, Some(true));
|
||||
}
|
||||
}
|
||||
other => panic!("expected ToolResult block, got {:?}", other),
|
||||
},
|
||||
other => panic!("expected Blocks content, got {:?}", other),
|
||||
}
|
||||
}
|
||||
@@ -247,10 +245,20 @@ fn tool_results_error_outcome_sets_is_error_true() {
|
||||
fn duplicate_detection_works() {
|
||||
let a = vec![make_call("1", "upper", serde_json::json!({"text": "same"}))];
|
||||
let b = vec![make_call("2", "upper", serde_json::json!({"text": "same"}))];
|
||||
assert!(is_duplicate(&a, &b), "same name+input with different IDs should be duplicate");
|
||||
assert!(
|
||||
is_duplicate(&a, &b),
|
||||
"same name+input with different IDs should be duplicate"
|
||||
);
|
||||
|
||||
let c = vec![make_call("3", "upper", serde_json::json!({"text": "different"}))];
|
||||
assert!(!is_duplicate(&a, &c), "different input should not be duplicate");
|
||||
let c = vec![make_call(
|
||||
"3",
|
||||
"upper",
|
||||
serde_json::json!({"text": "different"}),
|
||||
)];
|
||||
assert!(
|
||||
!is_duplicate(&a, &c),
|
||||
"different input should not be duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -454,7 +454,7 @@ async fn spawn_mock_backend() -> String {
|
||||
async fn spawn_proxy_with_shared_vk(config: Config) -> String {
|
||||
let state = shared_state(); // must call before building app to ensure set_virtual_keys fires
|
||||
let multi = anyllm_proxy::config::MultiConfig::from_single_config(&config);
|
||||
let base_app = routes::app_multi_with_shared(multi, Some(state), None, None);
|
||||
let base_app = routes::app_multi_with_shared(multi, Some(state), None, None, None);
|
||||
|
||||
// Add a test /admin/ route behind the same auth middleware so RBAC can be tested.
|
||||
let admin_test = Router::new()
|
||||
|
||||
Reference in New Issue
Block a user