[model-gateway] /parse/easoning and parse/function_call for sgl-model-gateway (#15568)
This commit is contained in:
@@ -10,6 +10,7 @@ pub mod embedding;
|
|||||||
pub mod event_types;
|
pub mod event_types;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod parser;
|
||||||
pub mod rerank;
|
pub mod rerank;
|
||||||
pub mod responses;
|
pub mod responses;
|
||||||
pub mod sampling_params;
|
pub mod sampling_params;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::protocols::common::Tool;
|
||||||
|
|
||||||
|
/// Request to parse function calls from model output text
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ParseFunctionCallRequest {
|
||||||
|
/// The text to parse for function calls
|
||||||
|
pub text: String,
|
||||||
|
/// The parser type/name to use for parsing (e.g., "json", "pythonic")
|
||||||
|
pub tool_call_parser: String,
|
||||||
|
/// The list of available tools that the model can call
|
||||||
|
pub tools: Vec<Tool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to separate reasoning from normal text in model output
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SeparateReasoningRequest {
|
||||||
|
/// The text to parse for reasoning content
|
||||||
|
pub text: String,
|
||||||
|
/// The parser type/name to use for reasoning detection (e.g., "step3", "deepseek_r1")
|
||||||
|
pub reasoning_parser: String,
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
|
|||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
app_context::AppContext,
|
||||||
config::types::RetryConfig,
|
config::types::RetryConfig,
|
||||||
core::{
|
core::{
|
||||||
is_retryable_status, ConnectionMode, RetryExecutor, Worker, WorkerLoadGuard,
|
is_retryable_status, ConnectionMode, RetryExecutor, Worker, WorkerLoadGuard,
|
||||||
@@ -34,18 +35,18 @@ use crate::{
|
|||||||
completion::CompletionRequest,
|
completion::CompletionRequest,
|
||||||
embedding::EmbeddingRequest,
|
embedding::EmbeddingRequest,
|
||||||
generate::GenerateRequest,
|
generate::GenerateRequest,
|
||||||
|
parser::{ParseFunctionCallRequest, SeparateReasoningRequest},
|
||||||
rerank::{RerankRequest, RerankResponse, RerankResult},
|
rerank::{RerankRequest, RerankResponse, RerankResult},
|
||||||
responses::{ResponsesGetParams, ResponsesRequest},
|
responses::{ResponsesGetParams, ResponsesRequest},
|
||||||
},
|
},
|
||||||
routers::{
|
routers::{
|
||||||
error,
|
error,
|
||||||
grpc::utils::{error_type_from_status, route_to_endpoint},
|
grpc::utils::{error_type_from_status, route_to_endpoint},
|
||||||
header_utils, RouterTrait,
|
header_utils, parse, RouterTrait,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Regular router that uses injected load balancing policies
|
/// Regular router that uses injected load balancing policies
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Router {
|
pub struct Router {
|
||||||
worker_registry: Arc<WorkerRegistry>,
|
worker_registry: Arc<WorkerRegistry>,
|
||||||
policy_registry: Arc<PolicyRegistry>,
|
policy_registry: Arc<PolicyRegistry>,
|
||||||
@@ -53,11 +54,26 @@ pub struct Router {
|
|||||||
dp_aware: bool,
|
dp_aware: bool,
|
||||||
enable_igw: bool,
|
enable_igw: bool,
|
||||||
retry_config: RetryConfig,
|
retry_config: RetryConfig,
|
||||||
|
context: Option<Arc<AppContext>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Router {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Router")
|
||||||
|
.field("worker_registry", &self.worker_registry)
|
||||||
|
.field("policy_registry", &self.policy_registry)
|
||||||
|
.field("client", &self.client)
|
||||||
|
.field("dp_aware", &self.dp_aware)
|
||||||
|
.field("enable_igw", &self.enable_igw)
|
||||||
|
.field("retry_config", &self.retry_config)
|
||||||
|
.field("context", &"<AppContext>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Router {
|
impl Router {
|
||||||
/// Create a new router with injected policy and client
|
/// Create a new router with injected policy and client
|
||||||
pub async fn new(ctx: &Arc<crate::app_context::AppContext>) -> Result<Self, String> {
|
pub async fn new(ctx: &Arc<AppContext>) -> Result<Self, String> {
|
||||||
Ok(Router {
|
Ok(Router {
|
||||||
worker_registry: ctx.worker_registry.clone(),
|
worker_registry: ctx.worker_registry.clone(),
|
||||||
policy_registry: ctx.policy_registry.clone(),
|
policy_registry: ctx.policy_registry.clone(),
|
||||||
@@ -65,6 +81,7 @@ impl Router {
|
|||||||
dp_aware: ctx.router_config.dp_aware,
|
dp_aware: ctx.router_config.dp_aware,
|
||||||
enable_igw: ctx.router_config.enable_igw,
|
enable_igw: ctx.router_config.enable_igw,
|
||||||
retry_config: ctx.router_config.effective_retry_config(),
|
retry_config: ctx.router_config.effective_retry_config(),
|
||||||
|
context: Some(ctx.clone()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +807,14 @@ impl RouterTrait for Router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn parse_function_call(&self, req: &ParseFunctionCallRequest) -> Response {
|
||||||
|
parse::parse_function_call(self.context.as_ref(), req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_reasoning(&self, req: &SeparateReasoningRequest) -> Response {
|
||||||
|
parse::parse_reasoning(self.context.as_ref(), req).await
|
||||||
|
}
|
||||||
|
|
||||||
fn router_type(&self) -> &'static str {
|
fn router_type(&self) -> &'static str {
|
||||||
"regular"
|
"regular"
|
||||||
}
|
}
|
||||||
@@ -824,6 +849,7 @@ mod tests {
|
|||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
retry_config: RetryConfig::default(),
|
retry_config: RetryConfig::default(),
|
||||||
enable_igw: false,
|
enable_igw: false,
|
||||||
|
context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::protocols::{
|
|||||||
completion::CompletionRequest,
|
completion::CompletionRequest,
|
||||||
embedding::EmbeddingRequest,
|
embedding::EmbeddingRequest,
|
||||||
generate::GenerateRequest,
|
generate::GenerateRequest,
|
||||||
|
parser::{ParseFunctionCallRequest, SeparateReasoningRequest},
|
||||||
rerank::RerankRequest,
|
rerank::RerankRequest,
|
||||||
responses::{ResponsesGetParams, ResponsesRequest},
|
responses::{ResponsesGetParams, ResponsesRequest},
|
||||||
};
|
};
|
||||||
@@ -27,6 +28,7 @@ pub mod grpc;
|
|||||||
pub mod header_utils;
|
pub mod header_utils;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
|
pub mod parse;
|
||||||
pub mod router_manager;
|
pub mod router_manager;
|
||||||
|
|
||||||
pub use factory::RouterFactory;
|
pub use factory::RouterFactory;
|
||||||
@@ -191,6 +193,16 @@ pub trait RouterTrait: Send + Sync + Debug {
|
|||||||
(StatusCode::NOT_IMPLEMENTED, "Rerank not implemented").into_response()
|
(StatusCode::NOT_IMPLEMENTED, "Rerank not implemented").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse function calls from text
|
||||||
|
async fn parse_function_call(&self, req: &ParseFunctionCallRequest) -> Response {
|
||||||
|
parse::parse_function_call(None, req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate reasoning from normal text
|
||||||
|
async fn parse_reasoning(&self, req: &SeparateReasoningRequest) -> Response {
|
||||||
|
parse::parse_reasoning(None, req).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Get router type name
|
/// Get router type name
|
||||||
fn router_type(&self) -> &'static str;
|
fn router_type(&self) -> &'static str;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Parser handlers for function calls and reasoning extraction
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app_context::AppContext,
|
||||||
|
protocols::parser::{ParseFunctionCallRequest, SeparateReasoningRequest},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse function calls from model output text
|
||||||
|
pub async fn parse_function_call(
|
||||||
|
context: Option<&Arc<AppContext>>,
|
||||||
|
req: &ParseFunctionCallRequest,
|
||||||
|
) -> Response {
|
||||||
|
match context {
|
||||||
|
Some(ctx) => match &ctx.tool_parser_factory {
|
||||||
|
Some(factory) => match factory.registry().get_pooled_parser(&req.tool_call_parser) {
|
||||||
|
Some(pooled_parser) => {
|
||||||
|
let parser = pooled_parser.lock().await;
|
||||||
|
match parser.parse_complete(&req.text).await {
|
||||||
|
Ok((remaining_text, tool_calls)) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"remaining_text": remaining_text,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
"success": true
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to parse function calls: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Failed to parse function calls: {}", e),
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Unknown tool parser: {}", req.tool_call_parser),
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Tool parser factory not initialized",
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Context not initialized",
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and separate reasoning from normal text
|
||||||
|
pub async fn parse_reasoning(
|
||||||
|
context: Option<&Arc<AppContext>>,
|
||||||
|
req: &SeparateReasoningRequest,
|
||||||
|
) -> Response {
|
||||||
|
match context {
|
||||||
|
Some(ctx) => match &ctx.reasoning_parser_factory {
|
||||||
|
Some(factory) => match factory.registry().get_pooled_parser(&req.reasoning_parser) {
|
||||||
|
Some(pooled_parser) => {
|
||||||
|
let mut parser = pooled_parser.lock().await;
|
||||||
|
match parser.detect_and_parse_reasoning(&req.text) {
|
||||||
|
Ok(result) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"normal_text": result.normal_text,
|
||||||
|
"reasoning_text": result.reasoning_text,
|
||||||
|
"success": true
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to separate reasoning: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Failed to separate reasoning: {}", e),
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Unknown reasoning parser: {}", req.reasoning_parser),
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Reasoning parser factory not initialized",
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Context not initialized",
|
||||||
|
"success": false
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! Parser module for function calls and reasoning extraction
|
||||||
|
//!
|
||||||
|
//! This module provides parsing operations for model output, including:
|
||||||
|
//! - Function call extraction from text
|
||||||
|
//! - Reasoning separation from normal text
|
||||||
|
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
pub use handlers::{parse_function_call, parse_reasoning};
|
||||||
@@ -44,6 +44,7 @@ use crate::{
|
|||||||
completion::CompletionRequest,
|
completion::CompletionRequest,
|
||||||
embedding::EmbeddingRequest,
|
embedding::EmbeddingRequest,
|
||||||
generate::GenerateRequest,
|
generate::GenerateRequest,
|
||||||
|
parser::{ParseFunctionCallRequest, SeparateReasoningRequest},
|
||||||
rerank::{RerankRequest, V1RerankReqInput},
|
rerank::{RerankRequest, V1RerankReqInput},
|
||||||
responses::{ResponsesGetParams, ResponsesRequest},
|
responses::{ResponsesGetParams, ResponsesRequest},
|
||||||
validated::ValidatedJson,
|
validated::ValidatedJson,
|
||||||
@@ -63,6 +64,20 @@ pub struct AppState {
|
|||||||
pub router_manager: Option<Arc<RouterManager>>,
|
pub router_manager: Option<Arc<RouterManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn parse_function_call(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<ParseFunctionCallRequest>,
|
||||||
|
) -> Response {
|
||||||
|
state.router.parse_function_call(&req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_reasoning(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<SeparateReasoningRequest>,
|
||||||
|
) -> Response {
|
||||||
|
state.router.parse_reasoning(&req).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn sink_handler() -> Response {
|
async fn sink_handler() -> Response {
|
||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
@@ -708,6 +723,8 @@ pub fn build_app(
|
|||||||
let admin_routes = Router::new()
|
let admin_routes = Router::new()
|
||||||
.route("/flush_cache", post(flush_cache))
|
.route("/flush_cache", post(flush_cache))
|
||||||
.route("/get_loads", get(get_loads))
|
.route("/get_loads", get(get_loads))
|
||||||
|
.route("/parse/function_call", post(parse_function_call))
|
||||||
|
.route("/parse/reasoning", post(parse_reasoning))
|
||||||
.route("/wasm", post(add_wasm_module))
|
.route("/wasm", post(add_wasm_module))
|
||||||
.route("/wasm/{module_uuid}", delete(remove_wasm_module))
|
.route("/wasm/{module_uuid}", delete(remove_wasm_module))
|
||||||
.route("/wasm", get(list_wasm_modules))
|
.route("/wasm", get(list_wasm_modules))
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::Request,
|
||||||
|
http::{header::CONTENT_TYPE, StatusCode},
|
||||||
|
};
|
||||||
|
use common::mock_worker::{MockWorker, MockWorkerConfig};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
|
use sgl_model_gateway::{
|
||||||
|
app_context::AppContext,
|
||||||
|
config::{RouterConfig, RoutingMode},
|
||||||
|
routers::{RouterFactory, RouterTrait},
|
||||||
|
};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
/// Test context that manages mock workers and app
|
||||||
|
struct ParserTestContext {
|
||||||
|
workers: Vec<MockWorker>,
|
||||||
|
router: Arc<dyn RouterTrait>,
|
||||||
|
_client: Client,
|
||||||
|
_config: RouterConfig,
|
||||||
|
app_context: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParserTestContext {
|
||||||
|
async fn new(worker_configs: Vec<MockWorkerConfig>) -> Self {
|
||||||
|
// Create router config with parser support enabled
|
||||||
|
let config = RouterConfig::builder()
|
||||||
|
.regular_mode(vec![])
|
||||||
|
.random_policy()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(3003)
|
||||||
|
.max_payload_size(256 * 1024 * 1024)
|
||||||
|
.request_timeout_secs(600)
|
||||||
|
.worker_startup_timeout_secs(1)
|
||||||
|
.worker_startup_check_interval_secs(1)
|
||||||
|
.max_concurrent_requests(64)
|
||||||
|
.queue_timeout_secs(60)
|
||||||
|
.build_unchecked();
|
||||||
|
|
||||||
|
Self::new_with_config(config, worker_configs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_with_config(
|
||||||
|
mut config: RouterConfig,
|
||||||
|
worker_configs: Vec<MockWorkerConfig>,
|
||||||
|
) -> Self {
|
||||||
|
let mut workers = Vec::new();
|
||||||
|
let mut worker_urls = Vec::new();
|
||||||
|
|
||||||
|
// Start mock workers if any
|
||||||
|
for worker_config in worker_configs {
|
||||||
|
let mut worker = MockWorker::new(worker_config);
|
||||||
|
let url = worker.start().await.unwrap();
|
||||||
|
worker_urls.push(url);
|
||||||
|
workers.push(worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !workers.is_empty() {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config with worker URLs if not already set
|
||||||
|
match &mut config.mode {
|
||||||
|
RoutingMode::Regular {
|
||||||
|
worker_urls: ref mut urls,
|
||||||
|
} => {
|
||||||
|
if urls.is_empty() {
|
||||||
|
*urls = worker_urls.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoutingMode::OpenAI {
|
||||||
|
worker_urls: ref mut urls,
|
||||||
|
} => {
|
||||||
|
if urls.is_empty() {
|
||||||
|
*urls = worker_urls.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // PrefillDecode mode has its own setup
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(config.request_timeout_secs))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create app context
|
||||||
|
let app_context = common::create_test_context(config.clone()).await;
|
||||||
|
|
||||||
|
// Create router
|
||||||
|
let router = RouterFactory::create_router(&app_context).await.unwrap();
|
||||||
|
let router = Arc::from(router);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
workers,
|
||||||
|
router,
|
||||||
|
_client: client,
|
||||||
|
_config: config,
|
||||||
|
app_context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(&self) -> axum::Router {
|
||||||
|
common::test_app::create_test_app_with_context(
|
||||||
|
Arc::clone(&self.router),
|
||||||
|
Arc::clone(&self.app_context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown(mut self) {
|
||||||
|
for worker in &mut self.workers {
|
||||||
|
worker.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod parse_function_call_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_function_call_success() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": r#"I need to call the weather function <tool_call>{"function_name": "get_weather", "parameters": {"location": "Beijing"}}</tool_call>"#,
|
||||||
|
"tool_call_parser": "json",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather information",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The location"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["location"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Parser endpoint should return 200 for valid requests (or SERVICE_UNAVAILABLE if parser factory not initialized)
|
||||||
|
// Since we're in a test without explicit parser factory setup, it may return 503
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_function_call_invalid_parser() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "some text",
|
||||||
|
"tool_call_parser": "nonexistent_parser",
|
||||||
|
"tools": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should return either 400 (parser not found) or 503 (factory not initialized)
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::BAD_REQUEST
|
||||||
|
|| resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected BAD_REQUEST (400) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body_json["success"], false);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_function_call_missing_fields() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
// Missing 'text' field
|
||||||
|
let payload = json!({
|
||||||
|
"tool_call_parser": "json",
|
||||||
|
"tools": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// Missing 'tool_call_parser' field
|
||||||
|
let payload = json!({
|
||||||
|
"text": "some text",
|
||||||
|
"tools": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_function_call_empty_text() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "",
|
||||||
|
"tool_call_parser": "json",
|
||||||
|
"tools": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Parser should handle empty text gracefully - return 200 or 503
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod separate_reasoning_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_success() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "<think>Let me think about this problem. The user is asking for help.</think>Sure, I can help you with that.",
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should return 200 or 503 depending on whether parser factory is initialized
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
if resp.status() == StatusCode::OK {
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// Check response structure
|
||||||
|
assert_eq!(body_json["success"], true);
|
||||||
|
assert!(body_json.get("normal_text").is_some());
|
||||||
|
assert!(body_json.get("reasoning_text").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_invalid_parser() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "some text",
|
||||||
|
"reasoning_parser": "invalid_parser_type"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should return 400 (parser not found) or 503 (factory not initialized)
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::BAD_REQUEST
|
||||||
|
|| resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected BAD_REQUEST (400) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body_json["success"], false);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_missing_fields() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
// Missing 'text' field
|
||||||
|
let payload = json!({
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// Missing 'reasoning_parser' field
|
||||||
|
let payload = json!({
|
||||||
|
"text": "some text"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_empty_text() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "",
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Parser should handle empty text gracefully
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_without_reasoning_tags() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "Just a normal text without any reasoning tags",
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should still return 200 or 503, parser should handle gracefully
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
if resp.status() == StatusCode::OK {
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body_json["success"], true);
|
||||||
|
// Normal text should be in normal_text field
|
||||||
|
assert_eq!(
|
||||||
|
body_json["normal_text"].as_str().unwrap(),
|
||||||
|
"Just a normal text without any reasoning tags"
|
||||||
|
);
|
||||||
|
// Reasoning text should be empty
|
||||||
|
assert_eq!(body_json["reasoning_text"].as_str().unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_separate_reasoning_multiple_reasoning_blocks() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
// Some parsers may handle multiple reasoning blocks
|
||||||
|
let payload = json!({
|
||||||
|
"text": "<think>First thought</think>Text 1<think>Second thought</think>Text 2",
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should handle multiple blocks gracefully
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::OK || resp.status() == StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Expected OK (200) or SERVICE_UNAVAILABLE (503), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod api_routing_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_routes_accessible() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
// Test that both endpoints exist and are accessible (even if parser factory not initialized)
|
||||||
|
let payload = json!({
|
||||||
|
"text": "test",
|
||||||
|
"tool_call_parser": "json",
|
||||||
|
"tools": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should not be 404
|
||||||
|
assert_ne!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"text": "test",
|
||||||
|
"reasoning_parser": "step3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should not be 404
|
||||||
|
assert_ne!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_endpoints_only_accept_post() {
|
||||||
|
let ctx = ParserTestContext::new(vec![]).await;
|
||||||
|
let app = ctx.create_app().await;
|
||||||
|
|
||||||
|
// Test GET request to parse/function_call
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/parse/function_call")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should not accept GET (should be 405 or 404)
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::METHOD_NOT_ALLOWED
|
||||||
|
|| resp.status() == StatusCode::NOT_FOUND,
|
||||||
|
"Expected METHOD_NOT_ALLOWED (405) or NOT_FOUND (404), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test GET request to parse/reasoning
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/parse/reasoning")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
// Should not accept GET
|
||||||
|
assert!(
|
||||||
|
resp.status() == StatusCode::METHOD_NOT_ALLOWED
|
||||||
|
|| resp.status() == StatusCode::NOT_FOUND,
|
||||||
|
"Expected METHOD_NOT_ALLOWED (405) or NOT_FOUND (404), got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user