From ef001fb811fe97c734884156cf5975cb2fc1c940 Mon Sep 17 00:00:00 2001 From: Haoran Wang <70007833+UbeCc@users.noreply.github.com> Date: Mon, 22 Dec 2025 01:39:24 +0800 Subject: [PATCH] [model-gateway] /parse/easoning and parse/function_call for sgl-model-gateway (#15568) --- sgl-model-gateway/src/protocols/mod.rs | 1 + sgl-model-gateway/src/protocols/parser.rs | 23 + sgl-model-gateway/src/routers/http/router.rs | 32 +- sgl-model-gateway/src/routers/mod.rs | 12 + .../src/routers/parse/handlers.rs | 139 ++++ sgl-model-gateway/src/routers/parse/mod.rs | 9 + sgl-model-gateway/src/server.rs | 17 + .../tests/parser_endpoints_test.rs | 599 ++++++++++++++++++ 8 files changed, 829 insertions(+), 3 deletions(-) create mode 100644 sgl-model-gateway/src/protocols/parser.rs create mode 100644 sgl-model-gateway/src/routers/parse/handlers.rs create mode 100644 sgl-model-gateway/src/routers/parse/mod.rs create mode 100644 sgl-model-gateway/tests/parser_endpoints_test.rs diff --git a/sgl-model-gateway/src/protocols/mod.rs b/sgl-model-gateway/src/protocols/mod.rs index b776058f8..19cf2b8fa 100644 --- a/sgl-model-gateway/src/protocols/mod.rs +++ b/sgl-model-gateway/src/protocols/mod.rs @@ -10,6 +10,7 @@ pub mod embedding; pub mod event_types; pub mod generate; pub mod messages; +pub mod parser; pub mod rerank; pub mod responses; pub mod sampling_params; diff --git a/sgl-model-gateway/src/protocols/parser.rs b/sgl-model-gateway/src/protocols/parser.rs new file mode 100644 index 000000000..47a53a0d5 --- /dev/null +++ b/sgl-model-gateway/src/protocols/parser.rs @@ -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, +} + +/// 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, +} diff --git a/sgl-model-gateway/src/routers/http/router.rs b/sgl-model-gateway/src/routers/http/router.rs index e39783567..643313110 100644 --- a/sgl-model-gateway/src/routers/http/router.rs +++ b/sgl-model-gateway/src/routers/http/router.rs @@ -16,6 +16,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error}; use crate::{ + app_context::AppContext, config::types::RetryConfig, core::{ is_retryable_status, ConnectionMode, RetryExecutor, Worker, WorkerLoadGuard, @@ -34,18 +35,18 @@ use crate::{ completion::CompletionRequest, embedding::EmbeddingRequest, generate::GenerateRequest, + parser::{ParseFunctionCallRequest, SeparateReasoningRequest}, rerank::{RerankRequest, RerankResponse, RerankResult}, responses::{ResponsesGetParams, ResponsesRequest}, }, routers::{ error, grpc::utils::{error_type_from_status, route_to_endpoint}, - header_utils, RouterTrait, + header_utils, parse, RouterTrait, }, }; /// Regular router that uses injected load balancing policies -#[derive(Debug)] pub struct Router { worker_registry: Arc, policy_registry: Arc, @@ -53,11 +54,26 @@ pub struct Router { dp_aware: bool, enable_igw: bool, retry_config: RetryConfig, + context: Option>, +} + +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", &"") + .finish() + } } impl Router { /// Create a new router with injected policy and client - pub async fn new(ctx: &Arc) -> Result { + pub async fn new(ctx: &Arc) -> Result { Ok(Router { worker_registry: ctx.worker_registry.clone(), policy_registry: ctx.policy_registry.clone(), @@ -65,6 +81,7 @@ impl Router { dp_aware: ctx.router_config.dp_aware, enable_igw: ctx.router_config.enable_igw, 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 { "regular" } @@ -824,6 +849,7 @@ mod tests { client: Client::new(), retry_config: RetryConfig::default(), enable_igw: false, + context: None, } } diff --git a/sgl-model-gateway/src/routers/mod.rs b/sgl-model-gateway/src/routers/mod.rs index 6ee0f3bf4..b0f49f277 100644 --- a/sgl-model-gateway/src/routers/mod.rs +++ b/sgl-model-gateway/src/routers/mod.rs @@ -16,6 +16,7 @@ use crate::protocols::{ completion::CompletionRequest, embedding::EmbeddingRequest, generate::GenerateRequest, + parser::{ParseFunctionCallRequest, SeparateReasoningRequest}, rerank::RerankRequest, responses::{ResponsesGetParams, ResponsesRequest}, }; @@ -27,6 +28,7 @@ pub mod grpc; pub mod header_utils; pub mod http; pub mod openai; +pub mod parse; pub mod router_manager; pub use factory::RouterFactory; @@ -191,6 +193,16 @@ pub trait RouterTrait: Send + Sync + Debug { (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 fn router_type(&self) -> &'static str; diff --git a/sgl-model-gateway/src/routers/parse/handlers.rs b/sgl-model-gateway/src/routers/parse/handlers.rs new file mode 100644 index 000000000..5b9579663 --- /dev/null +++ b/sgl-model-gateway/src/routers/parse/handlers.rs @@ -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>, + 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>, + 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(), + } +} diff --git a/sgl-model-gateway/src/routers/parse/mod.rs b/sgl-model-gateway/src/routers/parse/mod.rs new file mode 100644 index 000000000..bb4889fab --- /dev/null +++ b/sgl-model-gateway/src/routers/parse/mod.rs @@ -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}; diff --git a/sgl-model-gateway/src/server.rs b/sgl-model-gateway/src/server.rs index 3bc0d13b8..30a586ee9 100644 --- a/sgl-model-gateway/src/server.rs +++ b/sgl-model-gateway/src/server.rs @@ -44,6 +44,7 @@ use crate::{ completion::CompletionRequest, embedding::EmbeddingRequest, generate::GenerateRequest, + parser::{ParseFunctionCallRequest, SeparateReasoningRequest}, rerank::{RerankRequest, V1RerankReqInput}, responses::{ResponsesGetParams, ResponsesRequest}, validated::ValidatedJson, @@ -63,6 +64,20 @@ pub struct AppState { pub router_manager: Option>, } +async fn parse_function_call( + State(state): State>, + Json(req): Json, +) -> Response { + state.router.parse_function_call(&req).await +} + +async fn parse_reasoning( + State(state): State>, + Json(req): Json, +) -> Response { + state.router.parse_reasoning(&req).await +} + async fn sink_handler() -> Response { StatusCode::NOT_FOUND.into_response() } @@ -708,6 +723,8 @@ pub fn build_app( let admin_routes = Router::new() .route("/flush_cache", post(flush_cache)) .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/{module_uuid}", delete(remove_wasm_module)) .route("/wasm", get(list_wasm_modules)) diff --git a/sgl-model-gateway/tests/parser_endpoints_test.rs b/sgl-model-gateway/tests/parser_endpoints_test.rs new file mode 100644 index 000000000..f1f600eaa --- /dev/null +++ b/sgl-model-gateway/tests/parser_endpoints_test.rs @@ -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, + router: Arc, + _client: Client, + _config: RouterConfig, + app_context: Arc, +} + +impl ParserTestContext { + async fn new(worker_configs: Vec) -> 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, + ) -> 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 {"function_name": "get_weather", "parameters": {"location": "Beijing"}}"#, + "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": "Let me think about this problem. The user is asking for help.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": "First thoughtText 1Second thoughtText 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; + } +}