[model-gateway] change sgl-router to sgl-model-gateway (#14312)

This commit is contained in:
Simo Lin
2025-12-05 12:04:48 -08:00
committed by GitHub
parent 1ea6b740a7
commit 49dfa1d891
431 changed files with 86 additions and 93 deletions
+15
View File
@@ -0,0 +1,15 @@
[build]
rustflags = []
incremental = true
[target.aarch64-apple-darwin]
rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
]
[target.x86_64-apple-darwin]
rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
]
+181
View File
@@ -0,0 +1,181 @@
[package]
name = "sgl-model-gateway"
version = "0.2.3"
edition = "2021"
[features]
default = ["grpc-client"]
grpc-client = []
grpc-server = []
vendored-openssl = ["openssl/vendored"]
[lints.rust]
unused_qualifications = "warn"
[lib]
name = "sgl_model_gateway"
crate-type = ["rlib"]
[[bin]]
name = "sgl-model-gateway"
path = "src/main.rs"
[[bin]]
name = "smg"
path = "src/main.rs"
[[bin]]
name = "amg"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive", "env"] }
axum = { version = "0.8.4", features = ["macros", "ws", "tracing"] }
tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "compression-gzip", "cors", "timeout", "limit", "request-id", "util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", default-features = false, features = [
"std",
"preserve_order",
] }
serde_bytes = "0.11"
bytes = "1.8.0"
rand = "0.9.2"
reqwest = { version = "0.12.8", features = ["stream", "blocking", "json", "rustls-tls"], default-features = false }
futures-util = "0.3"
futures = "0.3"
dashmap = "6.1.0"
lru = "0.16.2"
blake3 = "1.5"
http = "1.1.0"
tokio = { version = "1.42.0", features = ["full"] }
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "chrono"] }
tracing-log = "0.2"
tracing-appender = "0.2.3"
chrono = "0.4"
kube = { version = "1.1.0", features = ["runtime", "derive"] }
k8s-openapi = { version = "0.25.0", features = ["v1_33"] }
metrics = "0.24.2"
metrics-exporter-prometheus = "0.17.0"
uuid = { version = "1.10", features = ["v4", "serde"] }
ulid = "1.2.1"
parking_lot = "0.12.4"
rayon = "1.10"
thiserror = "2.0.12"
regex = "1.10"
url = "2.5.4"
validator = { version = "0.20.0", features = ["derive"] }
tokio-stream = { version = "0.1", features = ["sync"] }
anyhow = "1.0"
tokenizers = { version = "0.22.0" }
tiktoken-rs = { version = "0.7.0" }
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins"] }
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
openssl = "0.10.73"
hf-hub = { version = "0.4.3", features = ["tokio"] }
rmcp = { version = "0.8.3", features = ["client", "server",
"transport-child-process",
"transport-sse-client-reqwest",
"transport-streamable-http-client-reqwest",
"transport-streamable-http-server",
"transport-streamable-http-server-session",
"reqwest",
"auth"] }
serde_yaml = "0.9"
oracle = { version = "0.6.3", features = ["chrono"] }
subtle = "2.6"
rustpython-parser = "0.4.0"
num-traits = "0.2"
image = { version = "0.25.4", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "tiff", "webp"] }
ndarray = "0.16"
base64 = "0.22"
openai-harmony = { git = "https://github.com/openai/harmony", tag = "v0.0.4" }
openmetrics-parser = "0.4.4"
# gRPC and Protobuf dependencies
tonic = { version = "0.14.2", features = ["gzip", "transport"] }
prost = "0.14.1"
prost-types = "0.14.1"
tonic-prost = "0.14.2"
deadpool = { version = "0.12", features = ["managed", "rt_tokio_1"] }
backoff = { version = "0.4", features = ["tokio"] }
strum = { version = "0.26", features = ["derive"] }
bitflags = "2.10.0"
once_cell = "1.21.3"
tokio-postgres = { version = "0.7.15", features = ["runtime","with-chrono-0_4","with-serde_json-1","array-impls"] }
deadpool-postgres = "0.14.1"
# wasm dependencies
sha2 = "0.10"
wasmtime = { version = "38.0", features = ["component-model", "async"] }
wasmtime-wasi = "38.0"
async-channel = "2.5"
[build-dependencies]
tonic-prost-build = "0.14.2"
prost-build = "0.14.1"
chrono = { version = "0.4", features = ["clock"] }
toml = "0.9"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
portpicker = "0.1"
tempfile = "3.8"
lazy_static = "1.4"
wasm-encoder = "0.242"
npyz = { version = "0.8", features = ["npz"] } # For reading numpy .npz files in golden tests
[[bench]]
name = "request_processing"
harness = false
path = "benches/request_processing.rs"
[[bench]]
name = "tokenizer_benchmark"
harness = false
path = "benches/tokenizer_benchmark.rs"
[[bench]]
name = "tool_parser_benchmark"
harness = false
path = "benches/tool_parser_benchmark.rs"
[profile.release]
opt-level = "z" # Optimize for size
lto = "fat" # Full LTO for smaller binaries
codegen-units = 1 # Better optimization, slower compile
strip = true # Strip debug symbols
[profile.ci]
inherits = "release"
opt-level = 2 # Lighter optimization (still fast runtime, much faster compile)
lto = "thin" # Thin LTO - good balance
codegen-units = 16 # More parallelization for faster builds
strip = true
[profile.dev]
opt-level = 0
debug = 1
split-debuginfo = "unpacked"
incremental = true
codegen-units = 256
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev.build-override]
opt-level = 3
codegen-units = 1
[profile.dev-opt]
inherits = "dev"
opt-level = 1
+1
View File
@@ -0,0 +1 @@
../LICENSE
+159
View File
@@ -0,0 +1,159 @@
# Model Gateway Makefile
# Provides convenient shortcuts for common development tasks
# Python bindings directory
PYTHON_DIR := bindings/python
# Auto-detect CPU cores and cap at reasonable limit to avoid thread exhaustion
# Can be overridden: make python-dev JOBS=4
NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 8)
JOBS ?= $(shell echo $$(($(NPROC) > 16 ? 16 : $(NPROC))))
# Check if sccache is available and set RUSTC_WRAPPER accordingly
SCCACHE := $(shell which sccache 2>/dev/null)
ifdef SCCACHE
export RUSTC_WRAPPER := $(SCCACHE)
$(info Using sccache for compilation caching)
else
$(info sccache not found. Install it for faster builds: cargo install sccache)
endif
.PHONY: help build test clean docs check fmt dev-setup pre-commit setup-sccache sccache-stats sccache-clean sccache-stop \
python-dev python-build python-build-release python-install python-clean python-test python-check \
release-notes
help: ## Show this help message
@echo "Model Gateway Development Commands"
@echo "=================================="
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo ""
build: ## Build the project in release mode
@echo "Building SGLang Model Gateway..."
@cargo build --release
test: ## Run all tests
@echo "Running tests..."
@cargo test
clean: ## Clean build artifacts
@echo "Cleaning build artifacts..."
@cargo clean
docs: ## Generate and open documentation
@echo "Generating documentation..."
@cargo doc --open
check: ## Run cargo check and clippy
@echo "Running cargo check..."
@cargo check
@echo "Running clippy..."
@cargo clippy --all-targets --all-features -- -D warnings
fmt: ## Format code with rustfmt
@echo "Formatting code..."
@rustup run nightly cargo fmt
# Development workflow shortcuts
dev-setup: build test ## Set up development environment
@echo "Development environment ready!"
pre-commit: fmt check test ## Run pre-commit checks
@echo "Pre-commit checks passed!"
# sccache management targets
setup-sccache: ## Install and configure sccache
@echo "Setting up sccache..."
@./scripts/setup-sccache.sh
sccache-stats: ## Show sccache statistics
@if [ -n "$(SCCACHE)" ]; then \
echo "sccache statistics:"; \
sccache -s; \
else \
echo "sccache not installed. Run 'make setup-sccache' to install it."; \
fi
sccache-clean: ## Clear sccache cache
@if [ -n "$(SCCACHE)" ]; then \
echo "Clearing sccache cache..."; \
sccache -C; \
echo "sccache cache cleared"; \
else \
echo "sccache not installed"; \
fi
sccache-stop: ## Stop the sccache server
@if [ -n "$(SCCACHE)" ]; then \
echo "Stopping sccache server..."; \
sccache --stop-server || true; \
else \
echo "sccache not installed"; \
fi
# Python bindings (maturin) targets
python-dev: ## Build Python bindings in development mode (fast, debug build)
@echo "Building Python bindings in development mode (using $(JOBS) parallel jobs with sccache)..."
@cd $(PYTHON_DIR) && CARGO_BUILD_JOBS=$(JOBS) maturin develop
python-build: ## Build Python wheel (release mode with vendored OpenSSL)
@echo "Building Python wheel (release, vendored OpenSSL, using $(JOBS) parallel jobs with sccache)..."
@cd $(PYTHON_DIR) && CARGO_BUILD_JOBS=$(JOBS) maturin build --release --out dist --features vendored-openssl
python-build-release: python-build ## Alias for python-build
python-install: python-build ## Build and install Python wheel
@echo "Installing Python wheel..."
@pip install --force-reinstall $(PYTHON_DIR)/dist/*.whl
@echo "Python package installed!"
python-clean: ## Clean Python build artifacts
@echo "Cleaning Python build artifacts..."
@rm -rf $(PYTHON_DIR)/dist/
@rm -rf $(PYTHON_DIR)/target/
@rm -rf $(PYTHON_DIR)/sglang_router.egg-info/
@rm -rf $(PYTHON_DIR)/sglang_router/__pycache__/
@find $(PYTHON_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@find $(PYTHON_DIR) -name "*.pyc" -delete 2>/dev/null || true
@echo "Python build artifacts cleaned!"
python-test: ## Run Python tests
@echo "Running Python tests..."
@pytest py_test/ -v
python-check: ## Check Python package with twine
@echo "Checking Python package..."
@cd $(PYTHON_DIR) && CARGO_BUILD_JOBS=$(JOBS) maturin build --release --out dist --features vendored-openssl
@pip install twine 2>/dev/null || true
@twine check $(PYTHON_DIR)/dist/*
@echo "Python package check passed!"
# Combined shortcuts
dev: python-dev ## Quick development setup (build Python bindings in dev mode)
install: python-install ## Build and install everything
# Release management
release-notes: ## Generate release notes for gateway (usage: make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0)
@if [ -z "$(PREV)" ] || [ -z "$(CURR)" ]; then \
echo "Usage: make release-notes PREV=<previous-tag> CURR=<current-tag>"; \
echo "Example: make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0"; \
echo ""; \
echo "Options:"; \
echo " OUTPUT=<file> Save to file (default: stdout)"; \
echo " CREATE_RELEASE=1 Create GitHub draft release via gh CLI (default: draft)"; \
echo " DRAFT=0 Publish release immediately (skip draft)"; \
exit 1; \
fi
@ARGS="$(PREV) $(CURR)"; \
if [ -n "$(OUTPUT)" ]; then \
ARGS="$$ARGS --output $(OUTPUT)"; \
fi; \
if [ "$(CREATE_RELEASE)" = "1" ]; then \
ARGS="$$ARGS --create-release"; \
if [ "$(DRAFT)" = "0" ]; then \
ARGS="$$ARGS --no-draft"; \
fi; \
fi; \
./scripts/generate_gateway_release_notes.sh $$ARGS
+676
View File
@@ -0,0 +1,676 @@
# SGLang Model Gateway
High-performance model routing control and data plane for large-scale LLM deployments. The gateway orchestrates fleets of workers, balances traffic across HTTP and gRPC backends, and exposes OpenAI-compatible APIs with pluggable history storage and tool integrations—while remaining deeply optimized for the SGLang serving runtime.
## Overview
- Unified control plane for registering, monitoring, and orchestrating prefill, decode, and regular workers across heterogeneous model fleets.
- Data plane that routes requests across HTTP, PD (prefill/decode), gRPC, and OpenAI-compatible backends with shared reliability features.
- Industry-first gRPC pipeline with native Rust tokenization, reasoning, and tool-call execution for high-throughput OpenAI-compatible serving.
- Multi-model inference gateway mode (`--enable-igw`) that runs several routers at once and applies per-model policies.
- Conversation, response, and chat-history connectors that centralize state at the router, enabling compliant sharing across models/MCP loops with in-memory, no-op, or Oracle ATP storage options.
- Built-in reliability primitives: retries with exponential backoff, circuit breakers, token-bucket rate limiting, and queuing.
- First-class observability with structured logging and Prometheus metrics.
### Architecture at a Glance
**Control Plane**
- Worker Manager validates workers, discovers capabilities, and keeps the registry in sync.
- Job Queue serializes background operations (add/remove) and exposes status via `/workers/{url}`.
- Background health checker and load monitor keep circuit breakers and policies informed.
- Optional Kubernetes service discovery keeps the registry aligned with pods.
**Data Plane**
- SGLang HTTP routers for regular and PD (prefill/decode) traffic with policy-aware selection.
- SGLang gRPC router and pipeline that stream tokenized requests through SRT gRPC workers with fully Rust tokenizer, reasoning parser, and tool parser implementations for maximal OpenAI API performance, supporting both single-stage and PD serving topologies.
- OpenAI router that proxies OpenAI-style requests, responses, and conversations to remote vendors (OpenAI, xAI, Gemini, and other OpenAI-compatible providers) while preserving streaming/SSE semantics.
- Router Manager coordinates multiple router implementations when IGW is enabled.
- Resilience layer delivers token-bucket rate limiting, request queuing, retry executor, and per-worker circuit breakers to keep traffic flowing through failures.
- Advanced load balancing with cache-aware request reuse, load-aware (power-of-two) selection, and per-model policy overrides.
## Feature Highlights
- Multiple load balancing strategies (`random`, `round_robin`, `cache_aware`, `power_of_two`) with DP-aware scheduling.
- Multi-model HTTP serving and inference gateway routing with model-specific policies.
- Prefill/decode disaggregation, including bootstrap port handling and cache-aware merging.
- gRPC routing with fully Rust tokenizer loading, reasoning parser selection, and tool parser integration for OpenAI-compatible endpoints—supporting streaming and non-streaming modes across DeepSeek, Llama, Kimi K2, Qwen, GPT-OSS, Mistral, Step-3, GLM4, and other reasoning-capable models.
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/conversations`, `/v1/embeddings`, and `/v1/rerank` endpoints.
- Native MCP client integration supporting all MCP transport protocols (STDIO, HTTP, SSE, and Streamable) for tool execution loops.
- Pluggable history connectors: in-memory, disabled, or Oracle ATP (with pooling and credential support).
- Reliability controls: retry with jitter, worker-scoped circuit breakers, token bucket limiter with optional queue, and cache flush APIs.
- Service discovery for regular and PD workloads with independent selectors.
- Prometheus metrics and structured tracing for every stage of routing.
## Documentation
- **User Guide**: [docs.sglang.io/advanced_features/router.html](https://docs.sglang.io/advanced_features/router.html)
- Additional guides, API references, and deployment patterns are continuously updated alongside SGLang releases.
## Installation
### Prerequisites
- **Rust and Cargo**
```bash
# Install rustup (Rust installer and version manager)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Reload shell environment
source "$HOME/.cargo/env"
# Verify installation
rustc --version
cargo --version
```
- **Python** with `pip` and virtualenv tooling available.
### Rust Binary
```bash
# Build release binary
cargo build --release
```
### Python Package
```bash
pip install maturin
# Fast development mode (debug build, no wheel, instant)
# Uses system OpenSSL (requires libssl-dev/openssl-devel)
cd bindings/python
maturin develop
# Production build (optimized, creates wheel)
# Uses vendored OpenSSL (cross-platform compatibility)
cd bindings/python
maturin build --release --out dist --features vendored-openssl
pip install --force-reinstall dist/*.whl
# Development build with system OpenSSL (faster)
# Requires: apt install libssl-dev pkg-config (Ubuntu/Debian)
# or: yum install openssl-devel (RHEL/CentOS)
cd bindings/python
maturin build --release --out dist
pip install --force-reinstall dist/*.whl
```
> **Note:** Python bindings are located in `bindings/python/` with their own Cargo.toml. Use `maturin develop` for fast iteration during development (builds in debug mode and installs directly). Use `maturin build --release --features vendored-openssl` for production wheels with full optimizations (opt-level="z", lto="fat") and cross-platform compatibility. The package uses abi3 support for Python 3.8+ compatibility.
## Checking Version
After installation, verify the installation and check version information:
```bash
# Simple version (Rust binary)
./target/release/sgl-model-gateway --version
# or use aliases
./target/release/smg --version
./target/release/amg --version
# Full version info with build details
./target/release/sgl-model-gateway --version-verbose
# Python CLI
amg --version
amg --version-verbose
python3 -m sglang_router --version
```
The `--version` (or `-V`) flag displays the version string. Use `--version-verbose` for comprehensive build information including Git commit, build time, compiler versions, and platform details.
## Quick Start
### Regular HTTP Routing
- **Rust binary**
```bash
./target/release/sgl-model-gateway \
--worker-urls http://worker1:8000 http://worker2:8000 \
--policy cache_aware
```
`cargo run --release -- …` provides the same behavior during development.
- **Python launcher**
```bash
python3 -m sglang_router.launch_router \
--worker-urls http://worker1:8000 http://worker2:8000 \
--policy cache_aware
```
### Prefill/Decode Disaggregation (PD)
- **Rust binary**
```bash
./target/release/sgl-model-gateway \
--pd-disaggregation \
--prefill http://prefill1:30001 9001 \
--prefill http://prefill2:30002 \
--decode http://decode1:30011 \
--decode http://decode2:30012 \
--policy cache_aware \
--prefill-policy cache_aware \
--decode-policy power_of_two
```
- **Python launcher**
```bash
python3 -m sglang_router.launch_router \
--pd-disaggregation \
--prefill http://prefill1:30001 9001 \
--prefill http://prefill2:30002 \
--decode http://decode1:30011 \
--decode http://decode2:30012 \
--policy cache_aware
```
Prefill entries accept an optional bootstrap port. PD mode merges prefill metadata with decode outputs and streams results back to the client.
### Multi-Model Inference Gateway
Enable IGW mode to route multiple models through a single router while applying per-model policies:
```bash
./target/release/sgl-model-gateway \
--enable-igw \
--policy cache_aware \
--max-concurrent-requests 512
# Register workers dynamically
curl -X POST http://localhost:30000/workers \
-H "Content-Type: application/json" \
-d '{
"url": "http://worker-a:8000",
"model_id": "mistral",
"priority": 10,
"labels": {"tier": "gold"}
}'
# Add another worker with a different model/policy hint
curl -X POST http://localhost:30000/workers \
-H "Content-Type: application/json" \
-d '{
"url": "http://worker-b:8000",
"model_id": "llama3",
"priority": 20,
"labels": {"policy": "power_of_two", "tier": "silver"}
}'
# Inspect registered workers
curl http://localhost:30000/workers
```
Sample response (http workers):
```json
{
"workers": [
{"id":"http://0.0.0.0:31378","url":"http://0.0.0.0:31378","model_id":"mistral","priority":50,"cost":1.0,"worker_type":"regular","is_healthy":true,"load":0,"connection_mode":"Http"},
{"id":"http://0.0.0.0:34881","url":"http://0.0.0.0:34881","model_id":"llama3","priority":50,"cost":1.0,"worker_type":"regular","is_healthy":true,"load":0,"connection_mode":"Http"}
],
"total": 2,
"stats": {
"prefill_count": 0,
"decode_count": 0,
"regular_count": 2
}
}
```
Add more workers with the same API; include optional `labels` (for per-model policies) or `tokenizer_path` / `reasoning_parser` / `tool_parser` fields as needed. `/workers/{url}` exposes queued job status while background jobs finalize registration.
### gRPC Routing
- **Rust binary**
```bash
./target/release/sgl-model-gateway \
--worker-urls grpc://worker-grpc-0:31001 grpc://worker-grpc-1:31002 \
--tokenizer-path /path/to/tokenizer.json \
--reasoning-parser deepseek-r1 \
--tool-call-parser json
```
- **Python router**
```bash
python3 -m sglang_router.launch_router \
--worker-urls grpc://127.0.0.1:20000 \
--model-path meta-llama/Llama-3.1-8B-Instruct \
--host 0.0.0.0 \
--port 8080
```
The gRPC router tokenizes inputs locally, supports tool-call parsing, and streams responses. It supports both regular HTTP-equivalent serving and PD (prefill/decode) serving when the worker registry contains PD workers. Provide `--model-path` or `--tokenizer-path` (HuggingFace ID or local directory) whenever connection mode resolves to gRPC.
Use `--reasoning-parser` to select built-in reasoning pipelines (DeepSeek-R1, Qwen3, Step-3, GLM4, etc.) and `--tool-call-parser` for JSON/Pythonic/XML tool contracts in streaming or non-streaming modes.
### OpenAI Backend Mode
Route requests to OpenAI or OpenAI-compatible endpoints:
```bash
# Route to OpenAI API
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls https://api.openai.com \
# Route to custom OpenAI-compatible endpoint (Gemini, xAI, etc.)
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls http://my-openai-compatible-service:8000 \
```
**Notes**
- OpenAI backend mode acts as a proxy to a single remote endpoint; load balancing is not applied.
- Provide exactly one `--worker-urls` entry per router instance.
- The Rust binary supports the same flags (`./target/release/sgl-model-gateway --backend openai ...`).
### MCP Integration
The SGL Model Gateway provides native Model Context Protocol (MCP) client integration, enabling tool calling across STDIO, SSE, and Streamable transports. MCP servers are configured via a YAML configuration file and registered at startup through the workflow engine.
#### Basic Usage
```bash
# Rust binary
./target/release/sgl-model-gateway \
--mcp-config-path /path/to/mcp-config.yaml \
--worker-urls http://worker1:8000
# Python launcher
python3 -m sglang_router.launch_router \
--mcp-config-path /path/to/mcp-config.yaml \
--worker-urls http://worker1:8000
```
#### MCP Configuration File
Create an MCP configuration file to define servers, transports, and connection settings:
```yaml
servers:
- name: "filesystem"
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
protocol: "stdio"
required: false
- name: "github"
url: "https://api.github.com/mcp"
token: "ghp_xxxxx"
protocol: "sse"
required: false
- name: "custom-tools"
url: "https://tools.example.com/mcp"
protocol: "streamable"
required: true
pool:
max_connections: 100
idle_timeout: 300 # seconds
proxy:
http: "http://proxy.internal:8080"
https: "https://proxy.internal:8443"
no_proxy: "localhost,127.0.0.1,*.internal"
inventory:
enable_refresh: true
tool_ttl: 300 # seconds - how long tools are considered fresh
refresh_interval: 300 # seconds - background refresh interval
```
#### Configuration Options
**Server Configuration** (`servers` array):
- `name`: Unique identifier for the MCP server
- `command` + `args`: For STDIO transport (local process execution)
- `url`: For SSE or Streamable transports (HTTP/HTTPS endpoints)
- `token`: Optional authentication token for HTTP-based transports
- `protocol`: Protocol type (`"sse"`, `"streamable"`, or `"stdio"`)
- `required`: If `true`, router fails to start if server is unreachable (default: `false`)
- `envs`: Environment variables for STDIO processes (optional)
- `proxy`: Per-server proxy override (set to `null` to bypass global proxy)
**Connection Pool** (`pool`):
- `max_connections`: Maximum pooled connections for dynamic servers (default: 100)
- `idle_timeout`: Idle connection timeout in seconds before cleanup (default: 300)
**Proxy Configuration** (`proxy`):
- `http`/`https`: Proxy URLs for MCP server connections (not LLM traffic)
- `no_proxy`: Comma-separated hosts to exclude from proxying (supports wildcards)
- **Note**: Proxy settings are currently ignored for `streamable` transport. Use STDIO or SSE transports if proxy support is required.
**Inventory Settings** (`inventory`):
- `enable_refresh`: Enable automatic background refresh of tool inventory (default: true)
- `tool_ttl`: Tool cache TTL in seconds - how long tools are considered fresh (default: 300)
- `refresh_interval`: Background refresh interval in seconds - proactive inventory refresh (default: 300)
#### Transport Types
**STDIO** (Local Process):
```yaml
name: "local-tools"
command: "python"
args: ["-m", "my_mcp_server"]
envs:
API_KEY: "secret"
DEBUG: "true"
```
**SSE** (Server-Sent Events):
```yaml
name: "remote-sse"
url: "https://mcp.example.com/events"
token: "bearer-token"
protocol: "sse"
```
**Streamable** (Bidirectional Streaming):
```yaml
name: "streaming-tools"
url: "https://mcp.example.com/stream"
protocol: "streamable"
required: true
```
#### Server Lifecycle
- MCP servers are registered via the workflow engine with retry logic (100 attempts, 2-hour timeout for STDIO servers)
- Discovery phase identifies tools, prompts, and resources
- Tool inventory is cached with configurable TTL and periodic refresh
- Failed optional servers log warnings; required servers halt startup
- Static servers (from config) are permanent; dynamic servers (per-request) use connection pooling
Check Prometheus metrics for MCP activity (`mcp_*` metrics) and workflow job status via the admin API.
### Python Launcher (Router + Workers)
Launch router and SGLang worker processes together; `launch_server` spins up workers (HTTP or gRPC) and the router in one shot.
```bash
python3 -m sglang_router.launch_server --host 0.0.0.0
```
Add flags as needed for production deployments:
```bash
python3 -m sglang_router.launch_server \
--host 0.0.0.0 \
--port 8080 \
--model meta-llama/Llama-3.1-8B-Instruct \
--tp-size 1 \
--dp-size 8 \
--grpc-mode
```
Omit `--grpc-mode` to start HTTP workers; the router automatically configures worker URLs and schedules them based on the provided DP size.
### Mini Load Balancer (Debug)
```bash
python3 -m sglang_router.launch_router \
--mini-lb \
--pd-disaggregation \
--prefill http://localhost:30001 \
--decode http://localhost:30011
```
MiniLB forwards PD requests using simple random routing and is intended for local debugging only.
### Running Worker Servers
Use upstream SGLang binaries to start dedicated worker processes.
- **Prefill worker server (gRPC mode)**:
```bash
python3 -m sglang.launch_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--port 20000 \
--tp-size 1 \
--grpc-mode
```
Remove `--grpc-mode` for HTTP workers. Combine with the router commands above to register the worker via CLI flags or the control-plane API.
## Control Plane
### Worker Lifecycle & Job Queue
- `JobQueue` handles asynchronous add/remove operations to avoid blocking clients.
- `WorkerManager` inspects worker metadata (`/get_server_info`, `/get_model_info`), tracks load, and exposes `flush_cache` and `get_loads`.
- Per-worker circuit breakers and health probes keep the registry healthy; load monitor feeds metrics to cache-aware and power-of-two policies.
### Administrative & Worker APIs
| Method | Path | Description |
|----------|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `POST` | `/workers` | Queue worker registration (prefill/decode/regular). Body matches `WorkerConfigRequest`. Returns `202 Accepted` while the job queue processes the request. |
| `GET` | `/workers` | List workers with health, load, policy metadata, and queued job status. |
| `GET` | `/workers/{url}` | Inspect a specific worker or job queue entry. |
| `DELETE` | `/workers/{url}` | Queue worker removal. |
| `POST` | `/flush_cache` | Trigger cache flush across HTTP workers with success/failure breakdown. |
| `GET` | `/get_loads` | Sample current load reported by each worker. |
All administrative routes inherit router API-key protection when `--api-key` is supplied. Job status includes `pending`, `processing`, and `failed` phases with timestamps.
### Service Discovery
Enable Kubernetes discovery to reconcile workers automatically:
```bash
./target/release/sgl-model-gateway \
--service-discovery \
--selector app=sglang-worker role=inference \
--service-discovery-namespace sglang-system \
--service-discovery-port 8000
```
PD mode accepts dedicated selectors:
```bash
--pd-disaggregation \
--prefill-selector app=sglang component=prefill \
--decode-selector app=sglang component=decode \
--service-discovery
```
Prefill pods can expose bootstrap ports via the `sglang.ai/bootstrap-port` annotation. RBAC must allow `get`, `list`, and `watch` on pods.
## Data Plane
### Router Capabilities (HTTP & gRPC)
Both router stacks:
- Share load-balancing policies (random, round-robin, cache-aware, power-of-two) with DP-aware scheduling, retries, circuit breakers, and rate limiting.
- Record metrics per request, track running load, and integrate with the router-wide policy registry.
The HTTP router exposes the full OpenAI-compatible surface area (`/generate`, `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/responses`, `/v1/rerank`, etc.). The gRPC router delivers blazing-fast `/generate` and `/v1/chat/completions` today, with the remaining endpoints returning `501 Not Implemented` until their pipelines are finalised.
#### HTTP Router specifics
- **Regular router** handles classic single-stage workers with per-model policy overrides.
- **Prefill/Decode router** coordinates disaggregated prefill and decode workers, merges metadata, and manages streaming fan-in.
#### gRPC Router specifics
- Industry-first fully Rust implementation of an OpenAI-compatible gRPC inference gateway, including tokenizer, reasoning parser, and tool parser execution in-process for maximum throughput.
- Supports both single-stage and PD (prefill/decode) worker topologies; the router automatically selects the appropriate pipeline per model.
- Provides the same `/v1/*` APIs as the HTTP router while streaming tokenized requests/responses directly to SRT gRPC workers.
- Built-in reasoning parsers for DeepSeek, Qwen, Llama, Mistral, GPT-OSS, Step-3, GLM4, Kimi K2, and other structured-thought models.
- Tool-call parsers for JSON, Pythonic, XML, and custom schemas with streaming and non-streaming execution loops.
- Tokenizer factory supporting HuggingFace models, local tokenizer.json files, and chat template overrides (see `src/tokenizer`).
- Explore the code paths in `src/reasoning_parser`, `src/tool_parser`, and `src/tokenizer` for the end-to-end Rust implementations that power gRPC mode.
### OpenAI Router
- Proxies OpenAI-compatible chat completions and responses APIs, preserving headers and SSE streams end-to-end.
- Supports `/v1/responses` background jobs with cancellation, deletion, and listing input items—enabling agentic, multi-turn orchestration without persisting data at remote vendor endpoints.
- Conversation APIs (`/v1/conversations` and `/v1/conversations/{id}/items`) interact with the configured conversation storage backend for compliant chat-history management. Conversation state lives at the router tier, so the same history can drive different models or MCP loops without leaking data to upstream vendors.
- Chat history, agentic multi-turn `/v1/responses`, and the native MCP client (STDIO/HTTP/SSE/Streamable transports) are designed to satisfy enterprise data-privacy requirements by keeping sensitive state within the router.
### Request Endpoints
| Endpoint | Notes |
|----------------------------------------------------------------------------------|------------------------------------------------------------|
| `POST /generate` | SGLang generate API. |
| `POST /v1/chat/completions` | OpenAI-compatible chat. Supports streaming and tool calls. |
| `POST /v1/completions` | OpenAI-compatible text completions. |
| `POST /v1/responses` | Create background responses, returns response IDs. |
| `GET /v1/responses/{id}` | Retrieve stored responses. |
| Conversation endpoints (`/v1/conversations`, `/v1/conversations/{id}`, `/v1/conversations/{id}/items`) | Manage chat history. |
| `POST /v1/embeddings` | Forward embedding requests. |
| `POST /v1/rerank`, `POST /rerank` | Ranking APIs. |
Public health endpoints (`/liveness`, `/readiness`, `/health`, `/health_generate`) reflect registry state; readiness ensures PD workers are paired and IGW has at least one healthy route.
## Conversations, Responses, and Data Connectors
- `--history-backend memory` (default) stores responses and conversations in-process.
- `--history-backend none` disables persistence while keeping APIs.
- `--history-backend oracle` uses Oracle Autonomous Database; provide credentials via flags or environment variables.
- `--history-backend postgres` uses PostgreSQL Database.
- Conversation item storage mirrors the history backend (Oracle or memory). The same storage powers OpenAI `/responses` and conversation APIs.
### History Backend (OpenAI Router Mode)
Store conversation and response data for tracking, debugging, or analytics.
> **Note:** History backends are currently supported only when running with `--backend openai`. gRPC mode support for the `/v1/responses` API is planned.
#### Available storage options
- **Memory** (default): In-memory storage, fast but ephemeral.
- **None**: No storage, minimal overhead.
- **Oracle**: Persistent storage backed by Oracle Autonomous Database.
- **Postgres**: Persistent storage backed by PostgreSQL Database.
```bash
# Memory backend (default)
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls https://api.openai.com \
--history-backend memory
# No storage for maximum performance
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls https://api.openai.com \
--history-backend none
# Oracle ATP backend (see configuration below)
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls https://api.openai.com \
--history-backend oracle
# PostgreSQL backend
python3 -m sglang_router.launch_router \
--backend openai \
--worker-urls https://api.openai.com \
--history-backend postgres
```
#### Oracle configuration
Install the Oracle Instant Client and set `LD_LIBRARY_PATH` accordingly. Choose **one** connection method:
```bash
# Option 1: Full connection descriptor
export ATP_DSN="(description=(address=(protocol=tcps)(port=1522)(host=adb.region.oraclecloud.com))(connect_data=(service_name=service_name)))"
# Option 2: TNS alias (requires wallet)
export ATP_TNS_ALIAS="sglroutertestatp_high"
export ATP_WALLET_PATH="/path/to/wallet"
```
Provide database credentials and optional pool sizing:
```bash
export ATP_USER="admin"
export ATP_PASSWORD="YourPassword123"
export ATP_POOL_MIN=4
export ATP_POOL_MAX=32
```
Router flags map to these values:
- `--oracle-dsn` (env: `ATP_DSN`) or `--oracle-tns-alias` with `--oracle-wallet-path`.
- `--oracle-user` / `--oracle-password` (`ATP_USER` / `ATP_PASSWORD`).
- `--oracle-wallet-path` (`ATP_WALLET_PATH`) when using TNS alias.
- `--oracle-pool-min`, `--oracle-pool-max`, `--oracle-pool-timeout-secs`.
Only one of `--oracle-dsn` or `--oracle-tns-alias` should be supplied.
## Reliability & Flow Control
- **Retries**: Default max retries = 5 with exponential backoff (`--retry-max-retries`, `--retry-initial-backoff-ms`, `--retry-max-backoff-ms`, `--retry-backoff-multiplier`, `--retry-jitter-factor`). Retries trigger on 408/429/500/502/503/504.
- **Circuit Breakers**: Per worker thresholds (`--cb-failure-threshold`, `--cb-success-threshold`, `--cb-timeout-duration-secs`, `--cb-window-duration-secs`). Disable via `--disable-circuit-breaker`.
- **Rate Limiting**: Token bucket driven by `--max-concurrent-requests`. Set `--rate-limit-tokens-per-second` to override refill rate. Configure request queue via `--queue-size` and `--queue-timeout-secs`; queued requests observe FIFO order and respect cancellation.
- **Health Checks**: Runtime probes via `--health-check-interval-secs`, `--health-check-timeout-secs`, failure/success thresholds, and `--health-check-endpoint`.
- **Cache Management**: `/flush_cache` ensures LRU eviction when redeploying PD workers.
## Load Balancing Policies
- `random`: uniform random worker selection.
- `round_robin`: sequential rotation with atomic counters.
- `cache_aware`: maintains a prefix tree of prompts to route repeat traffic and evens load with configurable thresholds (`--cache-threshold`, `--balance-abs-threshold`, `--balance-rel-threshold`, `--eviction-interval`, `--max-tree-size`).
- `power_of_two`: chooses the lighter worker among two random candidates; integrates with `LoadMonitor`.
Per-model overrides are available in PD mode (`--prefill-policy`, `--decode-policy`) and IGW mode via the worker registry.
## Observability
- **Logging**: Structured tracing through `tracing` with optional file sink (`--log-dir`) and `--log-level` (`debug`, `info`, `warn`, `error`).
- **Prometheus Metrics**: Enable with `--prometheus-host`/`--prometheus-port` (defaults to `0.0.0.0:29000`). Metrics cover request latency, retry behavior, circuit breaker states, worker health/load, queue depth, PD pipeline stats, tokenizer timings, and MCP activity.
- **Request IDs**: Configurable headers via `--request-id-headers`; responses include `x-request-id`.
- **CORS**: Set `--cors-allowed-origins` for browser access.
## Security
### Router and Worker API Keys
- **Router API key (`--api-key`)** protects client access to router endpoints; all protected routes expect `Authorization: Bearer <key>`.
- Workers listed in `--worker-urls` inherit the router API key automatically.
- When adding workers dynamically, provide explicit API keys via payload or query string; they do **not** inherit automatically.
```bash
# Router and initial workers share the same key
python3 -m sglang_router.launch_router \
--api-key "shared-api-key" \
--worker-urls http://worker1:8000 http://worker2:8000
# Adding a worker without key while router has one triggers a warning and leaves the worker unprotected
curl -X POST http://localhost:8080/add_worker?url=http://worker3:8000
# Add worker with explicit key
curl -X POST "http://localhost:8080/add_worker?url=http://worker3:8000&api_key=worker3-specific-key"
```
### Security Configurations
1. **No Authentication** (default): Router and workers accept requests without keys—use only in trusted environments.
2. **Router-only Authentication**: Provide `--api-key`; clients must present the key, router accesses workers without credentials.
3. **Worker-only Authentication**: Router open to clients; each worker requires its own key. Supply keys when calling `/workers` or `/add_worker`.
4. **Full Authentication**: Set router API key and provide per-worker keys. Example:
```bash
python3 -m sglang_router.launch_router --api-key "router-key"
curl -H "Authorization: Bearer router-key" \
-X POST http://localhost:8080/add_worker?url=http://worker:8000&api_key=worker-key
```
### Important Notes
- Initial workers declared via CLI inherit the router key; dynamic workers must supply keys explicitly.
- Router logs a warning when a worker is registered without a key while the router expects authentication.
- When router and workers share the same key, still include the key when invoking dynamic registration APIs.
## Development & Testing
```bash
# Build Rust components (debug mode, fast)
cargo build
# Run Rust tests
cargo test
# Fast Python development (rebuilds and installs in debug mode)
cd bindings/python && maturin develop
# Run Python tests
cd ../.. # Back to sgl-model-gateway root
pytest py_test/
```
For production builds, use `maturin build --release --out dist` from the `bindings/python/` directory to create optimized wheels. During development, `maturin develop` rebuilds and installs instantly without creating wheel files. Use `python -m sglang_router.launch_server` to co-launch router and SGLang workers in small clusters for local validation.
---
## Release Management
### Creating Gateway Releases
Create releases for the Gateway/Router component with filtered commits:
```bash
# Using make
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0
# Save to file
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0 OUTPUT=RELEASE_NOTES.md
# Create draft release (requires gh CLI, DEFAULT behavior)
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0 CREATE_RELEASE=1
# Publish release immediately (requires gh CLI)
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0 CREATE_RELEASE=1 DRAFT=0
```
**Tag Naming**: Use `gateway-*` or `router-*` prefixes to avoid triggering unrelated CI workflows.
### Release Workflow
1. **Create and push tag**:
```bash
git tag -a gateway-v1.0.0 <commit-hash> -m "Gateway release v1.0.0"
git push origin gateway-v1.0.0
```
2. **Generate release notes** (automatically filters gateway-related commits):
```bash
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0
```
3. **Create GitHub release**:
```bash
# Create draft (DEFAULT - review before publishing)
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0 CREATE_RELEASE=1
# Or publish immediately (skip draft)
make release-notes PREV=gateway-v0.2.2 CURR=gateway-v1.0.0 CREATE_RELEASE=1 DRAFT=0
```
### Filtered Paths
Release notes only include commits touching:
- `sgl-model-gateway/` - Router codebase
- `python/sglang/srt/grpc/` - gRPC protocol
- `python/sglang/srt/entrypoints/grpc_server.py` - gRPC server
The script automatically extracts author attribution, PR links, and identifies new contributors.
---
SGLang Model Gateway continues to evolve alongside the core SGLang runtime. Contributions should keep CLI flags, documentation, and Python bindings in sync with the Rust implementation.
@@ -0,0 +1,670 @@
use std::time::Instant;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use serde_json::{from_str, to_string, to_value, to_vec};
use sgl_model_gateway::{
core::{BasicWorker, BasicWorkerBuilder, Worker, WorkerType},
protocols::{
chat::{ChatCompletionRequest, ChatMessage, MessageContent},
common::StringOrArray,
completion::CompletionRequest,
generate::GenerateRequest,
sampling_params::SamplingParams,
},
routers::http::pd_types::{generate_room_id, RequestWithBootstrap},
};
fn create_test_worker() -> BasicWorker {
BasicWorkerBuilder::new("http://test-server:8000")
.worker_type(WorkerType::Prefill {
bootstrap_port: Some(5678),
})
.build()
}
// Helper function to get bootstrap info from worker
fn get_bootstrap_info(worker: &BasicWorker) -> (String, Option<u16>) {
let hostname = worker.bootstrap_host().to_string();
let bootstrap_port = worker.bootstrap_port();
(hostname, bootstrap_port)
}
/// Create a default GenerateRequest for benchmarks with minimal fields set
fn default_generate_request() -> GenerateRequest {
GenerateRequest {
text: None,
model: None,
input_ids: None,
input_embeds: None,
image_data: None,
video_data: None,
audio_data: None,
sampling_params: None,
return_logprob: None,
logprob_start_len: None,
top_logprobs_num: None,
token_ids_logprob: None,
return_text_in_logprobs: false,
stream: false,
log_metrics: true,
return_hidden_states: false,
modalities: None,
session_params: None,
lora_path: None,
lora_id: None,
custom_logit_processor: None,
bootstrap_host: None,
bootstrap_port: None,
bootstrap_room: None,
bootstrap_pair_key: None,
data_parallel_rank: None,
background: false,
conversation_id: None,
priority: None,
extra_key: None,
no_logs: false,
custom_labels: None,
return_bytes: false,
return_entropy: false,
rid: None,
}
}
/// Create a default ChatCompletionRequest for benchmarks with minimal fields set
#[allow(deprecated)]
fn default_chat_completion_request() -> ChatCompletionRequest {
ChatCompletionRequest {
// Required fields in OpenAI order
messages: vec![],
model: String::new(),
// Use default for all other fields
..Default::default()
}
}
/// Create a default CompletionRequest for benchmarks with minimal fields set
fn default_completion_request() -> CompletionRequest {
CompletionRequest {
model: String::new(),
prompt: StringOrArray::String(String::new()),
suffix: None,
max_tokens: None,
temperature: None,
top_p: None,
n: None,
stream: false,
stream_options: None,
logprobs: None,
echo: false,
stop: None,
presence_penalty: None,
frequency_penalty: None,
best_of: None,
logit_bias: None,
user: None,
seed: None,
// SGLang Extensions
top_k: None,
min_p: None,
min_tokens: None,
repetition_penalty: None,
regex: None,
ebnf: None,
json_schema: None,
stop_token_ids: None,
no_stop_trim: false,
ignore_eos: false,
skip_special_tokens: true,
// SGLang Extensions
lora_path: None,
session_params: None,
return_hidden_states: false,
sampling_seed: None,
other: serde_json::Map::new(),
}
}
// Sample request data for benchmarks
fn create_sample_generate_request() -> GenerateRequest {
GenerateRequest {
text: Some("Write a story about artificial intelligence".to_string()),
sampling_params: Some(SamplingParams {
max_new_tokens: Some(100),
temperature: Some(0.8),
top_p: Some(0.9),
top_k: Some(50),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
repetition_penalty: Some(1.0),
..Default::default()
}),
..default_generate_request()
}
}
#[allow(deprecated)]
fn create_sample_chat_completion_request() -> ChatCompletionRequest {
ChatCompletionRequest {
model: "gpt-3.5-turbo".to_string(),
messages: vec![
ChatMessage::System {
content: MessageContent::Text("You are a helpful assistant".to_string()),
name: None,
},
ChatMessage::User {
content: MessageContent::Text(
"Explain quantum computing in simple terms".to_string(),
),
name: None,
},
],
max_tokens: Some(150),
max_completion_tokens: Some(150),
temperature: Some(0.7),
top_p: Some(1.0),
n: Some(1),
presence_penalty: Some(0.0),
frequency_penalty: Some(0.0),
parallel_tool_calls: Some(true),
..default_chat_completion_request()
}
}
fn create_sample_completion_request() -> CompletionRequest {
CompletionRequest {
model: "text-davinci-003".to_string(),
prompt: StringOrArray::String("Complete this sentence: The future of AI is".to_string()),
max_tokens: Some(50),
temperature: Some(0.8),
top_p: Some(1.0),
n: Some(1),
presence_penalty: Some(0.0),
frequency_penalty: Some(0.0),
best_of: Some(1),
..default_completion_request()
}
}
#[allow(deprecated)]
fn create_large_chat_completion_request() -> ChatCompletionRequest {
let mut messages = vec![ChatMessage::System {
content: MessageContent::Text(
"You are a helpful assistant with extensive knowledge.".to_string(),
),
name: None,
}];
// Add many user/assistant pairs to simulate a long conversation
for i in 0..50 {
messages.push(ChatMessage::User {
content: MessageContent::Text(format!("Question {}: What do you think about topic number {} which involves complex reasoning about multiple interconnected systems and their relationships?", i, i)),
name: None,
});
messages.push(ChatMessage::Assistant {
content: Some(MessageContent::Text(format!("Answer {}: This is a detailed response about topic {} that covers multiple aspects and provides comprehensive analysis of the interconnected systems you mentioned.", i, i))),
name: None,
tool_calls: None,
reasoning_content: None,
});
}
ChatCompletionRequest {
model: "gpt-4".to_string(),
messages,
max_tokens: Some(1000),
max_completion_tokens: Some(1000),
temperature: Some(0.7),
top_p: Some(0.95),
n: Some(1),
presence_penalty: Some(0.1),
frequency_penalty: Some(0.1),
top_logprobs: Some(5),
seed: Some(42),
parallel_tool_calls: Some(true),
..default_chat_completion_request()
}
}
// Benchmark JSON serialization
fn bench_json_serialization(c: &mut Criterion) {
let mut group = c.benchmark_group("json_serialization");
let generate_req = create_sample_generate_request();
let chat_req = create_sample_chat_completion_request();
let completion_req = create_sample_completion_request();
let large_chat_req = create_large_chat_completion_request();
group.bench_function("generate_request", |b| {
b.iter(|| {
let json = to_string(black_box(&generate_req)).unwrap();
black_box(json);
});
});
group.bench_function("chat_completion_request", |b| {
b.iter(|| {
let json = to_string(black_box(&chat_req)).unwrap();
black_box(json);
});
});
group.bench_function("completion_request", |b| {
b.iter(|| {
let json = to_string(black_box(&completion_req)).unwrap();
black_box(json);
});
});
group.bench_function("large_chat_completion_request", |b| {
b.iter(|| {
let json = to_string(black_box(&large_chat_req)).unwrap();
black_box(json);
});
});
group.bench_function("generate_request_to_bytes", |b| {
b.iter(|| {
let bytes = to_vec(black_box(&generate_req)).unwrap();
black_box(bytes);
});
});
group.finish();
}
// Benchmark JSON deserialization
fn bench_json_deserialization(c: &mut Criterion) {
let mut group = c.benchmark_group("json_deserialization");
let generate_json = to_string(&create_sample_generate_request()).unwrap();
let chat_json = to_string(&create_sample_chat_completion_request()).unwrap();
let completion_json = to_string(&create_sample_completion_request()).unwrap();
let large_chat_json = to_string(&create_large_chat_completion_request()).unwrap();
group.bench_function("generate_request", |b| {
b.iter(|| {
let req: GenerateRequest = from_str(black_box(&generate_json)).unwrap();
black_box(req);
});
});
group.bench_function("chat_completion_request", |b| {
b.iter(|| {
let req: ChatCompletionRequest = from_str(black_box(&chat_json)).unwrap();
black_box(req);
});
});
group.bench_function("completion_request", |b| {
b.iter(|| {
let req: CompletionRequest = from_str(black_box(&completion_json)).unwrap();
black_box(req);
});
});
group.bench_function("large_chat_completion_request", |b| {
b.iter(|| {
let req: ChatCompletionRequest = from_str(black_box(&large_chat_json)).unwrap();
black_box(req);
});
});
group.finish();
}
// Benchmark bootstrap injection (replaces request adaptation)
fn bench_bootstrap_injection(c: &mut Criterion) {
let mut group = c.benchmark_group("bootstrap_injection");
let generate_req = create_sample_generate_request();
let chat_req = create_sample_chat_completion_request();
let completion_req = create_sample_completion_request();
let large_chat_req = create_large_chat_completion_request();
let worker = create_test_worker();
let (hostname, bootstrap_port) = get_bootstrap_info(&worker);
group.bench_function("generate_bootstrap_injection", |b| {
b.iter(|| {
let request_with_bootstrap = RequestWithBootstrap {
original: &generate_req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let json = to_value(black_box(&request_with_bootstrap)).unwrap();
black_box(json);
});
});
group.bench_function("chat_completion_bootstrap_injection", |b| {
b.iter(|| {
let request_with_bootstrap = RequestWithBootstrap {
original: &chat_req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let json = to_value(black_box(&request_with_bootstrap)).unwrap();
black_box(json);
});
});
group.bench_function("completion_bootstrap_injection", |b| {
b.iter(|| {
let request_with_bootstrap = RequestWithBootstrap {
original: &completion_req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let json = to_value(black_box(&request_with_bootstrap)).unwrap();
black_box(json);
});
});
group.bench_function("large_chat_completion_bootstrap_injection", |b| {
b.iter(|| {
let request_with_bootstrap = RequestWithBootstrap {
original: &large_chat_req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let json = to_value(black_box(&request_with_bootstrap)).unwrap();
black_box(json);
});
});
group.finish();
}
// Benchmark direct JSON routing (replaces regular routing)
fn bench_direct_json_routing(c: &mut Criterion) {
let mut group = c.benchmark_group("direct_json_routing");
let generate_req = create_sample_generate_request();
let chat_req = create_sample_chat_completion_request();
let completion_req = create_sample_completion_request();
group.bench_function("generate_to_json", |b| {
b.iter(|| {
let json = to_value(black_box(&generate_req)).unwrap();
black_box(json);
});
});
group.bench_function("generate_to_json_string", |b| {
b.iter(|| {
let json = to_string(black_box(&generate_req)).unwrap();
black_box(json);
});
});
group.bench_function("generate_to_bytes", |b| {
b.iter(|| {
let bytes = to_vec(black_box(&generate_req)).unwrap();
black_box(bytes);
});
});
group.bench_function("chat_completion_to_json", |b| {
b.iter(|| {
let json = to_value(black_box(&chat_req)).unwrap();
black_box(json);
});
});
group.bench_function("chat_completion_to_json_string", |b| {
b.iter(|| {
let json = to_string(black_box(&chat_req)).unwrap();
black_box(json);
});
});
group.bench_function("completion_to_json", |b| {
b.iter(|| {
let json = to_value(black_box(&completion_req)).unwrap();
black_box(json);
});
});
group.finish();
}
// Benchmark throughput with different request sizes
fn bench_throughput_by_size(c: &mut Criterion) {
let mut group = c.benchmark_group("throughput_by_size");
// Create requests of different sizes
let small_generate = GenerateRequest {
text: Some("Hi".to_string()),
..default_generate_request()
};
let medium_generate = GenerateRequest {
text: Some("Write a medium length story about AI".repeat(10)),
..default_generate_request()
};
let large_generate = GenerateRequest {
text: Some("Write a very long and detailed story about artificial intelligence and its impact on society".repeat(100)),
..default_generate_request()
};
let worker = create_test_worker();
let (hostname, bootstrap_port) = get_bootstrap_info(&worker);
for (name, req) in [
("small", &small_generate),
("medium", &medium_generate),
("large", &large_generate),
] {
let json = to_string(req).unwrap();
let size_bytes = json.len();
let hostname_clone = hostname.clone();
group.throughput(Throughput::Bytes(size_bytes as u64));
group.bench_with_input(BenchmarkId::new("serialize", name), &req, |b, req| {
b.iter(|| {
let json = to_string(black_box(req)).unwrap();
black_box(json);
});
});
group.bench_with_input(
BenchmarkId::new("deserialize", name),
&json,
|b, json_str| {
b.iter(|| {
let req: GenerateRequest = black_box(from_str(json_str)).unwrap();
black_box(req);
});
},
);
group.bench_with_input(
BenchmarkId::new("bootstrap_inject", name),
&req,
move |b, req| {
let hostname = hostname_clone.clone();
b.iter(|| {
let request_with_bootstrap = RequestWithBootstrap {
original: req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let json = to_value(&request_with_bootstrap).unwrap();
black_box(json);
});
},
);
}
group.finish();
}
// Benchmark full round-trip: deserialize -> inject bootstrap -> serialize
fn bench_full_round_trip(c: &mut Criterion) {
let mut group = c.benchmark_group("full_round_trip");
let generate_json = to_string(&create_sample_generate_request()).unwrap();
let chat_json = to_string(&create_sample_chat_completion_request()).unwrap();
let completion_json = to_string(&create_sample_completion_request()).unwrap();
let worker = create_test_worker();
let (hostname, bootstrap_port) = get_bootstrap_info(&worker);
group.bench_function("generate_openai_to_pd_pipeline", |b| {
b.iter(|| {
// Deserialize OpenAI request
let req: GenerateRequest = from_str(black_box(&generate_json)).unwrap();
// Create wrapper with bootstrap fields
let request_with_bootstrap = RequestWithBootstrap {
original: &req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
// Serialize final request
let pd_json = to_string(&request_with_bootstrap).unwrap();
black_box(pd_json);
});
});
group.bench_function("chat_completion_openai_to_pd_pipeline", |b| {
b.iter(|| {
let req: ChatCompletionRequest = from_str(black_box(&chat_json)).unwrap();
let request_with_bootstrap = RequestWithBootstrap {
original: &req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let pd_json = to_string(&request_with_bootstrap).unwrap();
black_box(pd_json);
});
});
group.bench_function("completion_openai_to_pd_pipeline", |b| {
b.iter(|| {
let req: CompletionRequest = from_str(black_box(&completion_json)).unwrap();
let request_with_bootstrap = RequestWithBootstrap {
original: &req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let pd_json = to_string(&request_with_bootstrap).unwrap();
black_box(pd_json);
});
});
group.bench_function("generate_direct_json_pipeline", |b| {
b.iter(|| {
// Deserialize OpenAI request
let req: GenerateRequest = from_str(black_box(&generate_json)).unwrap();
// Convert to JSON for direct routing (no bootstrap injection)
let routing_json = to_value(&req).unwrap();
let json_string = to_string(&routing_json).unwrap();
black_box(json_string);
});
});
group.finish();
}
fn benchmark_summary(c: &mut Criterion) {
let group = c.benchmark_group("benchmark_summary");
println!("\nSGLang Router Performance Benchmark Suite");
println!("=============================================");
// Quick performance overview
let generate_req = create_sample_generate_request();
let worker = create_test_worker();
println!("\nQuick Performance Overview:");
// Measure serialization
let start = Instant::now();
for _ in 0..1000 {
let _ = black_box(to_string(&generate_req).unwrap());
}
let serialize_time = start.elapsed().as_nanos() / 1000;
println!(" * Serialization (avg): {:>8} ns/req", serialize_time);
// Measure deserialization
let json = to_string(&generate_req).unwrap();
let start = Instant::now();
for _ in 0..1000 {
let _: GenerateRequest = black_box(from_str(&json).unwrap());
}
let deserialize_time = start.elapsed().as_nanos() / 1000;
println!(
" * Deserialization (avg): {:>8} ns/req",
deserialize_time
);
// Measure bootstrap injection (replaces adaptation)
let (hostname, bootstrap_port) = get_bootstrap_info(&worker);
let start = Instant::now();
for _ in 0..1000 {
let request_with_bootstrap = RequestWithBootstrap {
original: &generate_req,
bootstrap_host: hostname.clone(),
bootstrap_port,
bootstrap_room: generate_room_id(),
};
let _ = black_box(to_value(&request_with_bootstrap).unwrap());
}
let inject_time = start.elapsed().as_nanos() / 1000;
println!(" * Bootstrap Injection (avg): {:>6} ns/req", inject_time);
// Calculate ratios
let total_pipeline = serialize_time + deserialize_time + inject_time;
println!(" * Total Pipeline (avg): {:>8} ns/req", total_pipeline);
println!("\nPerformance Insights:");
if deserialize_time > serialize_time * 2 {
println!(" • Deserialization is significantly faster than serialization");
}
if inject_time < serialize_time / 10 {
println!(
" • Bootstrap injection overhead is negligible ({:.1}% of serialization)",
(inject_time as f64 / serialize_time as f64) * 100.0
);
}
if total_pipeline < 100_000 {
println!(" • Total pipeline latency is excellent (< 100μs)");
}
println!("\nSimplification Benefits:");
println!(" • Eliminated complex type conversion layer");
println!(" • Reduced memory allocations");
println!(" • Automatic field preservation (no manual mapping)");
println!(" • Direct JSON manipulation improves performance");
println!("\nRecommendations:");
if serialize_time > deserialize_time {
println!(" • Focus optimization efforts on serialization rather than deserialization");
}
println!(" • PD mode overhead is minimal - safe to use for latency-sensitive workloads");
println!(" • Consider batching small requests to improve overall throughput");
println!("\n{}", "=".repeat(50));
group.finish();
}
criterion_group!(
benches,
benchmark_summary,
bench_json_serialization,
bench_json_deserialization,
bench_bootstrap_injection,
bench_direct_json_routing,
bench_throughput_by_size,
bench_full_round_trip
);
criterion_main!(benches);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,887 @@
//! Comprehensive tool parser benchmark for measuring performance under various scenarios
//!
//! This benchmark tests:
//! - Single parser parsing performance
//! - Registry creation overhead
//! - Concurrent parsing with shared parsers
//! - Streaming vs complete parsing
//! - Different model formats (JSON, Mistral, Qwen, Pythonic, etc.)
use std::{
collections::BTreeMap,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, Instant},
};
use criterion::{black_box, criterion_group, BenchmarkId, Criterion, Throughput};
use serde_json::json;
use sgl_model_gateway::{
protocols::common::{Function, Tool},
tool_parser::{JsonParser, ParserFactory as ToolParserFactory, ToolParser},
};
use tokio::runtime::Runtime;
// Test data for different parser formats - realistic complex examples
const JSON_SIMPLE: &str = r#"{"name": "code_interpreter", "arguments": "{\"language\": \"python\", \"code\": \"import numpy as np\\nimport matplotlib.pyplot as plt\\n\\n# Generate sample data\\nx = np.linspace(0, 10, 100)\\ny = np.sin(x) * np.exp(-x/10)\\n\\n# Create the plot\\nplt.figure(figsize=(10, 6))\\nplt.plot(x, y, 'b-', linewidth=2)\\nplt.grid(True)\\nplt.xlabel('Time (s)')\\nplt.ylabel('Amplitude')\\nplt.title('Damped Oscillation')\\nplt.show()\"}"}"#;
const JSON_ARRAY: &str = r#"[{"name": "web_search", "arguments": "{\"query\": \"latest developments in quantum computing 2024\", \"num_results\": 10, \"search_type\": \"news\", \"date_range\": \"2024-01-01:2024-12-31\", \"exclude_domains\": [\"reddit.com\", \"facebook.com\"], \"language\": \"en\"}"}, {"name": "analyze_sentiment", "arguments": "{\"text\": \"The breakthrough in quantum error correction represents a significant milestone. Researchers are optimistic about practical applications within the next decade.\", \"granularity\": \"sentence\", \"aspects\": [\"technology\", \"timeline\", \"impact\"], \"confidence_threshold\": 0.85}"}, {"name": "create_summary", "arguments": "{\"content_ids\": [\"doc_1234\", \"doc_5678\", \"doc_9012\"], \"max_length\": 500, \"style\": \"technical\", \"include_citations\": true}"}]"#;
const JSON_WITH_PARAMS: &str = r#"{"name": "database_query", "parameters": {"connection_string": "postgresql://user:pass@localhost:5432/analytics", "query": "SELECT customer_id, COUNT(*) as order_count, SUM(total_amount) as lifetime_value, AVG(order_amount) as avg_order_value FROM orders WHERE created_at >= '2024-01-01' GROUP BY customer_id HAVING COUNT(*) > 5 ORDER BY lifetime_value DESC LIMIT 100", "timeout_ms": 30000, "read_consistency": "strong", "partition_key": "customer_id"}}"#;
const MISTRAL_FORMAT: &str = r#"I'll help you analyze the sales data and create visualizations. Let me start by querying the database and then create some charts.
[TOOL_CALLS] [{"name": "sql_query", "arguments": {"database": "sales_analytics", "query": "WITH monthly_sales AS (SELECT DATE_TRUNC('month', order_date) as month, SUM(total_amount) as revenue, COUNT(DISTINCT customer_id) as unique_customers, COUNT(*) as total_orders FROM orders WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' GROUP BY DATE_TRUNC('month', order_date)) SELECT month, revenue, unique_customers, total_orders, LAG(revenue) OVER (ORDER BY month) as prev_month_revenue, (revenue - LAG(revenue) OVER (ORDER BY month)) / LAG(revenue) OVER (ORDER BY month) * 100 as growth_rate FROM monthly_sales ORDER BY month DESC", "format": "json", "timeout": 60000}}]
Based on the query results, I can see interesting trends in your sales data."#;
const MISTRAL_MULTI: &str = r#"Let me help you with a comprehensive analysis of your application's performance.
[TOOL_CALLS] [{"name": "get_metrics", "arguments": {"service": "api-gateway", "metrics": ["latency_p50", "latency_p95", "latency_p99", "error_rate", "requests_per_second"], "start_time": "2024-01-01T00:00:00Z", "end_time": "2024-01-01T23:59:59Z", "aggregation": "5m", "filters": {"environment": "production", "region": "us-east-1"}}}, {"name": "analyze_logs", "arguments": {"log_group": "/aws/lambda/process-orders", "query": "fields @timestamp, @message, @requestId, duration | filter @message like /ERROR/ | stats count() by bin(@timestamp, 5m) as time_window", "start_time": "2024-01-01T00:00:00Z", "end_time": "2024-01-01T23:59:59Z", "limit": 1000}}, {"name": "get_traces", "arguments": {"service": "order-processing", "operation": "ProcessOrder", "min_duration_ms": 1000, "max_results": 100, "include_downstream": true}}]
Now let me create a comprehensive report based on this data."#;
const QWEN_FORMAT: &str = r#"Let me search for information about machine learning frameworks and their performance benchmarks.
<tool_call>
{"name": "academic_search", "arguments": {"query": "transformer architecture optimization techniques GPU inference latency reduction", "databases": ["arxiv", "ieee", "acm"], "year_range": [2020, 2024], "citation_count_min": 10, "include_code": true, "page_size": 25, "sort_by": "relevance"}}
</tool_call>
I found several interesting papers on optimization techniques."#;
const QWEN_MULTI: &str = r#"I'll help you set up a complete data pipeline for your analytics system.
<tool_call>
{"name": "create_data_pipeline", "arguments": {"name": "customer_analytics_etl", "source": {"type": "kafka", "config": {"bootstrap_servers": "kafka1:9092,kafka2:9092", "topic": "customer_events", "consumer_group": "analytics_consumer", "auto_offset_reset": "earliest"}}, "transformations": [{"type": "filter", "condition": "event_type IN ('purchase', 'signup', 'churn')"}, {"type": "aggregate", "window": "1h", "group_by": ["customer_id", "event_type"], "metrics": ["count", "sum(amount)"]}], "destination": {"type": "bigquery", "dataset": "analytics", "table": "customer_metrics", "write_mode": "append"}}}
</tool_call>
<tool_call>
{"name": "schedule_job", "arguments": {"job_id": "customer_analytics_etl", "schedule": "0 */4 * * *", "timezone": "UTC", "retry_policy": {"max_attempts": 3, "backoff_multiplier": 2, "max_backoff": 3600}, "notifications": {"on_failure": ["ops-team@company.com"], "on_success": null}, "monitoring": {"sla_minutes": 30, "alert_threshold": 0.95}}}
</tool_call>
<tool_call>
{"name": "create_dashboard", "arguments": {"title": "Customer Analytics Dashboard", "widgets": [{"type": "time_series", "title": "Customer Acquisition", "query": "SELECT DATE(timestamp) as date, COUNT(DISTINCT customer_id) as new_customers FROM analytics.customer_metrics WHERE event_type = 'signup' GROUP BY date ORDER BY date", "visualization": "line"}, {"type": "metric", "title": "Total Revenue", "query": "SELECT SUM(amount) as total FROM analytics.customer_metrics WHERE event_type = 'purchase' AND DATE(timestamp) = CURRENT_DATE()", "format": "currency"}, {"type": "table", "title": "Top Customers", "query": "SELECT customer_id, COUNT(*) as purchases, SUM(amount) as total_spent FROM analytics.customer_metrics WHERE event_type = 'purchase' GROUP BY customer_id ORDER BY total_spent DESC LIMIT 10"}], "refresh_interval": 300}}
</tool_call>
The data pipeline has been configured and the dashboard is ready."#;
const LLAMA_FORMAT: &str = r#"<|python_tag|>{"name": "execute_code", "arguments": "{\"code\": \"import pandas as pd\\nimport numpy as np\\nfrom sklearn.model_selection import train_test_split\\nfrom sklearn.ensemble import RandomForestClassifier\\nfrom sklearn.metrics import classification_report, confusion_matrix\\nimport joblib\\n\\n# Load and preprocess data\\ndf = pd.read_csv('/data/customer_churn.csv')\\nprint(f'Dataset shape: {df.shape}')\\nprint(f'Missing values: {df.isnull().sum().sum()}')\\n\\n# Feature engineering\\ndf['tenure_months'] = pd.to_datetime('today') - pd.to_datetime(df['signup_date'])\\ndf['tenure_months'] = df['tenure_months'].dt.days // 30\\ndf['avg_monthly_spend'] = df['total_spend'] / df['tenure_months'].clip(lower=1)\\n\\n# Prepare features and target\\nfeature_cols = ['tenure_months', 'avg_monthly_spend', 'support_tickets', 'product_usage_hours', 'feature_adoption_score']\\nX = df[feature_cols]\\ny = df['churned']\\n\\n# Split and train\\nX_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)\\nrf_model = RandomForestClassifier(n_estimators=100, max_depth=10, min_samples_split=5, random_state=42)\\nrf_model.fit(X_train, y_train)\\n\\n# Evaluate\\ny_pred = rf_model.predict(X_test)\\nprint('Classification Report:')\\nprint(classification_report(y_test, y_pred))\\n\\n# Save model\\njoblib.dump(rf_model, '/models/churn_predictor_v1.pkl')\\nprint('Model saved successfully!')\"}"}"#;
const PYTHONIC_FORMAT: &str = r#"[retrieve_context(query="How do transformer models handle long-range dependencies in natural language processing tasks?", index="ml_knowledge_base", top_k=5, similarity_threshold=0.75, rerank=True, include_metadata=True, filters={"category": "deep_learning", "year": {"$gte": 2020}})]"#;
const PYTHONIC_MULTI: &str = r#"[fetch_api_data(endpoint="https://api.weather.com/v1/forecast", params={"lat": 37.7749, "lon": -122.4194, "units": "metric", "days": 7, "hourly": True}, headers={"API-Key": "${WEATHER_API_KEY}"}, timeout=30, retry_count=3), process_weather_data(data="${response}", extract_fields=["temperature", "humidity", "precipitation", "wind_speed", "uv_index"], aggregation="daily", calculate_trends=True), generate_report(data="${processed_data}", template="weather_forecast", format="html", include_charts=True, language="en")]"#;
const DEEPSEEK_FORMAT: &str = r#"I'll analyze your codebase and identify potential security vulnerabilities.
🤔[{"name": "scan_repository", "arguments": {"repo_path": "/src/application", "scan_types": ["security", "dependencies", "secrets", "code_quality"], "file_patterns": ["*.py", "*.js", "*.java", "*.go"], "exclude_dirs": ["node_modules", ".git", "vendor", "build"], "vulnerability_databases": ["cve", "nvd", "ghsa"], "min_severity": "medium", "check_dependencies": true, "deep_scan": true, "parallel_workers": 8}}]
Let me examine the scan results and provide recommendations."#;
const KIMIK2_FORMAT: &str = r#"⍼validate_and_deploy⍁{"deployment_config": {"application": "payment-service", "version": "2.3.1", "environment": "staging", "region": "us-west-2", "deployment_strategy": "blue_green", "health_check": {"endpoint": "/health", "interval": 30, "timeout": 5, "healthy_threshold": 2, "unhealthy_threshold": 3}, "rollback_on_failure": true, "canary_config": {"percentage": 10, "duration_minutes": 30, "metrics": ["error_rate", "latency_p99", "success_rate"], "thresholds": {"error_rate": 0.01, "latency_p99": 500, "success_rate": 0.99}}, "pre_deployment_hooks": ["run_tests", "security_scan", "backup_database"], "post_deployment_hooks": ["smoke_tests", "notify_team", "update_documentation"]}}"#;
const GLM4_FORMAT: &str = r#"<tool>
analyze_customer_behavior
<parameter>dataset_id=customer_interactions_2024</parameter>
<parameter>analysis_type=cohort_retention</parameter>
<parameter>cohort_definition=signup_month</parameter>
<parameter>retention_periods=[1, 7, 14, 30, 60, 90, 180, 365]</parameter>
<parameter>segment_by=["acquisition_channel", "pricing_tier", "industry", "company_size"]</parameter>
<parameter>metrics=["active_users", "revenue", "feature_usage", "engagement_score"]</parameter>
<parameter>statistical_tests=["chi_square", "anova", "trend_analysis"]</parameter>
<parameter>visualization_types=["heatmap", "line_chart", "funnel", "sankey"]</parameter>
<parameter>export_format=dashboard</parameter>
<parameter>confidence_level=0.95</parameter>
</tool>"#;
const STEP3_FORMAT: &str = r#"<step.tML version="0.1">
<call>
<name>orchestrate_ml_pipeline</name>
<parameters>
<parameter name="pipeline_name">fraud_detection_model_v3</parameter>
<parameter name="data_source">s3://ml-datasets/transactions/2024/</parameter>
<parameter name="preprocessing_steps">
<step order="1" type="clean">{"remove_duplicates": true, "handle_missing": "interpolate", "outlier_method": "isolation_forest"}</step>
<step order="2" type="feature_engineering">{"create_ratios": true, "time_features": ["hour", "day_of_week", "month"], "aggregations": ["mean", "std", "max"]}</step>
<step order="3" type="normalize">{"method": "robust_scaler", "clip_outliers": true}</step>
</parameter>
<parameter name="model_config">{"algorithm": "xgboost", "hyperparameters": {"n_estimators": 500, "max_depth": 8, "learning_rate": 0.01, "subsample": 0.8}, "cross_validation": {"method": "stratified_kfold", "n_splits": 5}}</parameter>
<parameter name="evaluation_metrics">["auc_roc", "precision_recall", "f1", "confusion_matrix"]</parameter>
<parameter name="deployment_target">sagemaker_endpoint</parameter>
<parameter name="monitoring_config">{"drift_detection": true, "performance_threshold": 0.92, "alert_emails": ["ml-team@company.com"]}</parameter>
</parameters>
</call>
</step.tML>"#;
const GPT_OSS_FORMAT: &str = r#"<Channel.vector_search>{"collection": "technical_documentation", "query_embedding": [0.0234, -0.1456, 0.0891, 0.2341, -0.0567, 0.1234, 0.0456, -0.0789, 0.1567, 0.0234, -0.1123, 0.0678, 0.2345, -0.0456, 0.0891, 0.1234, -0.0567, 0.0789, 0.1456, -0.0234, 0.0891, 0.1567, -0.0678, 0.0345, 0.1234, -0.0456, 0.0789, 0.1891, -0.0234, 0.0567, 0.1345, -0.0891], "top_k": 10, "similarity_metric": "cosine", "filters": {"language": "en", "last_updated": {"$gte": "2023-01-01"}, "categories": {"$in": ["api", "sdk", "integration"]}}, "include_metadata": true, "rerank_with_cross_encoder": true}</Channel.vector_search>"#;
// Create test tools for parsers that need them
fn create_test_tools() -> Vec<Tool> {
vec![
Tool {
tool_type: "function".to_string(),
function: Function {
name: "search".to_string(),
description: Some("Search for information".to_string()),
parameters: json!({
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "number"}
}
}),
strict: None,
},
},
Tool {
tool_type: "function".to_string(),
function: Function {
name: "code_interpreter".to_string(),
description: Some("Execute code".to_string()),
parameters: json!({
"type": "object",
"properties": {
"language": {"type": "string"},
"code": {"type": "string"}
}
}),
strict: None,
},
},
]
}
// Large test data for stress testing
fn generate_large_json(num_tools: usize) -> String {
let mut tools = Vec::new();
for i in 0..num_tools {
tools.push(format!(
r#"{{"name": "tool_{}", "arguments": {{"param1": "value{}", "param2": {}, "param3": true}}}}"#,
i, i, i
));
}
format!("[{}]", tools.join(", "))
}
// Global results storage
lazy_static::lazy_static! {
static ref BENCHMARK_RESULTS: Mutex<BTreeMap<String, String>> = Mutex::new(BTreeMap::new());
}
fn add_result(category: &str, result: String) {
let mut results = BENCHMARK_RESULTS.lock().unwrap();
let index = results.len();
results.insert(format!("{:03}_{}", index, category), result);
}
fn bench_registry_creation(c: &mut Criterion) {
let mut group = c.benchmark_group("registry_creation");
let printed = Arc::new(AtomicBool::new(false));
group.bench_function("new_registry", |b| {
let printed_clone = printed.clone();
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
let registry = black_box(ToolParserFactory::new());
// Force evaluation to prevent optimization
black_box(registry.list_parsers());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>12.0} | {:>12.1}µs | {:>15}",
"Registry Creation", ops_per_sec, time_per_op, "N/A"
);
add_result("registry", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
group.finish();
}
fn bench_parser_lookup(c: &mut Criterion) {
let registry = Arc::new(ToolParserFactory::new());
let models = vec![
"gpt-4",
"mistral-large",
"qwen-72b",
"llama-3.2",
"deepseek-v3",
"unknown-model",
];
let mut group = c.benchmark_group("parser_lookup");
for model in models {
let printed = Arc::new(AtomicBool::new(false));
let registry_clone = registry.clone();
group.bench_function(model, |b| {
let printed_clone = printed.clone();
let registry = registry_clone.clone();
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
let parser = black_box(registry.get_parser(model));
// Force evaluation
black_box(parser.is_some());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_nanos() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>12.0} | {:>12.1}ns | {:>15}",
format!("Lookup {}", model),
ops_per_sec,
time_per_op,
if registry.get_parser(model).is_some() {
"Found"
} else {
"Fallback"
}
);
add_result("lookup", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
}
group.finish();
}
fn bench_complete_parsing(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let registry = Arc::new(ToolParserFactory::new());
let test_cases = vec![
("json_simple", "json", JSON_SIMPLE),
("json_array", "json", JSON_ARRAY),
("json_params", "json", JSON_WITH_PARAMS),
("mistral_single", "mistral", MISTRAL_FORMAT),
("mistral_multi", "mistral", MISTRAL_MULTI),
("qwen_single", "qwen", QWEN_FORMAT),
("qwen_multi", "qwen", QWEN_MULTI),
("llama", "llama", LLAMA_FORMAT),
("pythonic_single", "pythonic", PYTHONIC_FORMAT),
("pythonic_multi", "pythonic", PYTHONIC_MULTI),
("deepseek", "deepseek", DEEPSEEK_FORMAT),
("kimik2", "kimik2", KIMIK2_FORMAT),
("glm4", "glm4_moe", GLM4_FORMAT),
("step3", "step3", STEP3_FORMAT),
("gpt_oss", "gpt_oss", GPT_OSS_FORMAT),
];
let mut group = c.benchmark_group("complete_parsing");
for (name, parser_name, input) in test_cases {
let printed = Arc::new(AtomicBool::new(false));
let registry_clone = registry.clone();
let input_len = input.len();
group.throughput(Throughput::Bytes(input_len as u64));
group.bench_function(name, |b| {
let printed_clone = printed.clone();
let registry = registry_clone.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let parser = registry.get_parser(parser_name).expect("Parser not found");
let start = Instant::now();
for _ in 0..iters {
let parser = parser.clone();
let result = rt.block_on(async { parser.parse_complete(input).await });
black_box(result.unwrap());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let bytes_per_sec = (iters as f64 * input_len as f64) / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>10} | {:>12.0} | {:>12.0} | {:>10.1}µs",
name, input_len, ops_per_sec, bytes_per_sec, time_per_op
);
add_result("complete", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
}
group.finish();
}
fn bench_streaming_parsing(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
// Streaming test with chunked input
let chunks = vec![
r#"{"na"#,
r#"me": "sear"#,
r#"ch", "argu"#,
r#"ments": {"qu"#,
r#"ery": "rust prog"#,
r#"ramming", "li"#,
r#"mit": 10, "off"#,
r#"set": 0}"#,
r#"}"#,
];
let mut group = c.benchmark_group("streaming_parsing");
let printed = Arc::new(AtomicBool::new(false));
group.bench_function("json_streaming", |b| {
let printed_clone = printed.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let tools = create_test_tools();
let start = Instant::now();
for _ in 0..iters {
let mut parser = JsonParser::new();
let mut complete_tools = Vec::new();
rt.block_on(async {
for chunk in &chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
if !result.calls.is_empty() {
complete_tools.extend(result.calls);
}
}
});
black_box(complete_tools);
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let chunks_per_sec = (iters as f64 * chunks.len() as f64) / duration.as_secs_f64();
let result = format!(
"{:<25} | {:>10} | {:>12.0} | {:>12.0} | {:>10.1}µs",
"JSON Streaming",
chunks.len(),
ops_per_sec,
chunks_per_sec,
time_per_op
);
add_result("streaming", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
group.finish();
}
fn bench_concurrent_parsing(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let registry = Arc::new(ToolParserFactory::new());
let parser = registry.get_parser("json").expect("Parser not found");
let thread_counts = vec![1, 2, 4, 8, 16, 32];
let operations_per_thread = 100;
let mut group = c.benchmark_group("concurrent_parsing");
group.measurement_time(Duration::from_secs(3));
for num_threads in thread_counts {
let printed = Arc::new(AtomicBool::new(false));
let parser_clone = parser.clone();
group.bench_with_input(
BenchmarkId::from_parameter(num_threads),
&num_threads,
|b, &threads| {
let printed_clone = printed.clone();
let parser = parser_clone.clone();
let rt = rt.handle().clone();
b.iter_custom(|_iters| {
let total_operations = Arc::new(AtomicU64::new(0));
let total_parsed = Arc::new(AtomicU64::new(0));
let start = Instant::now();
let handles: Vec<_> = (0..threads)
.map(|_thread_id| {
let parser = parser.clone();
let total_ops = total_operations.clone();
let total_p = total_parsed.clone();
let rt = rt.clone();
thread::spawn(move || {
let test_inputs = [JSON_SIMPLE, JSON_ARRAY, JSON_WITH_PARAMS];
for i in 0..operations_per_thread {
let input = test_inputs[i % test_inputs.len()];
let result =
rt.block_on(async { parser.parse_complete(input).await });
if let Ok((_normal_text, tools)) = result {
total_p.fetch_add(tools.len() as u64, Ordering::Relaxed);
}
}
total_ops
.fetch_add(operations_per_thread as u64, Ordering::Relaxed);
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let total_ops = total_operations.load(Ordering::Relaxed);
let total_p = total_parsed.load(Ordering::Relaxed);
let ops_per_sec = total_ops as f64 / duration.as_secs_f64();
let tools_per_sec = total_p as f64 / duration.as_secs_f64();
let result = format!(
"{:<25} | {:>10} | {:>12.0} | {:>12.0} | {:>10}",
format!("{}_threads", threads),
total_ops,
ops_per_sec,
tools_per_sec,
threads
);
add_result("concurrent", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
},
);
}
group.finish();
}
fn bench_large_payloads(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let registry = Arc::new(ToolParserFactory::new());
let parser = registry.get_parser("json").expect("Parser not found");
let sizes = vec![1, 10, 50, 100, 500];
let mut group = c.benchmark_group("large_payloads");
for size in sizes {
let large_json = generate_large_json(size);
let input_len = large_json.len();
let printed = Arc::new(AtomicBool::new(false));
let parser_clone = parser.clone();
group.throughput(Throughput::Bytes(input_len as u64));
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &num_tools| {
let printed_clone = printed.clone();
let parser = parser_clone.clone();
let rt = rt.handle().clone();
let input = &large_json;
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
let parser = parser.clone();
let result = rt.block_on(async { parser.parse_complete(input).await });
black_box(result.unwrap());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let bytes_per_sec = (iters as f64 * input_len as f64) / duration.as_secs_f64();
let time_per_op = duration.as_millis() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>10} | {:>10} | {:>12.0} | {:>12.0} | {:>10.1}ms",
format!("{}_tools", num_tools),
num_tools,
input_len,
ops_per_sec,
bytes_per_sec,
time_per_op
);
add_result("large", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
}
group.finish();
}
fn bench_parser_reuse(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let mut group = c.benchmark_group("parser_reuse");
// Benchmark creating new registry each time
let printed_new = Arc::new(AtomicBool::new(false));
group.bench_function("new_registry_each_time", |b| {
let printed_clone = printed_new.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
let registry = ToolParserFactory::new();
let parser = registry.get_parser("json").unwrap();
let result = rt.block_on(async { parser.parse_complete(JSON_SIMPLE).await });
black_box(result.unwrap());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>12.0} | {:>12.1}µs | {:>15}",
"New Registry Each Time", ops_per_sec, time_per_op, "Baseline"
);
add_result("reuse", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
// Benchmark reusing registry
let printed_reuse = Arc::new(AtomicBool::new(false));
let shared_registry = Arc::new(ToolParserFactory::new());
group.bench_function("reuse_registry", |b| {
let printed_clone = printed_reuse.clone();
let registry = shared_registry.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let parser = registry.get_parser("json").unwrap();
let start = Instant::now();
for _ in 0..iters {
let parser = parser.clone();
let result = rt.block_on(async { parser.parse_complete(JSON_SIMPLE).await });
black_box(result.unwrap());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>12.0} | {:>12.1}µs | {:>15}",
"Reuse Registry", ops_per_sec, time_per_op, "Optimized"
);
add_result("reuse", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
// Benchmark reusing parser
let printed_parser = Arc::new(AtomicBool::new(false));
let shared_parser = shared_registry.get_parser("json").unwrap();
group.bench_function("reuse_parser", |b| {
let printed_clone = printed_parser.clone();
let parser = shared_parser.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
let parser = parser.clone();
let result = rt.block_on(async { parser.parse_complete(JSON_SIMPLE).await });
black_box(result.unwrap());
}
let duration = start.elapsed();
if !printed_clone.load(Ordering::Relaxed) {
let ops_per_sec = iters as f64 / duration.as_secs_f64();
let time_per_op = duration.as_micros() as f64 / iters as f64;
let result = format!(
"{:<25} | {:>12.0} | {:>12.1}µs | {:>15}",
"Reuse Parser", ops_per_sec, time_per_op, "Best"
);
add_result("reuse", result);
printed_clone.store(true, Ordering::Relaxed);
}
duration
});
});
group.finish();
}
fn bench_latency_distribution(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let registry = Arc::new(ToolParserFactory::new());
let test_cases = vec![
("json", JSON_SIMPLE),
("mistral", MISTRAL_FORMAT),
("qwen", QWEN_FORMAT),
("pythonic", PYTHONIC_FORMAT),
];
let mut group = c.benchmark_group("latency");
for (parser_name, input) in test_cases {
let printed = Arc::new(AtomicBool::new(false));
let registry_clone = registry.clone();
group.bench_function(parser_name, |b| {
let printed_clone = printed.clone();
let registry = registry_clone.clone();
let rt = rt.handle().clone();
b.iter_custom(|iters| {
let parser = registry.get_parser(parser_name).expect("Parser not found");
let total_duration = if !printed_clone.load(Ordering::Relaxed) {
let mut latencies = Vec::new();
// Warm up
for _ in 0..100 {
let parser = parser.clone();
rt.block_on(async { parser.parse_complete(input).await })
.unwrap();
}
// Measure for statistics
for _ in 0..1000 {
let parser = parser.clone();
let start = Instant::now();
rt.block_on(async { parser.parse_complete(input).await })
.unwrap();
let latency = start.elapsed();
latencies.push(latency);
}
latencies.sort();
let p50 = latencies[latencies.len() / 2];
let p95 = latencies[latencies.len() * 95 / 100];
let p99 = latencies[latencies.len() * 99 / 100];
let max = latencies.last().unwrap();
let result = format!(
"{:<25} | {:>10.1} | {:>10.1} | {:>10.1} | {:>10.1} | {:>10}",
parser_name,
p50.as_micros() as f64,
p95.as_micros() as f64,
p99.as_micros() as f64,
max.as_micros() as f64,
1000
);
add_result("latency", result);
printed_clone.store(true, Ordering::Relaxed);
// Return median for consistency
p50 * iters as u32
} else {
// Regular benchmark iterations
let start = Instant::now();
for _ in 0..iters {
let parser = parser.clone();
rt.block_on(async { parser.parse_complete(input).await })
.unwrap();
}
start.elapsed()
};
total_duration
});
});
}
group.finish();
}
// Print final summary table
fn print_summary() {
println!("\n{}", "=".repeat(120));
println!("TOOL PARSER BENCHMARK SUMMARY");
println!("{}", "=".repeat(120));
let results = BENCHMARK_RESULTS.lock().unwrap();
let mut current_category = String::new();
for (key, value) in results.iter() {
let category = key.split('_').skip(1).collect::<Vec<_>>().join("_");
if category != current_category {
current_category = category.clone();
// Print section header based on category
println!("\n{}", "-".repeat(120));
match category.as_str() {
"registry" => {
println!("REGISTRY OPERATIONS");
println!(
"{:<25} | {:>12} | {:>12} | {:>15}",
"Operation", "Ops/sec", "Time/op", "Notes"
);
}
"lookup" => {
println!("PARSER LOOKUP PERFORMANCE");
println!(
"{:<25} | {:>12} | {:>12} | {:>15}",
"Model", "Lookups/sec", "Time/lookup", "Result"
);
}
"complete" => {
println!("COMPLETE PARSING PERFORMANCE");
println!(
"{:<25} | {:>10} | {:>12} | {:>12} | {:>12}",
"Parser Format", "Size(B)", "Ops/sec", "Bytes/sec", "Time/op"
);
}
"streaming" => {
println!("STREAMING PARSING PERFORMANCE");
println!(
"{:<25} | {:>10} | {:>12} | {:>12} | {:>12}",
"Parser", "Chunks", "Ops/sec", "Chunks/sec", "Time/op"
);
}
"concurrent" => {
println!("CONCURRENT PARSING");
println!(
"{:<25} | {:>10} | {:>12} | {:>12} | {:>10}",
"Configuration", "Total Ops", "Ops/sec", "Tools/sec", "Threads"
);
}
"large" => {
println!("LARGE PAYLOAD PARSING");
println!(
"{:<25} | {:>10} | {:>10} | {:>12} | {:>12} | {:>12}",
"Payload", "Tools", "Size(B)", "Ops/sec", "Bytes/sec", "Time/op"
);
}
"reuse" => {
println!("PARSER REUSE COMPARISON");
println!(
"{:<25} | {:>12} | {:>12} | {:>15}",
"Strategy", "Ops/sec", "Time/op", "Performance"
);
}
"latency" => {
println!("LATENCY DISTRIBUTION");
println!(
"{:<25} | {:>10} | {:>10} | {:>10} | {:>10} | {:>10}",
"Parser", "P50(µs)", "P95(µs)", "P99(µs)", "Max(µs)", "Samples"
);
}
_ => {}
}
println!("{}", "-".repeat(120));
}
println!("{}", value);
}
println!("\n{}", "=".repeat(120));
// Print performance analysis
println!("\nPERFORMANCE ANALYSIS:");
println!("{}", "-".repeat(120));
// Calculate and display key metrics
if let Some(new_registry) = results.get("007_reuse") {
if let Some(reuse_parser) = results.get("009_reuse") {
// Extract ops/sec values
let new_ops: f64 = new_registry
.split('|')
.nth(1)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
let reuse_ops: f64 = reuse_parser
.split('|')
.nth(1)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
if new_ops > 0.0 && reuse_ops > 0.0 {
let improvement = (reuse_ops / new_ops - 1.0) * 100.0;
println!("Parser Reuse Improvement: {:.1}% faster", improvement);
if improvement < 100.0 {
println!("⚠️ WARNING: Parser reuse improvement is lower than expected!");
println!(" Expected: >100% improvement with singleton pattern");
println!(" Actual: {:.1}% improvement", improvement);
println!(" Recommendation: Implement global singleton registry");
}
}
}
}
println!("{}", "=".repeat(120));
}
fn run_benchmarks(c: &mut Criterion) {
bench_registry_creation(c);
bench_parser_lookup(c);
bench_complete_parsing(c);
bench_streaming_parsing(c);
bench_concurrent_parsing(c);
bench_large_payloads(c);
bench_parser_reuse(c);
bench_latency_distribution(c);
// Print summary at the end
print_summary();
}
criterion_group!(benches, run_benchmarks);
criterion::criterion_main!(benches);
@@ -0,0 +1,24 @@
# Build artifacts
target/
lib/
# Compiled binaries
examples/simple/simple
examples/streaming/streaming
# Go build artifacts
*.o
*.a
*.so
*.dylib
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Environment files
.env
.env.local
@@ -0,0 +1,47 @@
[package]
name = "sgl-model-gateway-golang"
version = "0.2.3"
edition = "2021"
[lib]
name = "sgl_model_gateway_go"
crate-type = ["cdylib"]
[dependencies]
tokio = { version = "1.42.0", features = ["full"] }
serde_json = { version = "1.0", default-features = false, features = [
"std",
"preserve_order",
] }
uuid = { version = "1.10", features = ["v4", "serde"] }
once_cell = "1.21.3"
futures-util = "0.3"
tracing = "0.1"
[dependencies.sgl-model-gateway]
path = "../.."
default-features = true
[features]
default = []
vendored-openssl = ["sgl-model-gateway/vendored-openssl"]
[profile.release]
opt-level = "z" # Optimize for size
lto = "fat" # Full LTO for smaller binaries
codegen-units = 1 # Better optimization, slower compile
strip = true # Strip debug symbols
[profile.ci]
inherits = "release"
opt-level = 2 # Lighter optimization (still fast runtime, much faster compile)
lto = "thin" # Thin LTO - good balance
codegen-units = 16 # More parallelization for faster builds
strip = true
[profile.dev]
opt-level = 0
debug = 1
split-debuginfo = "unpacked"
incremental = true
codegen-units = 256
+103
View File
@@ -0,0 +1,103 @@
# Makefile for sglang-router golang bindings
# This builds the Rust FFI library and provides convenience targets for Go development
# Configuration
CARGO_BUILD_DIR ?= $(shell pwd)/target
BUILD_MODE ?= release
LIB_NAME = libsglang_router_rs
# Detect OS
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
LIB_EXT = .so
LD_LIBRARY_PATH_VAR = LD_LIBRARY_PATH
endif
ifeq ($(UNAME_S),Darwin)
LIB_EXT = .dylib
LD_LIBRARY_PATH_VAR = DYLD_LIBRARY_PATH
endif
# Paths
ROOT_DIR := $(shell pwd)
RUST_SRC_DIR := $(ROOT_DIR)/src
LIB_BUILD_DIR := $(CARGO_BUILD_DIR)/$(BUILD_MODE)
LIB_BUILD_PATH := $(LIB_BUILD_DIR)/$(LIB_NAME)$(LIB_EXT)
LIB_EXPORT_DIR := $(ROOT_DIR)/lib
LIB_EXPORT_PATH := $(LIB_EXPORT_DIR)/$(LIB_NAME)$(LIB_EXT)
# Python LDFLAGS (needed for Rust FFI that depends on Python)
PYTHON_LDFLAGS := $(shell python3-config --ldflags --embed 2>/dev/null || python3-config --ldflags 2>/dev/null || echo "")
# CGO flags - use exported lib directory if available, otherwise build directory
LIB_DIR := $(if $(wildcard $(LIB_EXPORT_PATH)),$(LIB_EXPORT_DIR),$(LIB_BUILD_DIR))
export CGO_LDFLAGS = -L$(LIB_DIR) -lsglang_router_rs $(PYTHON_LDFLAGS) -ldl
export $(LD_LIBRARY_PATH_VAR) := $(LIB_DIR):$($(LD_LIBRARY_PATH_VAR))
.PHONY: all build build-dev lib lib-clean clean test examples help run-simple run-streaming check-lib
help:
@echo "Available targets:"
@echo " build - Build release version of Rust FFI library"
@echo " build-dev - Build debug version of Rust FFI library"
@echo " lib - Copy built library to ./lib directory"
@echo " lib-clean - Clean ./lib directory"
@echo " clean - Clean build artifacts"
@echo " test - Run Go tests"
@echo " examples - Build example programs"
@echo " run-simple - Run simple example"
@echo " run-streaming - Run streaming example"
all: build
build:
@echo "Building Rust FFI library (release mode)..."
@CARGO_TARGET_DIR=$(CARGO_BUILD_DIR) cargo build --release --manifest-path Cargo.toml
@echo "Library built at: $(LIB_BUILD_PATH)"
build-dev:
@echo "Building Rust FFI library (debug mode)..."
@CARGO_TARGET_DIR=$(CARGO_BUILD_DIR) cargo build --manifest-path Cargo.toml
@echo "Library built at: $(LIB_BUILD_DIR)/debug/$(LIB_NAME)$(LIB_EXT)"
lib: build
@echo "Copying library to ./lib directory..."
@mkdir -p $(LIB_EXPORT_DIR)
@cp $(LIB_BUILD_PATH) $(LIB_EXPORT_PATH)
@echo "Library exported at: $(LIB_EXPORT_PATH)"
lib-clean:
@echo "Cleaning ./lib directory..."
@rm -rf $(LIB_EXPORT_DIR)
@echo "Lib directory cleaned"
clean: lib-clean
@echo "Cleaning build artifacts..."
@CARGO_TARGET_DIR=$(CARGO_BUILD_DIR) cargo clean --manifest-path Cargo.toml
@echo "Clean complete"
test: build
@echo "Running Go tests..."
@go test ./...
examples: build
@echo "Building example programs..."
@cd examples/simple && go build -o simple main.go
@cd examples/streaming && go build -o streaming main.go
@echo "Examples built"
run-simple: build
@echo "Running simple example..."
@cd examples/simple && bash run.sh
run-streaming: build
@echo "Running streaming example..."
@cd examples/streaming && bash run.sh
# Check if library exists (either in lib dir or build dir)
check-lib:
@if [ ! -f "$(LIB_EXPORT_PATH)" ] && [ ! -f "$(LIB_BUILD_PATH)" ]; then \
echo "Error: Library not found at $(LIB_EXPORT_PATH) or $(LIB_BUILD_PATH)"; \
echo "Run 'make build' or 'make lib' first"; \
exit 1; \
fi
@echo "Library found at: $(LIB_DIR)/$(LIB_NAME)$(LIB_EXT)"
+552
View File
@@ -0,0 +1,552 @@
# SGLang Go gRPC SDK
A high-level Go SDK for interacting with SGLang gRPC API, designed with an OpenAI-style API for familiarity and ease of use.
**Location**: `sgl-model-gateway/bindings/golang/`
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Basic Usage](#basic-usage)
- [Streaming Usage](#streaming-usage)
- [Examples](#examples)
- [Configuration](#configuration)
- [API Reference](#api-reference)
- [Testing](#testing)
- [Unit Tests](#unit-tests)
- [Integration Tests](#integration-tests)
- [Benchmarks](#benchmarks)
- [Documentation](#documentation)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [License](#license)
## Features
- **OpenAI-style API**: Familiar interface similar to OpenAI Go SDK
- **Streaming Support**: Real-time streaming chat completions
- **Non-streaming Support**: Simple request/response API
- **Tool Calling**: Support for function calling and tool use
- **Type-safe**: Full Go type definitions for requests and responses
- **Comprehensive Testing**: 18+ unit and integration tests
- **Thread-safe**: All public methods are safe for concurrent use
- **Well-documented**: Full API documentation with examples
## Installation
```bash
go get github.com/sglang/sglang-go-grpc-sdk
```
### Build Requirements
- Go 1.21 or later
- Rust toolchain (for building the FFI library)
- Python 3.x (for Python bindings in Rust FFI)
- Tokio runtime for async operations
## Quick Start
### Basic Usage (Non-streaming)
```go
package main
import (
"context"
"fmt"
"log"
"github.com/sglang/sglang-go-grpc-sdk"
)
func main() {
// Create client
client, err := sglang.NewClient(sglang.ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Create completion
resp, err := client.CreateChatCompletion(context.Background(), sglang.ChatCompletionRequest{
Model: "default",
Messages: []sglang.ChatMessage{
{Role: "user", Content: "Hello!"},
},
Stream: false,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Choices[0].Message.Content)
fmt.Printf("Usage: Prompt=%d, Completion=%d, Total=%d\n",
resp.Usage.PromptTokens,
resp.Usage.CompletionTokens,
resp.Usage.TotalTokens)
}
```
### Streaming Usage
```go
package main
import (
"context"
"fmt"
"io"
"log"
"github.com/sglang/sglang-go-grpc-sdk"
)
func main() {
// Create client
client, err := sglang.NewClient(sglang.ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Create streaming completion
ctx := context.Background()
stream, err := client.CreateChatCompletionStream(ctx, sglang.ChatCompletionRequest{
Model: "default",
Messages: []sglang.ChatMessage{
{Role: "user", Content: "Tell me a story"},
},
Stream: true,
MaxCompletionTokens: intPtr(500),
})
if err != nil {
log.Fatal(err)
}
defer stream.Close()
// Read streaming response
for {
chunk, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
fmt.Print(choice.Delta.Content)
}
}
}
fmt.Println() // newline
}
// Helper functions for optional pointer fields
func intPtr(i int) *int {
return &i
}
func float32Ptr(f float32) *float32 {
return &f
}
```
## Examples
The SDK includes several examples in the `examples/` directory:
- **simple**: Basic non-streaming chat completion example
- **streaming**: Real-time streaming with performance metrics
### Running Examples
```bash
# Run simple example
cd bindings/golang/examples/simple
bash run.sh
# Run streaming example
cd bindings/golang/examples/streaming
bash run.sh
# Or use Makefile from bindings/golang directory
cd bindings/golang
make run-simple
make run-streaming
```
Examples automatically detect the server endpoint and tokenizer path via environment variables or defaults.
## Configuration
### Environment Variables
- `SGL_GRPC_ENDPOINT`: gRPC server endpoint (default: `grpc://localhost:20000`)
- `SGL_TOKENIZER_PATH`: Path to tokenizer directory (required)
- `CARGO_BUILD_DIR`: Rust build output directory (auto-detected if not set)
### ClientConfig
```go
type ClientConfig struct {
// Endpoint is the gRPC endpoint URL (e.g., "grpc://localhost:20000")
// Required field. Must include the scheme (grpc://) and port number.
Endpoint string
// TokenizerPath is the path to the tokenizer directory containing
// tokenizer configuration files (e.g., tokenizer.json, vocab.json)
// Required field.
TokenizerPath string
}
```
## API Reference
### Client Methods
```go
type Client struct {
// Thread-safe client for SGLang gRPC API
}
// Creates a new client with the given configuration
func NewClient(config ClientConfig) (*Client, error)
// Closes the client and releases all resources
func (c *Client) Close() error
// Creates a non-streaming chat completion
func (c *Client) CreateChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error)
// Creates a streaming chat completion
func (c *Client) CreateChatCompletionStream(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionStream, error)
```
### Request Types
- `ChatCompletionRequest`: Main request type for chat completions
- Model, Messages, Stream, Temperature, TopP, MaxCompletionTokens, Tools, etc.
- `ChatMessage`: Individual message in a conversation
- Role, Content
- `Tool`: Tool/function definition for function calling
- Type, Function (name, description, parameters)
### Response Types
- `ChatCompletionResponse`: Non-streaming response
- ID, Model, Created, Choices, Usage
- `ChatCompletionStreamResponse`: Streaming response chunk
- Same structure as above but for incremental updates
- `Message`: Complete message with content and tool calls
- `ToolCall`: Tool call information with function and arguments
- `Usage`: Token usage statistics
- PromptTokens, CompletionTokens, TotalTokens
## Testing
The SDK includes comprehensive testing infrastructure with both unit and integration tests.
### Unit Tests
Unit tests are located in `client_test.go` and test individual components without requiring a server.
#### Running Unit Tests
```bash
# Run all unit tests
go test ./...
# Run with verbose output
go test -v ./...
# Run specific test
go test -run TestClientConfig
# Run tests with race detector (detects concurrency issues)
go test -race ./...
# Run with coverage analysis
go test -cover ./...
# Generate detailed coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
```
#### Unit Test Coverage
- **Configuration validation** (`TestClientConfig`) - Validates ClientConfig requirements
- **Type structures** - Verifying all struct types work correctly
- **Response handling** - Testing response parsing and validation
- **Concurrent operations** (`TestConcurrentClientOperations`) - Thread-safety verification
- **Benchmarks** (`BenchmarkChatCompletionRequest`) - Performance measurement
**Test Files**:
- `client_test.go` - 10 unit tests covering core functionality
- Tests cover: config validation, message types, request validation, close operations, response types, streaming, tools, concurrency, and context cancellation
### Integration Tests
Integration tests require a running SGLang server and test the full client-server interaction.
#### Prerequisites
1. Start an SGLang server:
```bash
# Using Python (requires sglang package installed)
python -m sglang.launch_server --model-path meta-llama/Llama-2-7b-hf
# Or using pre-built Docker image
docker run -p 20000:20000 lmsys/sglang:latest
# Or build your own
sglang launch_server --model-path <model_path>
```
2. Set required environment variables:
```bash
# Set the gRPC endpoint (default: grpc://localhost:20000)
export SGL_GRPC_ENDPOINT=grpc://localhost:20000
# Set the tokenizer path (required)
export SGL_TOKENIZER_PATH=/path/to/tokenizer
```
#### Running Integration Tests
```bash
# Run all integration tests
go test -tags=integration ./...
# Run specific integration test
go test -tags=integration -run TestIntegrationNonStreamingCompletion
# Run with verbose output
go test -tags=integration -v ./...
# Run with race detector
go test -tags=integration -race ./...
```
#### Integration Test Coverage
**Test File**: `integration_test.go` - 4 integration tests
- `TestIntegrationNonStreamingCompletion` - Basic non-streaming request/response
- `TestIntegrationStreamingCompletion` - Streaming response handling
- `TestIntegrationConcurrentRequests` - Multiple simultaneous requests
- `TestIntegrationContextCancellation` - Context timeout and cancellation
### Benchmarks
Measure performance of SDK operations:
```bash
# Run all benchmarks
go test -bench=. -benchmem ./...
# Run specific benchmark
go test -bench=BenchmarkChatCompletionRequest -benchmem
# Run for longer duration
go test -bench=. -benchtime=10s ./...
```
Current benchmarks:
- `BenchmarkChatCompletionRequest` - Measures request creation performance
### CI/CD Integration
Add to your GitHub Actions workflow:
```yaml
- name: Run Go tests
run: |
go test -race -cover ./...
- name: Run integration tests (on main branch)
if: github.ref == 'refs/heads/main'
env:
SGL_GRPC_ENDPOINT: grpc://localhost:20000
SGL_TOKENIZER_PATH: /path/to/tokenizer
run: go test -tags=integration ./...
```
## Documentation
### Code Documentation
All public types and functions include comprehensive documentation:
1. **Package-level documentation** in `client.go` with usage examples
2. **Type documentation** for all structs with field descriptions
3. **Function documentation** with:
- Purpose and behavior description
- Parameter documentation with types and constraints
- Return value documentation
- Error cases and handling
- Safety notes (for FFI functions)
- Usage examples
### Key Documented Components
- `Client` - Main client with thread-safety notes
- `ClientConfig` - Configuration requirements and validation rules
- `ChatCompletionRequest` - Request structure with field descriptions
- `ChatCompletionResponse` - Response structure and usage
- `ChatCompletionStreamResponse` - Streaming response format
- `Usage` - Token usage information structure
- `Tool`, `Function`, `ToolCall` - Tool call structures
### Viewing Documentation
Generate and view HTML documentation:
```bash
# Install godoc (if not already installed)
go install golang.org/x/tools/cmd/godoc@latest
# Generate and serve documentation
godoc -http=:6060
# Visit: http://localhost:6060/pkg/github.com/sglang/sglang-go-grpc-sdk/
```
## Development
### Building
```bash
cd bindings/golang
# Build the Go bindings (compiles Rust FFI library)
make build
# Clean build
make clean && make build
```
### Code Quality
Ensure code quality before committing:
```bash
# Run Go vet (check for potential bugs)
go vet ./...
# Format code
go fmt ./...
# Run all tests with race detection
go test -race ./...
```
### Project Structure
```
bindings/golang/
├── client.go # Main client implementation
├── client_test.go # Unit tests
├── integration_test.go # Integration tests
├── README.md # This file
├── Makefile # Build automation
├── Cargo.toml # Rust FFI dependencies
├── examples/ # Example programs
│ ├── simple/ # Non-streaming example
│ └── streaming/ # Streaming example
├── src/ # Rust FFI source
│ ├── client.rs # Client FFI
│ ├── stream.rs # Stream handling
│ ├── grpc_converter.rs # Response conversion
│ └── ...
└── internal/ # Internal packages
└── ffi/ # FFI bindings
```
## Troubleshooting
### Connection Errors
**Error**: `connection refused` or `failed to dial`
**Solution**:
1. Ensure SGLang server is running: `python -m sglang.launch_server`
2. Check endpoint: `echo $SGL_GRPC_ENDPOINT`
3. Verify port is not blocked: `nc -zv localhost 20000`
### Tokenizer Not Found
**Error**: `tokenizer path not found` or `tokenizer configuration missing`
**Solution**:
1. Set `SGL_TOKENIZER_PATH` environment variable
2. Verify path contains required files: `ls $SGL_TOKENIZER_PATH`
3. Files should include: `tokenizer.json`, `vocab.json`, `config.json`
### Build Failures
**Error**: `library 'sglang_router_rs' not found`
**Solution**:
1. Rebuild Rust library: `cd sgl-model-gateway/bindings/golang && make build`
2. Or manually with cargo: `cd sgl-model-gateway/bindings/golang && cargo build --release`
3. Set `CARGO_BUILD_DIR` if using non-standard build location
4. Ensure Rust toolchain is installed: `rustup toolchain list`
### Tests Hanging
**Error**: Tests seem to hang indefinitely
**Solution**:
1. Use timeout for hanging tests: `timeout 30s go test ./...`
2. Run with verbose output to see which test hangs: `go test -v ./...`
3. Ensure server is responsive: `grpcurl -plaintext localhost:20000 list`
### Memory Issues
**Error**: Out of memory during tests
**Solution**:
```bash
# Run with memory limit for long-running tests
GODEBUG=madvdontneed=1 go test -timeout 5m ./...
# Monitor memory during tests
watch -n1 'ps aux | grep test'
```
## Contributing
When adding new features:
1. Add comprehensive documentation to public types/functions
2. Include usage examples for complex APIs
3. Add unit tests covering happy path and error cases
4. Add integration tests if server interaction required
5. Ensure code passes `go vet` and `go test -race`
6. Update this README if adding new features
## License
See LICENSE file for details.
---
**Need Help?**
- Check examples in `examples/` directory
- Run tests to see working code: `go test -v ./...`
- Review function documentation: `godoc` or inline comments
- Check troubleshooting section above
+510
View File
@@ -0,0 +1,510 @@
// Package sglang provides a Go SDK for SGLang gRPC API.
//
// SGLang is a fast language model serving framework. This package provides a Go client
// library for interacting with SGLang's gRPC API, following the style of OpenAI's Go SDK.
//
// Basic usage:
//
// client, err := sglang.NewClient(sglang.ClientConfig{
// Endpoint: "grpc://localhost:20000",
// TokenizerPath: "/path/to/tokenizer",
// })
// if err != nil {
// log.Fatal(err)
// }
// defer client.Close()
//
// resp, err := client.CreateChatCompletion(ctx, sglang.ChatCompletionRequest{
// Model: "default",
// Messages: []sglang.ChatMessage{
// {Role: "user", Content: "Hello"},
// },
// })
//
// For streaming responses, use CreateChatCompletionStream instead.
package sglang
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/sglang/sglang-go-grpc-sdk/internal/ffi"
)
// Client is the main client for interacting with SGLang gRPC API.
// It manages the connection to the SGLang server and handles both streaming
// and non-streaming chat completions.
//
// Thread-safe: All public methods are safe for concurrent use.
type Client struct {
endpoint string
tokenizerPath string
clientHandle *ffi.SglangClientHandle
mu sync.RWMutex
}
// ClientConfig holds configuration for creating a new client.
type ClientConfig struct {
// Endpoint is the gRPC endpoint URL (e.g., "grpc://localhost:20000").
// Required field. Must include the scheme (grpc://) and port number.
Endpoint string
// TokenizerPath is the path to the tokenizer directory containing
// tokenizer configuration files (e.g., tokenizer.json, vocab.json).
// Required field.
TokenizerPath string
}
// NewClient creates a new SGLang client with the given configuration.
//
// The client maintains a long-lived connection to the SGLang server and should
// be reused for multiple requests. Call Close() to release resources.
//
// Returns an error if:
// - Endpoint is empty
// - TokenizerPath is empty
// - Connection to the server fails
func NewClient(config ClientConfig) (*Client, error) {
if config.Endpoint == "" {
return nil, errors.New("endpoint is required")
}
if config.TokenizerPath == "" {
return nil, errors.New("tokenizer path is required")
}
clientHandle, err := ffi.NewClient(config.Endpoint, config.TokenizerPath)
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}
return &Client{
endpoint: config.Endpoint,
tokenizerPath: config.TokenizerPath,
clientHandle: clientHandle,
}, nil
}
// Close closes the client and releases all resources.
//
// After Close() is called, the client cannot be used for further requests.
// Calling Close() multiple times is safe and idempotent.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.clientHandle != nil {
c.clientHandle.Free()
c.clientHandle = nil
}
return nil
}
// ChatCompletionRequest represents a request for chat completion.
// It follows the OpenAI API style for familiar usage.
type ChatCompletionRequest struct {
// Model specifies the model to use for completion (e.g., "default")
Model string `json:"model"`
// Messages is the list of messages in the conversation
Messages []ChatMessage `json:"messages"`
Temperature *float32 `json:"temperature,omitempty"`
TopP *float32 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Stream bool `json:"stream"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Stop interface{} `json:"stop,omitempty"`
StopTokenIDs []int `json:"stop_token_ids,omitempty"`
SkipSpecialTokens bool `json:"skip_special_tokens,omitempty"`
FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"`
PresencePenalty *float32 `json:"presence_penalty,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
Seed *int `json:"seed,omitempty"`
Logprobs bool `json:"logprobs,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
User string `json:"user,omitempty"`
}
// ChatMessage represents a single message in a chat conversation
type ChatMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
Name string `json:"name,omitempty"`
}
// Tool represents a tool/function that can be called
type Tool struct {
Type string `json:"type"`
Function Function `json:"function"`
}
// Function represents a function definition
type Function struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]interface{} `json:"parameters"`
}
// ResponseFormat represents the response format
type ResponseFormat struct {
Type string `json:"type"`
}
// ChatCompletionResponse represents a non-streaming chat completion response
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
// Choice represents a choice in the completion response
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Message represents a message in the response
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ToolCall represents a tool call in the response
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
// FunctionCall represents a function call
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// Usage represents token usage information
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatCompletionStreamResponse represents a streaming chat completion response
type ChatCompletionStreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
Choices []StreamChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// StreamChoice represents a choice in a streaming response
type StreamChoice struct {
Index int `json:"index"`
Delta MessageDelta `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
// MessageDelta represents incremental message updates
type MessageDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// CreateChatCompletion creates a non-streaming chat completion with context support.
//
// Context Support:
// The ctx parameter is fully supported for cancellation and timeouts:
// - If ctx is cancelled, the request will be interrupted on the next stream.Recv() call
// - If ctx times out, the request will return context.DeadlineExceeded
//
// Example with timeout:
//
// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// defer cancel()
// resp, err := client.CreateChatCompletion(ctx, req)
//
// Note: Internally, this creates a stream and collects all chunks,
// so context monitoring happens at the chunk level.
func (c *Client) CreateChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
// For non-streaming, we'll collect all chunks and return the final response
req.Stream = true // We still use streaming internally, but collect all chunks
// Prepare request: if Tools is empty, set to nil for proper JSON serialization
if len(req.Tools) == 0 {
req.Tools = nil
}
stream, err := c.CreateChatCompletionStream(ctx, req)
if err != nil {
return nil, err
}
defer stream.Close()
var fullContent strings.Builder
var fullToolCalls []ToolCall
var finishReason string
var usage Usage
var responseID string
var created int64
var model string
var systemFingerprint string
for {
chunk, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if chunk.ID != "" {
responseID = chunk.ID
}
if chunk.Created > 0 {
created = chunk.Created
}
if chunk.Model != "" {
model = chunk.Model
}
if chunk.SystemFingerprint != "" {
systemFingerprint = chunk.SystemFingerprint
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
fullContent.WriteString(choice.Delta.Content)
}
if len(choice.Delta.ToolCalls) > 0 {
fullToolCalls = append(fullToolCalls, choice.Delta.ToolCalls...)
}
// Always update finish_reason if present (even if empty string, but should not be empty)
// The last chunk (Complete message) should have finish_reason set
if choice.FinishReason != "" {
finishReason = choice.FinishReason
}
}
// Extract usage from chunk if available (usually in the last chunk)
// Always update usage if present, as the last chunk should have the final usage
if chunk.Usage != nil {
usage = *chunk.Usage
}
}
// Build final response
message := Message{
Role: "assistant",
Content: fullContent.String(),
}
if len(fullToolCalls) > 0 {
message.ToolCalls = fullToolCalls
}
// Ensure finish_reason is set (defensive check)
// If finish_reason is still empty, default to "stop"
if finishReason == "" {
finishReason = "stop"
}
return &ChatCompletionResponse{
ID: responseID,
Object: "chat.completion",
Created: created,
Model: model,
SystemFingerprint: systemFingerprint,
Choices: []Choice{
{
Index: 0,
Message: message,
FinishReason: finishReason,
},
},
Usage: usage,
}, nil
}
// ChatCompletionStream represents a streaming chat completion
type ChatCompletionStream struct {
stream *ffi.SglangStreamHandle
mu sync.Mutex
done bool // Track if stream has been marked as done
ctx context.Context // Context for cancellation support
cancel context.CancelFunc // Cancel function to stop monitoring goroutine
closed chan struct{} // Signal when stream is closed
}
// Recv receives the next chunk from the stream.
//
// Supports context cancellation: if the context passed to CreateChatCompletionStream
// is cancelled, Recv will return context.Canceled error on the next call.
func (s *ChatCompletionStream) Recv() (*ChatCompletionStreamResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if context was cancelled
select {
case <-s.ctx.Done():
return nil, s.ctx.Err() // Returns context.Canceled or context.DeadlineExceeded
default:
}
if s.stream == nil {
return nil, io.EOF
}
// If stream was already marked as done, immediately return EOF
// This prevents calling ReadNext() again after isDone=1
if s.done {
return nil, io.EOF
}
// Loop to handle empty responses (Ok(None) from Rust)
// Keep reading until we get actual data or stream ends
for {
responseJSON, isDone, err := s.stream.ReadNext()
if err != nil {
return nil, err
}
// Mark stream as done if ReadNext indicates completion
if isDone {
s.done = true
}
// If we have a response, parse and return it
if responseJSON != "" {
var response ChatCompletionStreamResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &response, nil
}
// If stream is done but no response, return EOF
if isDone {
return nil, io.EOF
}
// Empty response and stream not done - loop to read next chunk
// This handles Ok(None) cases where Rust returns no data but stream continues
}
}
// Close closes the stream and cancels any pending operations.
func (s *ChatCompletionStream) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
// Cancel the context to signal the monitoring goroutine to stop
if s.cancel != nil {
s.cancel()
}
// Signal that stream is closed
select {
case <-s.closed:
// Already closed
default:
close(s.closed)
}
// Free the stream to mark it as completed
// This prevents AbortOnDropStream from sending abort when dropped
if s.stream != nil {
s.stream.Free()
s.stream = nil
}
return nil
}
// CreateChatCompletionStream creates a streaming chat completion with context cancellation support.
//
// Context Support:
// The ctx parameter is now fully supported for cancellation and timeouts:
// - If ctx is cancelled, stream.Recv() will return context.Canceled on the next call
// - If ctx times out (WithTimeout), stream.Recv() will return context.DeadlineExceeded
// - Calling stream.Close() also cancels the context
//
// Example with timeout:
//
// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// defer cancel()
// stream, err := client.CreateChatCompletionStream(ctx, req)
// // Stream will auto-close if 30 seconds elapse
//
// Example with cancellation:
//
// ctx, cancel := context.WithCancel(context.Background())
// stream, err := client.CreateChatCompletionStream(ctx, req)
// go func() {
// time.Sleep(5*time.Second)
// cancel() // Cancel after 5 seconds
// }()
func (c *Client) CreateChatCompletionStream(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionStream, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.clientHandle == nil {
return nil, errors.New("client is closed")
}
// Marshal request to JSON, then ensure tools field is always present.
// Due to omitempty tag, empty Tools slice will be omitted from JSON.
// We need to ensure tools field is always present as [] when empty (not omitted),
// matching the behavior of complete_sdk example.
reqJSON, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Unmarshal into map and ensure tools field is present
var reqMap map[string]interface{}
if err := json.Unmarshal(reqJSON, &reqMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal request to map: %w", err)
}
// Add empty tools array if not present
if _, exists := reqMap["tools"]; !exists {
reqMap["tools"] = []interface{}{}
}
// Marshal back to JSON
reqJSON, err = json.Marshal(reqMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal request map to JSON: %w", err)
}
// Create stream
streamHandle, err := c.clientHandle.ChatCompletionStream(string(reqJSON))
if err != nil {
return nil, fmt.Errorf("failed to create stream: %w", err)
}
// Create a child context from the provided context for cancellation support
streamCtx, cancel := context.WithCancel(ctx)
stream := &ChatCompletionStream{
stream: streamHandle,
ctx: streamCtx,
cancel: cancel,
closed: make(chan struct{}),
}
return stream, nil
}
@@ -0,0 +1,325 @@
package sglang
import (
"context"
"testing"
)
// TestClientConfig tests ClientConfig validation
func TestClientConfig(t *testing.T) {
tests := []struct {
name string
config ClientConfig
wantErr bool
}{
{
name: "valid config",
config: ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
},
wantErr: false,
},
{
name: "missing endpoint",
config: ClientConfig{
Endpoint: "",
TokenizerPath: "/path/to/tokenizer",
},
wantErr: true,
},
{
name: "missing tokenizer path",
config: ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "",
},
wantErr: true,
},
{
name: "both missing",
config: ClientConfig{
Endpoint: "",
TokenizerPath: "",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewClient(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// TestChatMessageTypes tests ChatMessage struct and its variants
func TestChatMessageTypes(t *testing.T) {
msg := ChatMessage{
Role: "user",
Content: "Hello",
}
if msg.Role != "user" {
t.Errorf("Expected role 'user', got '%s'", msg.Role)
}
if msg.Content != "Hello" {
t.Errorf("Expected content 'Hello', got '%s'", msg.Content)
}
}
// TestChatCompletionRequestValidation tests ChatCompletionRequest validation
func TestChatCompletionRequestValidation(t *testing.T) {
// Test valid request
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "test"},
},
Stream: false,
}
if req.Model == "" {
t.Error("Expected model to be set")
}
if len(req.Messages) == 0 {
t.Error("Expected messages to be non-empty")
}
if req.Messages[0].Role != "user" {
t.Errorf("Expected first message role 'user', got '%s'", req.Messages[0].Role)
}
}
// TestClientClose tests that Close can be called multiple times safely
func TestClientClose(t *testing.T) {
// Create a mock client (note: in real tests, you might want to skip this
// if it requires actual server connection)
config := ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
}
// Skip if connection fails (expected in unit test environment)
client, err := NewClient(config)
if err != nil {
t.Skip("Skipping client close test: server not available")
}
// First close should succeed
if err := client.Close(); err != nil {
t.Errorf("First Close() failed: %v", err)
}
// Second close should also succeed (idempotent)
if err := client.Close(); err != nil {
t.Errorf("Second Close() failed: %v", err)
}
}
// TestChatCompletionResponseTypes tests response type structures
func TestChatCompletionResponseTypes(t *testing.T) {
resp := ChatCompletionResponse{
ID: "test-id",
Model: "default",
Created: 1234567890,
Choices: []Choice{
{
Message: Message{
Role: "assistant",
Content: "Hello",
},
FinishReason: "stop",
},
},
Usage: Usage{
PromptTokens: 10,
CompletionTokens: 20,
TotalTokens: 30,
},
}
if resp.ID != "test-id" {
t.Errorf("Expected ID 'test-id', got '%s'", resp.ID)
}
if len(resp.Choices) != 1 {
t.Errorf("Expected 1 choice, got %d", len(resp.Choices))
}
if resp.Choices[0].Message.Content != "Hello" {
t.Errorf("Expected content 'Hello', got '%s'", resp.Choices[0].Message.Content)
}
if resp.Usage.TotalTokens != 30 {
t.Errorf("Expected total tokens 30, got %d", resp.Usage.TotalTokens)
}
}
// TestStreamingResponseTypes tests streaming response structures
func TestStreamingResponseTypes(t *testing.T) {
chunk := ChatCompletionStreamResponse{
ID: "stream-id",
Created: 1234567890,
Choices: []StreamChoice{
{
Index: 0,
Delta: MessageDelta{
Content: "Hello",
},
FinishReason: "",
},
},
}
if chunk.ID != "stream-id" {
t.Errorf("Expected ID 'stream-id', got '%s'", chunk.ID)
}
if len(chunk.Choices) == 0 {
t.Error("Expected at least one choice")
}
if chunk.Choices[0].Delta.Content != "Hello" {
t.Errorf("Expected delta content 'Hello', got '%s'", chunk.Choices[0].Delta.Content)
}
}
// TestToolCallStructure tests Tool and ToolCall structures
func TestToolCallStructure(t *testing.T) {
tool := Tool{
Type: "function",
Function: Function{
Name: "get_weather",
Description: "Get the weather",
Parameters: map[string]interface{}{
"location": "string",
},
},
}
if tool.Type != "function" {
t.Errorf("Expected tool type 'function', got '%s'", tool.Type)
}
if tool.Function.Name != "get_weather" {
t.Errorf("Expected function name 'get_weather', got '%s'", tool.Function.Name)
}
toolCall := ToolCall{
ID: "call-123",
Type: "function",
Function: FunctionCall{
Name: "get_weather",
Arguments: `{"location": "San Francisco"}`,
},
}
if toolCall.ID != "call-123" {
t.Errorf("Expected tool call ID 'call-123', got '%s'", toolCall.ID)
}
}
// TestConcurrentClientOperations tests thread safety
// This is a basic test that just verifies concurrent calls don't panic
func TestConcurrentClientOperations(t *testing.T) {
config := ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
}
client, err := NewClient(config)
if err != nil {
t.Skip("Skipping concurrent operations test: server not available")
}
defer client.Close()
// Try concurrent Close calls (should not panic or race)
done := make(chan bool, 2)
go func() {
client.Close()
done <- true
}()
go func() {
client.Close()
done <- true
}()
<-done
<-done
}
// BenchmarkChatCompletionRequest benchmarks request creation
func BenchmarkChatCompletionRequest(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "test message"},
},
Stream: false,
Temperature: floatPtr(0.7),
MaxCompletionTokens: intPtr(100),
}
}
}
// Helper functions for benchmarks
func floatPtr(f float32) *float32 {
return &f
}
func intPtr(i int) *int {
return &i
}
// TestContextCancellation tests that cancelled context is handled gracefully.
//
// NOTE: Currently, the FFI layer is blocking and doesn't actively monitor context cancellation.
// This test verifies that the client at least returns an error rather than panicking or
// hanging indefinitely when a pre-cancelled context is passed.
//
// Future: When FFI supports context cancellation (via signals or async operations),
// this test should be updated to assert that the error is context.Canceled or wrapped
// context cancellation error.
func TestContextCancellation(t *testing.T) {
config := ClientConfig{
Endpoint: "grpc://localhost:20000",
TokenizerPath: "/path/to/tokenizer",
}
client, err := NewClient(config)
if err != nil {
t.Skip("Skipping context cancellation test: server not available")
}
defer client.Close()
// Create a pre-cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "test"},
},
}
// Attempt request with cancelled context
// Since FFI is blocking, we expect either:
// 1. An error from the server/network
// 2. The call to complete normally (FFI doesn't check context)
// What we DON'T expect is a panic or indefinite hang
_, err = client.CreateChatCompletion(ctx, req)
if err != nil {
t.Logf("Request with cancelled context returned error: %v", err)
} else {
t.Logf("Request with cancelled context completed (FFI may not support context cancellation)")
}
}
@@ -0,0 +1,85 @@
// Simple example demonstrating basic usage of SGLang Go SDK
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/sglang/sglang-go-grpc-sdk"
)
func main() {
// Get configuration from environment or command line
endpoint := os.Getenv("SGL_GRPC_ENDPOINT")
if endpoint == "" {
endpoint = "grpc://localhost:20000"
}
tokenizerPath := os.Getenv("SGL_TOKENIZER_PATH")
if tokenizerPath == "" {
tokenizerPath = "./examples/tokenizer"
}
// Create client
client, err := sglang.NewClient(sglang.ClientConfig{
Endpoint: endpoint,
TokenizerPath: tokenizerPath,
})
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
// Create chat completion request
req := sglang.ChatCompletionRequest{
Model: "default",
Messages: []sglang.ChatMessage{
{
Role: "system",
Content: "You are a helpful assistant.",
},
{
Role: "user",
Content: "写一首歌关于夏天",
},
},
Stream: false,
Temperature: float32Ptr(0.7),
MaxCompletionTokens: intPtr(200),
SkipSpecialTokens: true,
Tools: nil, // Use nil instead of empty slice to avoid template errors
}
// Create completion
ctx := context.Background()
resp, err := client.CreateChatCompletion(ctx, req)
if err != nil {
log.Fatalf("Failed to create completion: %v", err)
}
// Print response
fmt.Println("=== Response ===")
fmt.Printf("ID: %s\n", resp.ID)
fmt.Printf("Model: %s\n", resp.Model)
fmt.Printf("Created: %d\n", resp.Created)
fmt.Println("\nContent:")
for _, choice := range resp.Choices {
fmt.Println(choice.Message.Content)
}
fmt.Printf("\nFinish Reason: %s\n", resp.Choices[0].FinishReason)
fmt.Printf("\nUsage: Prompt=%d, Completion=%d, Total=%d\n",
resp.Usage.PromptTokens,
resp.Usage.CompletionTokens,
resp.Usage.TotalTokens,
)
}
func float32Ptr(f float32) *float32 {
return &f
}
func intPtr(i int) *int {
return &i
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Simple example runner
# Usage: ./run.sh [tokenizer_path] [endpoint]
# Set library path for Rust FFI library
# The library should be in ./lib directory (created by 'make lib')
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)/lib"
# Check if lib directory exists
if [ ! -d "$LIB_DIR" ]; then
echo "Error: Library directory not found at $LIB_DIR"
echo "Please run 'make lib' first to build and export the library"
exit 1
fi
# Get Python LDFLAGS (needed for Rust FFI that depends on Python)
PYTHON_LDFLAGS=$(python3-config --ldflags --embed 2>/dev/null || python3-config --ldflags 2>/dev/null || echo "")
# Set CGO_LDFLAGS to link with the Rust library
export CGO_LDFLAGS="-L${LIB_DIR} -lsglang_router_rs ${PYTHON_LDFLAGS} -ldl"
# macOS uses DYLD_LIBRARY_PATH, Linux uses LD_LIBRARY_PATH
if [[ "$OSTYPE" == "darwin"* ]]; then
export DYLD_LIBRARY_PATH="${LIB_DIR}:${DYLD_LIBRARY_PATH}"
else
export LD_LIBRARY_PATH="${LIB_DIR}:${LD_LIBRARY_PATH}"
fi
# Default configuration (can be overridden by environment variables or command line arguments)
# Tokenizer path: ../tokenizer (relative to this script)
DEFAULT_TOKENIZER_PATH="${SGL_TOKENIZER_PATH:-../tokenizer}"
DEFAULT_ENDPOINT="${SGL_GRPC_ENDPOINT:-grpc://localhost:20000}"
TOKENIZER_PATH="${1:-${DEFAULT_TOKENIZER_PATH}}"
ENDPOINT="${2:-${DEFAULT_ENDPOINT}}"
echo "Running simple example..."
echo "Library path: ${LIB_DIR}"
echo "Tokenizer: $TOKENIZER_PATH"
echo "Endpoint: $ENDPOINT"
echo ""
cd "$(dirname "${BASH_SOURCE[0]}")"
SGL_TOKENIZER_PATH="$TOKENIZER_PATH" SGL_GRPC_ENDPOINT="$ENDPOINT" go run main.go
@@ -0,0 +1,125 @@
// Streaming example demonstrating real-time streaming with SGLang Go SDK
package main
import (
"context"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"github.com/sglang/sglang-go-grpc-sdk"
)
func main() {
// Get configuration from environment or command line
endpoint := os.Getenv("SGL_GRPC_ENDPOINT")
if endpoint == "" {
endpoint = "grpc://localhost:20000"
}
tokenizerPath := os.Getenv("SGL_TOKENIZER_PATH")
if tokenizerPath == "" {
tokenizerPath = "./examples/tokenizer"
}
// Create client
client, err := sglang.NewClient(sglang.ClientConfig{
Endpoint: endpoint,
TokenizerPath: tokenizerPath,
})
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
// Create streaming chat completion request
req := sglang.ChatCompletionRequest{
Model: "default",
Messages: []sglang.ChatMessage{
{
Role: "system",
Content: "You are a helpful assistant.",
},
{
Role: "user",
Content: "写一首春天的诗歌",
},
},
Stream: true,
Temperature: float32Ptr(0.7),
MaxCompletionTokens: intPtr(500),
SkipSpecialTokens: true,
Tools: nil, // Use nil instead of empty slice to avoid template errors
}
// Create streaming completion
ctx := context.Background()
stream, err := client.CreateChatCompletionStream(ctx, req)
if err != nil {
log.Fatalf("Failed to create stream: %v", err)
}
defer stream.Close()
fmt.Println("=== Streaming Response ===")
fmt.Println()
var fullContent strings.Builder
chunkCount := 0
startTime := time.Now()
var firstTokenTime time.Time
firstTokenReceived := false
for {
chunk, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("Stream error: %v", err)
}
chunkCount++
// Extract content from delta
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
fmt.Print(choice.Delta.Content)
fullContent.WriteString(choice.Delta.Content)
// Track first token time (TTFT)
if !firstTokenReceived {
firstTokenTime = time.Now()
firstTokenReceived = true
ttft := firstTokenTime.Sub(startTime)
fmt.Printf("\n[TTFT: %v]\n", ttft)
}
}
if choice.FinishReason != "" {
fmt.Printf("\n\n[Finished: %s]\n", choice.FinishReason)
}
}
}
// Calculate metrics
if firstTokenReceived {
elapsed := time.Since(startTime)
tokensPerSecond := float64(fullContent.Len()) / elapsed.Seconds()
fmt.Printf("\n=== Metrics ===\n")
fmt.Printf("Total chunks: %d\n", chunkCount)
fmt.Printf("Total content length: %d characters\n", fullContent.Len())
fmt.Printf("Time elapsed: %v\n", elapsed)
fmt.Printf("Tokens per second: %.2f\n", tokensPerSecond)
}
}
func float32Ptr(f float32) *float32 {
return &f
}
func intPtr(i int) *int {
return &i
}
@@ -0,0 +1,46 @@
#!/bin/bash
# Streaming example runner
# Usage: ./run.sh [tokenizer_path] [endpoint]
# Set library path for Rust FFI library
# The library should be in ./lib directory (created by 'make lib')
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)/lib"
# Check if lib directory exists
if [ ! -d "$LIB_DIR" ]; then
echo "Error: Library directory not found at $LIB_DIR"
echo "Please run 'make lib' first to build and export the library"
exit 1
fi
# Get Python LDFLAGS (needed for Rust FFI that depends on Python)
PYTHON_LDFLAGS=$(python3-config --ldflags --embed 2>/dev/null || python3-config --ldflags 2>/dev/null || echo "")
# Set CGO_LDFLAGS to link with the Rust library
export CGO_LDFLAGS="-L${LIB_DIR} -lsglang_router_rs ${PYTHON_LDFLAGS} -ldl"
# macOS uses DYLD_LIBRARY_PATH, Linux uses LD_LIBRARY_PATH
if [[ "$OSTYPE" == "darwin"* ]]; then
export DYLD_LIBRARY_PATH="${LIB_DIR}:${DYLD_LIBRARY_PATH}"
else
export LD_LIBRARY_PATH="${LIB_DIR}:${LD_LIBRARY_PATH}"
fi
# Default configuration (can be overridden by environment variables or command line arguments)
# Tokenizer path: ../tokenizer (relative to this script)
DEFAULT_TOKENIZER_PATH="${SGL_TOKENIZER_PATH:-../tokenizer}"
DEFAULT_ENDPOINT="${SGL_GRPC_ENDPOINT:-grpc://localhost:20000}"
TOKENIZER_PATH="${1:-${DEFAULT_TOKENIZER_PATH}}"
ENDPOINT="${2:-${DEFAULT_ENDPOINT}}"
echo "Running streaming example..."
echo "Library path: ${LIB_DIR}"
echo "Tokenizer: $TOKENIZER_PATH"
echo "Endpoint: $ENDPOINT"
echo ""
cd "$(dirname "${BASH_SOURCE[0]}")"
SGL_TOKENIZER_PATH="$TOKENIZER_PATH" SGL_GRPC_ENDPOINT="$ENDPOINT" go run main.go
@@ -0,0 +1,228 @@
//go:build integration
// +build integration
// integration_test.go contains integration tests that require a running SGLang server
//
// To run these tests:
// 1. Start an SGLang server: python -m sglang.launch_server --model-path meta-llama/Llama-2-7b-hf
// 2. Run: go test -tags=integration -run TestIntegration
package sglang
import (
"context"
"io"
"os"
"testing"
"time"
)
// getTestConfig returns test configuration from environment or defaults
func getTestConfig(t *testing.T) ClientConfig {
endpoint := os.Getenv("SGL_GRPC_ENDPOINT")
if endpoint == "" {
endpoint = "grpc://localhost:20000"
}
tokenizerPath := os.Getenv("SGL_TOKENIZER_PATH")
if tokenizerPath == "" {
t.Skip("SGL_TOKENIZER_PATH not set")
}
return ClientConfig{
Endpoint: endpoint,
TokenizerPath: tokenizerPath,
}
}
// TestIntegrationNonStreamingCompletion tests non-streaming chat completion
func TestIntegrationNonStreamingCompletion(t *testing.T) {
config := getTestConfig(t)
client, err := NewClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "Say 'Hello, World!' only"},
},
Stream: false,
Temperature: float32Ptr(0.0),
MaxCompletionTokens: intPtr(50),
}
resp, err := client.CreateChatCompletion(ctx, req)
if err != nil {
t.Fatalf("CreateChatCompletion failed: %v", err)
}
if resp.ID == "" {
t.Error("Response ID is empty")
}
if len(resp.Choices) == 0 {
t.Error("Response has no choices")
}
if resp.Choices[0].Message.Content == "" {
t.Error("Response content is empty")
}
if resp.Usage == nil || resp.Usage.TotalTokens == 0 {
t.Error("Usage information is missing or invalid")
}
t.Logf("Response: %s", resp.Choices[0].Message.Content)
t.Logf("Usage: %+v", resp.Usage)
}
// TestIntegrationStreamingCompletion tests streaming chat completion
func TestIntegrationStreamingCompletion(t *testing.T) {
config := getTestConfig(t)
client, err := NewClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "Count from 1 to 5"},
},
Stream: true,
Temperature: float32Ptr(0.0),
MaxCompletionTokens: intPtr(100),
}
stream, err := client.CreateChatCompletionStream(ctx, req)
if err != nil {
t.Fatalf("CreateChatCompletionStream failed: %v", err)
}
defer stream.Close()
chunkCount := 0
totalContent := ""
for {
chunk, err := stream.Recv()
if err == io.EOF {
// io.EOF is expected at end of stream
break
}
if err != nil {
t.Fatalf("Stream error: %v", err)
}
chunkCount++
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
totalContent += choice.Delta.Content
}
}
}
if chunkCount == 0 {
t.Error("Received no chunks from stream")
}
if totalContent == "" {
t.Error("Received no content from stream")
}
t.Logf("Received %d chunks with content: %s", chunkCount, totalContent)
}
// TestIntegrationConcurrentRequests tests multiple concurrent requests
func TestIntegrationConcurrentRequests(t *testing.T) {
config := getTestConfig(t)
client, err := NewClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
numRequests := 3
done := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func(idx int) {
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "Say 'test'"},
},
Stream: false,
MaxCompletionTokens: intPtr(50),
}
_, err := client.CreateChatCompletion(ctx, req)
done <- err
}(i)
}
// Collect results
for i := 0; i < numRequests; i++ {
if err := <-done; err != nil {
t.Errorf("Request %d failed: %v", i, err)
}
}
t.Logf("All %d concurrent requests completed successfully", numRequests)
}
// TestIntegrationContextCancellation tests that context cancellation is handled
func TestIntegrationContextCancellation(t *testing.T) {
config := getTestConfig(t)
client, err := NewClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
defer client.Close()
// Create a context that cancels immediately
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := ChatCompletionRequest{
Model: "default",
Messages: []ChatMessage{
{Role: "user", Content: "test"},
},
Stream: false,
}
// Should handle cancelled context gracefully
_, err = client.CreateChatCompletion(ctx, req)
if err == nil {
t.Error("Expected error from cancelled context")
}
t.Logf("Cancelled context handled: %v", err)
}
// Helper functions
func float32Ptr(f float32) *float32 {
return &f
}
func intPtr(i int) *int {
return &i
}
@@ -0,0 +1,228 @@
// Package ffi provides Go bindings for SGLang's Rust FFI (Foreign Function Interface).
//
// This package wraps the Rust FFI layer of SGLang, providing low-level access to:
// - Client creation and connection management
// - Chat completion streaming
// - Stream reading and response conversion
// - Memory management for C strings
//
// Internal use only: This package is intended for internal use by the sglang package.
// End users should use the public sglang package instead.
package ffi
/*
#cgo LDFLAGS: -lsglang_router_rs -ldl
#include <stdlib.h>
#include <stdint.h>
// Error codes
typedef enum {
SGL_ERROR_SUCCESS = 0,
SGL_ERROR_INVALID_ARGUMENT = 1,
SGL_ERROR_TOKENIZATION_ERROR = 2,
SGL_ERROR_PARSING_ERROR = 3,
SGL_ERROR_MEMORY_ERROR = 4,
SGL_ERROR_UNKNOWN = 99
} SglErrorCode;
// Opaque handles
typedef void* SglangClientHandle;
typedef void* SglangStreamHandle;
// Client SDK functions
SglangClientHandle* sgl_client_create(const char* endpoint, const char* tokenizer_path, char** error_out);
void sgl_client_free(SglangClientHandle* handle);
SglErrorCode sgl_client_chat_completion_stream(SglangClientHandle* client_handle, const char* request_json, SglangStreamHandle** stream_handle_out, char** error_out);
SglErrorCode sgl_stream_read_next(SglangStreamHandle* stream_handle, char** response_json_out, int* is_done_out, char** error_out);
void sgl_stream_free(SglangStreamHandle* handle);
void sgl_free_string(char* s);
*/
import "C"
import (
"fmt"
"unsafe"
)
// ErrorCode represents FFI error codes returned by Rust functions.
//
// These codes indicate the result of FFI operations. Use Error() to get a human-readable
// error message.
type ErrorCode int
const (
// ErrorSuccess indicates the operation completed successfully
ErrorSuccess ErrorCode = 0
// ErrorInvalidArgument indicates invalid arguments were passed to the FFI function
ErrorInvalidArgument ErrorCode = 1
// ErrorTokenizationError indicates an error during tokenization
ErrorTokenizationError ErrorCode = 2
// ErrorParsingError indicates an error parsing the response or request
ErrorParsingError ErrorCode = 3
// ErrorMemoryError indicates a memory allocation error
ErrorMemoryError ErrorCode = 4
// ErrorUnknown indicates an unclassified error
ErrorUnknown ErrorCode = 99
)
// Error implements the error interface for ErrorCode.
func (e ErrorCode) Error() string {
switch e {
case ErrorSuccess:
return "success"
case ErrorInvalidArgument:
return "invalid argument"
case ErrorTokenizationError:
return "tokenization error"
case ErrorParsingError:
return "parsing error"
case ErrorMemoryError:
return "memory error"
case ErrorUnknown:
return "unknown error"
default:
return fmt.Sprintf("unknown error code: %d", e)
}
}
// SglangClientHandle wraps the Rust client SDK FFI handle.
//
// This struct maintains a connection to the SGLang gRPC server and is used
// to create streams and manage the underlying Rust client resources.
type SglangClientHandle struct {
handle *C.SglangClientHandle
}
// NewClient creates a new SGLang client handle via FFI.
//
// This function initializes the Rust client with the given endpoint and tokenizer path.
//
// Parameters:
// - endpoint: gRPC endpoint URL (e.g., "grpc://localhost:20000")
// - tokenizerPath: Path to tokenizer directory
//
// Returns:
// - *SglangClientHandle: A new client handle
// - error: An error if client creation failed
func NewClient(endpoint, tokenizerPath string) (*SglangClientHandle, error) {
cEndpoint := C.CString(endpoint)
defer C.free(unsafe.Pointer(cEndpoint))
cTokenizerPath := C.CString(tokenizerPath)
defer C.free(unsafe.Pointer(cTokenizerPath))
var errorPtr *C.char
handle := C.sgl_client_create(cEndpoint, cTokenizerPath, &errorPtr)
if handle == nil {
errorMsg := ""
if errorPtr != nil {
errorMsg = C.GoString(errorPtr)
C.sgl_free_string(errorPtr)
}
if errorMsg == "" {
errorMsg = "failed to create client"
}
return nil, fmt.Errorf("%s", errorMsg)
}
return &SglangClientHandle{handle: handle}, nil
}
// Free releases the client handle
func (h *SglangClientHandle) Free() {
if h.handle != nil {
C.sgl_client_free(h.handle)
h.handle = nil
}
}
// ChatCompletionStream creates a streaming chat completion request
func (h *SglangClientHandle) ChatCompletionStream(requestJSON string) (*SglangStreamHandle, error) {
if h.handle == nil {
return nil, fmt.Errorf("client handle is nil")
}
cRequestJSON := C.CString(requestJSON)
defer C.free(unsafe.Pointer(cRequestJSON))
var streamHandle *C.SglangStreamHandle
var errorPtr *C.char
result := C.sgl_client_chat_completion_stream(
h.handle,
cRequestJSON,
&streamHandle,
&errorPtr,
)
if ErrorCode(result) != ErrorSuccess {
errorMsg := ""
if errorPtr != nil {
errorMsg = C.GoString(errorPtr)
C.sgl_free_string(errorPtr)
}
if errorMsg == "" {
errorMsg = fmt.Sprintf("error code %d", result)
}
return nil, fmt.Errorf("%s", errorMsg)
}
if streamHandle == nil {
return nil, fmt.Errorf("stream handle is nil")
}
return &SglangStreamHandle{handle: streamHandle}, nil
}
// SglangStreamHandle wraps the Rust stream FFI handle
type SglangStreamHandle struct {
handle *C.SglangStreamHandle
}
// ReadNext reads the next chunk from the stream
// Returns: (responseJSON, isDone, error)
func (h *SglangStreamHandle) ReadNext() (string, bool, error) {
if h.handle == nil {
return "", true, fmt.Errorf("stream handle is nil")
}
var responseJSON *C.char
var isDone C.int
var errorPtr *C.char
result := C.sgl_stream_read_next(
h.handle,
&responseJSON,
&isDone,
&errorPtr,
)
if ErrorCode(result) != ErrorSuccess {
errorMsg := ""
if errorPtr != nil {
errorMsg = C.GoString(errorPtr)
C.sgl_free_string(errorPtr)
}
if errorMsg == "" {
errorMsg = fmt.Sprintf("error code %d", result)
}
return "", isDone == 1, fmt.Errorf("%s", errorMsg)
}
responseStr := ""
if responseJSON != nil {
responseStr = C.GoString(responseJSON)
C.sgl_free_string(responseJSON)
}
return responseStr, isDone == 1, nil
}
// Free releases the stream handle
func (h *SglangStreamHandle) Free() {
if h.handle != nil {
C.sgl_stream_free(h.handle)
h.handle = nil
}
}
@@ -0,0 +1,279 @@
//! Client SDK FFI functions
use std::ffi::{CStr, CString};
use std::os::raw::{c_char};
use std::ptr;
use std::sync::Arc;
use tokio::runtime::Runtime;
use once_cell::sync::Lazy;
use uuid::Uuid;
use sgl_model_gateway::tokenizer::create_tokenizer_from_file;
use sgl_model_gateway::tokenizer::traits::Tokenizer;
use sgl_model_gateway::grpc_client::sglang_scheduler::SglangSchedulerClient;
use sgl_model_gateway::protocols::chat::ChatCompletionRequest;
use sgl_model_gateway::routers::grpc::utils::{process_chat_messages, generate_tool_constraints};
use super::error::{SglErrorCode, set_error_message};
use super::grpc_converter::sgl_grpc_response_converter_create;
use super::tokenizer::TokenizerHandle;
use super::stream::SglangStreamHandle;
/// Global tokio runtime for async operations
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Runtime::new().expect("Failed to create tokio runtime for client FFI")
});
/// Handle for complete client SDK (gRPC client + tokenizer)
/// This handle manages the connection to sglang and provides a complete SDK interface
pub struct SglangClientHandle {
pub(crate) client: Arc<SglangSchedulerClient>,
pub(crate) tokenizer: Arc<dyn Tokenizer>,
}
/// Handle for streaming request (includes prompt token count)
#[allow(dead_code)]
pub struct StreamRequestState {
pub(crate) prompt_tokens: i32, // Number of prompt tokens for this request
}
/// Create a new SGLang client handle
///
/// # Arguments
/// * `endpoint` - gRPC endpoint (e.g., "grpc://localhost:20000")
/// * `tokenizer_path` - Path to tokenizer directory
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * Pointer to SglangClientHandle on success, null on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_client_create(
endpoint: *const c_char,
tokenizer_path: *const c_char,
error_out: *mut *mut c_char,
) -> *mut SglangClientHandle {
if endpoint.is_null() || tokenizer_path.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return ptr::null_mut();
}
let endpoint_str = match CStr::from_ptr(endpoint).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in endpoint");
return ptr::null_mut();
}
};
let tokenizer_path_str = match CStr::from_ptr(tokenizer_path).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in tokenizer_path");
return ptr::null_mut();
}
};
// Create tokenizer
let tokenizer = match create_tokenizer_from_file(tokenizer_path_str) {
Ok(t) => t,
Err(e) => {
set_error_message(error_out, &format!("Failed to create tokenizer: {}", e));
return ptr::null_mut();
}
};
// Create gRPC client
let client = match RUNTIME.block_on(async {
SglangSchedulerClient::connect(endpoint_str).await
}) {
Ok(c) => Arc::new(c),
Err(e) => {
set_error_message(error_out, &format!("Failed to connect to endpoint: {}", e));
return ptr::null_mut();
}
};
Box::into_raw(Box::new(SglangClientHandle {
client,
tokenizer,
}))
}
/// Free a client handle
#[no_mangle]
pub unsafe extern "C" fn sgl_client_free(handle: *mut SglangClientHandle) {
if !handle.is_null() {
let _ = Box::from_raw(handle);
}
}
/// Send a chat completion request and start streaming
///
/// # Arguments
/// * `client_handle` - Client handle
/// * `request_json` - OpenAI ChatCompletionRequest as JSON string
/// * `stream_handle_out` - Pointer to receive stream handle
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_client_chat_completion_stream(
client_handle: *mut SglangClientHandle,
request_json: *const c_char,
stream_handle_out: *mut *mut SglangStreamHandle,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if client_handle.is_null() || request_json.is_null() || stream_handle_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let request_str = match CStr::from_ptr(request_json).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in request_json");
return SglErrorCode::InvalidArgument;
}
};
let client_ref = &*client_handle;
let client = Arc::clone(&client_ref.client);
let tokenizer = Arc::clone(&client_ref.tokenizer);
// Parse OpenAI ChatCompletionRequest
let chat_request: ChatCompletionRequest = match serde_json::from_str(request_str) {
Ok(req) => req,
Err(e) => {
set_error_message(error_out, &format!("Failed to parse request JSON: {}", e));
return SglErrorCode::ParsingError;
}
};
// Process messages and apply chat template
let processed_messages = match process_chat_messages(&chat_request, tokenizer.as_ref()) {
Ok(msgs) => msgs,
Err(e) => {
set_error_message(error_out, &format!("Failed to process messages: {}", e));
return SglErrorCode::TokenizationError;
}
};
// Tokenize
let token_ids = match tokenizer.encode(&processed_messages.text) {
Ok(encoding) => encoding.token_ids().to_vec(),
Err(e) => {
set_error_message(error_out, &format!("Failed to tokenize: {}", e));
return SglErrorCode::TokenizationError;
}
};
let prompt_tokens = token_ids.len() as i32; // Save prompt token count
// Generate tool constraints if needed
let tool_constraint = if let Some(tools) = chat_request.tools.as_ref() {
match generate_tool_constraints(tools, &chat_request.tool_choice, &chat_request.model) {
Ok(Some((constraint_type, constraint_value))) => Some((constraint_type, constraint_value)),
Ok(None) => None,
Err(e) => {
set_error_message(error_out, &format!("Failed to generate tool constraints: {}", e));
return SglErrorCode::ParsingError;
}
}
} else {
None
};
// Build GenerateRequest
let request_id = format!("chatcmpl-{}", Uuid::new_v4());
let proto_request = match client.build_generate_request_from_chat(
request_id.clone(),
&chat_request,
processed_messages.text,
token_ids,
processed_messages.multimodal_inputs,
tool_constraint,
) {
Ok(req) => req,
Err(e) => {
set_error_message(error_out, &format!("Failed to build generate request: {}", e));
return SglErrorCode::ParsingError;
}
};
// Send request and get stream
let stream = match RUNTIME.block_on(async {
client.generate(proto_request).await
}) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to send request: {}", e));
return SglErrorCode::UnknownError;
}
};
// Create response converter
let tools_json = chat_request.tools.as_ref()
.and_then(|t| serde_json::to_string(t).ok())
.map(|s| CString::new(s).unwrap().into_raw());
let tool_choice_json = chat_request.tool_choice.as_ref()
.and_then(|tc| serde_json::to_string(tc).ok())
.map(|s| CString::new(s).unwrap().into_raw());
let stop_json = chat_request.stop.as_ref()
.and_then(|s| serde_json::to_string(s).ok())
.map(|s| CString::new(s).unwrap().into_raw());
let stop_token_ids_json = chat_request.stop_token_ids.as_ref()
.and_then(|ids| serde_json::to_string(ids).ok())
.map(|s| CString::new(s).unwrap().into_raw());
// Create tokenizer handle for converter (we'll create a temporary one)
let tokenizer_handle = Box::into_raw(Box::new(TokenizerHandle {
tokenizer: Arc::clone(&tokenizer),
}));
let converter = sgl_grpc_response_converter_create(
tokenizer_handle,
CString::new(chat_request.model.clone()).unwrap().as_ptr(),
CString::new(request_id.clone()).unwrap().as_ptr(),
tools_json.unwrap_or(ptr::null_mut()),
tool_choice_json.unwrap_or(ptr::null_mut()),
stop_json.unwrap_or(ptr::null_mut()),
stop_token_ids_json.unwrap_or(ptr::null_mut()),
if chat_request.skip_special_tokens { 1 } else { 0 },
error_out,
);
// Free temporary tokenizer handle (converter now owns the tokenizer)
let _ = Box::from_raw(tokenizer_handle);
if converter.is_null() {
return SglErrorCode::MemoryError;
}
// Clean up temporary CStrings
if let Some(ptr) = tools_json {
let _ = CString::from_raw(ptr);
}
if let Some(ptr) = tool_choice_json {
let _ = CString::from_raw(ptr);
}
if let Some(ptr) = stop_json {
let _ = CString::from_raw(ptr);
}
if let Some(ptr) = stop_token_ids_json {
let _ = CString::from_raw(ptr);
}
// Create converter handle and set initial_prompt_tokens immediately
let mut converter_handle = *Box::from_raw(converter);
converter_handle.initial_prompt_tokens = Some(prompt_tokens);
// Create stream handle with prompt_tokens
*stream_handle_out = Box::into_raw(Box::new(SglangStreamHandle {
stream: Arc::new(tokio::sync::Mutex::new(stream)),
converter: Arc::new(tokio::sync::Mutex::new(converter_handle)),
client: Arc::clone(&client),
prompt_tokens,
}));
SglErrorCode::Success
}
@@ -0,0 +1,50 @@
//! Error handling for FFI functions
use std::ffi::CString;
use std::os::raw::c_char;
use std::ptr;
/// Error codes returned by FFI functions
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SglErrorCode {
Success = 0,
InvalidArgument = 1,
TokenizationError = 2,
ParsingError = 3,
MemoryError = 4,
UnknownError = 99,
}
/// Helper to set error message in FFI output parameter
pub fn set_error_message(error_out: *mut *mut c_char, message: &str) {
unsafe {
if !error_out.is_null() {
if let Ok(cstr) = CString::new(message) {
*error_out = cstr.into_raw();
} else {
*error_out = ptr::null_mut();
}
}
}
}
/// Helper to set error message from format string
pub fn set_error_message_fmt(error_out: *mut *mut c_char, fmt: std::fmt::Arguments) {
if !error_out.is_null() {
let msg = format!("{}", fmt);
set_error_message(error_out, &msg);
}
}
/// Helper to clear error message
pub fn clear_error_message(error_out: *mut *mut c_char) {
unsafe {
if !error_out.is_null() {
*error_out = ptr::null_mut();
}
}
}
// Helper functions for error handling
// Note: Some helper functions are kept for potential future use
@@ -0,0 +1,758 @@
//! gRPC response converter FFI functions
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use std::sync::Arc;
use std::collections::HashMap;
use serde_json::Value;
use tokio::runtime::Runtime;
use once_cell::sync::Lazy;
use sgl_model_gateway::tokenizer::traits::Tokenizer;
use sgl_model_gateway::tokenizer::stream::DecodeStream;
use sgl_model_gateway::tool_parser::ToolParser;
use sgl_model_gateway::protocols::common::{Tool, ToolChoice, ToolChoiceValue, ToolCallDelta, FunctionCallDelta, Usage, StringOrArray};
use sgl_model_gateway::tokenizer::stop::StopSequenceDecoder;
use sgl_model_gateway::grpc_client::sglang_proto as proto;
use super::error::{SglErrorCode, set_error_message, clear_error_message};
use super::tokenizer::TokenizerHandle;
use super::utils::generate_tool_call_id;
/// Global parser factory (initialized once)
// Use the re-exported ParserFactory from tool_parser module
static PARSER_FACTORY: Lazy<sgl_model_gateway::tool_parser::ParserFactory> = Lazy::new(|| {
// ParserFactory is re-exported from tool_parser::factory, so we can use it directly
sgl_model_gateway::tool_parser::ParserFactory::default()
});
/// Global tokio runtime for async operations
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Runtime::new().expect("Failed to create tokio runtime for gRPC converter FFI")
});
/// Handle for gRPC response converter (maintains state for streaming)
#[repr(C)]
pub struct GrpcResponseConverterHandle {
pub(crate) tokenizer: Arc<dyn Tokenizer>,
pub(crate) tool_parser: Option<Arc<tokio::sync::Mutex<Box<dyn ToolParser>>>>,
pub(crate) stop_decoder: Option<Arc<tokio::sync::Mutex<StopSequenceDecoder>>>,
pub(crate) model: String,
pub(crate) request_id: String,
pub(crate) created: u64,
pub(crate) system_fingerprint: Option<String>,
pub(crate) tools: Option<Vec<Tool>>,
pub(crate) tool_choice: Option<ToolChoice>,
pub(crate) history_tool_calls_count: usize,
pub(crate) stream_buffers: HashMap<u32, String>, // Per-index text buffers
pub(crate) decode_streams: HashMap<u32, DecodeStream>, // Per-index incremental decoders
pub(crate) has_tool_calls: HashMap<u32, bool>, // Track if tool calls were emitted
pub(crate) is_first_chunk: HashMap<u32, bool>, // Track first chunk per index
pub(crate) prompt_tokens: HashMap<u32, i32>, // Track prompt tokens per index (from chunks)
pub(crate) completion_tokens: HashMap<u32, i32>, // Track completion tokens per index (cumulative)
pub(crate) initial_prompt_tokens: Option<i32>, // Initial prompt tokens from request (if available)
pub(crate) skip_special_tokens: bool, // Whether to skip special tokens when decoding
}
/// Create a gRPC response converter handle
///
/// # Arguments
/// * `tokenizer_handle` - Tokenizer handle (must be valid)
/// * `model` - Model name
/// * `request_id` - Request ID
/// * `tools_json` - Optional JSON array of tools
/// * `tool_choice_json` - Optional JSON object for tool_choice
/// * `stop` - Optional stop sequences (JSON array)
/// * `stop_token_ids` - Optional stop token IDs (JSON array)
/// * `skip_special_tokens` - Whether to skip special tokens
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * Pointer to GrpcResponseConverterHandle on success, null on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_grpc_response_converter_create(
tokenizer_handle: *mut TokenizerHandle,
model: *const c_char,
request_id: *const c_char,
tools_json: *const c_char,
tool_choice_json: *const c_char,
stop: *const c_char,
stop_token_ids: *const c_char,
skip_special_tokens: c_int,
error_out: *mut *mut c_char,
) -> *mut GrpcResponseConverterHandle {
if tokenizer_handle.is_null() || model.is_null() || request_id.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return ptr::null_mut();
}
let model_str = match CStr::from_ptr(model).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in model");
return ptr::null_mut();
}
};
let request_id_str = match CStr::from_ptr(request_id).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in request_id");
return ptr::null_mut();
}
};
let handle_ref = &*tokenizer_handle;
let tokenizer = Arc::clone(&handle_ref.tokenizer);
// Parse tools if provided
let tools: Option<Vec<Tool>> = if !tools_json.is_null() {
match CStr::from_ptr(tools_json).to_str() {
Ok(s) => serde_json::from_str::<Vec<Tool>>(s).ok(),
Err(_) => None,
}
} else {
None
};
// Parse tool_choice if provided
let tool_choice: Option<ToolChoice> = if !tool_choice_json.is_null() {
match CStr::from_ptr(tool_choice_json).to_str() {
Ok(s) => serde_json::from_str::<ToolChoice>(s).ok(),
Err(_) => None,
}
} else {
None
};
// Parse stop sequences
let stop: Option<StringOrArray> = if !stop.is_null() {
let stop_str = match CStr::from_ptr(stop).to_str() {
Ok(s) => s,
Err(_) => return ptr::null_mut(),
};
serde_json::from_str::<StringOrArray>(stop_str).ok()
} else {
None
};
// Parse stop token IDs
let stop_token_ids: Option<Vec<u32>> = if !stop_token_ids.is_null() {
let ids_str = match CStr::from_ptr(stop_token_ids).to_str() {
Ok(s) => s,
Err(_) => return ptr::null_mut(),
};
serde_json::from_str::<Vec<u32>>(ids_str).ok()
} else {
None
};
// Create stop decoder if needed
let stop_decoder = if stop.is_some() || stop_token_ids.is_some() {
Some(Arc::new(tokio::sync::Mutex::new(
sgl_model_gateway::routers::grpc::utils::create_stop_decoder(
&tokenizer,
stop.as_ref(),
stop_token_ids.as_ref(),
skip_special_tokens != 0,
false, // no_stop_trim
),
)))
} else {
None
};
// Create tool parser if tools are provided
let tool_parser = if tools.is_some() {
PARSER_FACTORY.registry().create_for_model(model_str)
.map(|p| Arc::new(tokio::sync::Mutex::new(p)))
} else {
None
};
// Get system fingerprint from model (simplified)
let system_fingerprint = Some("fp_placeholder".to_string()); // TODO: Get actual fingerprint
Box::into_raw(Box::new(GrpcResponseConverterHandle {
tokenizer,
tool_parser,
stop_decoder,
model: model_str.to_string(),
request_id: request_id_str.to_string(),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
system_fingerprint,
tools,
tool_choice,
history_tool_calls_count: 0,
stream_buffers: HashMap::new(),
decode_streams: HashMap::new(),
has_tool_calls: HashMap::new(),
is_first_chunk: HashMap::new(),
prompt_tokens: HashMap::new(),
completion_tokens: HashMap::new(),
initial_prompt_tokens: None, // Will be set from stream handle
skip_special_tokens: skip_special_tokens != 0,
}))
}
/// Convert a gRPC GenerateResponse chunk to OpenAI format
///
/// # Arguments
/// * `handle` - Converter handle
/// * `response_json` - JSON string of proto.GenerateResponse
/// * `result_json_out` - Pointer to receive OpenAI format JSON (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_grpc_response_converter_convert_chunk(
handle: *mut GrpcResponseConverterHandle,
response_json: *const c_char,
result_json_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || response_json.is_null() || result_json_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let response_str = match CStr::from_ptr(response_json).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in response_json");
return SglErrorCode::InvalidArgument;
}
};
// Parse proto.GenerateResponse from JSON
let json_value: Value = match serde_json::from_str(response_str) {
Ok(v) => v,
Err(e) => {
set_error_message(error_out, &format!("Failed to parse response JSON: {}", e));
return SglErrorCode::ParsingError;
}
};
// Build proto::GenerateResponse from JSON value
let mut proto_response = proto::GenerateResponse {
request_id: json_value.get("request_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
response: None,
};
// Parse the response oneof field
if let Some(chunk_json) = json_value.get("chunk") {
let chunk = proto::GenerateStreamChunk {
token_ids: chunk_json.get("token_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as u32)).collect())
.unwrap_or_default(),
prompt_tokens: chunk_json.get("prompt_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
completion_tokens: chunk_json.get("completion_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
cached_tokens: chunk_json.get("cached_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
output_logprobs: None,
hidden_states: vec![],
input_logprobs: None,
index: 0,
};
proto_response.response = Some(proto::generate_response::Response::Chunk(chunk));
} else if let Some(complete_json) = json_value.get("complete") {
let complete = proto::GenerateComplete {
output_ids: complete_json.get("output_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as u32)).collect())
.unwrap_or_default(),
finish_reason: complete_json.get("finish_reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
prompt_tokens: complete_json.get("prompt_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
completion_tokens: complete_json.get("completion_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
cached_tokens: complete_json.get("cached_tokens")
.and_then(|v| v.as_i64())
.map(|n| n as i32)
.unwrap_or(0),
output_logprobs: None,
all_hidden_states: vec![],
input_logprobs: None,
matched_stop: None,
index: 0,
};
proto_response.response = Some(proto::generate_response::Response::Complete(complete));
} else if let Some(error_json) = json_value.get("error") {
let error = proto::GenerateError {
message: error_json.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
http_status_code: error_json.get("http_status_code")
.and_then(|v| v.as_str())
.unwrap_or("500")
.to_string(),
details: error_json.get("details")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
};
proto_response.response = Some(proto::generate_response::Response::Error(error));
} else {
set_error_message(error_out, "Response JSON must contain 'chunk', 'complete', or 'error' field");
return SglErrorCode::ParsingError;
}
let handle_ref = &mut *handle;
let tokenizer = Arc::clone(&handle_ref.tokenizer);
let model = handle_ref.model.clone();
let request_id = handle_ref.request_id.clone();
let created = handle_ref.created;
let system_fingerprint = handle_ref.system_fingerprint.clone();
// Use tokio runtime to run async code
let result = RUNTIME.block_on(async {
convert_proto_chunk_to_openai(
proto_response,
handle_ref,
&tokenizer,
&model,
&request_id,
created,
system_fingerprint.as_deref(),
)
.await
});
match result {
Ok(Some(openai_response)) => {
// Serialize to JSON
let result_str = match serde_json::to_string(&openai_response) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to serialize response: {}", e));
return SglErrorCode::ParsingError;
}
};
let result_cstr = match CString::new(result_str) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_json_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Ok(None) => {
// No response to send (e.g., empty chunk)
let empty = CString::new("").unwrap();
*result_json_out = empty.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &format!("Conversion error: {}", e));
SglErrorCode::ParsingError
}
}
}
/// Helper function to convert proto chunk to OpenAI format
pub(crate) async fn convert_proto_chunk_to_openai(
proto_response: proto::GenerateResponse,
handle: &mut GrpcResponseConverterHandle,
tokenizer: &Arc<dyn Tokenizer>,
model: &str,
request_id: &str,
created: u64,
system_fingerprint: Option<&str>,
) -> Result<Option<sgl_model_gateway::protocols::chat::ChatCompletionStreamResponse>, String> {
use sgl_model_gateway::grpc_client::sglang_proto::generate_response::Response::*;
use sgl_model_gateway::protocols::chat::{ChatCompletionStreamResponse, ChatMessageDelta, ChatStreamChoice};
match proto_response.response {
Some(Chunk(chunk)) => {
let index = chunk.index;
// Mark as not first chunk if we've seen this index before
let is_first = handle.is_first_chunk.entry(index).or_insert(true);
let first_chunk = *is_first;
*is_first = false;
// Track token counts from chunks (cumulative values from proto)
// These are cumulative values, so we always use the latest value
// For prompt_tokens, if chunk value is 0, preserve existing value or use initial_prompt_tokens
// This prevents overwriting valid prompt_tokens with 0
if chunk.prompt_tokens > 0 {
handle.prompt_tokens.insert(index, chunk.prompt_tokens);
} else {
// If chunk.prompt_tokens is 0, try to preserve existing value or use initial_prompt_tokens
if !handle.prompt_tokens.contains_key(&index) {
// No existing value, try to use initial_prompt_tokens
if let Some(initial_prompt) = handle.initial_prompt_tokens {
handle.prompt_tokens.insert(index, initial_prompt);
}
}
// If existing value exists, keep it (don't overwrite with 0)
}
// For completion_tokens, always update (even if 0) as it's cumulative
handle.completion_tokens.insert(index, chunk.completion_tokens);
// Process tokens through stop decoder if available, otherwise use incremental decoder
let chunk_text = if let Some(ref stop_decoder) = handle.stop_decoder {
let mut decoder_guard = stop_decoder.lock().await;
let mut text = String::new();
for &token_id in &chunk.token_ids {
match decoder_guard.process_token(token_id).unwrap_or_else(|_| {
sgl_model_gateway::tokenizer::stop::SequenceDecoderOutput::Held
}) {
sgl_model_gateway::tokenizer::stop::SequenceDecoderOutput::Text(t) => {
text.push_str(&t);
}
sgl_model_gateway::tokenizer::stop::SequenceDecoderOutput::StoppedWithText(t) => {
text.push_str(&t);
break;
}
sgl_model_gateway::tokenizer::stop::SequenceDecoderOutput::Stopped => {
break;
}
sgl_model_gateway::tokenizer::stop::SequenceDecoderOutput::Held => {}
}
}
text
} else {
// Use incremental decoder to handle multi-byte character boundaries
let decode_stream = handle.decode_streams.entry(index).or_insert_with(|| {
DecodeStream::new(
Arc::clone(&tokenizer),
&[], // No prompt tokens for completion
handle.skip_special_tokens,
)
});
// Process tokens incrementally
let mut text_parts = Vec::new();
for &token_id in &chunk.token_ids {
if let Ok(Some(text)) = decode_stream.step(token_id) {
text_parts.push(text);
}
}
text_parts.join("")
};
if chunk_text.is_empty() {
return Ok(None);
}
// Send first chunk with role
if first_chunk {
let first_response = ChatCompletionStreamResponse {
id: request_id.to_string(),
object: "chat.completion.chunk".to_string(),
created,
model: model.to_string(),
system_fingerprint: system_fingerprint.map(|s| s.to_string()),
choices: vec![ChatStreamChoice {
index,
delta: ChatMessageDelta {
role: Some("assistant".to_string()),
content: None,
tool_calls: None,
reasoning_content: None,
},
logprobs: None,
finish_reason: None,
matched_stop: None,
}],
usage: None,
};
return Ok(Some(first_response));
}
// Update stream buffer
let stream_buffer = handle.stream_buffers.entry(index).or_default();
stream_buffer.push_str(&chunk_text);
// Handle tool calls if tools are provided
if let (Some(ref tools), Some(ref tool_parser)) = (handle.tools.as_ref(), handle.tool_parser.as_ref()) {
let tool_choice_enabled = !matches!(
handle.tool_choice,
Some(ToolChoice::Value(ToolChoiceValue::None))
);
if tool_choice_enabled {
let mut parser_guard = tool_parser.lock().await;
match parser_guard.parse_incremental(&chunk_text, tools).await {
Ok(streaming_result) => {
if !streaming_result.calls.is_empty() {
handle.has_tool_calls.insert(index, true);
// Convert tool call items to OpenAI format
let tool_call_deltas: Vec<_> = streaming_result
.calls
.into_iter()
.map(|item| {
let id = if let Some(ref name) = item.name {
generate_tool_call_id(
model,
name,
item.tool_index,
handle.history_tool_calls_count,
)
} else {
format!("call_{}", item.tool_index)
};
ToolCallDelta {
index: item.tool_index as u32,
id: Some(id),
tool_type: if item.name.is_some() {
Some("function".to_string())
} else {
None
},
function: Some(FunctionCallDelta {
name: item.name,
arguments: if !item.parameters.is_empty() {
Some(item.parameters)
} else {
None
},
}),
}
})
.collect();
let tool_response = ChatCompletionStreamResponse {
id: request_id.to_string(),
object: "chat.completion.chunk".to_string(),
created,
model: model.to_string(),
system_fingerprint: system_fingerprint.map(|s| s.to_string()),
choices: vec![ChatStreamChoice {
index,
delta: ChatMessageDelta {
role: Some("assistant".to_string()),
content: None,
tool_calls: Some(tool_call_deltas),
reasoning_content: None,
},
logprobs: None,
finish_reason: None,
matched_stop: None,
}],
usage: None,
};
return Ok(Some(tool_response));
}
}
Err(e) => {
// Log error but continue with regular content
tracing::warn!("Tool parser error: {}", e);
}
}
}
}
// Regular content emission
let content_response = ChatCompletionStreamResponse {
id: request_id.to_string(),
object: "chat.completion.chunk".to_string(),
created,
model: model.to_string(),
system_fingerprint: system_fingerprint.map(|s| s.to_string()),
choices: vec![ChatStreamChoice {
index,
delta: ChatMessageDelta {
role: Some("assistant".to_string()),
content: Some(chunk_text),
tool_calls: None,
reasoning_content: None,
},
logprobs: None,
finish_reason: None,
matched_stop: None,
}],
usage: None,
};
Ok(Some(content_response))
}
Some(Complete(complete)) => {
let index = complete.index;
// Flush any remaining text
// Flush any remaining text from decode stream
let mut final_text = handle.stream_buffers.remove(&index).unwrap_or_default();
if let Some(ref mut decode_stream) = handle.decode_streams.get_mut(&index) {
if let Ok(Some(remaining)) = decode_stream.flush() {
final_text.push_str(&remaining);
}
}
handle.decode_streams.remove(&index);
// Determine finish reason - ensure it's never empty
// If finish_reason is empty, try to infer from other fields or use default
let finish_reason = if handle.has_tool_calls.get(&index).copied().unwrap_or(false)
&& (complete.finish_reason == "stop" || complete.finish_reason.is_empty())
{
"tool_calls".to_string()
} else if complete.finish_reason.is_empty() || complete.finish_reason.trim().is_empty() {
// If finish_reason is empty, try to infer from completion_tokens or use default
if complete.completion_tokens > 0 {
// If we have completion tokens, likely stopped normally
"stop".to_string()
} else if !complete.output_ids.is_empty() {
// If we have output_ids, likely stopped normally
"stop".to_string()
} else {
// Default fallback - always ensure we have a value
"stop".to_string()
}
} else {
complete.finish_reason.clone()
};
// Ensure finish_reason is never empty (defensive check)
let finish_reason = if finish_reason.is_empty() || finish_reason.trim().is_empty() {
"stop".to_string()
} else {
finish_reason
};
// Extract matched_stop
let matched_stop = match &complete.matched_stop {
Some(proto::generate_complete::MatchedStop::MatchedTokenId(token_id)) => {
Some(Value::Number(serde_json::Number::from(*token_id)))
}
Some(proto::generate_complete::MatchedStop::MatchedStopStr(stop_str)) => {
Some(Value::String(stop_str.clone()))
}
None => None,
};
// Build usage - prefer values from complete message, but fallback to accumulated values from chunks
// Complete message should have the final values, but sometimes they might be 0 or missing
// Always use the latest cumulative value from chunks if available, otherwise use complete message value
let mut prompt_tokens = handle.prompt_tokens.get(&index)
.copied()
.filter(|&v| v > 0)
.unwrap_or(complete.prompt_tokens);
let mut completion_tokens = handle.completion_tokens.get(&index)
.copied()
.filter(|&v| v > 0)
.unwrap_or(complete.completion_tokens);
// Always try to use initial_prompt_tokens if prompt_tokens is 0 or missing
// This is the most reliable source for prompt tokens since we calculate it from the request
if prompt_tokens == 0 {
if let Some(initial_prompt) = handle.initial_prompt_tokens {
prompt_tokens = initial_prompt;
}
}
// If completion_tokens is 0, try to infer from output_ids or accumulated chunks
if completion_tokens == 0 {
// Try to use completion_tokens from complete message even if 0
// Or calculate from output_ids
if complete.completion_tokens > 0 {
completion_tokens = complete.completion_tokens;
} else if !complete.output_ids.is_empty() {
completion_tokens = complete.output_ids.len() as i32;
} else if let Some(&last_completion) = handle.completion_tokens.get(&index) {
completion_tokens = last_completion;
}
}
// Final fallback: if both are still 0, try to use initial_prompt_tokens for prompt
// and calculate completion from output_ids
if prompt_tokens == 0 && completion_tokens == 0 {
// Try to infer from output_ids if available
let output_ids_len = complete.output_ids.len() as i32;
if output_ids_len > 0 {
completion_tokens = output_ids_len;
// Always try to use initial_prompt_tokens for prompt
if let Some(initial_prompt) = handle.initial_prompt_tokens {
prompt_tokens = initial_prompt;
}
}
}
// Final defensive check: ensure prompt_tokens is set if we have initial_prompt_tokens
if prompt_tokens == 0 {
if let Some(initial_prompt) = handle.initial_prompt_tokens {
prompt_tokens = initial_prompt;
}
}
// Always create usage, even if values are 0 (defensive)
let usage = Some(Usage {
prompt_tokens: prompt_tokens.max(0) as u32,
completion_tokens: completion_tokens.max(0) as u32,
total_tokens: (prompt_tokens.max(0) + completion_tokens.max(0)) as u32,
completion_tokens_details: None,
});
let finish_response = ChatCompletionStreamResponse {
id: request_id.to_string(),
object: "chat.completion.chunk".to_string(),
created,
model: model.to_string(),
system_fingerprint: system_fingerprint.map(|s| s.to_string()),
choices: vec![ChatStreamChoice {
index,
delta: ChatMessageDelta {
role: Some("assistant".to_string()),
content: if !final_text.is_empty() {
Some(final_text)
} else {
None
},
tool_calls: None,
reasoning_content: None,
},
logprobs: None,
finish_reason: Some(finish_reason),
matched_stop,
}],
usage,
};
Ok(Some(finish_response))
}
Some(Error(error)) => {
Err(format!("Server error: {} (status: {})", error.message, error.http_status_code))
}
None => Ok(None),
}
}
/// Free a gRPC response converter handle
#[no_mangle]
pub unsafe extern "C" fn sgl_grpc_response_converter_free(handle: *mut GrpcResponseConverterHandle) {
if !handle.is_null() {
let _ = Box::from_raw(handle);
}
}
@@ -0,0 +1,88 @@
//! FFI module for exposing sgl-model-gateway preprocessing and postprocessing functions
//! to C-compatible languages (e.g., Golang via cgo)
//!
//! This module provides C-compatible function signatures for:
//! - Tokenizer operations (encode, decode, chat template)
//! - Tool parser operations (parse tool calls)
//! - Tool constraint generation
//! - gRPC client SDK (complete request-response flow)
//!
//! # Safety
//! All functions marked with `#[no_mangle]` and `extern "C"` must be called
//! with valid pointers and follow the documented memory management rules.
// Re-export error types
pub use error::{SglErrorCode, set_error_message, set_error_message_fmt, clear_error_message};
// Re-export memory management functions
pub use memory::{sgl_free_string, sgl_free_token_ids};
// Re-export tokenizer functions
pub use tokenizer::{
TokenizerHandle,
sgl_tokenizer_create_from_file,
sgl_tokenizer_encode,
sgl_tokenizer_apply_chat_template,
sgl_tokenizer_apply_chat_template_with_tools,
sgl_tokenizer_decode,
sgl_tokenizer_free,
};
// Re-export tool parser functions
pub use tool_parser::{
ToolParserHandle,
sgl_tool_parser_create,
sgl_tool_parser_parse_complete,
sgl_tool_parser_parse_incremental,
sgl_tool_parser_reset,
sgl_tool_parser_free,
};
// Re-export gRPC converter functions
pub use grpc_converter::{
GrpcResponseConverterHandle,
sgl_grpc_response_converter_create,
sgl_grpc_response_converter_convert_chunk,
sgl_grpc_response_converter_free,
};
// Re-export client SDK functions
pub use client::{
SglangClientHandle,
sgl_client_create,
sgl_client_free,
};
// Re-export stream functions
pub use stream::{
SglangStreamHandle,
sgl_stream_read_next,
sgl_stream_free,
};
// Re-export client stream function (defined in client.rs but used by stream)
pub use client::sgl_client_chat_completion_stream;
// Re-export utility functions
pub use utils::sgl_generate_tool_constraints;
// Sub-modules
mod error;
mod memory;
mod tokenizer;
mod tool_parser;
mod grpc_converter;
mod client;
mod stream;
mod utils;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_codes() {
assert_eq!(SglErrorCode::Success as i32, 0);
assert_eq!(SglErrorCode::InvalidArgument as i32, 1);
}
}
@@ -0,0 +1,28 @@
//! Memory management for FFI functions
use std::ffi::CString;
use std::os::raw::c_char;
/// Free a C string allocated by Rust
///
/// # Safety
/// This function must only be called with pointers returned by other FFI functions.
/// Calling with arbitrary pointers or multiple times on the same pointer is undefined behavior.
#[no_mangle]
pub unsafe extern "C" fn sgl_free_string(s: *mut c_char) {
if !s.is_null() {
let _ = CString::from_raw(s);
}
}
/// Free token IDs array allocated by Rust
///
/// # Safety
/// This function must only be called with pointers returned by `sgl_tokenizer_encode`.
/// The `count` parameter must match the length of the array.
#[no_mangle]
pub unsafe extern "C" fn sgl_free_token_ids(ptr: *mut u32, count: usize) {
if !ptr.is_null() && count > 0 {
let _ = Vec::from_raw_parts(ptr, count, count);
}
}
@@ -0,0 +1,288 @@
//! Stream handling FFI functions
//!
//! This module provides FFI (Foreign Function Interface) functions for managing
//! streaming responses from the SGLang gRPC API. It handles:
//!
//! - Creating and managing stream handles
//! - Reading chunks from streams and converting them to OpenAI format
//! - Managing automatic abort on stream drop (via AbortOnDropStream)
//! - Thread-safe access to streams and response converters
//!
//! # Safety
//!
//! All FFI functions are marked `unsafe` as per Rust FFI conventions. Callers must:
//! - Pass valid pointers
//! - Ensure proper pointer lifetime management
//! - Call corresponding free functions for cleanup
use std::ffi::CString;
use std::os::raw::{c_char, c_int};
use std::ptr;
use std::sync::Arc;
use tokio::runtime::Runtime;
use once_cell::sync::Lazy;
use futures_util::StreamExt;
use sgl_model_gateway::grpc_client::{sglang_proto as proto, sglang_scheduler::{SglangSchedulerClient, AbortOnDropStream}};
use super::error::{SglErrorCode, set_error_message};
use super::grpc_converter::{GrpcResponseConverterHandle, convert_proto_chunk_to_openai};
/// Global tokio runtime for async operations
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Runtime::new().expect("Failed to create tokio runtime for stream FFI")
});
/// Handle for an active streaming request.
///
/// This struct manages the stream and response converter for a single request.
/// It is wrapped in Arc and Mutex for thread-safe concurrent access.
///
/// # Fields
///
/// * `stream` - The gRPC stream wrapped in AbortOnDropStream for automatic cleanup
/// * `converter` - Response converter that transforms proto messages to OpenAI format
/// * `client` - The underlying gRPC client connection
/// * `prompt_tokens` - Number of prompt tokens from the original request
pub struct SglangStreamHandle {
pub(crate) stream: Arc<tokio::sync::Mutex<AbortOnDropStream>>,
pub(crate) converter: Arc<tokio::sync::Mutex<GrpcResponseConverterHandle>>,
#[allow(dead_code)]
pub(crate) client: Arc<SglangSchedulerClient>,
#[allow(dead_code)]
pub(crate) prompt_tokens: i32, // Number of prompt tokens for this request
}
/// Read next chunk from stream and convert to OpenAI format.
///
/// This function reads the next chunk from the gRPC stream, converts it from the
/// internal protocol format to OpenAI-compatible JSON format, and returns it via
/// the output parameters.
///
/// # Arguments
///
/// * `stream_handle` - Mutable pointer to the stream handle
/// * `response_json_out` - Pointer to receive OpenAI format JSON string
/// - Caller must free this with `sgl_free_string`
/// - May be NULL if no data available
/// * `is_done_out` - Pointer to receive completion status
/// - 0 = stream has more data
/// - 1 = stream is complete
/// * `error_out` - Optional pointer to receive error message
/// - Only set if function returns an error code
/// - Must be freed with `sgl_free_string` if not NULL
///
/// # Returns
///
/// * `SglErrorCode::Success` - Successfully read a chunk or reached end of stream
/// * Other error codes - See `SglErrorCode` for details
///
/// # Safety
///
/// - All pointers must be valid and properly aligned
/// - `stream_handle` must point to a valid `SglangStreamHandle`
/// - Output pointers must be writable
///
/// # Notes
///
/// - Complete messages are identified by the presence of `proto::GenerateResponse::Complete`
/// - When is_done=1, this may be the last readable chunk or the stream may be ending
/// - Subsequent calls after is_done=1 will mark the stream as complete internally
#[no_mangle]
pub unsafe extern "C" fn sgl_stream_read_next(
stream_handle: *mut SglangStreamHandle,
response_json_out: *mut *mut c_char,
is_done_out: *mut c_int,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if stream_handle.is_null() || response_json_out.is_null() || is_done_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let handle_ref = &*stream_handle;
let stream = Arc::clone(&handle_ref.stream);
let converter = Arc::clone(&handle_ref.converter);
// Read next chunk from stream
let chunk_result = RUNTIME.block_on(async {
let mut stream_guard = stream.lock().await;
stream_guard.next().await
});
match chunk_result {
Some(Ok(proto_response)) => {
// Convert proto response to OpenAI format
// We need to get the converter lock first
let conversion_result = RUNTIME.block_on(async {
let mut converter_guard = converter.lock().await;
// Clone necessary fields for conversion
let tokenizer = Arc::clone(&converter_guard.tokenizer);
let model = converter_guard.model.clone();
let request_id = converter_guard.request_id.clone();
let created = converter_guard.created;
let system_fingerprint = converter_guard.system_fingerprint.clone();
// Call the conversion function
convert_proto_chunk_to_openai(
proto_response.clone(),
&mut *converter_guard,
&tokenizer,
&model,
&request_id,
created,
system_fingerprint.as_deref(),
)
.await
});
match conversion_result {
Ok(Some(openai_response)) => {
// Serialize to JSON
let result_str = match serde_json::to_string(&openai_response) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to serialize response: {}", e));
return SglErrorCode::ParsingError;
}
};
let result_cstr = match CString::new(result_str) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
// Check if this is a complete response (stream done)
let is_complete = matches!(proto_response.response, Some(proto::generate_response::Response::Complete(_)) | Some(proto::generate_response::Response::Error(_)));
*response_json_out = result_cstr.into_raw();
*is_done_out = if is_complete { 1 } else { 0 };
if is_complete {
// Mark stream as completed
// Ensure mark_completed() completes and is visible before returning
// Use yield_now to ensure Release ordering is fully propagated
RUNTIME.block_on(async {
let stream_guard = stream.lock().await;
stream_guard.mark_completed();
// Keep the guard until mark_completed() is fully executed
drop(stream_guard);
// Yield to ensure Release ordering is propagated before returning
// This prevents race condition where Free() is called immediately
// and Drop might not see the mark_completed() write
tokio::task::yield_now().await;
});
}
SglErrorCode::Success
}
Ok(None) => {
// No response to send (e.g., empty chunk)
// Don't mark as completed - stream might continue
// Just return null and let caller read more
*response_json_out = ptr::null_mut();
*is_done_out = 0; // Keep stream open, not done yet
SglErrorCode::Success
}
Err(e) => {
// Conversion error - don't mark as completed
// Let the stream end naturally or return error without stopping stream
set_error_message(error_out, &format!("Conversion error: {}", e));
*response_json_out = ptr::null_mut();
*is_done_out = 0; // Don't mark as done - let caller decide
SglErrorCode::ParsingError
}
}
}
Some(Err(e)) => {
// Stream error - mark as completed to prevent abort
RUNTIME.block_on(async {
let stream_guard = stream.lock().await;
stream_guard.mark_completed();
drop(stream_guard);
// Yield to ensure Release ordering is propagated
tokio::task::yield_now().await;
});
set_error_message(error_out, &format!("Stream error: {}", e));
*is_done_out = 1;
SglErrorCode::UnknownError
}
None => {
// Stream ended naturally (no more chunks)
// Mark stream as completed before returning to prevent abort
RUNTIME.block_on(async {
let stream_guard = stream.lock().await;
stream_guard.mark_completed();
drop(stream_guard);
// Yield to ensure Release ordering is propagated
tokio::task::yield_now().await;
});
*response_json_out = ptr::null_mut();
*is_done_out = 1;
SglErrorCode::Success
}
}
}
/// Free a stream handle and release all associated resources.
///
/// This function must be called exactly once for each stream handle returned by
/// `sgl_client_chat_completion_stream`. It marks the stream as completed internally
/// to prevent abort signals from being sent when resources are cleaned up.
///
/// # Arguments
///
/// * `handle` - Mutable pointer to the stream handle to free
/// - If NULL, this function does nothing
///
/// # Safety
///
/// - Must be called only once per handle
/// - Handle must not be used after calling this function
/// - After this call, the stream is no longer valid
///
/// # Notes
///
/// - This function internally calls `mark_completed()` before freeing to ensure
/// the stream cleanup doesn't trigger an abort RPC to the server
/// - Memory fences are used to ensure visibility across threads
#[no_mangle]
pub unsafe extern "C" fn sgl_stream_free(handle: *mut SglangStreamHandle) {
if !handle.is_null() {
let handle_ref = Box::from_raw(handle);
// Mark stream as completed to prevent abort on drop
// By this point, the stream should already be completed by ReadNext()
// but we call it again to be safe
RUNTIME.block_on(async {
let stream_guard = handle_ref.stream.lock().await;
stream_guard.mark_completed();
// Keep guard alive to ensure mark_completed() write completes
drop(stream_guard);
// Yield to ensure the atomic write is visible
tokio::task::yield_now().await;
});
// Use a strong memory fence to ensure mark_completed()'s Release write
// is visible before we drop the last Arc reference
std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst);
// Now drop all references - if mark_completed() was called successfully,
// the drop won't send an abort
drop(handle_ref.stream);
// Free converter
let converter = Arc::try_unwrap(handle_ref.converter)
.ok()
.map(|m| m.into_inner());
if let Some(conv) = converter {
super::grpc_converter::sgl_grpc_response_converter_free(Box::into_raw(Box::new(conv)));
}
}
}
@@ -0,0 +1,379 @@
//! Tokenizer FFI functions
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use std::sync::Arc;
use serde_json::Value;
use sgl_model_gateway::tokenizer::{
create_tokenizer_from_file,
traits::Tokenizer as TokenizerTrait,
chat_template::ChatTemplateParams,
huggingface::HuggingFaceTokenizer,
};
use super::error::{SglErrorCode, set_error_message, clear_error_message};
/// Opaque handle for a tokenizer instance
#[repr(C)]
pub struct TokenizerHandle {
pub(crate) tokenizer: Arc<dyn TokenizerTrait>,
}
/// Create a tokenizer from a file path
///
/// # Arguments
/// * `path` - Path to tokenizer.json file (null-terminated C string)
/// * `error_out` - Optional pointer to receive error message (must be freed with sgl_free_string)
///
/// # Returns
/// * Pointer to TokenizerHandle on success, null on failure
///
/// # Safety
/// The returned handle must be freed with `sgl_tokenizer_free`.
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_create_from_file(
path: *const c_char,
error_out: *mut *mut c_char,
) -> *mut TokenizerHandle {
if path.is_null() {
set_error_message(error_out, "path cannot be null");
return ptr::null_mut();
}
let path_str = match CStr::from_ptr(path).to_str() {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Invalid UTF-8 in path: {}", e));
return ptr::null_mut();
}
};
match create_tokenizer_from_file(path_str) {
Ok(tokenizer) => {
clear_error_message(error_out);
Box::into_raw(Box::new(TokenizerHandle {
tokenizer,
}))
}
Err(e) => {
set_error_message(error_out, &e.to_string());
ptr::null_mut()
}
}
}
/// Encode text to token IDs
///
/// # Arguments
/// * `handle` - Tokenizer handle (must not be null)
/// * `text` - Input text (null-terminated C string)
/// * `token_ids_out` - Pointer to receive array of token IDs (must be freed with sgl_free_token_ids)
/// * `token_count_out` - Pointer to receive token count
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
///
/// # Safety
/// The token_ids_out array must be freed with sgl_free_token_ids() after use.
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_encode(
handle: *mut TokenizerHandle,
text: *const c_char,
token_ids_out: *mut *mut u32,
token_count_out: *mut usize,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || text.is_null() || token_ids_out.is_null() || token_count_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let text_str = match CStr::from_ptr(text).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in text");
return SglErrorCode::InvalidArgument;
}
};
let tokenizer = &(*handle).tokenizer;
match tokenizer.encode(text_str) {
Ok(encoding) => {
let token_ids = encoding.token_ids();
let count = token_ids.len();
// Allocate memory for token IDs using Vec, then leak to give ownership to C
let vec = token_ids.to_vec();
let ptr = vec.as_ptr() as *mut u32;
let _ = std::mem::ManuallyDrop::new(vec);
*token_ids_out = ptr;
*token_count_out = count;
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &e.to_string());
SglErrorCode::TokenizationError
}
}
}
/// Apply chat template to messages with tools support
///
/// # Arguments
/// * `handle` - Tokenizer handle
/// * `messages_json` - JSON string of messages array
/// * `tools_json` - Optional JSON string of tools array (null or empty string for no tools)
/// * `result_out` - Pointer to receive result string (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_apply_chat_template_with_tools(
handle: *mut TokenizerHandle,
messages_json: *const c_char,
tools_json: *const c_char,
result_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || messages_json.is_null() || result_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let messages_str = match CStr::from_ptr(messages_json).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in messages_json");
return SglErrorCode::InvalidArgument;
}
};
// Parse JSON messages
let messages: Vec<Value> = match serde_json::from_str(messages_str) {
Ok(msgs) => msgs,
Err(e) => {
set_error_message(error_out, &format!("Failed to parse messages JSON: {}", e));
return SglErrorCode::InvalidArgument;
}
};
// Parse tools JSON if provided
let tools: Option<Vec<Value>> = if tools_json.is_null() {
None
} else {
let tools_str = match CStr::from_ptr(tools_json).to_str() {
Ok(s) => {
if s.is_empty() {
None
} else {
match serde_json::from_str::<Vec<Value>>(s) {
Ok(t) => Some(t),
Err(e) => {
set_error_message(error_out, &format!("Failed to parse tools JSON: {}", e));
return SglErrorCode::InvalidArgument;
}
}
}
}
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in tools_json");
return SglErrorCode::InvalidArgument;
}
};
tools_str
};
// Get the tokenizer from handle
let handle_ref = &*handle;
let tokenizer = &handle_ref.tokenizer;
// Try to downcast to HuggingFaceTokenizer
if let Some(hf_tokenizer) = tokenizer.as_any().downcast_ref::<HuggingFaceTokenizer>() {
// Apply chat template with tools
let empty_docs: [Value; 0] = [];
let tools_slice = tools.as_ref().map(|t| t.as_slice());
let params = ChatTemplateParams {
add_generation_prompt: true,
tools: tools_slice,
documents: Some(&empty_docs),
template_kwargs: None,
};
match hf_tokenizer.apply_chat_template(&messages, params) {
Ok(result) => {
let result_cstr = match CString::new(result) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &format!("Failed to apply chat template: {}", e));
SglErrorCode::TokenizationError
}
}
} else {
set_error_message(error_out, "Chat template is only supported for HuggingFace tokenizers");
SglErrorCode::TokenizationError
}
}
/// Apply chat template to messages
///
/// # Arguments
/// * `handle` - Tokenizer handle
/// * `messages_json` - JSON string of messages array
/// * `result_out` - Pointer to receive result string (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_apply_chat_template(
handle: *mut TokenizerHandle,
messages_json: *const c_char,
result_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || messages_json.is_null() || result_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let messages_str = match CStr::from_ptr(messages_json).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in messages_json");
return SglErrorCode::InvalidArgument;
}
};
// Parse JSON messages
let messages: Vec<Value> = match serde_json::from_str(messages_str) {
Ok(msgs) => msgs,
Err(e) => {
set_error_message(error_out, &format!("Failed to parse messages JSON: {}", e));
return SglErrorCode::InvalidArgument;
}
};
// Get the tokenizer from handle
let handle_ref = &*handle;
let tokenizer = &handle_ref.tokenizer;
// Try to downcast to HuggingFaceTokenizer
if let Some(hf_tokenizer) = tokenizer.as_any().downcast_ref::<HuggingFaceTokenizer>() {
// Apply chat template with default parameters
// Use empty arrays instead of None to avoid template errors
// Set add_generation_prompt to true so the model knows to start generating
let empty_tools: [Value; 0] = [];
let empty_docs: [Value; 0] = [];
let params = ChatTemplateParams {
add_generation_prompt: true, // Important: tells the model to start generating
tools: Some(&empty_tools),
documents: Some(&empty_docs),
template_kwargs: None,
};
match hf_tokenizer.apply_chat_template(&messages, params) {
Ok(result) => {
let result_cstr = match CString::new(result) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &format!("Failed to apply chat template: {}", e));
SglErrorCode::TokenizationError
}
}
} else {
set_error_message(error_out, "Chat template is only supported for HuggingFace tokenizers");
SglErrorCode::TokenizationError
}
}
/// Decode token IDs to text
///
/// # Arguments
/// * `handle` - Tokenizer handle
/// * `token_ids` - Array of token IDs
/// * `token_count` - Number of tokens
/// * `skip_special_tokens` - Whether to skip special tokens
/// * `result_out` - Pointer to receive result string (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_decode(
handle: *mut TokenizerHandle,
token_ids: *const u32,
token_count: usize,
skip_special_tokens: c_int,
result_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || token_ids.is_null() || result_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
if token_count == 0 {
let empty = CString::new("").unwrap();
*result_out = empty.into_raw();
clear_error_message(error_out);
return SglErrorCode::Success;
}
// Convert C array to Rust slice
let token_slice = std::slice::from_raw_parts(token_ids, token_count);
let tokenizer = &(*handle).tokenizer;
match tokenizer.decode(token_slice, skip_special_tokens != 0) {
Ok(text) => {
let result_cstr = match CString::new(text) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &e.to_string());
SglErrorCode::TokenizationError
}
}
}
/// Free a tokenizer handle
///
/// # Safety
/// This function must only be called once per handle, and the handle must not be used after calling.
#[no_mangle]
pub unsafe extern "C" fn sgl_tokenizer_free(handle: *mut TokenizerHandle) {
if !handle.is_null() {
let _ = Box::from_raw(handle);
}
}
@@ -0,0 +1,329 @@
//! Tool parser FFI functions
use std::ffi::{CStr, CString};
use std::os::raw::{c_char};
use std::ptr;
use std::sync::Arc;
use std::collections::HashMap;
use serde_json::{json, Value};
use tokio::runtime::Runtime;
use once_cell::sync::Lazy;
use sgl_model_gateway::tool_parser::{ParserFactory, ToolParser};
use sgl_model_gateway::protocols::common::Tool;
use super::error::{SglErrorCode, set_error_message, clear_error_message};
use super::utils::generate_tool_call_id;
/// Global parser factory (initialized once)
static PARSER_FACTORY: Lazy<ParserFactory> = Lazy::new(|| ParserFactory::new());
/// Global tokio runtime for async operations
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Runtime::new().expect("Failed to create tokio runtime for tool parser FFI")
});
/// Opaque handle for a tool parser instance
/// Note: For streaming, we need mutable access, so we use Arc<Mutex<>> internally
/// Note: This is an opaque handle, C code doesn't access fields directly
pub struct ToolParserHandle {
parser: Arc<tokio::sync::Mutex<Box<dyn ToolParser>>>,
model: String, // Store model name for ID generation
history_tool_calls_count: usize, // Track tool call count for ID generation
tool_index_to_id: HashMap<usize, String>, // Map tool_index to ID for incremental updates
}
/// Create a tool parser
///
/// # Arguments
/// * `parser_type` - Parser type name (e.g., "json", "llama", "mistral") or model name (e.g., "gpt-4")
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * Pointer to ToolParserHandle on success, null on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tool_parser_create(
parser_type: *const c_char,
error_out: *mut *mut c_char,
) -> *mut ToolParserHandle {
if parser_type.is_null() {
set_error_message(error_out, "parser_type cannot be null");
return ptr::null_mut();
}
let type_str = match CStr::from_ptr(parser_type).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in parser_type");
return ptr::null_mut();
}
};
// Create parser using factory
// The factory will determine the parser type based on model name or use the provided type
let parser = if let Some(parser_box) = PARSER_FACTORY.registry().create_for_model(type_str) {
parser_box
} else if let Some(parser_box) = PARSER_FACTORY.registry().create_parser(type_str) {
parser_box
} else {
set_error_message(error_out, &format!("Unknown parser type: {}", type_str));
return ptr::null_mut();
};
Box::into_raw(Box::new(ToolParserHandle {
parser: Arc::new(tokio::sync::Mutex::new(parser)),
model: type_str.to_string(),
history_tool_calls_count: 0,
tool_index_to_id: HashMap::new(),
}))
}
/// Parse complete tool calls from text
///
/// # Arguments
/// * `handle` - Tool parser handle
/// * `text` - Input text to parse
/// * `result_json_out` - Pointer to receive JSON result (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tool_parser_parse_complete(
handle: *mut ToolParserHandle,
text: *const c_char,
result_json_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || text.is_null() || result_json_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let text_str = match CStr::from_ptr(text).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in text");
return SglErrorCode::InvalidArgument;
}
};
let handle_ref = &*handle;
let parser = Arc::clone(&handle_ref.parser);
let model = handle_ref.model.clone();
let history_count = handle_ref.history_tool_calls_count;
// Use tokio runtime to run async code
let result = RUNTIME.block_on(async {
let parser_guard = parser.lock().await;
parser_guard.parse_complete(text_str).await
});
match result {
Ok((normal_text, tool_calls)) => {
// Convert Rust ToolCall to OpenAI format
let openai_tool_calls: Vec<Value> = tool_calls
.into_iter()
.enumerate()
.map(|(index, tc)| {
// Generate ID for this tool call
let id = generate_tool_call_id(&model, &tc.function.name, index, history_count);
json!({
"id": id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
})
})
.collect();
// Build result JSON
let result_json = json!({
"normal_text": normal_text,
"tool_calls": openai_tool_calls
});
let result_str = match serde_json::to_string(&result_json) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to serialize JSON: {}", e));
return SglErrorCode::ParsingError;
}
};
let result_cstr = match CString::new(result_str) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_json_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &format!("Parse error: {}", e));
SglErrorCode::ParsingError
}
}
}
/// Parse tool calls incrementally from streaming chunks
///
/// # Arguments
/// * `handle` - Tool parser handle
/// * `chunk` - New text chunk from stream
/// * `tools_json` - JSON array of available tools (for validation, can be null/empty)
/// * `result_json_out` - Pointer to receive JSON result (must be freed with sgl_free_string)
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_tool_parser_parse_incremental(
handle: *mut ToolParserHandle,
chunk: *const c_char,
tools_json: *const c_char,
result_json_out: *mut *mut c_char,
error_out: *mut *mut c_char,
) -> SglErrorCode {
if handle.is_null() || chunk.is_null() || result_json_out.is_null() {
set_error_message(error_out, "Invalid arguments: null pointer");
return SglErrorCode::InvalidArgument;
}
let chunk_str = match CStr::from_ptr(chunk).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in chunk");
return SglErrorCode::InvalidArgument;
}
};
// Parse tools JSON if provided
let tools: Vec<Tool> = if !tools_json.is_null() {
let tools_str = match CStr::from_ptr(tools_json).to_str() {
Ok(s) => s,
Err(_) => {
set_error_message(error_out, "Invalid UTF-8 in tools_json");
return SglErrorCode::InvalidArgument;
}
};
match serde_json::from_str::<Vec<Tool>>(tools_str) {
Ok(t) => t,
Err(_) => vec![], // If parsing fails, use empty tools
}
} else {
vec![]
};
let handle_ref = &*handle;
let parser = Arc::clone(&handle_ref.parser);
let model = handle_ref.model.clone();
let history_count = handle_ref.history_tool_calls_count;
// Use tokio runtime to run async code
let result = RUNTIME.block_on(async {
let mut parser_guard = parser.lock().await;
parser_guard.parse_incremental(chunk_str, &tools).await
});
match result {
Ok(streaming_result) => {
// Convert StreamingParseResult to OpenAI format
let handle_mut = &mut *handle;
let openai_tool_calls: Vec<Value> = streaming_result
.calls
.into_iter()
.map(|item| {
// For incremental parsing, we may not have complete tool calls yet
// Generate or reuse ID based on tool_index
let id = if let Some(ref name) = item.name {
// New tool call with name - generate ID and store it
let id = generate_tool_call_id(&model, name, item.tool_index, history_count);
handle_mut.tool_index_to_id.insert(item.tool_index, id.clone());
id
} else {
// Parameter update - reuse existing ID for this tool_index
handle_mut.tool_index_to_id
.get(&item.tool_index)
.cloned()
.unwrap_or_else(|| format!("call_{}", item.tool_index))
};
json!({
"id": id,
"type": "function",
"function": {
"name": item.name.unwrap_or_default(),
"arguments": item.parameters
}
})
})
.collect();
// Build result JSON
let result_json = json!({
"normal_text": streaming_result.normal_text,
"tool_calls": openai_tool_calls
});
let result_str = match serde_json::to_string(&result_json) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to serialize JSON: {}", e));
return SglErrorCode::ParsingError;
}
};
let result_cstr = match CString::new(result_str) {
Ok(s) => s,
Err(e) => {
set_error_message(error_out, &format!("Failed to create result string: {}", e));
return SglErrorCode::MemoryError;
}
};
*result_json_out = result_cstr.into_raw();
clear_error_message(error_out);
SglErrorCode::Success
}
Err(e) => {
set_error_message(error_out, &format!("Parse incremental error: {}", e));
SglErrorCode::ParsingError
}
}
}
/// Reset the parser state for reuse
#[no_mangle]
pub unsafe extern "C" fn sgl_tool_parser_reset(handle: *mut ToolParserHandle) {
if handle.is_null() {
return;
}
let handle_ref = &mut *handle;
let parser = Arc::clone(&handle_ref.parser);
// Reset parser state
RUNTIME.block_on(async {
let mut parser_guard = parser.lock().await;
parser_guard.reset();
});
// Reset history count and tool index mapping
handle_ref.history_tool_calls_count = 0;
handle_ref.tool_index_to_id.clear();
}
/// Free a tool parser handle
#[no_mangle]
pub unsafe extern "C" fn sgl_tool_parser_free(handle: *mut ToolParserHandle) {
if !handle.is_null() {
let _ = Box::from_raw(handle);
}
}
@@ -0,0 +1,44 @@
//! Utility functions for FFI
use uuid::Uuid;
/// Helper function to generate tool call ID (matches router implementation)
pub fn generate_tool_call_id(
model: &str,
function_name: &str,
index: usize,
history_tool_calls_count: usize,
) -> String {
if model.to_lowercase().contains("kimi") {
// KimiK2 format: functions.{name}:{global_index}
format!("functions.{}:{}", function_name, history_tool_calls_count + index)
} else {
// Standard OpenAI format: call_{24-char-uuid}
format!("call_{}", &Uuid::new_v4().simple().to_string()[..24])
}
}
/// Generate tool constraints (placeholder implementation)
///
/// # Arguments
/// * `tools_json` - JSON array of tools
/// * `tool_choice_json` - JSON object representing tool_choice
/// * `constraint_type_out` - Pointer to receive constraint type (e.g., "json_schema")
/// * `constraint_schema_out` - Pointer to receive constraint schema JSON
/// * `error_out` - Optional pointer to receive error message
///
/// # Returns
/// * SglErrorCode::Success on success, error code on failure
#[no_mangle]
pub unsafe extern "C" fn sgl_generate_tool_constraints(
_tools_json: *const std::os::raw::c_char,
_tool_choice_json: *const std::os::raw::c_char,
_constraint_type_out: *mut *mut std::os::raw::c_char,
_constraint_schema_out: *mut *mut std::os::raw::c_char,
error_out: *mut *mut std::os::raw::c_char,
) -> super::error::SglErrorCode {
// Implementation would parse JSON and call generate_tool_constraints
// This is a placeholder
super::error::set_error_message(error_out, "Tool constraint generation not yet implemented in FFI");
super::error::SglErrorCode::UnknownError
}
@@ -0,0 +1,9 @@
[run]
source = sglang_router
omit =
*/mini_lb.py
*/cli.py
*/__main__.py
[report]
fail_under = 80
@@ -0,0 +1,27 @@
[package]
name = "sgl-model-gateway-python"
version = "0.2.3"
edition = "2021"
[lib]
name = "sglang_router_rs"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.27.1", features = ["extension-module", "abi3-py38"] }
tokio = { version = "1.42.0", features = ["full"] }
[dependencies.sgl-model-gateway]
path = "../.."
default-features = true
[features]
default = ["pyo3/extension-module"]
vendored-openssl = ["sgl-model-gateway/vendored-openssl"]
[profile.ci]
inherits = "release"
opt-level = 2 # Lighter optimization (still fast runtime, much faster compile)
lto = "thin" # Thin LTO - good balance
codegen-units = 16 # More parallelization for faster builds
strip = true
@@ -0,0 +1,9 @@
# Must include:
include Cargo.toml # Python bindings Cargo configuration
include ../../Cargo.toml # Main Rust project configuration
include ../../build.rs # Build script for protobuf generation
include ../../LICENSE
recursive-include src *.rs # Python bindings wrapper
recursive-include ../../src *.rs # Main Rust source files
recursive-include ../../src/proto *.proto # Protobuf definitions
recursive-include sglang_router *.py # Python source files
@@ -0,0 +1,71 @@
# SGLang Model Gateway Python Bindings
This directory contains the Python bindings for the SGLang Router, built using [maturin](https://github.com/PyO3/maturin) and [PyO3](https://github.com/PyO3/pyo3).
## Directory Structure
```
bindings/python/
├── src/ # Rust source code for Python bindings
│ └── lib.rs # PyO3 bindings implementation
├── sglang_router/ # Python source code
│ ├── __init__.py
│ ├── version.py
│ ├── launch_server.py
│ ├── launch_router.py
│ ├── router.py
│ ├── router_args.py
│ └── mini_lb.py
├── Cargo.toml # Rust package configuration for bindings
├── pyproject.toml # Python package configuration
├── setup.py # Setup configuration
├── MANIFEST.in # Package manifest
├── .coveragerc # Test coverage configuration
└── README.md # This file
```
## Building
### Development Build
```bash
# Install maturin
pip install maturin
# Build and install in development mode
cd sgl-model-gateway/bindings/python
maturin develop --features vendored-openssl
```
### Production Build
```bash
# Build wheel
cd sgl-model-gateway/bindings/python
maturin build --release --out dist --features vendored-openssl
# Install the built wheel
pip install dist/sglang_router-*.whl
```
## Testing
```bash
# Run Python tests
cd sgl-model-gateway
pytest py_test/
```
## Configuration
- **pyproject.toml**: Defines package metadata, dependencies, and build configuration
- **python-source**: Set to "." to indicate Python source is in the same directory as pyproject.toml
- **module-name**: `sglang_router.sglang_router_rs` - the Rust extension module name
## Notes
- The Rust bindings source code is located in `src/lib.rs`
- The bindings have their own `Cargo.toml` in this directory
- The main sglang-router library is located in `../../` and is used as a dependency
- The package includes both Python code and Rust extensions built with PyO3
- PyO3 types are prefixed with `Py` in Rust but exposed to Python without the prefix using the `name` attribute
@@ -0,0 +1,54 @@
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "sglang-router"
version = "0.2.3"
description = "High-performance Rust-based load balancer for SGLang with multiple routing algorithms and prefill-decode disaggregation support"
authors = [
{name = "Simo Lin", email = "linsimo.mark@gmail.com"},
{name = "Chang Su", email = "mckvtl@gmail.com"},
{name = "Keyang Ru", email = "rukeyang@gmail.com"},
{name = "Byron Hsu", email = "byronhsu1230@gmail.com"}
]
requires-python = ">=3.8"
readme = "../../README.md"
license = { text = "Apache-2.0" }
classifiers = [
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Rust",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"setproctitle",
"aiohttp",
"orjson",
"uvicorn",
"fastapi",
]
[project.optional-dependencies]
dev = [
"requests>=2.25.0",
]
[project.scripts]
smg = "sglang_router.cli:main"
amg = "sglang_router.cli:main"
sglang-router = "sglang_router.cli:main"
[tool.maturin]
python-source = "."
module-name = "sglang_router.sglang_router_rs"
# Exclude bindings/python/README.md to use root README only
exclude = ["README.md"]
@@ -0,0 +1,28 @@
import os
import warnings
from setuptools import setup
with_rust = os.environ.get("SGLANG_ROUTER_BUILD_WITH_RUST", None)
with_rust = with_rust is None or (not with_rust.lower() in ["0", "false", "no"])
rust_extensions = []
if with_rust:
from setuptools_rust import Binding, RustExtension
rust_extensions.append(
RustExtension(
target="sglang_router_rs",
path="Cargo.toml",
binding=Binding.PyO3,
)
)
else:
warnings.warn(
"Building 'sglang-router' without Rust support. Performance may be degraded."
)
setup(
rust_extensions=rust_extensions,
zip_safe=False,
)
@@ -0,0 +1,3 @@
from sglang_router.version import __version__
__all__ = ["__version__"]
@@ -0,0 +1,8 @@
"""
Allow running the CLI via: python -m sglang_router
"""
from sglang_router.cli import main
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
SGLang Model Gateway CLI
Provides convenient command-line interface for launching the router and server.
Usage:
smg launch [args] # Launch router only
smg server [args] # Launch router + server
smg --help # Show help
"""
import argparse
import os
import sys
from typing import List, Optional
from sglang_router.sglang_router_rs import (
get_verbose_version_string,
get_version_string,
)
def create_parser() -> argparse.ArgumentParser:
"""Create the main CLI parser with subcommands."""
prog_name = os.path.basename(sys.argv[0]) if sys.argv else "smg"
parser = argparse.ArgumentParser(
prog=prog_name,
description="SGLang Model Gateway - High-performance inference router",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Launch router subcommand
launch_parser = subparsers.add_parser(
"launch",
help="Launch router only (requires existing worker URLs)",
description="Launch the SGLang router with existing worker instances",
add_help=False, # Let router handle --help
)
# Launch server + router subcommand
server_parser = subparsers.add_parser(
"server",
help="Launch router and server processes together",
description="Launch both SGLang router and server processes",
add_help=False, # Let server handle --help
)
return parser
def main(argv: Optional[List[str]] = None) -> None:
"""Main CLI entry point."""
if argv is None:
argv = sys.argv[1:]
# Handle version flags before parsing
if argv and argv[0] in ["--version", "-V", "--version-verbose"]:
if argv[0] == "--version-verbose":
print(get_verbose_version_string())
else:
print(get_version_string())
sys.exit(0)
# Handle empty command - show help
if not argv or argv[0] not in ["launch", "server", "-h", "--help"]:
parser = create_parser()
parser.print_help()
sys.exit(1)
parser = create_parser()
args, unknown = parser.parse_known_args(argv)
if args.command == "launch":
# Import and call launch_router functions directly
from sglang_router.launch_router import launch_router, parse_router_args
# All router args are in unknown
router_args = parse_router_args(unknown)
launch_router(router_args)
elif args.command == "server":
# Import and call launch_server main with proper argv
# Note: launch_server.main() uses argparse internally which reads sys.argv
# We need to temporarily set sys.argv for compatibility
import sglang_router.launch_server as launch_server_module
# Preserve original sys.argv
original_argv = sys.argv
try:
# All server args are in unknown
prog_name = os.path.basename(sys.argv[0]) if sys.argv else "smg"
sys.argv = [f"{prog_name} server"] + unknown
launch_server_module.main()
finally:
# Restore original sys.argv
sys.argv = original_argv
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,113 @@
import argparse
import logging
import sys
from typing import List, Optional
import setproctitle
from sglang_router.mini_lb import MiniLoadBalancer
from sglang_router.router_args import RouterArgs
logger = logging.getLogger("router")
try:
from sglang_router.router import Router
except ImportError:
Router = None
logger.warning(
"Rust Router is not installed, only python MiniLB (debugging only) is available"
)
def launch_router(args: argparse.Namespace) -> Optional[Router]:
"""
Launch the SGLang router with the configuration from parsed arguments.
Args:
args: Namespace object containing router configuration
Can be either raw argparse.Namespace or converted RouterArgs
Returns:
Router instance if successful, None if failed
"""
setproctitle.setproctitle("sglang::router")
try:
# Convert to RouterArgs if needed
if not isinstance(args, RouterArgs):
router_args = RouterArgs.from_cli_args(args)
else:
router_args = args
if router_args.mini_lb:
mini_lb = MiniLoadBalancer(router_args)
mini_lb.start()
else:
# TODO: support tracing for router(Rust).
del router_args.enable_trace
del router_args.otlp_traces_endpoint
if Router is None:
raise RuntimeError("Rust Router is not installed")
router_args._validate_router_args()
router = Router.from_args(router_args)
router.start()
except Exception as e:
logger.error(f"Error starting router: {e}")
raise e
class CustomHelpFormatter(
argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter
):
"""Custom formatter that preserves both description formatting and shows defaults"""
pass
def parse_router_args(args: List[str]) -> RouterArgs:
"""Parse command line arguments and return RouterArgs instance."""
parser = argparse.ArgumentParser(
description="""SGLang Router - High-performance request distribution across worker nodes
Usage:
This launcher enables starting a router with individual worker instances. It is useful for
multi-node setups or when you want to start workers and router separately.
Examples:
# Regular mode
python -m sglang_router.launch_router --worker-urls http://worker1:8000 http://worker2:8000
# PD disaggregated mode with same policy for both
python -m sglang_router.launch_router --pd-disaggregation \\
--prefill http://prefill1:8000 9000 --prefill http://prefill2:8000 \\
--decode http://decode1:8001 --decode http://decode2:8001 \\
--policy cache_aware
# PD mode with optional bootstrap ports
python -m sglang_router.launch_router --pd-disaggregation \\
--prefill http://prefill1:8000 9000 \\ # With bootstrap port
--prefill http://prefill2:8000 none \\ # Explicitly no bootstrap port
--prefill http://prefill3:8000 \\ # Defaults to no bootstrap port
--decode http://decode1:8001 --decode http://decode2:8001
# PD mode with different policies for prefill and decode
python -m sglang_router.launch_router --pd-disaggregation \\
--prefill http://prefill1:8000 --prefill http://prefill2:8000 \\
--decode http://decode1:8001 --decode http://decode2:8001 \\
--prefill-policy cache_aware --decode-policy power_of_two
""",
formatter_class=CustomHelpFormatter,
)
RouterArgs.add_cli_args(parser, use_router_prefix=False)
return RouterArgs.from_cli_args(parser.parse_args(args), use_router_prefix=False)
def main() -> None:
router_args = parse_router_args(sys.argv[1:])
launch_router(router_args)
if __name__ == "__main__":
main()
@@ -0,0 +1,213 @@
import argparse
import asyncio
import copy
import logging
import multiprocessing as mp
import os
import random
import signal
import sys
import time
from typing import List
import requests
from setproctitle import setproctitle
from sglang_router.launch_router import RouterArgs, launch_router
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils import is_port_available
def setup_logger():
logger = logging.getLogger("router")
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
"[Router (Python)] %(asctime)s - %(levelname)s - %(message)s - %(filename)s:%(lineno)d",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
logger = setup_logger()
# Create new process group
def run_server(server_args, dp_rank):
"""
Note:
1. Without os.setpgrp(), all processes share the same PGID. When you press Ctrl+C, the terminal sends SIGINT to all processes in the group simultaneously.
This can cause leaf processes to terminate first, which messes up the cleaning order and produces orphaned processes.
Terminal (PGID=100)
└── Main Python Process (PGID=100)
└── Server Process 1 (PGID=100)
└── Scheduler 1
└── Detokenizer 1
└── Server Process 2 (PGID=100)
└── Scheduler 2
└── Detokenizer 2
2. With os.setpgrp(), the main Python process and its children are in a separate group. Now:
Terminal (PGID=100)
└── Main Python Process (PGID=200)
└── Server Process 1 (PGID=300)
└── Scheduler 1
└── Detokenizer 1
└── Server Process 2 (PGID=400)
└── Scheduler 2
└── Detokenizer 2
"""
# create new process group
os.setpgrp()
setproctitle("sglang::server")
# Set SGLANG_DP_RANK environment variable
os.environ["SGLANG_DP_RANK"] = str(dp_rank)
# Launch server in appropriate mode (HTTP or gRPC)
if server_args.grpc_mode:
from sglang.srt.entrypoints.grpc_server import serve_grpc
asyncio.run(serve_grpc(server_args))
else:
from sglang.srt.entrypoints.http_server import launch_server
launch_server(server_args)
def launch_server_process(
server_args: ServerArgs, worker_port: int, dp_id: int
) -> mp.Process:
"""Launch a single server process with the given args and port."""
server_args = copy.deepcopy(server_args)
server_args.port = worker_port
server_args.base_gpu_id = dp_id * server_args.tp_size
server_args.dp_size = 1
proc = mp.Process(target=run_server, args=(server_args, dp_id))
proc.start()
return proc
def wait_for_server_health(host: str, port: int, timeout: int = 300) -> bool:
"""Wait for server to be healthy by checking /health endpoint."""
start_time = time.perf_counter()
url = f"http://{host}:{port}/health"
while time.perf_counter() - start_time < timeout:
try:
response = requests.get(url, timeout=5)
if response.status_code == 200:
return True
except requests.exceptions.RequestException:
pass
time.sleep(1)
return False
def find_available_ports(base_port: int, count: int) -> List[int]:
"""Find consecutive available ports starting from base_port."""
available_ports = []
current_port = base_port
while len(available_ports) < count:
if is_port_available(current_port):
available_ports.append(current_port)
current_port += random.randint(100, 1000)
return available_ports
def cleanup_processes(processes: List[mp.Process]):
for process in processes:
logger.info(f"Terminating process group {process.pid}")
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
# Process group may already be terminated
pass
# Wait for processes to terminate
for process in processes:
process.join(timeout=5)
if process.is_alive():
logger.warning(
f"Process {process.pid} did not terminate gracefully, forcing kill"
)
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
logger.info("All process groups terminated")
def main():
# CUDA runtime isn't fork-safe, which can lead to subtle bugs or crashes
mp.set_start_method("spawn")
parser = argparse.ArgumentParser(
description="Launch SGLang router and server processes"
)
ServerArgs.add_cli_args(parser)
RouterArgs.add_cli_args(parser, use_router_prefix=True, exclude_host_port=True)
parser.add_argument(
"--router-dp-worker-base-port",
type=int,
default=31000,
help="Base port number for data parallel workers",
)
# No extra retry/CB flags here; RouterArgs.add_cli_args already defines them with router- prefix
args = parser.parse_args()
server_args = ServerArgs.from_cli_args(args)
router_args = RouterArgs.from_cli_args(args, use_router_prefix=True)
# Find available ports for workers
worker_ports = find_available_ports(
args.router_dp_worker_base_port, server_args.dp_size
)
# Start server processes
server_processes = []
for i, worker_port in enumerate(worker_ports):
logger.info(f"Launching DP server process {i} on port {worker_port}")
proc = launch_server_process(server_args, worker_port, i)
server_processes.append(proc)
signal.signal(signal.SIGINT, lambda sig, frame: cleanup_processes(server_processes))
signal.signal(
signal.SIGTERM, lambda sig, frame: cleanup_processes(server_processes)
)
signal.signal(
signal.SIGQUIT, lambda sig, frame: cleanup_processes(server_processes)
)
# Update router args with worker URLs
# Use grpc:// protocol if server is in gRPC mode, otherwise http://
protocol = "grpc" if server_args.grpc_mode else "http"
router_args.worker_urls = [
f"{protocol}://{server_args.host}:{port}" for port in worker_ports
]
# Start the router
try:
launch_router(router_args)
except Exception as e:
logger.error(f"Failed to start router: {e}")
cleanup_processes(server_processes)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,488 @@
"""
Minimal HTTP load balancer for prefill and decode servers for testing.
"""
import asyncio
import ipaddress
import logging
import random
import urllib
from http import HTTPStatus
from itertools import chain
from typing import Optional
import aiohttp
import orjson
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import ORJSONResponse, Response, StreamingResponse
from sglang_router.router_args import RouterArgs
try:
from sglang.srt.tracing.trace import (
process_tracing_init,
trace_get_remote_propagate_context,
trace_req_finish,
trace_req_start,
trace_set_thread_info,
trace_slice_end,
trace_slice_start,
)
trace_package_imported = True
except ImportError:
trace_package_imported = False
logger = logging.getLogger(__name__)
AIOHTTP_STREAM_READ_CHUNK_SIZE = (
1024 * 64
) # 64KB, to prevent aiohttp's "Chunk too big" error
def maybe_wrap_ipv6_address(address: str) -> str:
try:
ipaddress.IPv6Address(address)
return f"[{address}]"
except ValueError:
return address
class MiniLoadBalancer:
def __init__(
self,
router_args: RouterArgs,
):
self._validate_router_args(router_args)
self.host = router_args.host
self.port = router_args.port
self.timeout = router_args.request_timeout_secs
self.prefill_urls = [url[0] for url in router_args.prefill_urls]
self.prefill_bootstrap_ports = [url[1] for url in router_args.prefill_urls]
self.decode_urls = router_args.decode_urls
self.otlp_traces_endpoint = router_args.otlp_traces_endpoint
self.enable_trace = router_args.enable_trace
if self.enable_trace and not trace_package_imported:
logger.warning(
"Tracing is not supported in this environment. Please install sglang."
)
self.enable_trace = False
def _validate_router_args(self, router_args: RouterArgs):
logger.warning(
"\x1b[33mMiniLB is only for debugging purposes, it only supports random policy!\033[0m"
)
# NOTE: too many arguments unsupported, just validate some important ones
if router_args.policy != "random":
logger.warning("[MiniLB] Overriding policy to random")
router_args.policy = "random"
if not router_args.pd_disaggregation:
raise ValueError("MiniLB only supports PD disaggregation mode")
if len(router_args.prefill_urls) == 0 or len(router_args.decode_urls) == 0:
raise ValueError(
"MiniLB requires at least one prefill and one decode server"
)
def start(self):
global lb
lb = self
if self.enable_trace:
process_tracing_init(self.otlp_traces_endpoint, "sglang")
trace_set_thread_info("Mini lb")
uvicorn.run(app, host=self.host, port=self.port)
def select_pair(self):
assert len(self.prefill_urls) > 0, "No prefill servers available"
assert len(self.decode_urls) > 0, "No decode servers available"
pidx = random.randint(0, len(self.prefill_urls) - 1)
didx = random.randint(0, len(self.decode_urls) - 1)
return (
self.prefill_urls[pidx],
self.prefill_bootstrap_ports[pidx],
self.decode_urls[didx],
)
async def generate(
self, modified_request, prefill_server, decode_server, endpoint
) -> ORJSONResponse:
assert endpoint[0] != "/", f"Endpoint should not start with '/': {endpoint}"
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(
total=self.timeout
) # Add timeout for request reliability
) as session:
headers = {}
bootstrap_room_list = []
if self.enable_trace:
bootstrap_room_list = (
modified_request["bootstrap_room"]
if isinstance(modified_request["bootstrap_room"], list)
else [modified_request["bootstrap_room"]]
)
trace_context = trace_get_remote_propagate_context(bootstrap_room_list)
headers = {"trace_context": trace_context}
tasks = [
session.post(
f"{prefill_server}/{endpoint}",
json=modified_request,
headers=headers,
),
session.post(
f"{decode_server}/{endpoint}",
json=modified_request,
headers=headers,
),
]
for bootstrap_room in bootstrap_room_list:
trace_slice_end("mini_lb_launch", bootstrap_room, auto_next_anon=True)
# Wait for both responses to complete. Prefill should end first.
prefill_response, decode_response = await asyncio.gather(*tasks)
if "return_logprob" in modified_request:
prefill_json = await prefill_response.json()
ret_json = await decode_response.json()
# merge `meta_info.input_token_logprobs` from prefill to decode
if "meta_info" in ret_json:
if "input_token_logprobs" in ret_json["meta_info"]:
ret_json["meta_info"]["input_token_logprobs"] = (
prefill_json["meta_info"]["input_token_logprobs"]
+ ret_json["meta_info"]["input_token_logprobs"]
)
else:
ret_json = await decode_response.json()
for bootstrap_room in bootstrap_room_list:
trace_slice_end(
"wait_PD_finish",
bootstrap_room,
thread_finish_flag=True,
)
trace_req_finish(bootstrap_room)
return ORJSONResponse(
content=ret_json,
status_code=decode_response.status,
)
async def generate_stream(
self, modified_request, prefill_server, decode_server, endpoint="generate"
):
assert endpoint[0] != "/", f"Endpoint should not start with '/': {endpoint}"
async def stream_results():
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(
total=self.timeout
) # Add timeout for request reliability
) as session:
# Create the tasks for both prefill and decode requests
headers = {}
bootstrap_room_list = []
if self.enable_trace:
bootstrap_room_list = (
modified_request["bootstrap_room"]
if isinstance(modified_request["bootstrap_room"], list)
else [modified_request["bootstrap_room"]]
)
trace_context = trace_get_remote_propagate_context(
bootstrap_room_list
)
headers = {"trace_context": trace_context}
tasks = [
session.post(
f"{prefill_server}/{endpoint}",
json=modified_request,
headers=headers,
),
session.post(
f"{decode_server}/{endpoint}",
json=modified_request,
headers=headers,
),
]
for bootstrap_room in bootstrap_room_list:
trace_slice_end(
"mini_lb_launch", bootstrap_room, auto_next_anon=True
)
# Wait for both responses to complete. Since this is streaming, they return immediately.
prefill_response, decode_response = await asyncio.gather(*tasks)
if modified_request.get("return_logprob", False):
prefill_chunks = []
async for chunk in prefill_response.content:
prefill_chunks.append(chunk)
first_prefill_chunk = (
prefill_chunks[0].decode("utf-8")[5:].strip("\n")
)
first_prefill_chunk_json = orjson.loads(first_prefill_chunk)
async for chunk in decode_response.content:
# Note: This is inefficient
# merge prefill input_token_logprobs, output_token_logprobs to decode
decoded_chunk = chunk.decode("utf-8")
if (
decoded_chunk
and decoded_chunk.startswith("data:")
and "[DONE]" not in decoded_chunk
):
ret_json = orjson.loads(decoded_chunk[5:].strip("\n"))
ret_json["meta_info"]["input_token_logprobs"] = (
first_prefill_chunk_json["meta_info"][
"input_token_logprobs"
]
+ ret_json["meta_info"]["input_token_logprobs"]
)
yield b"data: " + orjson.dumps(ret_json) + b"\n\n"
else:
yield chunk
else:
async for chunk in decode_response.content.iter_chunked(
AIOHTTP_STREAM_READ_CHUNK_SIZE
):
yield chunk
for bootstrap_room in bootstrap_room_list:
trace_slice_end(
"wait_PD_finish",
bootstrap_room,
thread_finish_flag=True,
)
trace_req_finish(bootstrap_room)
return StreamingResponse(
stream_results(),
media_type="text/event-stream",
)
app = FastAPI()
lb: Optional[MiniLoadBalancer] = None
@app.get("/health")
async def health_check():
return Response(status_code=200)
@app.get("/health_generate")
async def health_generate():
async with aiohttp.ClientSession() as session:
# Create the tasks
tasks = []
for server in chain(lb.prefill_urls, lb.decode_urls):
tasks.append(session.get(f"{server}/health_generate"))
for i, response in enumerate(asyncio.as_completed(tasks)):
await response
return Response(status_code=200)
@app.post("/flush_cache")
async def flush_cache():
async with aiohttp.ClientSession() as session:
# Create the tasks
tasks = []
for server in chain(lb.prefill_urls, lb.decode_urls):
tasks.append(session.post(f"{server}/flush_cache"))
for i, response in enumerate(asyncio.as_completed(tasks)):
await response
return Response(status_code=200)
@app.get("/get_server_info")
async def get_server_info():
prefill_infos = []
decode_infos = []
all_internal_states = []
async with aiohttp.ClientSession() as session:
for server in lb.prefill_urls:
server_info = await session.get(f"{server}/get_server_info")
prefill_infos.append(await server_info.json())
for server in lb.decode_urls:
server_info = await session.get(f"{server}/get_server_info")
info_json = await server_info.json()
decode_infos.append(info_json)
# Extract internal_states from decode servers
if "internal_states" in info_json:
all_internal_states.extend(info_json["internal_states"])
# Return format expected by bench_one_batch_server.py
if all_internal_states:
return {
"internal_states": all_internal_states,
"prefill": prefill_infos,
"decode": decode_infos,
}
else:
# Fallback with dummy data if no internal states found
return {
"internal_states": [
{
"last_gen_throughput": 0.0,
"avg_spec_accept_length": None,
}
],
"prefill": prefill_infos,
"decode": decode_infos,
}
@app.get("/get_model_info")
async def get_model_info():
if not lb or not lb.prefill_urls:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="There is no server registered",
)
target_server_url = lb.prefill_urls[0]
endpoint_url = f"{target_server_url}/get_model_info"
async with aiohttp.ClientSession() as session:
try:
async with session.get(endpoint_url) as response:
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail=(
f"Failed to get model info from {target_server_url}"
f"Status: {response.status}, Response: {error_text}"
),
)
model_info_json = await response.json()
return ORJSONResponse(content=model_info_json)
except aiohttp.ClientError as e:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail=f"Failed to get model info from backend",
)
@app.post("/generate")
async def handle_generate_request(request_data: dict):
prefill_server, bootstrap_port, decode_server = lb.select_pair()
# Parse and transform prefill_server for bootstrap data
parsed_url = urllib.parse.urlparse(prefill_server)
hostname = maybe_wrap_ipv6_address(parsed_url.hostname)
modified_request = request_data.copy()
batch_size = _get_request_batch_size(modified_request)
if batch_size is not None:
modified_request.update(
{
"bootstrap_host": [hostname] * batch_size,
"bootstrap_port": [bootstrap_port] * batch_size,
"bootstrap_room": [
_generate_bootstrap_room() for _ in range(batch_size)
],
}
)
else:
modified_request.update(
{
"bootstrap_host": hostname,
"bootstrap_port": bootstrap_port,
"bootstrap_room": _generate_bootstrap_room(),
}
)
if request_data.get("stream", False):
return await lb.generate_stream(
modified_request, prefill_server, decode_server, "generate"
)
else:
return await lb.generate(
modified_request, prefill_server, decode_server, "generate"
)
async def _forward_to_backend(request_data: dict, endpoint_name: str):
prefill_server, bootstrap_port, decode_server = lb.select_pair()
# Parse and transform prefill_server for bootstrap data
parsed_url = urllib.parse.urlparse(prefill_server)
hostname = maybe_wrap_ipv6_address(parsed_url.hostname)
modified_request = request_data.copy()
modified_request.update(
{
"bootstrap_host": hostname,
"bootstrap_port": bootstrap_port,
"bootstrap_room": _generate_bootstrap_room(),
}
)
if request_data.get("stream", False):
return await lb.generate_stream(
modified_request,
prefill_server,
decode_server,
endpoint=endpoint_name,
)
else:
return await lb.generate(
modified_request,
prefill_server,
decode_server,
endpoint=endpoint_name,
)
@app.post("/v1/chat/completions")
async def handle_chat_completion_request(request_data: dict):
return await _forward_to_backend(request_data, "v1/chat/completions")
@app.post("/v1/completions")
async def handle_completion_request(request_data: dict):
return await _forward_to_backend(request_data, "v1/completions")
def _generate_bootstrap_room():
bootstrap_room = random.randint(0, 2**63 - 1)
if lb.enable_trace:
trace_req_start(bootstrap_room, bootstrap_room, role="router")
trace_slice_start("mini_lb_launch", bootstrap_room)
return bootstrap_room
# We may utilize `GenerateReqInput`'s logic later
def _get_request_batch_size(request):
if (text := request.get("text")) is not None:
return None if isinstance(text, str) else len(text)
if (input_ids := request.get("input_ids")) is not None:
return None if isinstance(input_ids[0], int) else len(input_ids)
return None
@app.get("/v1/models")
async def get_models():
prefill_server = lb.prefill_urls[0] # Get the first prefill server
async with aiohttp.ClientSession() as session:
try:
response = await session.get(f"{prefill_server}/v1/models")
if response.status != 200:
raise HTTPException(
status_code=response.status,
detail=f"Prefill server error: Status {response.status}",
)
return ORJSONResponse(content=await response.json())
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,226 @@
from typing import Optional
from sglang_router.router_args import RouterArgs
from sglang_router.sglang_router_rs import (
BackendType,
HistoryBackendType,
PolicyType,
PyOracleConfig,
PyPostgresConfig,
)
from sglang_router.sglang_router_rs import Router as _Router
def policy_from_str(policy_str: Optional[str]) -> PolicyType:
"""Convert policy string to PolicyType enum."""
if policy_str is None:
return None
policy_map = {
"random": PolicyType.Random,
"round_robin": PolicyType.RoundRobin,
"cache_aware": PolicyType.CacheAware,
"power_of_two": PolicyType.PowerOfTwo,
"bucket": PolicyType.Bucket,
}
return policy_map[policy_str]
def backend_from_str(backend_str: Optional[str]) -> BackendType:
"""Convert backend string to BackendType enum."""
if isinstance(backend_str, BackendType):
return backend_str
if backend_str is None:
return BackendType.Sglang
backend_map = {"sglang": BackendType.Sglang, "openai": BackendType.Openai}
backend_lower = backend_str.lower()
if backend_lower not in backend_map:
raise ValueError(
f"Unknown backend: {backend_str}. Valid options: {', '.join(backend_map.keys())}"
)
return backend_map[backend_lower]
def history_backend_from_str(backend_str: Optional[str]) -> HistoryBackendType:
"""Convert history backend string to HistoryBackendType enum."""
if isinstance(backend_str, HistoryBackendType):
return backend_str
if backend_str is None:
return HistoryBackendType.Memory
backend_lower = backend_str.lower()
if backend_lower == "memory":
return HistoryBackendType.Memory
elif backend_lower == "none":
# Use getattr to access 'None' which is a Python keyword
return getattr(HistoryBackendType, "None")
elif backend_lower == "oracle":
return HistoryBackendType.Oracle
elif backend_lower == "postgres":
return HistoryBackendType.Postgres
else:
raise ValueError(f"Unknown history backend: {backend_str}")
class Router:
"""
A high-performance router for distributing requests across worker nodes.
Args:
worker_urls: List of URLs for worker nodes that will handle requests. Each URL should include
the protocol, host, and port (e.g., ['http://worker1:8000', 'http://worker2:8000'])
policy: Load balancing policy to use. Options:
- PolicyType.Random: Randomly select workers
- PolicyType.RoundRobin: Distribute requests in round-robin fashion
- PolicyType.CacheAware: Distribute requests based on cache state and load balance
- PolicyType.PowerOfTwo: Select best of two random workers based on load (PD mode only)
host: Host address to bind the router server. Supports IPv4, IPv6 (e.g., ::, ::1), or 0.0.0.0 for all interfaces. Default: '0.0.0.0'
port: Port number to bind the router server. Default: 3001
worker_startup_timeout_secs: Timeout in seconds for worker startup and registration. Large models can take significant time to load into GPU memory. Default: 1800 (30 minutes)
worker_startup_check_interval: Interval in seconds between checks for worker initialization. Default: 10
cache_threshold: Cache threshold (0.0-1.0) for cache-aware routing. Routes to cached worker
if the match rate exceeds threshold, otherwise routes to the worker with the smallest
tree. Default: 0.5
balance_abs_threshold: Load balancing is triggered when (max_load - min_load) > abs_threshold
AND max_load > min_load * rel_threshold. Otherwise, use cache aware. Default: 32
balance_rel_threshold: Load balancing is triggered when (max_load - min_load) > abs_threshold
AND max_load > min_load * rel_threshold. Otherwise, use cache aware. Default: 1.0001
eviction_interval_secs: Interval in seconds between cache eviction operations in cache-aware
routing. Default: 60
max_payload_size: Maximum payload size in bytes. Default: 256MB
max_tree_size: Maximum size of the approximation tree for cache-aware routing. Default: 2^24
dp_aware: Enable data parallelism aware schedule. Default: False
enable_igw: Enable IGW (Inference-Gateway) mode for multi-model support. When enabled,
the router can manage multiple models simultaneously with per-model load balancing
policies. Default: False
api_key: The api key used for the authorization with the worker.
Useful when the dp aware scheduling strategy is enabled.
Default: None
log_dir: Directory to store log files. If None, logs are only output to console. Default: None
log_level: Logging level. Options: 'debug', 'info', 'warn', 'error'.
service_discovery: Enable Kubernetes service discovery. When enabled, the router will
automatically discover worker pods based on the selector. Default: False
selector: Dictionary mapping of label keys to values for Kubernetes pod selection.
Example: {"app": "sglang-worker"}. Default: {}
service_discovery_port: Port to use for service discovery. The router will generate
worker URLs using this port. Default: 80
service_discovery_namespace: Kubernetes namespace to watch for pods. If not provided,
watches pods across all namespaces (requires cluster-wide permissions). Default: None
prefill_selector: Dictionary mapping of label keys to values for Kubernetes pod selection
for prefill servers (PD mode only). Default: {}
decode_selector: Dictionary mapping of label keys to values for Kubernetes pod selection
for decode servers (PD mode only). Default: {}
prometheus_port: Port to expose Prometheus metrics. Default: None
prometheus_host: Host address to bind the Prometheus metrics server. Default: None
pd_disaggregation: Enable PD (Prefill-Decode) disaggregated mode. Default: False
prefill_urls: List of (url, bootstrap_port) tuples for prefill servers (PD mode only)
decode_urls: List of URLs for decode servers (PD mode only)
prefill_policy: Specific load balancing policy for prefill nodes (PD mode only).
If not specified, uses the main policy. Default: None
decode_policy: Specific load balancing policy for decode nodes (PD mode only).
If not specified, uses the main policy. Default: None
request_id_headers: List of HTTP headers to check for request IDs. If not specified,
uses common defaults: ['x-request-id', 'x-correlation-id', 'x-trace-id', 'request-id'].
Example: ['x-my-request-id', 'x-custom-trace-id']. Default: None
bootstrap_port_annotation: Kubernetes annotation name for bootstrap port (PD mode).
Default: 'sglang.ai/bootstrap-port'
request_timeout_secs: Request timeout in seconds. Default: 600
max_concurrent_requests: Maximum number of concurrent requests allowed for rate limiting. Default: 256
queue_size: Queue size for pending requests when max concurrent limit reached (0 = no queue, return 429 immediately). Default: 100
queue_timeout_secs: Maximum time (in seconds) a request can wait in queue before timing out. Default: 60
rate_limit_tokens_per_second: Token bucket refill rate (tokens per second). If not set, defaults to max_concurrent_requests. Default: None
cors_allowed_origins: List of allowed origins for CORS. Empty list allows all origins. Default: []
health_failure_threshold: Number of consecutive health check failures before marking worker unhealthy. Default: 3
health_success_threshold: Number of consecutive health check successes before marking worker healthy. Default: 2
health_check_timeout_secs: Timeout in seconds for health check requests. Default: 5
health_check_interval_secs: Interval in seconds between runtime health checks. Default: 60
health_check_endpoint: Health check endpoint path. Default: '/health'
model_path: Model path for loading tokenizer (HuggingFace model ID or local path). Default: None
tokenizer_path: Explicit tokenizer path (overrides model_path tokenizer if provided). Default: None
"""
def __init__(self, router: _Router):
self._router = router
@staticmethod
def from_args(args: RouterArgs) -> "Router":
"""Create a router from a RouterArgs instance."""
args_dict = vars(args)
# Convert RouterArgs to _Router parameters
args_dict["worker_urls"] = (
[]
if args_dict["service_discovery"] or args_dict["pd_disaggregation"]
else args_dict["worker_urls"]
)
args_dict["policy"] = policy_from_str(args_dict["policy"])
args_dict["prefill_urls"] = (
args_dict["prefill_urls"] if args_dict["pd_disaggregation"] else None
)
args_dict["decode_urls"] = (
args_dict["decode_urls"] if args_dict["pd_disaggregation"] else None
)
args_dict["prefill_policy"] = policy_from_str(args_dict["prefill_policy"])
args_dict["decode_policy"] = policy_from_str(args_dict["decode_policy"])
# Convert backend
args_dict["backend"] = backend_from_str(args_dict.get("backend"))
# Convert history_backend to enum first
history_backend_raw = args_dict.get("history_backend", "memory")
history_backend = history_backend_from_str(history_backend_raw)
# Convert Oracle config if needed
oracle_config = None
if history_backend == HistoryBackendType.Oracle:
# Prioritize TNS alias over connect descriptor
tns_alias = args_dict.get("oracle_tns_alias")
connect_descriptor = args_dict.get("oracle_connect_descriptor")
# Use TNS alias if provided, otherwise use connect descriptor
final_descriptor = tns_alias if tns_alias else connect_descriptor
oracle_config = PyOracleConfig(
password=args_dict.get("oracle_password"),
username=args_dict.get("oracle_username"),
connect_descriptor=final_descriptor,
wallet_path=args_dict.get("oracle_wallet_path"),
pool_min=args_dict.get("oracle_pool_min", 1),
pool_max=args_dict.get("oracle_pool_max", 16),
pool_timeout_secs=args_dict.get("oracle_pool_timeout_secs", 30),
)
args_dict["oracle_config"] = oracle_config
args_dict["history_backend"] = history_backend
# Convert Postgres config if needed
postgres_config = None
if history_backend == HistoryBackendType.Postgres:
postgres_config = PyPostgresConfig(
db_url=args_dict.get("postgres_db_url"),
pool_max=args_dict.get("postgres_pool_max", 16),
)
args_dict["postgres_config"] = postgres_config
# Remove fields that shouldn't be passed to Rust Router constructor
fields_to_remove = [
"mini_lb",
"oracle_wallet_path",
"oracle_tns_alias",
"oracle_connect_descriptor",
"oracle_username",
"oracle_password",
"oracle_pool_min",
"oracle_pool_max",
"oracle_pool_timeout_secs",
"postgres_db_url",
"postgres_pool_max",
]
for field in fields_to_remove:
args_dict.pop(field, None)
return Router(_Router(**args_dict))
def start(self) -> None:
"""Start the router server.
This method blocks until the server is shut down.
"""
self._router.start()
@@ -0,0 +1,782 @@
import argparse
import dataclasses
import logging
import os
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class RouterArgs:
# Worker configuration
worker_urls: List[str] = dataclasses.field(default_factory=list)
host: str = "0.0.0.0"
port: int = 30000
# PD-specific configuration
mini_lb: bool = False
pd_disaggregation: bool = False # Enable PD disaggregated mode
prefill_urls: List[tuple] = dataclasses.field(
default_factory=list
) # List of (url, bootstrap_port)
decode_urls: List[str] = dataclasses.field(default_factory=list)
# Routing policy
policy: str = "cache_aware"
prefill_policy: Optional[str] = None # Specific policy for prefill nodes in PD mode
decode_policy: Optional[str] = None # Specific policy for decode nodes in PD mode
worker_startup_timeout_secs: int = 1800
worker_startup_check_interval: int = 30
cache_threshold: float = 0.3
balance_abs_threshold: int = 64
balance_rel_threshold: float = 1.5
eviction_interval_secs: int = 120
max_tree_size: int = 2**26
max_payload_size: int = 512 * 1024 * 1024 # 512MB default for large batches
bucket_adjust_interval_secs: int = 5
dp_aware: bool = False
enable_igw: bool = False # Enable IGW (Inter-Gateway) mode for multi-model support
api_key: Optional[str] = None
log_dir: Optional[str] = None
log_level: Optional[str] = None
# Service discovery configuration
service_discovery: bool = False
selector: Dict[str, str] = dataclasses.field(default_factory=dict)
service_discovery_port: int = 80
service_discovery_namespace: Optional[str] = None
# PD service discovery configuration
prefill_selector: Dict[str, str] = dataclasses.field(default_factory=dict)
decode_selector: Dict[str, str] = dataclasses.field(default_factory=dict)
bootstrap_port_annotation: str = "sglang.ai/bootstrap-port"
# Prometheus configuration
prometheus_port: Optional[int] = None
prometheus_host: Optional[str] = None
# Request ID headers configuration
request_id_headers: Optional[List[str]] = None
# Request timeout in seconds
request_timeout_secs: int = 1800
# Max concurrent requests for rate limiting (-1 to disable)
max_concurrent_requests: int = -1
# Queue size for pending requests when max concurrent limit reached
queue_size: int = 100
# Maximum time (in seconds) a request can wait in queue before timing out
queue_timeout_secs: int = 60
# Token bucket refill rate (tokens per second). If not set, defaults to max_concurrent_requests
rate_limit_tokens_per_second: Optional[int] = None
# CORS allowed origins
cors_allowed_origins: List[str] = dataclasses.field(default_factory=list)
# Retry configuration
retry_max_retries: int = 5
retry_initial_backoff_ms: int = 50
retry_max_backoff_ms: int = 30_000
retry_backoff_multiplier: float = 1.5
retry_jitter_factor: float = 0.2
disable_retries: bool = False
# Health check configuration
health_failure_threshold: int = 3
health_success_threshold: int = 2
health_check_timeout_secs: int = 5
health_check_interval_secs: int = 60
health_check_endpoint: str = "/health"
# Circuit breaker configuration
cb_failure_threshold: int = 10
cb_success_threshold: int = 3
cb_timeout_duration_secs: int = 60
cb_window_duration_secs: int = 120
disable_circuit_breaker: bool = False
model_path: Optional[str] = None
tokenizer_path: Optional[str] = None
chat_template: Optional[str] = None
# Tokenizer cache configuration
tokenizer_cache_enable_l0: bool = False
tokenizer_cache_l0_max_entries: int = 10000
tokenizer_cache_enable_l1: bool = False
tokenizer_cache_l1_max_memory: int = 50 * 1024 * 1024 # 50MB
reasoning_parser: Optional[str] = None
tool_call_parser: Optional[str] = None
# MCP server configuration
mcp_config_path: Optional[str] = None
# Backend selection
backend: str = "sglang"
# History backend configuration
history_backend: str = "memory"
oracle_wallet_path: Optional[str] = None
oracle_tns_alias: Optional[str] = None
oracle_connect_descriptor: Optional[str] = None
oracle_username: Optional[str] = None
oracle_password: Optional[str] = None
oracle_pool_min: int = 1
oracle_pool_max: int = 16
oracle_pool_timeout_secs: int = 30
postgres_db_url: Optional[str] = None
postgres_pool_max: int = 16
# mTLS configuration for worker communication
client_cert_path: Optional[str] = None
client_key_path: Optional[str] = None
ca_cert_paths: List[str] = dataclasses.field(default_factory=list)
# Trace
enable_trace: bool = False
otlp_traces_endpoint: str = "localhost:4317"
@staticmethod
def add_cli_args(
parser: argparse.ArgumentParser,
use_router_prefix: bool = False,
exclude_host_port: bool = False,
):
"""
Add router-specific arguments to an argument parser.
Args:
parser: The argument parser to add arguments to
use_router_prefix: If True, prefix all arguments with 'router-' to avoid conflicts
exclude_host_port: If True, don't add host and port arguments (used when inheriting from server)
"""
prefix = "router-" if use_router_prefix else ""
# Worker configuration
if not exclude_host_port:
parser.add_argument(
"--host",
type=str,
default=RouterArgs.host,
help="Host address to bind the router server. Supports IPv4, IPv6 (e.g., ::, ::1), or 0.0.0.0 for all interfaces",
)
parser.add_argument(
"--port",
type=int,
default=RouterArgs.port,
help="Port number to bind the router server",
)
parser.add_argument(
"--worker-urls",
type=str,
nargs="*",
default=[],
help="List of worker URLs. Supports IPv4 and IPv6 addresses (use brackets for IPv6, e.g., http://[::1]:8000 http://192.168.1.1:8000)",
)
# Routing policy configuration
parser.add_argument(
f"--{prefix}policy",
type=str,
default=RouterArgs.policy,
choices=["random", "round_robin", "cache_aware", "power_of_two"],
help="Load balancing policy to use. In PD mode, this is used for both prefill and decode unless overridden",
)
parser.add_argument(
f"--{prefix}prefill-policy",
type=str,
default=None,
choices=["random", "round_robin", "cache_aware", "power_of_two", "bucket"],
help="Specific policy for prefill nodes in PD mode. If not specified, uses the main policy",
)
parser.add_argument(
f"--{prefix}decode-policy",
type=str,
default=None,
choices=["random", "round_robin", "cache_aware", "power_of_two"],
help="Specific policy for decode nodes in PD mode. If not specified, uses the main policy",
)
# PD-specific arguments
parser.add_argument(
f"--{prefix}mini-lb",
action="store_true",
help="Enable MiniLB",
)
parser.add_argument(
f"--{prefix}pd-disaggregation",
action="store_true",
help="Enable PD (Prefill-Decode) disaggregated mode",
)
parser.add_argument(
f"--{prefix}prefill",
nargs="+",
action="append",
help="Prefill server URL and optional bootstrap port. Can be specified multiple times. "
"Format: --prefill URL [BOOTSTRAP_PORT]. "
"BOOTSTRAP_PORT can be a port number, 'none', or omitted (defaults to none).",
)
parser.add_argument(
f"--{prefix}decode",
nargs=1,
action="append",
metavar=("URL",),
help="Decode server URL. Can be specified multiple times.",
)
parser.add_argument(
f"--{prefix}worker-startup-timeout-secs",
type=int,
default=RouterArgs.worker_startup_timeout_secs,
help="Timeout in seconds for worker startup and registration (default: 1800 / 30 minutes). Large models can take significant time to load into GPU memory.",
)
parser.add_argument(
f"--{prefix}worker-startup-check-interval",
type=int,
default=RouterArgs.worker_startup_check_interval,
help="Interval in seconds between checks for worker startup",
)
parser.add_argument(
f"--{prefix}cache-threshold",
type=float,
default=RouterArgs.cache_threshold,
help="Cache threshold (0.0-1.0) for cache-aware routing",
)
parser.add_argument(
f"--{prefix}balance-abs-threshold",
type=int,
default=RouterArgs.balance_abs_threshold,
help="Load balancing is triggered when (max_load - min_load) > abs_threshold AND max_load > min_load * rel_threshold. Otherwise, use cache aware",
)
parser.add_argument(
f"--{prefix}balance-rel-threshold",
type=float,
default=RouterArgs.balance_rel_threshold,
help="Load balancing is triggered when (max_load - min_load) > abs_threshold AND max_load > min_load * rel_threshold. Otherwise, use cache aware",
)
parser.add_argument(
f"--{prefix}bucket-adjust-interval-secs",
type=int,
default=RouterArgs.bucket_adjust_interval_secs,
help="Interval in seconds between bucket boundary adjustment operations",
)
parser.add_argument(
f"--{prefix}eviction-interval-secs",
type=int,
default=RouterArgs.eviction_interval_secs,
help="Interval in seconds between cache eviction operations",
)
parser.add_argument(
f"--{prefix}max-tree-size",
type=int,
default=RouterArgs.max_tree_size,
help="Maximum size of the approximation tree for cache-aware routing",
)
parser.add_argument(
f"--{prefix}max-payload-size",
type=int,
default=RouterArgs.max_payload_size,
help="Maximum payload size in bytes",
)
parser.add_argument(
f"--{prefix}dp-aware",
action="store_true",
help="Enable data parallelism aware schedule",
)
parser.add_argument(
f"--{prefix}enable-igw",
action="store_true",
help="Enable IGW (Inference-Gateway) mode for multi-model support",
)
parser.add_argument(
f"--{prefix}api-key",
type=str,
default=None,
help="The api key used for the authorization with the worker. Useful when the dp aware scheduling strategy is enaled.",
)
parser.add_argument(
f"--{prefix}log-dir",
type=str,
default=None,
help="Directory to store log files. If not specified, logs are only output to console.",
)
parser.add_argument(
f"--{prefix}log-level",
type=str,
default="info",
choices=["debug", "info", "warn", "error"],
help="Set the logging level. If not specified, defaults to INFO.",
)
parser.add_argument(
f"--{prefix}service-discovery",
action="store_true",
help="Enable Kubernetes service discovery",
)
parser.add_argument(
f"--{prefix}selector",
type=str,
nargs="+",
default={},
help="Label selector for Kubernetes service discovery (format: key1=value1 key2=value2)",
)
parser.add_argument(
f"--{prefix}service-discovery-port",
type=int,
default=RouterArgs.service_discovery_port,
help="Port to use for discovered worker pods",
)
parser.add_argument(
f"--{prefix}service-discovery-namespace",
type=str,
help="Kubernetes namespace to watch for pods. If not provided, watches all namespaces (requires cluster-wide permissions)",
)
parser.add_argument(
f"--{prefix}prefill-selector",
type=str,
nargs="+",
default={},
help="Label selector for prefill server pods in PD mode (format: key1=value1 key2=value2)",
)
parser.add_argument(
f"--{prefix}decode-selector",
type=str,
nargs="+",
default={},
help="Label selector for decode server pods in PD mode (format: key1=value1 key2=value2)",
)
# Prometheus configuration
parser.add_argument(
f"--{prefix}prometheus-port",
type=int,
default=29000,
help="Port to expose Prometheus metrics. If not specified, Prometheus metrics are disabled",
)
parser.add_argument(
f"--{prefix}prometheus-host",
type=str,
default="0.0.0.0",
help="Host address to bind the Prometheus metrics server. Supports IPv4, IPv6 (e.g., ::, ::1), or 0.0.0.0 for all interfaces",
)
parser.add_argument(
f"--{prefix}request-id-headers",
type=str,
nargs="*",
help="Custom HTTP headers to check for request IDs (e.g., x-request-id x-trace-id). If not specified, uses common defaults.",
)
parser.add_argument(
f"--{prefix}request-timeout-secs",
type=int,
default=RouterArgs.request_timeout_secs,
help="Request timeout in seconds",
)
# Retry configuration
parser.add_argument(
f"--{prefix}retry-max-retries",
type=int,
default=RouterArgs.retry_max_retries,
)
parser.add_argument(
f"--{prefix}retry-initial-backoff-ms",
type=int,
default=RouterArgs.retry_initial_backoff_ms,
)
parser.add_argument(
f"--{prefix}retry-max-backoff-ms",
type=int,
default=RouterArgs.retry_max_backoff_ms,
)
parser.add_argument(
f"--{prefix}retry-backoff-multiplier",
type=float,
default=RouterArgs.retry_backoff_multiplier,
)
parser.add_argument(
f"--{prefix}retry-jitter-factor",
type=float,
default=RouterArgs.retry_jitter_factor,
)
parser.add_argument(
f"--{prefix}disable-retries",
action="store_true",
help="Disable retries (equivalent to setting retry_max_retries=1)",
)
# Circuit breaker configuration
parser.add_argument(
f"--{prefix}cb-failure-threshold",
type=int,
default=RouterArgs.cb_failure_threshold,
)
parser.add_argument(
f"--{prefix}cb-success-threshold",
type=int,
default=RouterArgs.cb_success_threshold,
)
parser.add_argument(
f"--{prefix}cb-timeout-duration-secs",
type=int,
default=RouterArgs.cb_timeout_duration_secs,
)
parser.add_argument(
f"--{prefix}cb-window-duration-secs",
type=int,
default=RouterArgs.cb_window_duration_secs,
)
parser.add_argument(
f"--{prefix}disable-circuit-breaker",
action="store_true",
help="Disable circuit breaker (equivalent to setting cb_failure_threshold to u32::MAX)",
)
# Health check configuration
parser.add_argument(
f"--{prefix}health-failure-threshold",
type=int,
default=RouterArgs.health_failure_threshold,
help="Number of consecutive health check failures before marking worker unhealthy",
)
parser.add_argument(
f"--{prefix}health-success-threshold",
type=int,
default=RouterArgs.health_success_threshold,
help="Number of consecutive health check successes before marking worker healthy",
)
parser.add_argument(
f"--{prefix}health-check-timeout-secs",
type=int,
default=RouterArgs.health_check_timeout_secs,
help="Timeout in seconds for health check requests",
)
parser.add_argument(
f"--{prefix}health-check-interval-secs",
type=int,
default=RouterArgs.health_check_interval_secs,
help="Interval in seconds between runtime health checks",
)
parser.add_argument(
f"--{prefix}health-check-endpoint",
type=str,
default=RouterArgs.health_check_endpoint,
help="Health check endpoint path",
)
parser.add_argument(
f"--{prefix}max-concurrent-requests",
type=int,
default=RouterArgs.max_concurrent_requests,
help="Maximum number of concurrent requests allowed (for rate limiting). Set to -1 to disable rate limiting.",
)
parser.add_argument(
f"--{prefix}queue-size",
type=int,
default=RouterArgs.queue_size,
help="Queue size for pending requests when max concurrent limit reached (0 = no queue, return 429 immediately)",
)
parser.add_argument(
f"--{prefix}queue-timeout-secs",
type=int,
default=RouterArgs.queue_timeout_secs,
help="Maximum time (in seconds) a request can wait in queue before timing out",
)
parser.add_argument(
f"--{prefix}rate-limit-tokens-per-second",
type=int,
default=RouterArgs.rate_limit_tokens_per_second,
help="Token bucket refill rate (tokens per second). If not set, defaults to max_concurrent_requests",
)
parser.add_argument(
f"--{prefix}cors-allowed-origins",
type=str,
nargs="*",
default=[],
help="CORS allowed origins (e.g., http://localhost:3000 https://example.com)",
)
# Tokenizer configuration
parser.add_argument(
f"--{prefix}model-path",
type=str,
default=None,
help="Model path for loading tokenizer (HuggingFace model ID or local path)",
)
parser.add_argument(
f"--{prefix}tokenizer-path",
type=str,
default=None,
help="Explicit tokenizer path (overrides model_path tokenizer if provided)",
)
parser.add_argument(
f"--{prefix}chat-template",
type=str,
default=None,
help="Chat template path (optional)",
)
parser.add_argument(
f"--{prefix}tokenizer-cache-enable-l0",
action="store_true",
default=RouterArgs.tokenizer_cache_enable_l0,
help="Enable L0 (whole-string exact match) tokenizer cache (default: False)",
)
parser.add_argument(
f"--{prefix}tokenizer-cache-l0-max-entries",
type=int,
default=RouterArgs.tokenizer_cache_l0_max_entries,
help="Maximum number of entries in L0 tokenizer cache (default: 10000)",
)
parser.add_argument(
f"--{prefix}tokenizer-cache-enable-l1",
action="store_true",
default=RouterArgs.tokenizer_cache_enable_l1,
help="Enable L1 (prefix matching) tokenizer cache (default: False)",
)
parser.add_argument(
f"--{prefix}tokenizer-cache-l1-max-memory",
type=int,
default=RouterArgs.tokenizer_cache_l1_max_memory,
help="Maximum memory for L1 tokenizer cache in bytes (default: 50MB)",
)
parser.add_argument(
f"--{prefix}reasoning-parser",
type=str,
default=None,
help="Specify the parser for reasoning models (e.g., deepseek-r1, qwen3)",
)
parser.add_argument(
f"--{prefix}tool-call-parser",
type=str,
default=None,
help="Specify the parser for handling tool-call interactions",
)
# MCP server configuration
parser.add_argument(
f"--{prefix}mcp-config-path",
type=str,
default=None,
help="Path to MCP (Model Context Protocol) server configuration file",
)
# Backend selection
parser.add_argument(
f"--{prefix}backend",
type=str,
default=RouterArgs.backend,
choices=["sglang", "openai"],
help="Backend runtime to use (default: sglang)",
)
# History backend configuration
parser.add_argument(
f"--{prefix}history-backend",
type=str,
default=RouterArgs.history_backend,
choices=["memory", "none", "oracle", "postgres"],
help="History storage backend for conversations and responses (default: memory)",
)
# Oracle configuration
parser.add_argument(
f"--{prefix}oracle-wallet-path",
type=str,
default=os.getenv("ATP_WALLET_PATH"),
help="Path to Oracle ATP wallet directory (env: ATP_WALLET_PATH)",
)
parser.add_argument(
f"--{prefix}oracle-tns-alias",
type=str,
default=os.getenv("ATP_TNS_ALIAS"),
help="Oracle TNS alias from tnsnames.ora (env: ATP_TNS_ALIAS).",
)
parser.add_argument(
f"--{prefix}oracle-connect-descriptor",
type=str,
default=os.getenv("ATP_DSN"),
help="Oracle connection descriptor/DSN (full connection string) (env: ATP_DSN)",
)
parser.add_argument(
f"--{prefix}oracle-username",
type=str,
default=os.getenv("ATP_USER"),
help="Oracle database username (env: ATP_USER)",
)
parser.add_argument(
f"--{prefix}oracle-password",
type=str,
default=os.getenv("ATP_PASSWORD"),
help="Oracle database password (env: ATP_PASSWORD)",
)
parser.add_argument(
f"--{prefix}oracle-pool-min",
type=int,
default=int(os.getenv("ATP_POOL_MIN", RouterArgs.oracle_pool_min)),
help="Minimum Oracle connection pool size (default: 1, env: ATP_POOL_MIN)",
)
parser.add_argument(
f"--{prefix}oracle-pool-max",
type=int,
default=int(os.getenv("ATP_POOL_MAX", RouterArgs.oracle_pool_max)),
help="Maximum Oracle connection pool size (default: 16, env: ATP_POOL_MAX)",
)
parser.add_argument(
f"--{prefix}oracle-pool-timeout-secs",
type=int,
default=int(
os.getenv("ATP_POOL_TIMEOUT_SECS", RouterArgs.oracle_pool_timeout_secs)
),
help="Oracle connection pool timeout in seconds (default: 30, env: ATP_POOL_TIMEOUT_SECS)",
)
# Postgres configuration
parser.add_argument(
f"--{prefix}postgres-db-url",
type=str,
default=os.getenv("POSTGRES_DB_URL"),
help="PostgreSQL database connection URL (env: POSTGRES_DB_URL)",
)
parser.add_argument(
f"--{prefix}postgres-pool-max",
type=int,
default=int(os.getenv("POSTGRES_POOL_MAX", RouterArgs.postgres_pool_max)),
help="Maximum PostgreSQL connection pool size (default: 16, env: POSTGRES_POOL_MAX)",
)
# mTLS configuration
parser.add_argument(
f"--{prefix}client-cert-path",
type=str,
default=None,
help="Path to client certificate for mTLS authentication with workers",
)
parser.add_argument(
f"--{prefix}client-key-path",
type=str,
default=None,
help="Path to client private key for mTLS authentication with workers",
)
parser.add_argument(
f"--{prefix}ca-cert-paths",
type=str,
nargs="*",
default=[],
help="Path(s) to CA certificate(s) for verifying worker TLS certificates. Can specify multiple CAs.",
)
parser.add_argument(
f"--{prefix}enable-trace",
action="store_true",
help="Enable opentelemetry trace",
)
parser.add_argument(
f"--{prefix}otlp-traces-endpoint",
type=str,
default="localhost:4317",
help="Config opentelemetry collector endpoint if --enable-trace is set. format: <ip>:<port>",
)
@classmethod
def from_cli_args(
cls, args: argparse.Namespace, use_router_prefix: bool = False
) -> "RouterArgs":
"""
Create RouterArgs instance from parsed command line arguments.
Args:
args: Parsed command line arguments
use_router_prefix: If True, look for arguments with 'router-' prefix
"""
prefix = "router_" if use_router_prefix else ""
cli_args_dict = vars(args)
args_dict = {}
for attr in dataclasses.fields(cls):
# Auto strip prefix from args
if f"{prefix}{attr.name}" in cli_args_dict:
args_dict[attr.name] = cli_args_dict[f"{prefix}{attr.name}"]
elif attr.name in cli_args_dict:
args_dict[attr.name] = cli_args_dict[attr.name]
# parse special arguments and remove "--prefill" and "--decode" from cli_args_dict
args_dict["prefill_urls"] = cls._parse_prefill_urls(
cli_args_dict.get(f"{prefix}prefill", None)
)
args_dict["decode_urls"] = cls._parse_decode_urls(
cli_args_dict.get(f"{prefix}decode", None)
)
args_dict["selector"] = cls._parse_selector(
cli_args_dict.get(f"{prefix}selector", None)
)
args_dict["prefill_selector"] = cls._parse_selector(
cli_args_dict.get(f"{prefix}prefill_selector", None)
)
args_dict["decode_selector"] = cls._parse_selector(
cli_args_dict.get(f"{prefix}decode_selector", None)
)
# Mooncake-specific annotation
args_dict["bootstrap_port_annotation"] = "sglang.ai/bootstrap-port"
return cls(**args_dict)
def _validate_router_args(self):
# Validate configuration based on mode
if self.pd_disaggregation:
# Allow empty URLs even without service discovery to support dynamic worker addition
# URLs will be validated separately if provided
pass
# Warn about policy usage in PD mode
if self.prefill_policy and self.decode_policy and self.policy:
logger.warning(
"Both --prefill-policy and --decode-policy are specified. "
"The main --policy flag will be ignored for PD mode."
)
elif self.prefill_policy and not self.decode_policy and self.policy:
logger.info(
f"Using --prefill-policy '{self.prefill_policy}' for prefill nodes "
f"and --policy '{self.policy}' for decode nodes."
)
elif self.decode_policy and not self.prefill_policy and self.policy:
logger.info(
f"Using --policy '{self.policy}' for prefill nodes "
f"and --decode-policy '{self.decode_policy}' for decode nodes."
)
@staticmethod
def _parse_selector(selector_list):
if not selector_list:
return {}
# Support `- --selector\n- a=b c=d` case
if len(selector_list) == 1 and (" " in selector_list[0]):
selector_list = selector_list[0].split(" ")
selector = {}
for item in selector_list:
if "=" in item:
key, value = item.split("=", 1)
selector[key] = value
return selector
@staticmethod
def _parse_prefill_urls(prefill_list):
"""Parse prefill URLs from --prefill arguments.
Format: --prefill URL [BOOTSTRAP_PORT]
Example:
--prefill http://prefill1:8080 9000 # With bootstrap port
--prefill http://prefill2:8080 none # Explicitly no bootstrap port
--prefill http://prefill3:8080 # Defaults to no bootstrap port
"""
if not prefill_list:
return []
prefill_urls = []
for prefill_args in prefill_list:
url = prefill_args[0]
# Handle optional bootstrap port
if len(prefill_args) >= 2:
bootstrap_port_str = prefill_args[1]
# Handle 'none' as None
if bootstrap_port_str.lower() == "none":
bootstrap_port = None
else:
try:
bootstrap_port = int(bootstrap_port_str)
except ValueError:
raise ValueError(
f"Invalid bootstrap port: {bootstrap_port_str}. Must be a number or 'none'"
)
else:
# No bootstrap port specified, default to None
bootstrap_port = None
prefill_urls.append((url, bootstrap_port))
return prefill_urls
@staticmethod
def _parse_decode_urls(decode_list):
"""Parse decode URLs from --decode arguments.
Format: --decode URL
Example: --decode http://decode1:8081 --decode http://decode2:8081
"""
if not decode_list:
return []
# decode_list is a list of single-element lists due to nargs=1
return [url[0] for url in decode_list]
@@ -0,0 +1 @@
__version__ = "0.2.3"
@@ -0,0 +1,730 @@
use pyo3::prelude::*;
use sgl_model_gateway::*;
use std::collections::HashMap;
// Define the enums with PyO3 bindings
#[pyclass(eq)]
#[derive(Clone, PartialEq, Debug)]
pub enum PolicyType {
Random,
RoundRobin,
CacheAware,
PowerOfTwo,
Bucket,
}
#[pyclass(eq)]
#[derive(Clone, PartialEq, Debug)]
pub enum BackendType {
Sglang,
Openai,
}
#[pyclass(eq)]
#[derive(Clone, PartialEq, Debug)]
pub enum HistoryBackendType {
Memory,
None,
Oracle,
Postgres,
}
#[pyclass]
#[derive(Clone, PartialEq)]
pub struct PyOracleConfig {
#[pyo3(get, set)]
pub wallet_path: Option<String>,
#[pyo3(get, set)]
pub connect_descriptor: Option<String>,
#[pyo3(get, set)]
pub username: Option<String>,
#[pyo3(get, set)]
pub password: Option<String>,
#[pyo3(get, set)]
pub pool_min: usize,
#[pyo3(get, set)]
pub pool_max: usize,
#[pyo3(get, set)]
pub pool_timeout_secs: u64,
}
impl std::fmt::Debug for PyOracleConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PyOracleConfig")
.field("wallet_path", &self.wallet_path)
.field("connect_descriptor", &"<redacted>")
.field("username", &self.username)
.field("password", &"<redacted>")
.field("pool_min", &self.pool_min)
.field("pool_max", &self.pool_max)
.field("pool_timeout_secs", &self.pool_timeout_secs)
.finish()
}
}
#[pymethods]
impl PyOracleConfig {
#[new]
#[pyo3(signature = (
password = None,
username = None,
connect_descriptor = None,
wallet_path = None,
pool_min = 1,
pool_max = 16,
pool_timeout_secs = 30,
))]
fn new(
password: Option<String>,
username: Option<String>,
connect_descriptor: Option<String>,
wallet_path: Option<String>,
pool_min: usize,
pool_max: usize,
pool_timeout_secs: u64,
) -> PyResult<Self> {
if pool_min == 0 {
return Err(pyo3::exceptions::PyValueError::new_err(
"pool_min must be at least 1",
));
}
if pool_max < pool_min {
return Err(pyo3::exceptions::PyValueError::new_err(
"pool_max must be >= pool_min",
));
}
Ok(PyOracleConfig {
wallet_path,
connect_descriptor,
username,
password,
pool_min,
pool_max,
pool_timeout_secs,
})
}
}
impl PyOracleConfig {
pub fn to_config_oracle(&self) -> config::OracleConfig {
config::OracleConfig {
wallet_path: self.wallet_path.clone(),
connect_descriptor: self.connect_descriptor.clone().unwrap_or_default(),
username: self.username.clone().unwrap_or_default(),
password: self.password.clone().unwrap_or_default(),
pool_min: self.pool_min,
pool_max: self.pool_max,
pool_timeout_secs: self.pool_timeout_secs,
}
}
}
#[pyclass]
#[derive(Debug, Clone, PartialEq)]
pub struct PyPostgresConfig {
#[pyo3(get, set)]
pub db_url: Option<String>,
#[pyo3(get, set)]
pub pool_max: usize,
}
#[pymethods]
impl PyPostgresConfig {
#[new]
#[pyo3(signature = (db_url = None,pool_max = 16,))]
fn new(db_url: Option<String>, pool_max: usize) -> PyResult<Self> {
Ok(PyPostgresConfig { db_url, pool_max })
}
}
impl PyPostgresConfig {
pub fn to_config_postgres(&self) -> config::PostgresConfig {
config::PostgresConfig {
db_url: self.db_url.clone().unwrap_or_default(),
pool_max: self.pool_max,
}
}
}
#[pyclass]
#[derive(Debug, Clone, PartialEq)]
struct Router {
host: String,
port: u16,
worker_urls: Vec<String>,
policy: PolicyType,
worker_startup_timeout_secs: u64,
worker_startup_check_interval: u64,
cache_threshold: f32,
balance_abs_threshold: usize,
balance_rel_threshold: f32,
eviction_interval_secs: u64,
max_tree_size: usize,
max_payload_size: usize,
dp_aware: bool,
api_key: Option<String>,
log_dir: Option<String>,
log_level: Option<String>,
service_discovery: bool,
selector: HashMap<String, String>,
service_discovery_port: u16,
service_discovery_namespace: Option<String>,
prefill_selector: HashMap<String, String>,
decode_selector: HashMap<String, String>,
bootstrap_port_annotation: String,
prometheus_port: Option<u16>,
prometheus_host: Option<String>,
request_timeout_secs: u64,
request_id_headers: Option<Vec<String>>,
pd_disaggregation: bool,
bucket_adjust_interval_secs: usize,
prefill_urls: Option<Vec<(String, Option<u16>)>>,
decode_urls: Option<Vec<String>>,
prefill_policy: Option<PolicyType>,
decode_policy: Option<PolicyType>,
max_concurrent_requests: i32,
cors_allowed_origins: Vec<String>,
retry_max_retries: u32,
retry_initial_backoff_ms: u64,
retry_max_backoff_ms: u64,
retry_backoff_multiplier: f32,
retry_jitter_factor: f32,
disable_retries: bool,
cb_failure_threshold: u32,
cb_success_threshold: u32,
cb_timeout_duration_secs: u64,
cb_window_duration_secs: u64,
disable_circuit_breaker: bool,
health_failure_threshold: u32,
health_success_threshold: u32,
health_check_timeout_secs: u64,
health_check_interval_secs: u64,
health_check_endpoint: String,
enable_igw: bool,
queue_size: usize,
queue_timeout_secs: u64,
rate_limit_tokens_per_second: Option<i32>,
connection_mode: core::ConnectionMode,
model_path: Option<String>,
tokenizer_path: Option<String>,
chat_template: Option<String>,
tokenizer_cache_enable_l0: bool,
tokenizer_cache_l0_max_entries: usize,
tokenizer_cache_enable_l1: bool,
tokenizer_cache_l1_max_memory: usize,
reasoning_parser: Option<String>,
tool_call_parser: Option<String>,
mcp_config_path: Option<String>,
backend: BackendType,
history_backend: HistoryBackendType,
oracle_config: Option<PyOracleConfig>,
postgres_config: Option<PyPostgresConfig>,
client_cert_path: Option<String>,
client_key_path: Option<String>,
ca_cert_paths: Vec<String>,
}
impl Router {
fn determine_connection_mode(worker_urls: &[String]) -> core::ConnectionMode {
for url in worker_urls {
if url.starts_with("grpc://") || url.starts_with("grpcs://") {
return core::ConnectionMode::Grpc { port: None };
}
}
core::ConnectionMode::Http
}
pub fn to_router_config(&self) -> config::ConfigResult<config::RouterConfig> {
use config::{
DiscoveryConfig, MetricsConfig, PolicyConfig as ConfigPolicyConfig, RoutingMode,
};
let convert_policy = |policy: &PolicyType| -> ConfigPolicyConfig {
match policy {
PolicyType::Random => ConfigPolicyConfig::Random,
PolicyType::RoundRobin => ConfigPolicyConfig::RoundRobin,
PolicyType::CacheAware => ConfigPolicyConfig::CacheAware {
cache_threshold: self.cache_threshold,
balance_abs_threshold: self.balance_abs_threshold,
balance_rel_threshold: self.balance_rel_threshold,
eviction_interval_secs: self.eviction_interval_secs,
max_tree_size: self.max_tree_size,
},
PolicyType::PowerOfTwo => ConfigPolicyConfig::PowerOfTwo {
load_check_interval_secs: 5,
},
PolicyType::Bucket => ConfigPolicyConfig::Bucket {
balance_abs_threshold: self.balance_abs_threshold,
balance_rel_threshold: self.balance_rel_threshold,
bucket_adjust_interval_secs: self.bucket_adjust_interval_secs,
},
}
};
let mode = if self.enable_igw {
RoutingMode::Regular {
worker_urls: vec![],
}
} else if matches!(self.backend, BackendType::Openai) {
RoutingMode::OpenAI {
worker_urls: self.worker_urls.clone(),
}
} else if self.pd_disaggregation {
RoutingMode::PrefillDecode {
prefill_urls: self.prefill_urls.clone().unwrap_or_default(),
decode_urls: self.decode_urls.clone().unwrap_or_default(),
prefill_policy: self.prefill_policy.as_ref().map(convert_policy),
decode_policy: self.decode_policy.as_ref().map(convert_policy),
}
} else {
RoutingMode::Regular {
worker_urls: self.worker_urls.clone(),
}
};
let policy = convert_policy(&self.policy);
let discovery = if self.service_discovery {
Some(DiscoveryConfig {
enabled: true,
namespace: self.service_discovery_namespace.clone(),
port: self.service_discovery_port,
check_interval_secs: 60,
selector: self.selector.clone(),
prefill_selector: self.prefill_selector.clone(),
decode_selector: self.decode_selector.clone(),
bootstrap_port_annotation: self.bootstrap_port_annotation.clone(),
})
} else {
None
};
let metrics = match (self.prometheus_port, self.prometheus_host.as_ref()) {
(Some(port), Some(host)) => Some(MetricsConfig {
port,
host: host.clone(),
}),
_ => None,
};
let history_backend = match self.history_backend {
HistoryBackendType::Memory => config::HistoryBackend::Memory,
HistoryBackendType::None => config::HistoryBackend::None,
HistoryBackendType::Oracle => config::HistoryBackend::Oracle,
HistoryBackendType::Postgres => config::HistoryBackend::Postgres,
};
let oracle = if matches!(self.history_backend, HistoryBackendType::Oracle) {
self.oracle_config
.as_ref()
.map(|cfg| cfg.to_config_oracle())
} else {
None
};
let postgres_config = if matches!(self.history_backend, HistoryBackendType::Postgres) {
self.postgres_config
.as_ref()
.map(|cfg| cfg.to_config_postgres())
} else {
None
};
config::RouterConfig::builder()
.mode(mode)
.policy(policy)
.host(&self.host)
.port(self.port)
.connection_mode(self.connection_mode.clone())
.max_payload_size(self.max_payload_size)
.request_timeout_secs(self.request_timeout_secs)
.worker_startup_timeout_secs(self.worker_startup_timeout_secs)
.worker_startup_check_interval_secs(self.worker_startup_check_interval)
.max_concurrent_requests(self.max_concurrent_requests)
.queue_size(self.queue_size)
.queue_timeout_secs(self.queue_timeout_secs)
.cors_allowed_origins(self.cors_allowed_origins.clone())
.retry_config(config::RetryConfig {
max_retries: self.retry_max_retries,
initial_backoff_ms: self.retry_initial_backoff_ms,
max_backoff_ms: self.retry_max_backoff_ms,
backoff_multiplier: self.retry_backoff_multiplier,
jitter_factor: self.retry_jitter_factor,
})
.circuit_breaker_config(config::CircuitBreakerConfig {
failure_threshold: self.cb_failure_threshold,
success_threshold: self.cb_success_threshold,
timeout_duration_secs: self.cb_timeout_duration_secs,
window_duration_secs: self.cb_window_duration_secs,
})
.health_check_config(config::HealthCheckConfig {
failure_threshold: self.health_failure_threshold,
success_threshold: self.health_success_threshold,
timeout_secs: self.health_check_timeout_secs,
check_interval_secs: self.health_check_interval_secs,
endpoint: self.health_check_endpoint.clone(),
})
.tokenizer_cache(config::TokenizerCacheConfig {
enable_l0: self.tokenizer_cache_enable_l0,
l0_max_entries: self.tokenizer_cache_l0_max_entries,
enable_l1: self.tokenizer_cache_enable_l1,
l1_max_memory: self.tokenizer_cache_l1_max_memory,
})
.history_backend(history_backend)
.maybe_api_key(self.api_key.as_ref())
.maybe_discovery(discovery)
.maybe_metrics(metrics)
.maybe_log_dir(self.log_dir.as_ref())
.maybe_log_level(self.log_level.as_ref())
.maybe_request_id_headers(self.request_id_headers.clone())
.maybe_rate_limit_tokens_per_second(self.rate_limit_tokens_per_second)
.maybe_model_path(self.model_path.as_ref())
.maybe_tokenizer_path(self.tokenizer_path.as_ref())
.maybe_chat_template(self.chat_template.as_ref())
.maybe_oracle(oracle)
.maybe_postgres(postgres_config)
.maybe_reasoning_parser(self.reasoning_parser.as_ref())
.maybe_tool_call_parser(self.tool_call_parser.as_ref())
.maybe_mcp_config_path(self.mcp_config_path.as_ref())
.dp_aware(self.dp_aware)
.retries(!self.disable_retries)
.circuit_breaker(!self.disable_circuit_breaker)
.igw(self.enable_igw)
.maybe_client_cert_and_key(
self.client_cert_path.as_ref(),
self.client_key_path.as_ref(),
)
.add_ca_certificates(self.ca_cert_paths.clone())
.build()
}
}
#[pymethods]
impl Router {
#[new]
#[pyo3(signature = (
worker_urls,
policy = PolicyType::RoundRobin,
host = String::from("0.0.0.0"),
port = 3001,
worker_startup_timeout_secs = 600,
worker_startup_check_interval = 30,
cache_threshold = 0.3,
balance_abs_threshold = 64,
balance_rel_threshold = 1.5,
eviction_interval_secs = 120,
max_tree_size = 2usize.pow(26),
max_payload_size = 512 * 1024 * 1024,
dp_aware = false,
api_key = None,
log_dir = None,
log_level = None,
service_discovery = false,
selector = HashMap::new(),
service_discovery_port = 80,
service_discovery_namespace = None,
prefill_selector = HashMap::new(),
decode_selector = HashMap::new(),
bootstrap_port_annotation = String::from("sglang.ai/bootstrap-port"),
prometheus_port = None,
prometheus_host = None,
request_timeout_secs = 1800,
request_id_headers = None,
pd_disaggregation = false,
bucket_adjust_interval_secs = 5,
prefill_urls = None,
decode_urls = None,
prefill_policy = None,
decode_policy = None,
max_concurrent_requests = -1,
cors_allowed_origins = vec![],
retry_max_retries = 5,
retry_initial_backoff_ms = 50,
retry_max_backoff_ms = 30_000,
retry_backoff_multiplier = 1.5,
retry_jitter_factor = 0.2,
disable_retries = false,
cb_failure_threshold = 10,
cb_success_threshold = 3,
cb_timeout_duration_secs = 60,
cb_window_duration_secs = 120,
disable_circuit_breaker = false,
health_failure_threshold = 3,
health_success_threshold = 2,
health_check_timeout_secs = 5,
health_check_interval_secs = 60,
health_check_endpoint = String::from("/health"),
enable_igw = false,
queue_size = 100,
queue_timeout_secs = 60,
rate_limit_tokens_per_second = None,
model_path = None,
tokenizer_path = None,
chat_template = None,
tokenizer_cache_enable_l0 = false,
tokenizer_cache_l0_max_entries = 10000,
tokenizer_cache_enable_l1 = false,
tokenizer_cache_l1_max_memory = 52428800,
reasoning_parser = None,
tool_call_parser = None,
mcp_config_path = None,
backend = BackendType::Sglang,
history_backend = HistoryBackendType::Memory,
oracle_config = None,
postgres_config = None,
client_cert_path = None,
client_key_path = None,
ca_cert_paths = vec![],
))]
#[allow(clippy::too_many_arguments)]
fn new(
worker_urls: Vec<String>,
policy: PolicyType,
host: String,
port: u16,
worker_startup_timeout_secs: u64,
worker_startup_check_interval: u64,
cache_threshold: f32,
balance_abs_threshold: usize,
balance_rel_threshold: f32,
eviction_interval_secs: u64,
max_tree_size: usize,
max_payload_size: usize,
dp_aware: bool,
api_key: Option<String>,
log_dir: Option<String>,
log_level: Option<String>,
service_discovery: bool,
selector: HashMap<String, String>,
service_discovery_port: u16,
service_discovery_namespace: Option<String>,
prefill_selector: HashMap<String, String>,
decode_selector: HashMap<String, String>,
bootstrap_port_annotation: String,
prometheus_port: Option<u16>,
prometheus_host: Option<String>,
request_timeout_secs: u64,
request_id_headers: Option<Vec<String>>,
pd_disaggregation: bool,
bucket_adjust_interval_secs: usize,
prefill_urls: Option<Vec<(String, Option<u16>)>>,
decode_urls: Option<Vec<String>>,
prefill_policy: Option<PolicyType>,
decode_policy: Option<PolicyType>,
max_concurrent_requests: i32,
cors_allowed_origins: Vec<String>,
retry_max_retries: u32,
retry_initial_backoff_ms: u64,
retry_max_backoff_ms: u64,
retry_backoff_multiplier: f32,
retry_jitter_factor: f32,
disable_retries: bool,
cb_failure_threshold: u32,
cb_success_threshold: u32,
cb_timeout_duration_secs: u64,
cb_window_duration_secs: u64,
disable_circuit_breaker: bool,
health_failure_threshold: u32,
health_success_threshold: u32,
health_check_timeout_secs: u64,
health_check_interval_secs: u64,
health_check_endpoint: String,
enable_igw: bool,
queue_size: usize,
queue_timeout_secs: u64,
rate_limit_tokens_per_second: Option<i32>,
model_path: Option<String>,
tokenizer_path: Option<String>,
chat_template: Option<String>,
tokenizer_cache_enable_l0: bool,
tokenizer_cache_l0_max_entries: usize,
tokenizer_cache_enable_l1: bool,
tokenizer_cache_l1_max_memory: usize,
reasoning_parser: Option<String>,
tool_call_parser: Option<String>,
mcp_config_path: Option<String>,
backend: BackendType,
history_backend: HistoryBackendType,
oracle_config: Option<PyOracleConfig>,
postgres_config: Option<PyPostgresConfig>,
client_cert_path: Option<String>,
client_key_path: Option<String>,
ca_cert_paths: Vec<String>,
) -> PyResult<Self> {
let mut all_urls = worker_urls.clone();
if let Some(ref prefill_urls) = prefill_urls {
for (url, _) in prefill_urls {
all_urls.push(url.clone());
}
}
if let Some(ref decode_urls) = decode_urls {
all_urls.extend(decode_urls.clone());
}
let connection_mode = Self::determine_connection_mode(&all_urls);
Ok(Router {
host,
port,
worker_urls,
policy,
worker_startup_timeout_secs,
worker_startup_check_interval,
cache_threshold,
balance_abs_threshold,
balance_rel_threshold,
eviction_interval_secs,
max_tree_size,
max_payload_size,
dp_aware,
api_key,
log_dir,
log_level,
service_discovery,
selector,
service_discovery_port,
service_discovery_namespace,
prefill_selector,
decode_selector,
bootstrap_port_annotation,
prometheus_port,
prometheus_host,
request_timeout_secs,
request_id_headers,
pd_disaggregation,
bucket_adjust_interval_secs,
prefill_urls,
decode_urls,
prefill_policy,
decode_policy,
max_concurrent_requests,
cors_allowed_origins,
retry_max_retries,
retry_initial_backoff_ms,
retry_max_backoff_ms,
retry_backoff_multiplier,
retry_jitter_factor,
disable_retries,
cb_failure_threshold,
cb_success_threshold,
cb_timeout_duration_secs,
cb_window_duration_secs,
disable_circuit_breaker,
health_failure_threshold,
health_success_threshold,
health_check_timeout_secs,
health_check_interval_secs,
health_check_endpoint,
enable_igw,
queue_size,
queue_timeout_secs,
rate_limit_tokens_per_second,
connection_mode,
model_path,
tokenizer_path,
chat_template,
tokenizer_cache_enable_l0,
tokenizer_cache_l0_max_entries,
tokenizer_cache_enable_l1,
tokenizer_cache_l1_max_memory,
reasoning_parser,
tool_call_parser,
mcp_config_path,
backend,
history_backend,
oracle_config,
postgres_config,
client_cert_path,
client_key_path,
ca_cert_paths,
})
}
fn start(&self) -> PyResult<()> {
use metrics::PrometheusConfig;
let router_config = self.to_router_config().map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!("Configuration error: {}", e))
})?;
router_config.validate().map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!(
"Configuration validation failed: {}",
e
))
})?;
let service_discovery_config = if self.service_discovery {
Some(service_discovery::ServiceDiscoveryConfig {
enabled: true,
selector: self.selector.clone(),
check_interval: std::time::Duration::from_secs(60),
port: self.service_discovery_port,
namespace: self.service_discovery_namespace.clone(),
pd_mode: self.pd_disaggregation,
prefill_selector: self.prefill_selector.clone(),
decode_selector: self.decode_selector.clone(),
bootstrap_port_annotation: self.bootstrap_port_annotation.clone(),
})
} else {
None
};
let prometheus_config = Some(PrometheusConfig {
port: self.prometheus_port.unwrap_or(29000),
host: self
.prometheus_host
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string()),
});
let runtime = tokio::runtime::Runtime::new()
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
runtime.block_on(async move {
server::startup(server::ServerConfig {
host: self.host.clone(),
port: self.port,
router_config,
max_payload_size: self.max_payload_size,
log_dir: self.log_dir.clone(),
log_level: self.log_level.clone(),
service_discovery_config,
prometheus_config,
request_timeout_secs: self.request_timeout_secs,
request_id_headers: self.request_id_headers.clone(),
})
.await
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
})
}
}
/// Get simple version string (default for --version)
#[pyfunction]
fn get_version_string() -> String {
version::get_version_string()
}
/// Get verbose version information string with full build details (for --version-verbose)
#[pyfunction]
fn get_verbose_version_string() -> String {
version::get_verbose_version_string()
}
#[pymodule]
fn sglang_router_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PolicyType>()?;
m.add_class::<BackendType>()?;
m.add_class::<HistoryBackendType>()?;
m.add_class::<PyOracleConfig>()?;
m.add_class::<PyPostgresConfig>()?;
m.add_class::<Router>()?;
m.add_function(wrap_pyfunction!(get_version_string, m)?)?;
m.add_function(wrap_pyfunction!(get_verbose_version_string, m)?)?;
Ok(())
}
+124
View File
@@ -0,0 +1,124 @@
use std::process::Command;
const DEFAULT_VERSION: &str = "0.0.0";
const DEFAULT_PROJECT_NAME: &str = "sgl-model-gateway";
/// Set a compile-time environment variable with the SGL_MODEL_GATEWAY_ prefix
macro_rules! set_env {
($name:expr, $value:expr) => {
println!("cargo:rustc-env=SGL_MODEL_GATEWAY_{}={}", $name, $value);
};
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Rebuild triggers
println!("cargo:rerun-if-changed=src/proto/sglang_scheduler.proto");
println!("cargo:rerun-if-changed=src/proto/vllm_engine.proto");
println!("cargo:rerun-if-changed=Cargo.toml");
// Compile protobuf files
tonic_prost_build::configure()
.build_server(true)
.build_client(true)
.type_attribute("GetModelInfoResponse", "#[derive(serde::Serialize)]")
.protoc_arg("--experimental_allow_proto3_optional")
.compile_protos(
&[
"src/proto/sglang_scheduler.proto",
"src/proto/vllm_engine.proto",
],
&["src/proto"],
)?;
// Set version info environment variables
let version = read_cargo_version().unwrap_or_else(|_| DEFAULT_VERSION.to_string());
let target = std::env::var("TARGET").unwrap_or_else(|_| get_rustc_host().unwrap_or_default());
let profile = std::env::var("PROFILE").unwrap_or_default();
set_env!("PROJECT_NAME", DEFAULT_PROJECT_NAME);
set_env!("VERSION", version);
set_env!(
"BUILD_TIME",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
);
set_env!(
"BUILD_MODE",
if profile == "release" {
"release"
} else {
"debug"
}
);
set_env!("TARGET_TRIPLE", target);
set_env!(
"GIT_BRANCH",
git_branch().unwrap_or_else(|| "unknown".into())
);
set_env!(
"GIT_COMMIT",
git_commit().unwrap_or_else(|| "unknown".into())
);
set_env!(
"GIT_STATUS",
git_status().unwrap_or_else(|| "unknown".into())
);
set_env!(
"RUSTC_VERSION",
rustc_version().unwrap_or_else(|| "unknown".into())
);
set_env!(
"CARGO_VERSION",
cargo_version().unwrap_or_else(|| "unknown".into())
);
Ok(())
}
fn read_cargo_version() -> Result<String, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string("Cargo.toml")?;
let toml: toml::Value = toml::from_str(&content)?;
toml.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Missing version in Cargo.toml".into())
}
fn run_cmd(cmd: &str, args: &[&str]) -> Option<String> {
Command::new(cmd)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn git_branch() -> Option<String> {
run_cmd("git", &["rev-parse", "--abbrev-ref", "HEAD"])
}
fn git_commit() -> Option<String> {
run_cmd("git", &["rev-parse", "--short", "HEAD"])
}
fn git_status() -> Option<String> {
run_cmd("git", &["status", "--porcelain"])
.map(|s| if s.is_empty() { "clean" } else { "dirty" }.into())
}
fn rustc_version() -> Option<String> {
run_cmd("rustc", &["--version"])
}
fn cargo_version() -> Option<String> {
run_cmd("cargo", &["--version"])
}
fn get_rustc_host() -> Option<String> {
run_cmd("rustc", &["-vV"])?
.lines()
.find(|l| l.starts_with("host: "))
.and_then(|l| l.strip_prefix("host: "))
.map(|s| s.trim().to_string())
}
@@ -0,0 +1,27 @@
# Rust build artifacts
target/
**/target/
# Cargo lock files (examples don't need locked dependencies)
Cargo.lock
**/Cargo.lock
# Generated WASM files
*.wasm
*.component.wasm
**/*.wasm
**/*.component.wasm
# Build scripts output
build/
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
+102
View File
@@ -0,0 +1,102 @@
# WASM Guest Examples for sgl-router
This directory contains example WASM middleware components demonstrating how to implement custom middleware for sgl-router using the WebAssembly Component Model.
## Examples Overview
### [wasm-guest-auth](./wasm-guest-auth/)
API key authentication middleware that validates API keys for requests to `/api` and `/v1` paths.
**Features:**
- Validates API keys from `Authorization` header or `x-api-key` header
- Returns `401 Unauthorized` for missing or invalid keys
- Attach point: `OnRequest` only
**Use case:** Protect API endpoints with API key authentication.
### [wasm-guest-logging](./wasm-guest-logging/)
Request tracking and status code conversion middleware.
**Features:**
- Adds tracking headers (`x-request-id`, `x-wasm-processed`, `x-processed-at`, `x-api-route`)
- Converts `500` errors to `503` for better client handling
- Attach points: `OnRequest` and `OnResponse`
**Use case:** Request tracing and error status code conversion.
### [wasm-guest-ratelimit](./wasm-guest-ratelimit/)
Rate limiting middleware with configurable limits.
**Features:**
- Rate limiting per identifier (API Key, IP, or Request ID)
- Default: 60 requests per minute
- Returns `429 Too Many Requests` when limit exceeded
- Attach point: `OnRequest` only
**Note:** This is a simplified demonstration with per-instance state. For production, use router-level rate limiting with shared state.
**Use case:** Protect against request flooding and abuse.
## Quick Start
Each example includes its own README with detailed build and deployment instructions. See individual example directories for:
- Build instructions
- Deployment configuration
- Customization options
- Testing examples
## Common Prerequisites
All examples require:
- Rust toolchain (latest stable)
- `wasm32-wasip2` target: `rustup target add wasm32-wasip2`
- `wasm-tools`: `cargo install wasm-tools`
- sgl-router running with WASM enabled (`--enable-wasm`)
## Building All Examples
```bash
cd examples/wasm
for example in wasm-guest-auth wasm-guest-logging wasm-guest-ratelimit; do
echo "Building $example..."
cd $example && ./build.sh && cd ..
done
```
## Deploying Multiple Modules
You can deploy all three modules together:
```bash
curl -X POST http://localhost:3000/wasm \
-H "Content-Type: application/json" \
-d '{
"modules": [
{
"name": "auth-middleware",
"file_path": "/path/to/wasm_guest_auth.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}]
},
{
"name": "logging-middleware",
"file_path": "/path/to/wasm_guest_logging.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}, {"Middleware": "OnResponse"}]
},
{
"name": "ratelimit-middleware",
"file_path": "/path/to/wasm_guest_ratelimit.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}]
}
]
}'
```
Modules execute in the order they are deployed. If a module returns `Reject`, subsequent modules won't execute.
@@ -0,0 +1,10 @@
[package]
name = "wasm-guest-auth"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = { version = "0.21", features = ["macros"] }
@@ -0,0 +1,62 @@
# WASM Auth Example for sgl-router
This example demonstrates API key authentication middleware for sgl-router using the WebAssembly Component Model.
## Overview
This middleware validates API keys for requests to `/api` and `/v1` paths:
- Supports `Authorization: Bearer <key>` header
- Supports `Authorization: ApiKey <key>` header
- Supports `x-api-key` header
- Returns `401 Unauthorized` for missing or invalid keys
**Default API Key**: `secret-api-key-12345`
## Quick Start
### Build and Deploy
```bash
# Build
cd examples/wasm-guest-auth
./build.sh
# Deploy (replace file_path with actual path)
curl -X POST http://localhost:3000/wasm \
-H "Content-Type: application/json" \
-d '{
"modules": [{
"name": "auth-middleware",
"file_path": "/absolute/path/to/wasm_guest_auth.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}]
}]
}'
```
### Customization
Modify `EXPECTED_API_KEY` in `src/lib.rs`:
```rust
const EXPECTED_API_KEY: &str = "your-secret-key";
```
## Testing
```bash
# Test unauthorized (returns 401)
curl -v http://localhost:3000/api/test
# Test authorized (passes)
curl -v http://localhost:3000/api/test \
-H "Authorization: Bearer secret-api-key-12345"
```
## Troubleshooting
- Verify API key matches `EXPECTED_API_KEY` in code
- Check request header format and path (`/api` or `/v1`)
- Verify module is attached to `OnRequest` phase
- Check router logs for errors
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# Build script for WASM guest auth example
# This script simplifies the build process for the WASM middleware component
set -e
echo "Building WASM guest auth example..."
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
echo "Error: Cargo.toml not found. Please run this script from the wasm-guest-auth directory."
exit 1
fi
# Check for required tools
command -v cargo >/dev/null 2>&1 || { echo "Error: cargo is required but not installed. Aborting." >&2; exit 1; }
# Check and install wasm32-wasip2 target
echo "Checking for wasm32-wasip2 target..."
if ! rustup target list --installed | grep -q "wasm32-wasip2"; then
echo "wasm32-wasip2 target not found. Installing..."
rustup target add wasm32-wasip2
echo "✓ wasm32-wasip2 target installed"
else
echo "✓ wasm32-wasip2 target already installed"
fi
# Check for wasm-tools
if ! command -v wasm-tools >/dev/null 2>&1; then
echo "Error: wasm-tools is required but not installed."
echo "Install it with: cargo install wasm-tools"
exit 1
fi
# Build with cargo (wit-bindgen uses cargo, not wasm-pack)
echo "Running cargo build..."
cargo build --target wasm32-wasip2 --release
# Output locations
WASM_MODULE="target/wasm32-wasip2/release/wasm_guest_auth.wasm"
WASM_COMPONENT="target/wasm32-wasip2/release/wasm_guest_auth.component.wasm"
if [ ! -f "$WASM_MODULE" ]; then
echo "Error: Build failed - WASM module not found"
exit 1
fi
# Check if the file is already a component
echo "Checking WASM file format..."
if wasm-tools print "$WASM_MODULE" 2>/dev/null | grep -q "^(\s*component"; then
echo "✓ WASM file is already in component format"
# Copy to component path for consistency
cp "$WASM_MODULE" "$WASM_COMPONENT"
else
# Wrap the WASM module into a component format
echo "Wrapping WASM module into component format..."
wasm-tools component new "$WASM_MODULE" -o "$WASM_COMPONENT"
if [ ! -f "$WASM_COMPONENT" ]; then
echo "Error: Failed to create component file"
exit 1
fi
fi
if [ -f "$WASM_COMPONENT" ]; then
echo ""
echo "✓ Build successful!"
echo " WASM module: $WASM_MODULE"
echo " WASM component: $WASM_COMPONENT"
echo ""
echo "Next steps:"
echo "1. Use the component file ($WASM_COMPONENT) when adding the module"
echo "2. Prepare the module configuration (see README.md for JSON format)"
echo "3. Use the API endpoint to add the module (see README.md for details)"
else
echo "Error: Component file not found"
exit 1
fi
@@ -0,0 +1,70 @@
//! WASM Guest Auth Example for sgl-router
//!
//! This example demonstrates API key authentication middleware
//! for sgl-router using the WebAssembly Component Model.
//!
//! Features:
//! - API Key authentication
wit_bindgen::generate!({
path: "../../../src/wasm/interface",
world: "sgl-router",
});
use exports::sgl::router::{
middleware_on_request::Guest as OnRequestGuest,
middleware_on_response::Guest as OnResponseGuest,
};
use sgl::router::middleware_types::{Action, Request, Response};
/// Expected API Key (in production, this should be passed as configuration)
const EXPECTED_API_KEY: &str = "secret-api-key-12345";
/// Main middleware implementation
struct Middleware;
// Helper function to find header value
fn find_header_value(
headers: &[sgl::router::middleware_types::Header],
name: &str,
) -> Option<String> {
headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case(name))
.map(|h| h.value.clone())
}
// Implement on-request interface
impl OnRequestGuest for Middleware {
fn on_request(req: Request) -> Action {
// API Key Authentication
// Check for API key in Authorization header for /api routes
if req.path.starts_with("/api") || req.path.starts_with("/v1") {
let api_key = find_header_value(&req.headers, "authorization")
.and_then(|h| {
h.strip_prefix("Bearer ")
.or_else(|| h.strip_prefix("ApiKey "))
.map(|s| s.to_string())
})
.or_else(|| find_header_value(&req.headers, "x-api-key"));
// Reject if API key is missing or invalid
if api_key.as_deref() != Some(EXPECTED_API_KEY) {
return Action::Reject(401);
}
}
// Authentication passed, continue processing
Action::Continue
}
}
// Implement on-response interface (empty - not used for auth)
impl OnResponseGuest for Middleware {
fn on_response(_resp: Response) -> Action {
Action::Continue
}
}
// Export the component
export!(Middleware);
@@ -0,0 +1,10 @@
[package]
name = "wasm-guest-logging"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = { version = "0.21", features = ["macros"] }
@@ -0,0 +1,53 @@
# WASM Logging Example for sgl-router
This example demonstrates logging and tracing middleware for sgl-router using the WebAssembly Component Model.
## Overview
This middleware provides:
- **Request Tracking** - Adds tracking headers (`x-request-id`, `x-wasm-processed`, `x-processed-at`, `x-api-route`)
- **Status Code Conversion** - Converts `500` errors to `503`
## Quick Start
### Build and Deploy
```bash
# Build
cd examples/wasm-guest-logging
./build.sh
# Deploy (replace file_path with actual path)
curl -X POST http://localhost:3000/wasm \
-H "Content-Type: application/json" \
-d '{
"modules": [{
"name": "logging-middleware",
"file_path": "/absolute/path/to/wasm_guest_logging.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}, {"Middleware": "OnResponse"}]
}]
}'
```
### Customization
Modify `on_request` or `on_response` functions in `src/lib.rs` to add custom tracking headers or status code conversions.
## Testing
```bash
# Check tracking headers
curl -v http://localhost:3000/v1/models 2>&1 | \
grep -E "(x-request-id|x-wasm-processed|x-processed-at)"
# Test status code conversion (requires endpoint returning 500)
curl -v http://localhost:3000/some-endpoint 2>&1 | grep -E "(< HTTP|500|503)"
```
## Troubleshooting
- Verify module attached to both `OnRequest` and `OnResponse` phases
- Check router logs for execution errors
- Ensure module built successfully
@@ -0,0 +1,77 @@
#!/bin/bash
# Build script for WASM guest logging example
# This script simplifies the build process for the WASM middleware component
set -e
echo "Building WASM guest logging example..."
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
echo "Error: Cargo.toml not found. Please run this script from the wasm-guest-logging directory."
exit 1
fi
# Check for required tools
command -v cargo >/dev/null 2>&1 || { echo "Error: cargo is required but not installed. Aborting." >&2; exit 1; }
# Check and install wasm32-wasip2 target
echo "Checking for wasm32-wasip2 target..."
if ! rustup target list --installed | grep -q "wasm32-wasip2"; then
echo "wasm32-wasip2 target not found. Installing..."
rustup target add wasm32-wasip2
echo "✓ wasm32-wasip2 target installed"
else
echo "✓ wasm32-wasip2 target already installed"
fi
# Check for wasm-tools
if ! command -v wasm-tools >/dev/null 2>&1; then
echo "Error: wasm-tools is required but not installed."
echo "Install it with: cargo install wasm-tools"
exit 1
fi
# Build with cargo (wit-bindgen uses cargo, not wasm-pack)
echo "Running cargo build..."
cargo build --target wasm32-wasip2 --release
# Output locations
WASM_MODULE="target/wasm32-wasip2/release/wasm_guest_logging.wasm"
WASM_COMPONENT="target/wasm32-wasip2/release/wasm_guest_logging.component.wasm"
if [ ! -f "$WASM_MODULE" ]; then
echo "Error: Build failed - WASM module not found"
exit 1
fi
# Check if the file is already a component
echo "Checking WASM file format..."
if wasm-tools print "$WASM_MODULE" 2>/dev/null | grep -q "^(\s*component"; then
echo "✓ WASM file is already in component format"
# Copy to component path for consistency
cp "$WASM_MODULE" "$WASM_COMPONENT"
else
# Wrap the WASM module into a component format
echo "Wrapping WASM module into component format..."
wasm-tools component new "$WASM_MODULE" -o "$WASM_COMPONENT"
if [ ! -f "$WASM_COMPONENT" ]; then
echo "Error: Failed to create component file"
exit 1
fi
fi
if [ -f "$WASM_COMPONENT" ]; then
echo ""
echo "✓ Build successful!"
echo " WASM module: $WASM_MODULE"
echo " WASM component: $WASM_COMPONENT"
echo ""
echo "Next steps:"
echo "1. Use the component file ($WASM_COMPONENT) when adding the module"
echo "2. Prepare the module configuration (see README.md for JSON format)"
echo "3. Use the API endpoint to add the module (see README.md for details)"
else
echo "Error: Component file not found"
exit 1
fi
@@ -0,0 +1,88 @@
//! WASM Guest Logging Example for sgl-router
//!
//! This example demonstrates logging and tracing middleware
//! for sgl-router using the WebAssembly Component Model.
//!
//! Features:
//! - Request tracking and tracing headers
//! - Response status code conversion
wit_bindgen::generate!({
path: "../../../src/wasm/interface",
world: "sgl-router",
});
use exports::sgl::router::{
middleware_on_request::Guest as OnRequestGuest,
middleware_on_response::Guest as OnResponseGuest,
};
use sgl::router::middleware_types::{Action, Header, ModifyAction, Request, Response};
/// Main middleware implementation
struct Middleware;
// Helper function to create header
fn create_header(name: &str, value: &str) -> Header {
Header {
name: name.to_string(),
value: value.to_string(),
}
}
// Implement on-request interface
impl OnRequestGuest for Middleware {
fn on_request(req: Request) -> Action {
let mut modify_action = ModifyAction {
status: None,
headers_set: vec![],
headers_add: vec![],
headers_remove: vec![],
body_replace: None,
};
// Request Logging and Tracing
// Add tracing headers with request ID
modify_action
.headers_add
.push(create_header("x-request-id", &req.request_id));
modify_action
.headers_add
.push(create_header("x-wasm-processed", "true"));
modify_action.headers_add.push(create_header(
"x-processed-at",
&req.now_epoch_ms.to_string(),
));
// Add custom header for API requests
if req.path.starts_with("/api") || req.path.starts_with("/v1") {
modify_action
.headers_add
.push(create_header("x-api-route", "true"));
}
Action::Modify(modify_action)
}
}
// Implement on-response interface
impl OnResponseGuest for Middleware {
fn on_response(resp: Response) -> Action {
// Status code conversion: Convert 500 to 503 for better client handling
if resp.status == 500 {
let modify_action = ModifyAction {
status: Some(503),
headers_set: vec![],
headers_add: vec![],
headers_remove: vec![],
body_replace: None,
};
Action::Modify(modify_action)
} else {
// No modification needed
Action::Continue
}
}
}
// Export the component
export!(Middleware);
@@ -0,0 +1,10 @@
[package]
name = "wasm-guest-ratelimit"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = { version = "0.21", features = ["macros"] }
@@ -0,0 +1,68 @@
# WASM Rate Limit Example for sgl-router
This example demonstrates rate limiting middleware for sgl-router using the WebAssembly Component Model.
## Overview
This middleware provides rate limiting:
- **Default**: 60 requests per minute per identifier
- **Identifier Priority**: API Key > IP Address > Request ID
- **Response**: Returns `429 Too Many Requests` when limit exceeded
**Important**: This is a simplified demonstration. Since WASM components are stateless, each worker thread maintains its own counter. For production, implement rate limiting at the router/host level with shared state.
## Quick Start
### Build and Deploy
```bash
# Build
cd examples/wasm-guest-ratelimit
./build.sh
# Deploy (replace file_path with actual path)
curl -X POST http://localhost:3000/wasm \
-H "Content-Type: application/json" \
-d '{
"modules": [{
"name": "ratelimit-middleware",
"file_path": "/absolute/path/to/wasm_guest_ratelimit.component.wasm",
"module_type": "Middleware",
"attach_points": [{"Middleware": "OnRequest"}]
}]
}'
```
### Customization
Modify constants in `src/lib.rs`:
```rust
const RATE_LIMIT_REQUESTS: u64 = 100; // requests per window
const RATE_LIMIT_WINDOW_MS: u64 = 60_000; // time window in ms
```
## Testing
```bash
# Send multiple requests (first 60 succeed, then 429)
for i in {1..65}; do
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:3000/v1/models \
-H "Authorization: Bearer secret-api-key-12345"
done
```
## Limitations
- Per-instance state (not shared across workers)
- No cross-process state sharing
- Memory growth with unique identifiers
- State lost on instance restart
## Troubleshooting
- Verify module attached to `OnRequest` phase
- Check identifier extraction logic matches request format
- Note: Each WASM worker has separate counter
@@ -0,0 +1,77 @@
#!/bin/bash
# Build script for WASM guest rate limit example
# This script simplifies the build process for the WASM middleware component
set -e
echo "Building WASM guest rate limit example..."
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
echo "Error: Cargo.toml not found. Please run this script from the wasm-guest-ratelimit directory."
exit 1
fi
# Check for required tools
command -v cargo >/dev/null 2>&1 || { echo "Error: cargo is required but not installed. Aborting." >&2; exit 1; }
# Check and install wasm32-wasip2 target
echo "Checking for wasm32-wasip2 target..."
if ! rustup target list --installed | grep -q "wasm32-wasip2"; then
echo "wasm32-wasip2 target not found. Installing..."
rustup target add wasm32-wasip2
echo "✓ wasm32-wasip2 target installed"
else
echo "✓ wasm32-wasip2 target already installed"
fi
# Check for wasm-tools
if ! command -v wasm-tools >/dev/null 2>&1; then
echo "Error: wasm-tools is required but not installed."
echo "Install it with: cargo install wasm-tools"
exit 1
fi
# Build with cargo (wit-bindgen uses cargo, not wasm-pack)
echo "Running cargo build..."
cargo build --target wasm32-wasip2 --release
# Output locations
WASM_MODULE="target/wasm32-wasip2/release/wasm_guest_ratelimit.wasm"
WASM_COMPONENT="target/wasm32-wasip2/release/wasm_guest_ratelimit.component.wasm"
if [ ! -f "$WASM_MODULE" ]; then
echo "Error: Build failed - WASM module not found"
exit 1
fi
# Check if the file is already a component
echo "Checking WASM file format..."
if wasm-tools print "$WASM_MODULE" 2>/dev/null | grep -q "^(\s*component"; then
echo "✓ WASM file is already in component format"
# Copy to component path for consistency
cp "$WASM_MODULE" "$WASM_COMPONENT"
else
# Wrap the WASM module into a component format
echo "Wrapping WASM module into component format..."
wasm-tools component new "$WASM_MODULE" -o "$WASM_COMPONENT"
if [ ! -f "$WASM_COMPONENT" ]; then
echo "Error: Failed to create component file"
exit 1
fi
fi
if [ -f "$WASM_COMPONENT" ]; then
echo ""
echo "✓ Build successful!"
echo " WASM module: $WASM_MODULE"
echo " WASM component: $WASM_COMPONENT"
echo ""
echo "Next steps:"
echo "1. Use the component file ($WASM_COMPONENT) when adding the module"
echo "2. Prepare the module configuration (see README.md for JSON format)"
echo "3. Use the API endpoint to add the module (see README.md for details)"
else
echo "Error: Component file not found"
exit 1
fi
@@ -0,0 +1,155 @@
//! WASM Guest Rate Limit Example for sgl-router
//!
//! This example demonstrates rate limiting middleware
//! for sgl-router using the WebAssembly Component Model.
//!
//! Features:
//! - Rate limiting based on API Key or IP address
//! - Fixed time window (e.g., 60 requests per minute)
//! - Returns 429 Too Many Requests when limit exceeded
//!
//! Note: This is a simplified implementation. Since WASM components are stateless,
//! each instance maintains its own counters. For production use, consider
//! implementing rate limiting at the host/router level with shared state.
wit_bindgen::generate!({
path: "../../../src/wasm/interface",
world: "sgl-router",
});
use std::cell::RefCell;
use exports::sgl::router::{
middleware_on_request::Guest as OnRequestGuest,
middleware_on_response::Guest as OnResponseGuest,
};
use sgl::router::middleware_types::{Action, Request, Response};
/// Main middleware implementation
struct Middleware;
// Rate limit configuration
const RATE_LIMIT_REQUESTS: u64 = 60; // Maximum requests per window
const RATE_LIMIT_WINDOW_MS: u64 = 60_000; // Time window in milliseconds (1 minute)
// Simple in-memory counter (per WASM instance)
// In a real implementation, this would be shared across all instances
// This is a simplified example for demonstration purposes
struct RateLimitState {
requests: Vec<(String, u64)>, // (identifier, timestamp_ms)
}
impl RateLimitState {
fn new() -> Self {
Self {
requests: Vec::new(),
}
}
// Clean up old entries outside the time window
fn cleanup(&mut self, current_time_ms: u64) {
let cutoff = current_time_ms.saturating_sub(RATE_LIMIT_WINDOW_MS);
self.requests.retain(|(_, timestamp)| *timestamp > cutoff);
}
// Check if identifier has exceeded rate limit
fn check_limit(&mut self, identifier: &str, current_time_ms: u64) -> bool {
self.cleanup(current_time_ms);
// Count requests in current window for this identifier
let count = self
.requests
.iter()
.filter(|(id, timestamp)| {
id == identifier
&& *timestamp > current_time_ms.saturating_sub(RATE_LIMIT_WINDOW_MS)
})
.count() as u64;
if count >= RATE_LIMIT_REQUESTS {
return false; // Limit exceeded
}
// Add new request
self.requests
.push((identifier.to_string(), current_time_ms));
true // Within limit
}
}
// Thread-local state (per WASM instance thread)
// Using thread_local! is safer than static mut as it avoids unsafe blocks
// and provides separate state for each thread automatically
thread_local! {
static RATE_LIMIT_STATE: RefCell<RateLimitState> = RefCell::new(RateLimitState::new());
}
fn get_identifier(req: &Request) -> String {
// Helper function to find header value
let find_header_value =
|headers: &[sgl::router::middleware_types::Header], name: &str| -> Option<String> {
headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case(name))
.map(|h| h.value.clone())
};
// Prefer API Key as identifier (more stable than IP)
if let Some(auth_header) = find_header_value(&req.headers, "authorization") {
if auth_header.starts_with("Bearer ") {
return format!("api_key:{}", &auth_header[7..]);
} else if auth_header.starts_with("ApiKey ") {
return format!("api_key:{}", &auth_header[7..]);
}
}
if let Some(api_key) = find_header_value(&req.headers, "x-api-key") {
return format!("api_key:{}", api_key);
}
// Fall back to IP address from forwarded headers
if let Some(forwarded_for) = find_header_value(&req.headers, "x-forwarded-for") {
// Take first IP from comma-separated list
let ip = forwarded_for.split(',').next().unwrap_or("").trim();
if !ip.is_empty() {
return format!("ip:{}", ip);
}
}
if let Some(real_ip) = find_header_value(&req.headers, "x-real-ip") {
return format!("ip:{}", real_ip);
}
// Last resort: use request ID (not ideal, but better than nothing)
format!("req_id:{}", req.request_id)
}
// Implement on-request interface
impl OnRequestGuest for Middleware {
fn on_request(req: Request) -> Action {
let identifier = get_identifier(&req);
let current_time_ms = req.now_epoch_ms;
// Access thread-local state safely without unsafe blocks
// Each thread gets its own RateLimitState instance
RATE_LIMIT_STATE.with(|state| {
let mut state = state.borrow_mut();
if !state.check_limit(&identifier, current_time_ms) {
// Rate limit exceeded
return Action::Reject(429);
}
// Within rate limit, continue processing
Action::Continue
})
}
}
// Implement on-response interface (empty - not used for rate limiting)
impl OnResponseGuest for Middleware {
fn on_response(_resp: Response) -> Action {
Action::Continue
}
}
// Export the component
export!(Middleware);
+1
View File
@@ -0,0 +1 @@
"""Test package root for router Python tests."""
+15
View File
@@ -0,0 +1,15 @@
import sys
from importlib.util import find_spec
from pathlib import Path
# Only add bindings/python to path if the wheel is not installed (for local development)
# This ensures CI tests use the installed wheel which contains the Rust extension
_ROOT = Path(__file__).resolve().parents[1]
_SRC = _ROOT / "bindings" / "python"
# Check if sglang_router is already installed with the Rust extension
_wheel_installed = find_spec("sglang_router.sglang_router_rs") is not None
# Only add bindings/python if wheel is not installed (development mode)
if not _wheel_installed and str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
@@ -0,0 +1,343 @@
"""
gRPC Router E2E Test - OpenAI Server API Compatibility
This test file is REUSED from test/srt/openai_server/basic/test_openai_server.py
with minimal changes:
- Swap popen_launch_server() → popen_launch_workers_and_router()
- Update teardown to cleanup router + workers
- All test logic and assertions remain identical
Run with:
python3 -m pytest e2e_grpc/basic/test_openai_server.py -v
python3 -m unittest e2e_grpc.basic.test_openai_server.TestOpenAIServer.test_completion
"""
import json
import sys
import unittest
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_GPT_OSS_MODEL_PATH,
DEFAULT_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
get_tokenizer,
kill_process_tree,
)
class TestOpenAIServer(CustomTestCase):
"""
Test OpenAI API through gRPC router.
REUSED from test/srt/openai_server/basic/test_openai_server.py
ONLY CHANGE: Server launch mechanism
- Launches SGLang workers with --enable-grpc
- Launches gRPC router pointing to those workers
"""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
num_workers=1,
tp_size=2,
policy="round_robin",
api_key=cls.api_key,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
# ALL TEST METHODS BELOW ARE UNCHANGED FROM ORIGINAL
# They validate that the router maintains OpenAI API compatibility
def run_chat_completion(self, logprobs, parallel_sample_num):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{
"role": "user",
"content": "What is the capital of France? Answer in a few words.",
},
],
temperature=0,
logprobs=logprobs is not None and logprobs > 0,
top_logprobs=logprobs,
n=parallel_sample_num,
)
if logprobs:
assert isinstance(
response.choices[0].logprobs.content[0].top_logprobs[0].token, str
)
ret_num_top_logprobs = len(
response.choices[0].logprobs.content[0].top_logprobs
)
assert (
ret_num_top_logprobs == logprobs
), f"{ret_num_top_logprobs} vs {logprobs}"
assert len(response.choices) == parallel_sample_num
assert response.choices[0].message.role == "assistant"
assert isinstance(response.choices[0].message.content, str)
assert response.id
assert response.created
assert response.usage.prompt_tokens > 0
assert response.usage.completion_tokens > 0
assert response.usage.total_tokens > 0
def run_chat_completion_stream(self, logprobs, parallel_sample_num=1):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
generator = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{"role": "user", "content": "What is the capital of France?"},
],
temperature=0,
logprobs=logprobs is not None and logprobs > 0,
top_logprobs=logprobs,
stream=True,
stream_options={"include_usage": True},
n=parallel_sample_num,
)
is_firsts = {}
is_finished = {}
finish_reason_counts = {}
for response in generator:
usage = response.usage
if usage is not None:
assert usage.prompt_tokens > 0, f"usage.prompt_tokens was zero"
assert usage.completion_tokens > 0, f"usage.completion_tokens was zero"
assert usage.total_tokens > 0, f"usage.total_tokens was zero"
continue
index = response.choices[0].index
finish_reason = response.choices[0].finish_reason
if finish_reason is not None:
is_finished[index] = True
finish_reason_counts[index] = finish_reason_counts.get(index, 0) + 1
data = response.choices[0].delta
if is_firsts.get(index, True):
assert (
data.role == "assistant"
), f"data.role was not 'assistant' for first chunk"
is_firsts[index] = False
continue
if logprobs and not is_finished.get(index, False):
assert response.choices[0].logprobs, f"logprobs was not returned"
assert isinstance(
response.choices[0].logprobs.content[0].top_logprobs[0].token, str
), f"top_logprobs token was not a string"
assert isinstance(
response.choices[0].logprobs.content[0].top_logprobs, list
), f"top_logprobs was not a list"
ret_num_top_logprobs = len(
response.choices[0].logprobs.content[0].top_logprobs
)
assert (
ret_num_top_logprobs == logprobs
), f"{ret_num_top_logprobs} vs {logprobs}"
assert (
isinstance(data.content, str)
or isinstance(data.reasoning_content, str)
or (isinstance(data.tool_calls, list) and len(data.tool_calls) > 0)
or response.choices[0].finish_reason
)
assert response.id
assert response.created
for index in [i for i in range(parallel_sample_num)]:
assert not is_firsts.get(
index, True
), f"index {index} is not found in the response"
for index in range(parallel_sample_num):
assert (
index in finish_reason_counts
), f"No finish_reason found for index {index}"
assert (
finish_reason_counts[index] == 1
), f"Expected 1 finish_reason chunk for index {index}, got {finish_reason_counts[index]}"
def test_chat_completion(self):
for logprobs in [None, 5]:
for parallel_sample_num in [1, 2]:
self.run_chat_completion(logprobs, parallel_sample_num)
def test_chat_completion_stream(self):
for logprobs in [None, 5]:
for parallel_sample_num in [1, 2]:
self.run_chat_completion_stream(logprobs, parallel_sample_num)
def test_regex(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
regex = (
r"""\{\n"""
+ r""" "name": "[\w]+",\n"""
+ r""" "population": [\d]+\n"""
+ r"""\}"""
)
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{"role": "user", "content": "Introduce the capital of France."},
],
temperature=0,
max_tokens=128,
extra_body={"regex": regex},
)
text = response.choices[0].message.content
try:
js_obj = json.loads(text)
except (TypeError, json.decoder.JSONDecodeError):
raise
assert isinstance(js_obj["name"], str)
assert isinstance(js_obj["population"], int)
def test_penalty(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{"role": "user", "content": "Introduce the capital of France."},
],
temperature=0,
max_tokens=32,
frequency_penalty=1.0,
)
text = response.choices[0].message.content
assert isinstance(text, str)
def test_response_prefill(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{
"role": "user",
"content": """
Extract the name, size, price, and color from this product description as a JSON object:
<description>
The SmartHome Mini is a compact smart home assistant available in black or white for only $49.99. At just 5 inches wide, it lets you control lights, thermostats, and other connected devices via voice or app—no matter where you place it in your home. This affordable little hub brings convenient hands-free control to your smart devices.
</description>
""",
},
{
"role": "assistant",
"content": "{\n",
},
],
temperature=0,
extra_body={"continue_final_message": True},
)
assert (
response.choices[0]
.message.content.strip()
.startswith('"name": "SmartHome Mini",')
)
def test_model_list(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
models = list(client.models.list().data)
assert len(models) == 1
# assert isinstance(getattr(models[0], "max_model_len", None), int)
@unittest.skip("Skipping retrieve model test as it is not supported by the router")
def test_retrieve_model(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
retrieved_model = client.models.retrieve(self.model)
self.assertEqual(retrieved_model.id, self.model)
self.assertEqual(retrieved_model.root, self.model)
with self.assertRaises(openai.NotFoundError):
client.models.retrieve("non-existent-model")
class TestOpenAIServerGptOss(TestOpenAIServer):
"""
Test OpenAI API through gRPC router with openai/gpt-oss-20b model.
Extends TestOpenAIServer and only changes the model.
"""
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_GPT_OSS_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
num_workers=1,
tp_size=2,
policy="round_robin",
api_key=cls.api_key,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
def test_chat_completion(self):
for parallel_sample_num in [1, 2]:
self.run_chat_completion(None, parallel_sample_num)
def test_chat_completion_stream(self):
for parallel_sample_num in [1, 2]:
self.run_chat_completion_stream(None, parallel_sample_num)
@unittest.skip("Skipping for OSS models")
def test_regex(self):
super().test_regex()
@unittest.skip("Skipping for OSS models")
def test_response_prefill(self):
super().test_response_prefill()
@unittest.skip("Skipping for OSS models")
def test_penalty(self):
super().test_penalty()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,29 @@
"""
Pytest configuration for gRPC router e2e tests.
This module provides shared fixtures that can be used across all gRPC router tests.
"""
import sys
from pathlib import Path
import pytest # noqa: F401
# Ensure router bindings/python is importable
_ROUTER_ROOT = Path(__file__).resolve().parents[2]
_ROUTER_SRC = _ROUTER_ROOT / "bindings" / "python"
if str(_ROUTER_SRC) not in sys.path:
sys.path.insert(0, str(_ROUTER_SRC))
# Ensure e2e_grpc test utilities are importable
_E2E_GRPC_DIR = Path(__file__).parent
if str(_E2E_GRPC_DIR) not in sys.path:
sys.path.insert(0, str(_E2E_GRPC_DIR))
# Pytest markers for test organization
def pytest_configure(config):
config.addinivalue_line("markers", "e2e: end-to-end tests with real workers")
config.addinivalue_line("markers", "grpc: gRPC-specific tests")
config.addinivalue_line("markers", "slow: slow-running tests")
config.addinivalue_line("markers", "pd: prefill-decode disaggregation tests")
@@ -0,0 +1,194 @@
"""
Usage:
python3 -m unittest openai_server.features.test_enable_thinking.TestEnableThinking.test_chat_completion_with_reasoning
python3 -m unittest openai_server.features.test_enable_thinking.TestEnableThinking.test_chat_completion_without_reasoning
python3 -m unittest openai_server.features.test_enable_thinking.TestEnableThinking.test_stream_chat_completion_with_reasoning
python3 -m unittest openai_server.features.test_enable_thinking.TestEnableThinking.test_stream_chat_completion_without_reasoning
"""
import json
import sys
import unittest
from pathlib import Path
import requests
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_ENABLE_THINKING_MODEL_PATH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
kill_process_tree,
)
class TestEnableThinking(CustomTestCase):
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
cls.model = DEFAULT_ENABLE_THINKING_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-1234"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=120,
api_key=cls.api_key,
router_args=[
"--reasoning-parser",
"qwen3",
],
num_workers=1,
tp_size=4,
)
cls.additional_chat_kwargs = {}
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def test_chat_completion_with_reasoning(self):
# Test non-streaming with "enable_thinking": True, reasoning_content should not be empty
client = requests.post(
f"{self.base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "Hello"}],
"temperature": 0,
"separate_reasoning": True,
"chat_template_kwargs": {"enable_thinking": True},
**self.additional_chat_kwargs,
},
)
self.assertEqual(client.status_code, 200, f"Failed with: {client.text}")
data = client.json()
self.assertIn("choices", data)
self.assertTrue(len(data["choices"]) > 0)
self.assertIn("message", data["choices"][0])
self.assertIn("reasoning_content", data["choices"][0]["message"])
self.assertIsNotNone(data["choices"][0]["message"]["reasoning_content"])
def test_chat_completion_without_reasoning(self):
# Test non-streaming with "enable_thinking": False, reasoning_content should be empty
client = requests.post(
f"{self.base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "Hello"}],
"temperature": 0,
"separate_reasoning": True,
"chat_template_kwargs": {"enable_thinking": False},
**self.additional_chat_kwargs,
},
)
self.assertEqual(client.status_code, 200, f"Failed with: {client.text}")
data = client.json()
self.assertIn("choices", data)
self.assertTrue(len(data["choices"]) > 0)
self.assertIn("message", data["choices"][0])
if "reasoning_content" in data["choices"][0]["message"]:
self.assertIsNone(data["choices"][0]["message"]["reasoning_content"])
def test_stream_chat_completion_with_reasoning(self):
# Test streaming with "enable_thinking": True, reasoning_content should not be empty
response = requests.post(
f"{self.base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "Hello"}],
"temperature": 0,
"separate_reasoning": True,
"stream": True,
"chat_template_kwargs": {"enable_thinking": True},
**self.additional_chat_kwargs,
},
stream=True,
)
self.assertEqual(response.status_code, 200, f"Failed with: {response.text}")
has_reasoning = False
has_content = False
for line in response.iter_lines():
if line:
line = line.decode("utf-8")
if line.startswith("data:") and not line.startswith("data: [DONE]"):
data = json.loads(line[6:])
if "choices" in data and len(data["choices"]) > 0:
delta = data["choices"][0].get("delta", {})
if "reasoning_content" in delta and delta["reasoning_content"]:
has_reasoning = True
if "content" in delta and delta["content"]:
has_content = True
self.assertTrue(
has_reasoning,
"The reasoning content is not included in the stream response",
)
self.assertTrue(
has_content, "The stream response does not contain normal content"
)
def test_stream_chat_completion_without_reasoning(self):
# Test streaming with "enable_thinking": False, reasoning_content should be empty
response = requests.post(
f"{self.base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "Hello"}],
"temperature": 0,
"separate_reasoning": True,
"stream": True,
"chat_template_kwargs": {"enable_thinking": False},
**self.additional_chat_kwargs,
},
stream=True,
)
self.assertEqual(response.status_code, 200, f"Failed with: {response.text}")
has_reasoning = False
has_content = False
for line in response.iter_lines():
if line:
line = line.decode("utf-8")
if line.startswith("data:") and not line.startswith("data: [DONE]"):
data = json.loads(line[6:])
if "choices" in data and len(data["choices"]) > 0:
delta = data["choices"][0].get("delta", {})
if "reasoning_content" in delta and delta["reasoning_content"]:
has_reasoning = True
if "content" in delta and delta["content"]:
has_content = True
self.assertFalse(
has_reasoning,
"The reasoning content should not be included in the stream response",
)
self.assertTrue(
has_content, "The stream response does not contain normal content"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,194 @@
"""
Usage:
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentAPI.test_streaming_separate_reasoning_false
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentAPI.test_streaming_separate_reasoning_true
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentAPI.test_streaming_separate_reasoning_true_stream_reasoning_false
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentAPI.test_nonstreaming_separate_reasoning_false
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentAPI.test_nonstreaming_separate_reasoning_true
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentStartup.test_nonstreaming
python3 -m unittest openai_server.features.test_reasoning_content.TestReasoningContentStartup.test_streaming
"""
import sys
import unittest
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_REASONING_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
kill_process_tree,
)
class TestReasoningContentAPI(CustomTestCase):
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
cls.model = DEFAULT_REASONING_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-1234"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
"--reasoning-parser",
"deepseek_r1",
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def test_streaming_separate_reasoning_false(self):
# Test streaming with separate_reasoning=False, reasoning_content should be empty
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": "What is 1+3?",
}
],
"max_tokens": 100,
"stream": True,
"extra_body": {"separate_reasoning": False},
}
response = client.chat.completions.create(**payload)
reasoning_content = ""
content = ""
for chunk in response:
if chunk.choices[0].delta.content:
content += chunk.choices[0].delta.content
elif chunk.choices[0].delta.reasoning_content:
reasoning_content += chunk.choices[0].delta.reasoning_content
assert len(reasoning_content) == 0
assert len(content) > 0
def test_streaming_separate_reasoning_true(self):
# Test streaming with separate_reasoning=True, reasoning_content should not be empty
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": "What is 1+3?",
}
],
"max_tokens": 100,
"stream": True,
"extra_body": {"separate_reasoning": True},
}
response = client.chat.completions.create(**payload)
reasoning_content = ""
content = ""
for chunk in response:
if chunk.choices[0].delta.content:
content += chunk.choices[0].delta.content
elif chunk.choices[0].delta.reasoning_content:
reasoning_content += chunk.choices[0].delta.reasoning_content
assert len(reasoning_content) > 0
assert len(content) > 0
def test_streaming_separate_reasoning_true_stream_reasoning_false(self):
# Test streaming with separate_reasoning=True, reasoning_content should not be empty
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": "What is 1+3?",
}
],
"max_tokens": 100,
"stream": True,
"extra_body": {"separate_reasoning": True, "stream_reasoning": False},
}
response = client.chat.completions.create(**payload)
reasoning_content = ""
content = ""
first_chunk = False
for chunk in response:
if chunk.choices[0].delta.reasoning_content:
reasoning_content = chunk.choices[0].delta.reasoning_content
first_chunk = True
if chunk.choices[0].delta.content:
content += chunk.choices[0].delta.content
if not first_chunk:
reasoning_content = chunk.choices[0].delta.reasoning_content
first_chunk = True
if not first_chunk:
assert (
not chunk.choices[0].delta.reasoning_content
or len(chunk.choices[0].delta.reasoning_content) == 0
)
assert len(reasoning_content) > 0
assert len(content) > 0
def test_nonstreaming_separate_reasoning_false(self):
# Test non-streaming with separate_reasoning=False, reasoning_content should be empty
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": "What is 1+3?",
}
],
"max_tokens": 100,
"extra_body": {"separate_reasoning": False},
}
response = client.chat.completions.create(**payload)
assert (
not response.choices[0].message.reasoning_content
or len(response.choices[0].message.reasoning_content) == 0
)
assert len(response.choices[0].message.content) > 0
def test_nonstreaming_separate_reasoning_true(self):
# Test non-streaming with separate_reasoning=True, reasoning_content should not be empty
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": "What is 1+3?",
}
],
"max_tokens": 100,
"extra_body": {"separate_reasoning": True},
}
response = client.chat.completions.create(**payload)
assert len(response.choices[0].message.reasoning_content) > 0
assert len(response.choices[0].message.content) > 0
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,342 @@
"""
Fixtures for launching gRPC router + workers for e2e testing.
This module provides fixtures for launching SGLang workers and gRPC router separately:
1. Launch N SGLang workers with gRPC enabled
2. Launch router pointing to those workers
This approach gives more control and matches production deployment patterns.
"""
import logging
import socket
import subprocess
import time
from typing import Optional
import requests
logger = logging.getLogger(__name__)
def find_free_port() -> int:
"""Find an available port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_for_workers_ready(
router_url: str,
expected_workers: int,
timeout: int = 300,
api_key: Optional[str] = None,
) -> None:
"""
Wait for router to have all workers connected.
Polls the /workers endpoint until the 'total' field matches expected_workers.
Example response from /workers endpoint:
{"workers":[],"total":0,"stats":{"prefill_count":0,"decode_count":0,"regular_count":0}}
Args:
router_url: Base URL of router (e.g., "http://127.0.0.1:30000")
expected_workers: Number of workers expected to be connected
timeout: Max seconds to wait
api_key: Optional API key for authentication
"""
start_time = time.time()
last_error = None
attempt = 0
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with requests.Session() as session:
while time.time() - start_time < timeout:
attempt += 1
elapsed = int(time.time() - start_time)
# Log progress every 10 seconds
if elapsed > 0 and elapsed % 10 == 0 and attempt % 10 == 0:
logger.info(
f" Still waiting for workers... ({elapsed}/{timeout}s elapsed)"
)
try:
response = session.get(
f"{router_url}/workers", headers=headers, timeout=5
)
if response.status_code == 200:
data = response.json()
total_workers = data.get("total", 0)
if total_workers == expected_workers:
logger.info(
f" All {expected_workers} workers connected after {elapsed}s"
)
return
else:
last_error = f"Workers: {total_workers}/{expected_workers}"
else:
last_error = f"HTTP {response.status_code}"
except requests.ConnectionError:
last_error = "Connection refused (router not ready yet)"
except requests.Timeout:
last_error = "Timeout"
except requests.RequestException as e:
last_error = str(e)
except (ValueError, KeyError) as e:
last_error = f"Invalid response: {e}"
time.sleep(1)
raise TimeoutError(
f"Router at {router_url} did not get {expected_workers} workers within {timeout}s.\n"
f"Last status: {last_error}\n"
f"Hint: Run with SHOW_ROUTER_LOGS=1 to see startup logs"
)
def popen_launch_workers_and_router(
model: str,
base_url: str,
timeout: int = 300,
num_workers: int = 2,
policy: str = "round_robin",
api_key: Optional[str] = None,
worker_args: Optional[list] = None,
router_args: Optional[list] = None,
tp_size: int = 1,
env: Optional[dict] = None,
stdout=None,
stderr=None,
) -> dict:
"""
Launch SGLang workers and gRPC router separately.
This approach:
1. Starts N SGLang workers with --grpc-mode flag
2. Waits for workers to initialize (process startup)
3. Starts a gRPC router pointing to those workers
4. Waits for router health check to pass (router validates worker connectivity)
This matches production deployment patterns better than the integrated approach.
Args:
model: Model path (e.g., /home/ubuntu/models/llama-3.1-8b-instruct)
base_url: Base URL for router (e.g., "http://127.0.0.1:8080")
timeout: Timeout for server startup (default: 300s)
num_workers: Number of workers to launch
policy: Routing policy (round_robin, random, power_of_two, cache_aware)
api_key: Optional API key for router
worker_args: Additional arguments for workers (e.g., ["--context-len", "8192"])
router_args: Additional arguments for router (e.g., ["--max-total-token", "1536"])
tp_size: Tensor parallelism size for workers (default: 1)
env: Optional environment variables for workers (e.g., {"SGLANG_CLIP_MAX_NEW_TOKENS_ESTIMATION": "256"})
stdout: Optional file handle for worker stdout (default: subprocess.PIPE)
stderr: Optional file handle for worker stderr (default: subprocess.PIPE)
Returns:
dict with:
- workers: list of worker process objects
- worker_urls: list of gRPC worker URLs
- router: router process object
- base_url: router URL (HTTP endpoint)
Example:
>>> cluster = popen_launch_workers_and_router(model, base_url, num_workers=2)
>>> # Use cluster['base_url'] for HTTP requests
>>> # Cleanup:
>>> for worker in cluster['workers']:
>>> kill_process_tree(worker.pid)
>>> kill_process_tree(cluster['router'].pid)
"""
import os
show_output = os.environ.get("SHOW_ROUTER_LOGS", "0") == "1"
# Note: timeout parameter is used for router health check below
# Parse router port from base_url
if ":" in base_url.split("//")[-1]:
router_port = int(base_url.split(":")[-1])
else:
router_port = find_free_port()
logger.info(f"\n{'='*70}")
logger.info(f"Launching gRPC cluster (separate workers + router)")
logger.info(f"{'='*70}")
logger.info(f" Model: {model}")
logger.info(f" Router port: {router_port}")
logger.info(f" Workers: {num_workers}")
logger.info(f" TP size: {tp_size}")
logger.info(f" Policy: {policy}")
# Step 1: Launch workers with gRPC enabled
workers = []
worker_urls = []
for i in range(num_workers):
worker_port = find_free_port()
worker_url = f"grpc://127.0.0.1:{worker_port}"
worker_urls.append(worker_url)
logger.info(f"\n[Worker {i+1}/{num_workers}]")
logger.info(f" Port: {worker_port}")
logger.info(f" URL: {worker_url}")
# Build worker command
worker_cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
model,
"--host",
"127.0.0.1",
"--port",
str(worker_port),
"--grpc-mode", # Enable gRPC for this worker
"--mem-fraction-static",
"0.8",
]
# Add TP size
if tp_size > 1:
worker_cmd.extend(["--tp-size", str(tp_size)])
# Add worker-specific args
if worker_args:
worker_cmd.extend(worker_args)
# Launch worker with optional environment variables
if show_output:
worker_proc = subprocess.Popen(
worker_cmd,
env=env,
stdout=stdout,
stderr=stderr,
)
else:
worker_proc = subprocess.Popen(
worker_cmd,
stdout=stdout if stdout is not None else subprocess.PIPE,
stderr=stderr if stderr is not None else subprocess.PIPE,
env=env,
)
workers.append(worker_proc)
logger.info(f" PID: {worker_proc.pid}")
# Give workers a moment to start binding to ports
# The router will check worker health when it starts
logger.info(f"\nWaiting for {num_workers} workers to initialize (20s)...")
time.sleep(20)
# Quick check: make sure worker processes are still alive
for i, worker in enumerate(workers):
if worker.poll() is not None:
logger.error(
f" ✗ Worker {i+1} died during startup (exit code: {worker.poll()})"
)
# Cleanup: kill all workers
for w in workers:
try:
w.kill()
except:
pass
raise RuntimeError(f"Worker {i+1} failed to start")
logger.info(
f"✓ All {num_workers} workers started (router will verify connectivity)"
)
# Step 2: Launch router pointing to workers
logger.info(f"\n[Router]")
logger.info(f" Port: {router_port}")
logger.info(f" Worker URLs: {', '.join(worker_urls)}")
# Build router command
router_cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
"127.0.0.1",
"--port",
str(router_port),
"--prometheus-port",
"9321",
"--policy",
policy,
"--model-path",
model,
"--log-level",
"warn",
]
# Add worker URLs
router_cmd.append("--worker-urls")
router_cmd.extend(worker_urls)
# Add API key
if api_key:
router_cmd.extend(["--api-key", api_key])
# Add router-specific args
if router_args:
router_cmd.extend(router_args)
if show_output:
logger.info(f" Command: {' '.join(router_cmd)}")
# Launch router
if show_output:
router_proc = subprocess.Popen(router_cmd)
else:
router_proc = subprocess.Popen(
router_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
logger.info(f" PID: {router_proc.pid}")
# Wait for router to be ready
router_url = f"http://127.0.0.1:{router_port}"
logger.info(f"\nWaiting for router to start at {router_url}...")
try:
wait_for_workers_ready(
router_url, expected_workers=num_workers, timeout=180, api_key=api_key
)
logger.info(f"✓ Router ready at {router_url}")
except TimeoutError:
logger.error(f"✗ Router failed to start")
# Cleanup: kill router and all workers
try:
router_proc.kill()
except:
pass
for worker in workers:
try:
worker.kill()
except:
pass
raise
logger.info(f"\n{'='*70}")
logger.info(f"✓ gRPC cluster ready!")
logger.info(f" Router: {router_url}")
logger.info(f" Workers: {len(workers)}")
logger.info(f"{'='*70}\n")
return {
"workers": workers,
"worker_urls": worker_urls,
"router": router_proc,
"base_url": router_url,
}
@@ -0,0 +1,950 @@
"""
gRPC Router E2E Test - Test Openai Function Calling
This test file is REUSED from test/srt/openai_server/function_call/test_openai_function_calling.py
with minimal changes:
num_workers=2,
- Swap popen_launch_server() → popen_launch_workers_and_router()
- Update teardown to cleanup router + workers
- All test logic and assertions remain identical
Run with:
pytest py_test/e2e_grpc/e2e_grpc/function_call/test_openai_function_calling.py -v
"""
import json
import sys
import unittest
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_MODEL_PATH,
DEFAULT_SMALL_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
get_tokenizer,
kill_process_tree,
)
class TestOpenAIServerFunctionCalling(CustomTestCase):
# NOTE: this system_message is for Llama3.2 system prompt. Without this,
# sometimes Llama3.2 gives a different tool call format such as:
# '<|python_tag|>{"type": "function", "function": "add", "parameters": {"a": "3", "b": "5"}}'
SYSTEM_MESSAGE = (
"You are a helpful assistant with tool calling capabilities. "
"Only reply with a tool call if the function exists in the library provided by the user. "
"If it doesn't exist, just reply directly in natural language. "
"When you receive a tool call response, use the output to format an answer to the original user question. "
"You have access to the following functions. "
"To call a function, please respond with JSON for a function call. "
'Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. '
"Do not use variables.\n\n"
)
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
# Using small model for function calling tests
cls.model = DEFAULT_SMALL_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
# Start the local OpenAI Server. If necessary, you can add other parameters such as --enable-tools.
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
# If your server needs extra parameters to test function calling, please add them here.
"--tool-call-parser",
"llama",
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def test_function_calling_format(self):
"""
Test: Whether the function call format returned by the AI is correct.
When returning a tool call, message.content should be None, and tool_calls should be a list.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "add",
"description": "Compute the sum of two numbers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "integer",
"description": "A number",
},
"b": {
"type": "integer",
"description": "A number",
},
},
"required": ["a", "b"],
},
},
}
]
messages = [
{"role": "system", "content": self.SYSTEM_MESSAGE},
{"role": "user", "content": "Compute (3+5)"},
]
response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
)
tool_calls = response.choices[0].message.tool_calls
assert (
isinstance(tool_calls, list) and len(tool_calls) > 0
), "tool_calls should be a non-empty list"
function_name = tool_calls[0].function.name
assert function_name == "add", "Function name should be 'add'"
# This unit test is too difficult for default model. Mark it as optional unit tests so it won't trigger unless specified.
def _test_function_calling_multiturn(self):
"""
Test: Whether the function call format returned by the AI is correct.
When returning a tool call, message.content should be None, and tool_calls should be a list.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "add",
"description": "Compute the sum of two numbers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "integer",
"description": "A number",
},
"b": {
"type": "integer",
"description": "A number",
},
},
"required": ["a", "b"],
},
},
}
]
messages = [{"role": "user", "content": "Compute (3+5)"}]
response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
)
tool_call = response.choices[0].message.tool_calls[0]
function_name = tool_call.function.name
assert function_name == "add", "Function name should be 'add'"
function_arguments = tool_call.function.arguments
function_arguments = json.loads(tool_call.function.arguments)
assert function_arguments in [
{"a": 3, "b": 5},
{"a": "3", "b": "5"},
], f"Unexpected function arguments: {function_arguments}"
messages.append(response.choices[0].message)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": "8",
"name": function_name,
}
)
final_response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
)
assert (
"8" in final_response.choices[0].message.content
), "tool_call response should have the sum 8 in the content"
def test_function_calling_streaming_simple(self):
"""
Test: Whether the function name can be correctly recognized in streaming mode.
- Expect a function call to be found, and the function name to be correct.
- Verify that streaming mode returns at least multiple chunks.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to find the weather for",
},
"unit": {
"type": "string",
"description": "Weather unit (celsius or fahrenheit)",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["city", "unit"],
},
},
}
]
messages = [
{"role": "system", "content": self.SYSTEM_MESSAGE},
{
"role": "user",
"content": "What is the temperature in Paris in celsius??",
},
]
response_stream = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=True,
tools=tools,
)
chunks = list(response_stream)
self.assertTrue(len(chunks) > 0, "Streaming should return at least one chunk")
found_function_name = False
for chunk in chunks:
choice = chunk.choices[0]
# Check whether the current chunk contains tool_calls
if choice.delta.tool_calls:
tool_call = choice.delta.tool_calls[0]
if tool_call.function.name:
self.assertEqual(
tool_call.function.name,
"get_current_weather",
"Function name should be 'get_current_weather'",
)
found_function_name = True
break
self.assertTrue(
found_function_name,
"Target function name 'get_current_weather' was not found in the streaming chunks",
)
finish_reason = chunks[-1].choices[0].finish_reason
self.assertEqual(
finish_reason,
"tool_calls",
"Final response of function calling should have finish_reason 'tool_calls'",
)
def test_function_calling_streaming_args_parsing(self):
"""
Test: Whether the function call arguments returned in streaming mode can be correctly concatenated into valid JSON.
- The user request requires multiple parameters.
- AI may return the arguments in chunks that need to be concatenated.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "add",
"description": "Compute the sum of two integers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "integer",
"description": "First integer",
},
"b": {
"type": "integer",
"description": "Second integer",
},
},
"required": ["a", "b"],
},
"strict": True, # Llama-3.2-1B is flaky in tool call. It won't always respond with parameters unless we set strict.
},
}
]
messages = [
{"role": "system", "content": self.SYSTEM_MESSAGE},
{"role": "user", "content": "Please sum 5 and 7, just call the function."},
]
response_stream = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.9,
top_p=0.9,
stream=True,
tools=tools,
)
argument_fragments = []
chunks = list(response_stream)
function_name = None
for chunk in chunks:
choice = chunk.choices[0]
if choice.delta.tool_calls:
tool_call = choice.delta.tool_calls[0]
# Record the function name on first occurrence
function_name = tool_call.function.name or function_name
# In case of multiple chunks, JSON fragments may need to be concatenated
if tool_call.function.arguments is not None:
argument_fragments.append(tool_call.function.arguments)
self.assertEqual(function_name, "add", "Function name should be 'add'")
joined_args = "".join(argument_fragments)
self.assertTrue(
len(joined_args) > 0,
"No parameter fragments were returned in the function call",
)
finish_reason = chunks[-1].choices[0].finish_reason
self.assertEqual(
finish_reason,
"tool_calls",
"Final response of function calling should have finish_reason 'tool_calls'",
)
# Check whether the concatenated JSON is valid
try:
args_obj = json.loads(joined_args)
except json.JSONDecodeError:
self.fail(
"The concatenated tool call arguments are not valid JSON, parsing failed"
)
self.assertIn("a", args_obj, "Missing parameter 'a'")
self.assertIn("b", args_obj, "Missing parameter 'b'")
self.assertEqual(str(args_obj["a"]), "5", "Parameter a should be 5")
self.assertEqual(str(args_obj["b"]), "7", "Parameter b should be 7")
@unittest.skip(
"Skipping function call strict test as it is not supported by the router"
)
def test_function_call_strict(self):
"""
Test: Whether the strict mode of function calling works as expected.
- When strict mode is enabled, the AI should not return a function call if the function name is not recognized.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "sub",
"description": "Compute the difference of two integers",
"parameters": {
"type": "object",
"properties": {
"int_a": {
"type": "integer",
"description": "First integer",
},
"int_b": {
"type": "integer",
"description": "Second integer",
},
},
"required": ["int_a", "int_b"],
},
"strict": True,
},
}
]
messages = [
{"role": "user", "content": "Please compute 5 - 7, using your tool."}
]
response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
)
tool_calls = response.choices[0].message.tool_calls
function_name = tool_calls[0].function.name
arguments = tool_calls[0].function.arguments
args_obj = json.loads(arguments)
self.assertEqual(function_name, "sub", "Function name should be 'sub'")
self.assertEqual(str(args_obj["int_a"]), "5", "Parameter int_a should be 5")
self.assertEqual(str(args_obj["int_b"]), "7", "Parameter int_b should be 7")
def test_function_call_required(self):
"""
Test: Whether tool_choice: "required" works as expected
- When tool_choice == "required", the model should return one or more tool_calls.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "sub",
"description": "Compute the difference of two integers",
"parameters": {
"type": "object",
"properties": {
"int_a": {
"type": "integer",
"description": "First integer",
},
"int_b": {
"type": "integer",
"description": "Second integer",
},
},
"required": ["int_a", "int_b"],
},
"strict": True,
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "use this to get latest weather information for a city given its name",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "name of the city to get weather for",
}
},
"required": ["city"],
},
},
},
]
messages = [{"role": "user", "content": "What is the capital of France?"}]
response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
tool_choice="required",
)
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls, "No tool_calls in the response")
function_name = tool_calls[0].function.name
arguments = tool_calls[0].function.arguments
args_obj = json.loads(arguments)
self.assertEqual(
function_name,
"get_weather",
f"Function name should be 'get_weather', got: {function_name}",
)
self.assertIn(
"city", args_obj, f"Function arguments should have 'city', got: {args_obj}"
)
# Make the test more robust by checking type and accepting valid responses
city_value = args_obj["city"]
self.assertIsInstance(
city_value,
str,
f"Parameter city should be a string, got: {type(city_value)}",
)
self.assertTrue(
"Paris" in city_value or "France" in city_value,
f"Parameter city should contain either 'Paris' or 'France', got: {city_value}",
)
def test_function_call_specific(self):
"""
Test: Whether tool_choice: ToolChoice works as expected
- When tool_choice is a specific ToolChoice, the model should return one or more tool_calls.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "sub",
"description": "Compute the difference of two integers",
"parameters": {
"type": "object",
"properties": {
"int_a": {
"type": "integer",
"description": "First integer",
},
"int_b": {
"type": "integer",
"description": "Second integer",
},
},
"required": ["int_a", "int_b"],
},
"strict": True,
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "use this to get latest weather information for a city given its name",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "name of the city to get weather for",
}
},
"required": ["city"],
},
},
},
]
messages = [{"role": "user", "content": "What is the capital of France?"}]
response = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=False,
tools=tools,
tool_choice={"type": "function", "function": {"name": "get_weather"}},
)
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls, "No tool_calls in the response")
function_name = tool_calls[0].function.name
arguments = tool_calls[0].function.arguments
args_obj = json.loads(arguments)
self.assertEqual(
function_name, "get_weather", "Function name should be 'get_weather'"
)
self.assertIn("city", args_obj, "Function arguments should have 'city'")
def test_streaming_multiple_choices_finish_reason(self):
"""
Test: Verify that each choice gets its own finish_reason chunk in streaming mode with n > 1.
This tests the fix for the bug where only the last index got a finish_reason chunk.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["location"],
},
},
}
]
messages = [
{"role": "user", "content": "What is the weather like in Los Angeles?"}
]
# Request with n=2 to get multiple choices
response_stream = client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=2048,
temperature=0.8,
stream=True,
tools=tools,
tool_choice="required", # Force tool calls
n=2, # Multiple choices
)
chunks = list(response_stream)
# Track finish_reason chunks for each index
finish_reason_chunks = {}
for chunk in chunks:
if chunk.choices:
for choice in chunk.choices:
if choice.finish_reason is not None:
index = choice.index
if index not in finish_reason_chunks:
finish_reason_chunks[index] = []
finish_reason_chunks[index].append(choice.finish_reason)
# Verify we got finish_reason chunks for both indices
self.assertEqual(
len(finish_reason_chunks),
2,
f"Expected finish_reason chunks for 2 indices, got {len(finish_reason_chunks)}",
)
# Verify both index 0 and 1 have finish_reason
self.assertIn(
0, finish_reason_chunks, "Missing finish_reason chunk for index 0"
)
self.assertIn(
1, finish_reason_chunks, "Missing finish_reason chunk for index 1"
)
# Verify the finish_reason is "tool_calls" since we forced tool calls
for index, reasons in finish_reason_chunks.items():
self.assertEqual(
reasons[-1], # Last finish_reason for this index
"tool_calls",
f"Expected finish_reason 'tool_calls' for index {index}, got {reasons[-1]}",
)
def test_function_calling_streaming_no_tool_call(self):
"""
Test: Whether the finish_reason is stop in streaming mode when no tool call is given.
- Expect no function call to be found.
- Verify that finish_reason is stop
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to find the weather for",
},
"unit": {
"type": "string",
"description": "Weather unit (celsius or fahrenheit)",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["city", "unit"],
},
},
}
]
messages = [{"role": "user", "content": "Who are you?"}]
response_stream = client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=messages,
temperature=0.8,
top_p=0.8,
stream=True,
tools=tools,
tool_choice="none",
)
chunks = list(response_stream)
self.assertTrue(len(chunks) > 0, "Streaming should return at least one chunk")
found_tool_call = False
for chunk in chunks:
choice = chunk.choices[0]
# Check whether the current chunk contains tool_calls
found_tool_call = choice.delta.tool_calls is not None
self.assertFalse(
found_tool_call,
"Shouldn't have any tool_call in the streaming chunks",
)
finish_reason = chunks[-1].choices[0].finish_reason
self.assertEqual(
finish_reason,
"stop",
"Final response of no function calling should have finish_reason 'stop'",
)
def test_streaming_multiple_choices_without_tools(self):
"""
Test: Verify that each choice gets its own finish_reason chunk without tool calls.
This tests the fix for regular content streaming with multiple choices.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
messages = [{"role": "user", "content": "Say hello in one word."}]
# Request with n=2 to get multiple choices, no tools
response_stream = client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.8,
stream=True,
max_tokens=10, # Keep it short
n=2, # Multiple choices
)
chunks = list(response_stream)
# Track finish_reason chunks for each index
finish_reason_chunks = {}
for chunk in chunks:
if chunk.choices:
for choice in chunk.choices:
if choice.finish_reason is not None:
index = choice.index
if index not in finish_reason_chunks:
finish_reason_chunks[index] = []
finish_reason_chunks[index].append(choice.finish_reason)
# Verify we got finish_reason chunks for both indices
self.assertEqual(
len(finish_reason_chunks),
2,
f"Expected finish_reason chunks for 2 indices, got {len(finish_reason_chunks)}",
)
# Verify both index 0 and 1 have finish_reason
self.assertIn(
0, finish_reason_chunks, "Missing finish_reason chunk for index 0"
)
self.assertIn(
1, finish_reason_chunks, "Missing finish_reason chunk for index 1"
)
# Verify the finish_reason is "stop" (regular completion)
for index, reasons in finish_reason_chunks.items():
self.assertIn(
reasons[-1],
["stop", "length"], # Could be either depending on how model responds
f"Expected finish_reason 'stop' or 'length' for index {index}, got {reasons[-1]}",
)
class TestOpenAIPythonicFunctionCalling(CustomTestCase):
PYTHONIC_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a given location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The name of the city or location.",
}
},
"required": ["location"],
},
},
},
{
"type": "function",
"function": {
"name": "get_tourist_attractions",
"description": "Get a list of top tourist attractions for a given city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The name of the city to find attractions for.",
}
},
"required": ["city"],
},
},
},
]
PYTHONIC_MESSAGES = [
{
"role": "system",
"content": (
"You are a travel assistant. "
"When asked to call functions, ALWAYS respond ONLY with a python list of function calls, "
"using this format: [func_name1(param1=value1, param2=value2), func_name2(param=value)]. "
"Do NOT use JSON, do NOT use variables, do NOT use any other format. "
"Here is an example:\n"
'[get_weather(location="Paris"), get_tourist_attractions(city="Paris")]'
),
},
{
"role": "user",
"content": (
"I'm planning a trip to Tokyo next week. What's the weather like and what are some top tourist attractions? "
"Propose parallel tool calls at once, using the python list of function calls format as shown above."
),
},
]
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
cls.model = DEFAULT_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
"--tool-call-parser",
"pythonic",
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def test_pythonic_tool_call_prompt(self):
"""
Test: Explicit prompt for pythonic tool call format without chat template.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response = client.chat.completions.create(
model=self.model,
messages=self.PYTHONIC_MESSAGES,
tools=self.PYTHONIC_TOOLS,
temperature=0.1,
stream=False,
)
tool_calls = response.choices[0].message.tool_calls
self.assertIsInstance(tool_calls, list, "No tool_calls found")
self.assertGreaterEqual(len(tool_calls), 1)
names = [tc.function.name for tc in tool_calls]
self.assertTrue(
"get_weather" in names or "get_tourist_attractions" in names,
f"Function name '{names}' should container either 'get_weather' or 'get_tourist_attractions'",
)
def test_pythonic_tool_call_streaming(self):
"""
Test: Streaming pythonic tool call format; assert tool_call index is present.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response_stream = client.chat.completions.create(
model=self.model,
messages=self.PYTHONIC_MESSAGES,
tools=self.PYTHONIC_TOOLS,
temperature=0.1,
stream=True,
)
found_tool_calls = False
found_index = False
found_names = set()
for chunk in response_stream:
choice = chunk.choices[0]
if getattr(choice.delta, "tool_calls", None):
found_tool_calls = True
tool_call = choice.delta.tool_calls[0]
if hasattr(tool_call, "index") or (
isinstance(tool_call, dict) and "index" in tool_call
):
found_index = True
found_names.add(str(tool_call.function.name))
self.assertTrue(found_tool_calls, "No tool_calls found in streaming response")
self.assertTrue(found_index, "No index field found in any streamed tool_call")
self.assertTrue(
"get_weather" in found_names or "get_tourist_attractions" in found_names,
f"Function name '{found_names}' should container either 'get_weather' or 'get_tourist_attractions'",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,795 @@
"""
Test script for tool_choice functionality in SGLang
Tests: required, auto, and specific function choices in both streaming and non-streaming modes
# To run the tests, use the following command:
#
# python3 -m unittest openai_server.function_call.test_tool_choice
"""
import json
import sys
import unittest
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_MISTRAL_FUNCTION_CALLING_MODEL_PATH,
DEFAULT_QWEN_FUNCTION_CALLING_MODEL_PATH,
DEFAULT_SMALL_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
get_tokenizer,
kill_process_tree,
)
class TestToolChoiceLlama32(CustomTestCase):
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
# Mark flaky tests for this model
cls.flaky_tests = {
"test_multi_tool_scenario_auto",
"test_multi_tool_scenario_required",
}
# Use a model that supports function calling
cls.model = DEFAULT_SMALL_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
# Start the local OpenAI Server with tool calling support
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
"--tool-call-parser",
"llama", # Default parser for the test model
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def setUp(self):
self.client = openai.Client(base_url=self.base_url, api_key=self.api_key)
self.model_name = self.client.models.list().data[0].id
def _is_flaky_test(self):
"""Check if the current test is marked as flaky for this class"""
return (
hasattr(self.__class__, "flaky_tests")
and self._testMethodName in self.__class__.flaky_tests
)
def get_test_tools(self):
"""Get the test tools for function calling"""
return [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "use this to get latest weather information for a city given its name",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "name of the city to get weather for",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_pokemon_info",
"description": "get detailed information about a pokemon given its name",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "name of the pokemon to get info for",
}
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "make_next_step_decision",
"description": "You will be given a trace of thinking process in the following format.\n\nQuestion: the input question you must answer\nTOOL: think about what to do, and choose a tool to use ONLY IF there are defined tools. \n You should never call the same tool with the same input twice in a row.\n If the previous conversation history already contains the information that can be retrieved from the tool, you should not call the tool again.\nOBSERVATION: the result of the tool call, NEVER include this in your response, this information will be provided\n... (this TOOL/OBSERVATION can repeat N times)\nANSWER: If you know the answer to the original question, require for more information,\n or you don't know the answer and there are no defined tools or all available tools are not helpful, respond with the answer without mentioning anything else.\n If the previous conversation history already contains the answer, respond with the answer right away.\n\n If no tools are configured, naturally mention this limitation while still being helpful. Briefly note that adding tools in the agent configuration would expand capabilities.\n\nYour task is to respond with the next step to take, based on the traces, \nor answer the question if you have enough information.",
"parameters": {
"type": "object",
"properties": {
"decision": {
"type": "string",
"description": 'The next step to take, it must be either "TOOL" or "ANSWER". If the previous conversation history already contains the information that can be retrieved from the tool, you should not call the tool again. If there are no defined tools, you should not return "TOOL" in your response.',
},
"content": {
"type": "string",
"description": 'The content of the next step. If the decision is "TOOL", this should be a short and concise reasoning of why you chose the tool, MUST include the tool name. If the decision is "ANSWER", this should be the answer to the question. If no tools are available, integrate this limitation conversationally without sounding scripted.',
},
},
"required": ["decision", "content"],
},
},
},
]
def get_test_messages(self):
"""Get test messages that should trigger tool usage"""
return [
{
"role": "user",
"content": "Answer the following questions as best you can:\n\nYou will be given a trace of thinking process in the following format.\n\nQuestion: the input question you must answer\nTOOL: think about what to do, and choose a tool to use ONLY IF there are defined tools\nOBSERVATION: the result of the tool call or the observation of the current task, NEVER include this in your response, this information will be provided\n... (this TOOL/OBSERVATION can repeat N times)\nANSWER: If you know the answer to the original question, require for more information, \nif the previous conversation history already contains the answer, \nor you don't know the answer and there are no defined tools or all available tools are not helpful, respond with the answer without mentioning anything else.\nYou may use light Markdown formatting to improve clarity (e.g. lists, **bold**, *italics*), but keep it minimal and unobtrusive.\n\nYour task is to respond with the next step to take, based on the traces, \nor answer the question if you have enough information.\n\nQuestion: what is the weather in top 5 populated cities in the US in celsius?\n\nTraces:\n\n\nThese are some additional instructions that you should follow:",
}
]
def get_travel_tools(self):
"""Get tools for travel assistant scenario that should trigger multiple tool calls"""
return [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a given location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The name of the city or location.",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["location"],
},
},
},
{
"type": "function",
"function": {
"name": "get_tourist_attractions",
"description": "Get a list of top tourist attractions for a given city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The name of the city to find attractions for.",
}
},
"required": ["city"],
},
},
},
]
def get_travel_messages(self):
"""Get travel assistant messages that should trigger multiple tool calls"""
return [
{
"content": "You are a travel assistant providing real-time weather updates and top tourist attractions.",
"role": "system",
},
{
"content": "I'm planning a trip to Tokyo next week. What's the weather like? What are the most amazing sights?",
"role": "user",
},
]
def test_tool_choice_auto_non_streaming(self):
"""Test tool_choice='auto' in non-streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice="auto",
stream=False,
)
self.assertIsNotNone(response.choices[0].message)
# With auto, tool calls are optional
def test_tool_choice_auto_streaming(self):
"""Test tool_choice='auto' in streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice="auto",
stream=True,
)
# Collect streaming response
content_chunks = []
tool_call_chunks = []
for chunk in response:
if chunk.choices[0].delta.content:
content_chunks.append(chunk.choices[0].delta.content)
elif chunk.choices[0].delta.tool_calls:
tool_call_chunks.extend(chunk.choices[0].delta.tool_calls)
# Should complete without errors
self.assertIsInstance(content_chunks, list)
self.assertIsInstance(tool_call_chunks, list)
def test_tool_choice_required_non_streaming(self):
"""Test tool_choice='required' in non-streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
temperature=0.2,
tools=tools,
tool_choice="required",
stream=False,
)
# With required, we should get tool calls
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls)
self.assertGreater(len(tool_calls), 0)
def test_tool_choice_required_streaming(self):
"""Test tool_choice='required' in streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice="required",
stream=True,
)
# Collect streaming response
tool_call_chunks = []
for chunk in response:
if chunk.choices[0].delta.tool_calls:
tool_call_chunks.extend(chunk.choices[0].delta.tool_calls)
# With required, we should get tool call chunks
self.assertGreater(len(tool_call_chunks), 0)
def test_tool_choice_specific_function_non_streaming(self):
"""Test tool_choice with specific function in non-streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
tool_choice = {"type": "function", "function": {"name": "get_weather"}}
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice=tool_choice,
stream=False,
)
# Should call the specific function
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls)
# Our messages ask the top 5 populated cities in the US, so the model could get 5 tool calls
self.assertGreaterEqual(len(tool_calls), 1)
for tool_call in tool_calls:
self.assertEqual(tool_call.function.name, "get_weather")
def test_tool_choice_specific_function_streaming(self):
"""Test tool_choice with specific function in streaming mode"""
tools = self.get_test_tools()
messages = self.get_test_messages()
tool_choice = {"type": "function", "function": {"name": "get_weather"}}
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice=tool_choice,
stream=True,
)
# Collect streaming response
tool_call_chunks = []
for chunk in response:
if chunk.choices[0].delta.tool_calls:
tool_call_chunks.extend(chunk.choices[0].delta.tool_calls)
# Should get tool call chunks for the specific function
self.assertGreater(len(tool_call_chunks), 0)
# Find function name in chunks
found_name = None
for chunk in tool_call_chunks:
if chunk.function and chunk.function.name:
found_name = chunk.function.name
break
self.assertEqual(found_name, "get_weather")
def test_required_streaming_arguments_chunks_json(self):
"""In streaming required mode, complete tool call arguments should be valid JSON when all chunks are combined"""
tools = self.get_test_tools()
messages = self.get_test_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=1024,
temperature=0.1,
tools=tools,
tool_choice="required",
stream=True,
)
# Collect all tool call chunks and reconstruct complete tool calls
tool_calls_by_index = {}
for chunk in response:
if chunk.choices[0].delta.tool_calls:
for tool_call_delta in chunk.choices[0].delta.tool_calls:
tool_index = tool_call_delta.index
# Initialize tool call if not seen before
if tool_index not in tool_calls_by_index:
tool_calls_by_index[tool_index] = {
"id": tool_call_delta.id,
"type": "function",
"function": {"name": "", "arguments": ""},
}
# Update function name if present (first chunk)
if tool_call_delta.function and tool_call_delta.function.name:
tool_calls_by_index[tool_index]["function"][
"name"
] = tool_call_delta.function.name
# Accumulate arguments (all chunks)
if tool_call_delta.function and tool_call_delta.function.arguments:
tool_calls_by_index[tool_index]["function"][
"arguments"
] += tool_call_delta.function.arguments
self.assertGreater(len(tool_calls_by_index), 0)
# Validate that complete tool calls have valid JSON arguments
for tool_call in tool_calls_by_index.values():
self.assertIsNotNone(tool_call["function"]["name"])
self.assertIsNotNone(tool_call["function"]["arguments"])
# The complete arguments should be valid JSON
try:
args = json.loads(tool_call["function"]["arguments"])
self.assertIsInstance(args, dict)
except json.JSONDecodeError:
self.fail(
f"Invalid JSON in complete tool call arguments: {tool_call['function']['arguments']}"
)
def test_complex_parameters_required_non_streaming(self):
"""Validate complex nested parameter schemas in non-streaming required mode"""
complex_tools = [
{
"type": "function",
"function": {
"name": "analyze_data",
"description": "Analyze complex data structures",
"parameters": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"metrics": {
"type": "array",
"items": {"type": "string"},
},
"config": {
"type": "object",
"properties": {
"threshold": {"type": "number"},
"enabled": {"type": "boolean"},
},
},
},
"required": ["metrics"],
},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
},
},
},
},
"required": ["data"],
},
},
}
]
messages = [
{
"role": "user",
"content": "Analyze some data with metrics and configuration",
}
]
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=1024,
temperature=0.1,
tools=complex_tools,
tool_choice="required",
stream=False,
)
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls)
self.assertGreater(len(tool_calls), 0)
for tool_call in tool_calls:
self.assertEqual(tool_call.function.name, "analyze_data")
try:
args = json.loads(tool_call.function.arguments)
self.assertIsInstance(args, dict)
self.assertIn("data", args)
self.assertIsInstance(args["data"], dict)
except json.JSONDecodeError:
self.fail(
f"Invalid JSON in complex tool call arguments: {tool_call.function.arguments}"
)
def test_multi_tool_scenario_auto(self):
"""Test multi-tool scenario with tool_choice='auto'"""
tools = self.get_travel_tools()
messages = self.get_travel_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
temperature=0.2,
tools=tools,
tool_choice="auto",
stream=False,
)
# Should complete without errors
self.assertIsNotNone(response.choices[0].message)
tool_calls = response.choices[0].message.tool_calls
expected_functions = {"get_weather", "get_tourist_attractions"}
if self._is_flaky_test():
# For flaky tests, just verify all called functions are available tools
if tool_calls:
available_names = [tool["function"]["name"] for tool in tools]
for call in tool_calls:
self.assertIn(call.function.name, available_names)
else:
# For non-flaky tests, enforce strict requirements
self.assertIsNotNone(tool_calls, "Expected tool calls but got none")
self.assertEqual(
len(tool_calls), 2, f"Expected 2 tool calls, got {len(tool_calls)}"
)
called_functions = {call.function.name for call in tool_calls}
self.assertEqual(
called_functions,
expected_functions,
f"Expected functions {expected_functions}, got {called_functions}",
)
def test_multi_tool_scenario_required(self):
"""Test multi-tool scenario with tool_choice='required'"""
tools = self.get_travel_tools()
messages = self.get_travel_messages()
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
temperature=0.2,
tools=tools,
tool_choice="required",
stream=False,
)
# With required, we should get at least one tool call
tool_calls = response.choices[0].message.tool_calls
self.assertIsNotNone(tool_calls)
self.assertGreater(len(tool_calls), 0)
# Verify all called functions are available tools
available_names = [tool["function"]["name"] for tool in tools]
expected_functions = {"get_weather", "get_tourist_attractions"}
for tool_call in tool_calls:
self.assertIsNotNone(tool_call.function.name)
self.assertIsNotNone(tool_call.function.arguments)
if self._is_flaky_test():
# For flaky tests, just ensure basic functionality works
self.assertGreater(
len(tool_calls),
0,
f"Expected at least 1 tool call, got {len(tool_calls)}",
)
for call in tool_calls:
self.assertIn(call.function.name, available_names)
else:
# For non-flaky tests, enforce strict requirements
self.assertEqual(
len(tool_calls), 2, f"Expected 2 tool calls, got {len(tool_calls)}"
)
called_functions = {call.function.name for call in tool_calls}
self.assertEqual(
called_functions,
expected_functions,
f"Expected functions {expected_functions}, got {called_functions}",
)
def test_error_handling_invalid_tool_choice(self):
"""Test error handling for invalid tool_choice"""
tools = self.get_test_tools()
messages = self.get_test_messages()
# Test with invalid function name
tool_choice = {"type": "function", "function": {"name": "nonexistent_function"}}
# Expect a 400 BadRequestError to be raised for invalid tool_choice
with self.assertRaises(openai.BadRequestError) as context:
self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=2048,
tools=tools,
tool_choice=tool_choice,
stream=False,
)
# Verify the error message contains the expected text
self.assertIn(
"function 'nonexistent_function' not found in",
str(context.exception),
)
def test_invalid_tool_missing_name(self):
"""Test what happens when user doesn't provide a tool name in request"""
# Test with malformed JSON in tool parameters - missing required "name" field
invalid_tools = [
{
"type": "function",
"function": {
# Missing required "name" field
"description": "Test function with invalid schema",
"parameters": {
"type": "object",
"properties": {
"test_field": {
"type": "string",
"description": "Test field",
}
},
"required": ["test_field"],
},
},
}
]
messages = [
{
"role": "user",
"content": "Test the function",
}
]
# Should raise BadRequestError due to missing required 'name' field
with self.assertRaises(openai.BadRequestError) as context:
self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=100,
temperature=0.1,
tools=invalid_tools,
tool_choice="required",
stream=False,
)
# Verify the error message indicates missing name field
error_msg = str(context.exception).lower()
self.assertIn("name", error_msg)
def test_conflicting_defs_required_tool_choice(self):
"""Test that conflicting $defs with required tool_choice returns 400 error"""
conflicting_tools = [
{
"type": "function",
"function": {
"name": "tool1",
"description": "Tool 1 with conflicting $defs",
"parameters": {
"type": "object",
"properties": {
"data": {"$ref": "#/$defs/DataType"},
},
"required": ["data"],
"$defs": {
"DataType": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
},
},
},
},
{
"type": "function",
"function": {
"name": "tool2",
"description": "Tool 2 with conflicting $defs",
"parameters": {
"type": "object",
"properties": {
"data": {"$ref": "#/$defs/DataType"},
},
"required": ["data"],
"$defs": {
"DataType": { # Different definition for DataType
"type": "object",
"properties": {"value": {"type": "number"}},
"required": ["value"],
},
},
},
},
},
]
messages = [
{
"role": "user",
"content": "Test the conflicting tools",
}
]
# Should raise BadRequestError due to conflicting $defs
with self.assertRaises(openai.BadRequestError) as context:
self.client.chat.completions.create(
model=self.model_name,
messages=messages,
max_tokens=100,
temperature=0.1,
tools=conflicting_tools,
tool_choice="required",
stream=False,
)
# Verify the error message indicates conflicting tool definitions
error_msg = str(context.exception).lower()
self.assertIn("invalid tool configuration", error_msg)
self.assertIn("not supported", error_msg)
class TestToolChoiceQwen25(TestToolChoiceLlama32):
"""Test tool_choice functionality with Qwen2.5 model"""
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
cls.flaky_tests = {}
cls.model = DEFAULT_QWEN_FUNCTION_CALLING_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
"--tool-call-parser",
"qwen",
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
class TestToolChoiceMistral(TestToolChoiceLlama32):
"""Test tool_choice functionality with Mistral model"""
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
# Mark flaky tests for this model
cls.flaky_tests = {
"test_multi_tool_scenario_auto",
"test_multi_tool_scenario_required",
}
cls.model = DEFAULT_MISTRAL_FUNCTION_CALLING_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
router_args=[
"--tool-call-parser",
"mistral",
],
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@unittest.skip("Fails due to whitespace issue with Mistral - skipping")
def test_complex_parameters_required_non_streaming(self):
"""Validate complex nested parameter schemas in non-streaming required mode"""
super().test_complex_parameters_required_non_streaming()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,13 @@
[pytest]
# Show print statements and logs
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
# Show stdout/stderr
addopts = -v -s --tb=short
# Capture settings
# -s means don't capture stdout (show print statements)
# --tb=short means short traceback format
+260
View File
@@ -0,0 +1,260 @@
"""
Standalone utilities for e2e_grpc tests.
This module provides all necessary utilities without depending on sglang Python package.
Extracted and adapted from:
- sglang.srt.utils.kill_process_tree
- sglang.srt.utils.hf_transformers_utils.get_tokenizer
- sglang.test.test_utils (constants and CustomTestCase)
"""
import logging
import os
import signal
import threading
import unittest
from pathlib import Path
from typing import Optional, Union
import psutil
logger = logging.getLogger(__name__)
try:
from transformers import (
AutoTokenizer,
PreTrainedTokenizer,
PreTrainedTokenizerBase,
PreTrainedTokenizerFast,
)
except ImportError:
raise ImportError(
"transformers is required for tokenizer utilities. "
"Install with: pip install transformers"
)
# ============================================================================
# Constants
# ============================================================================
# Server and timeout constants
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH = 600
DEFAULT_PORT_FOR_SRT_TEST_RUNNER = 20000
DEFAULT_URL_FOR_TEST = f"http://127.0.0.1:{DEFAULT_PORT_FOR_SRT_TEST_RUNNER + 1000}"
# File name constants for test output
STDOUT_FILENAME = "/tmp/sglang_test_stdout.txt"
STDERR_FILENAME = "/tmp/sglang_test_stderr.txt"
# Model base path - can be overridden via environment variable
# By default, use HuggingFace model identifiers (no local path prefix)
# Set ROUTER_LOCAL_MODEL_PATH to use local models (e.g., "/home/ubuntu/models")
ROUTER_LOCAL_MODEL_PATH = os.environ.get("ROUTER_LOCAL_MODEL_PATH", "")
# Helper function to build model paths
def _get_model_path(model_identifier: str) -> str:
"""
Build model path from base path and model identifier.
If ROUTER_LOCAL_MODEL_PATH is set, prepend it to the identifier.
Otherwise, return the identifier as-is (for HuggingFace download).
"""
if ROUTER_LOCAL_MODEL_PATH:
return os.path.join(ROUTER_LOCAL_MODEL_PATH, model_identifier)
return model_identifier
# Model paths used in e2e_grpc tests
# These can be either HuggingFace identifiers or local paths (depending on ROUTER_LOCAL_MODEL_PATH)
# Main test model - Llama 3.1 8B Instruct
DEFAULT_MODEL_PATH = _get_model_path("meta-llama/Llama-3.1-8B-Instruct")
# Small models for function calling tests
DEFAULT_SMALL_MODEL_PATH = _get_model_path("meta-llama/Llama-3.2-1B-Instruct")
# Reasoning models
DEFAULT_REASONING_MODEL_PATH = _get_model_path(
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"
)
# Thinking-enabled models
DEFAULT_ENABLE_THINKING_MODEL_PATH = _get_model_path("Qwen/Qwen3-30B-A3B")
# Function calling models
DEFAULT_QWEN_FUNCTION_CALLING_MODEL_PATH = _get_model_path("Qwen/Qwen2.5-7B-Instruct")
DEFAULT_MISTRAL_FUNCTION_CALLING_MODEL_PATH = _get_model_path(
"mistralai/Mistral-7B-Instruct-v0.3"
)
# GPT-OSS models
DEFAULT_GPT_OSS_MODEL_PATH = _get_model_path("openai/gpt-oss-20b")
# ============================================================================
# Process Management
# ============================================================================
def kill_process_tree(parent_pid, include_parent: bool = True, skip_pid: int = None):
"""
Kill the process and all its child processes.
Args:
parent_pid: PID of the parent process
include_parent: Whether to kill the parent process itself
skip_pid: Optional PID to skip during cleanup
"""
# Remove sigchld handler to avoid spammy logs
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
if parent_pid is None:
parent_pid = os.getpid()
include_parent = False
try:
itself = psutil.Process(parent_pid)
except psutil.NoSuchProcess:
return
children = itself.children(recursive=True)
for child in children:
if child.pid == skip_pid:
continue
try:
child.kill()
except psutil.NoSuchProcess:
pass
if include_parent:
try:
itself.kill()
except psutil.NoSuchProcess:
pass
# ============================================================================
# Tokenizer Utilities
# ============================================================================
def check_gguf_file(model_path: str) -> bool:
"""Check if the model path points to a GGUF file."""
if not isinstance(model_path, str):
return False
return model_path.endswith(".gguf")
def is_remote_url(path: str) -> bool:
"""Check if the path is a remote URL."""
if not isinstance(path, str):
return False
return path.startswith("http://") or path.startswith("https://")
def get_tokenizer(
tokenizer_name: str,
*args,
tokenizer_mode: str = "auto",
trust_remote_code: bool = False,
tokenizer_revision: Optional[str] = None,
**kwargs,
) -> Union[PreTrainedTokenizer, PreTrainedTokenizerFast]:
"""
Gets a tokenizer for the given model name via Huggingface.
Args:
tokenizer_name: Name or path of the tokenizer
tokenizer_mode: Mode for tokenizer loading ("auto", "slow")
trust_remote_code: Whether to trust remote code
tokenizer_revision: Specific revision to use
**kwargs: Additional arguments passed to AutoTokenizer.from_pretrained
Returns:
Loaded tokenizer instance
"""
if tokenizer_mode == "slow":
if kwargs.get("use_fast", False):
raise ValueError("Cannot use the fast tokenizer in slow tokenizer mode.")
kwargs["use_fast"] = False
# Handle special model name mapping
if tokenizer_name == "mistralai/Devstral-Small-2505":
tokenizer_name = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
is_gguf = check_gguf_file(tokenizer_name)
if is_gguf:
kwargs["gguf_file"] = tokenizer_name
tokenizer_name = Path(tokenizer_name).parent
# Note: Removed remote URL handling and local directory download
# as they depend on sglang-specific utilities
try:
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_name,
*args,
trust_remote_code=trust_remote_code,
tokenizer_revision=tokenizer_revision,
**kwargs,
)
except TypeError as e:
# Handle specific errors
err_msg = (
"Failed to load the tokenizer. If you are running a model with "
"a custom tokenizer, please set the --trust-remote-code flag."
)
raise RuntimeError(err_msg) from e
if not isinstance(tokenizer, PreTrainedTokenizerFast):
logger.warning(
f"Using a slow tokenizer. This might cause a performance "
f"degradation. Consider using a fast tokenizer instead."
)
return tokenizer
def get_tokenizer_from_processor(processor):
"""Extract tokenizer from a processor object."""
if isinstance(processor, PreTrainedTokenizerBase):
return processor
return processor.tokenizer
# ============================================================================
# Test Utilities
# ============================================================================
class CustomTestCase(unittest.TestCase):
"""
Custom test case base class with retry support.
This provides automatic test retry functionality based on environment variables.
"""
def _callTestMethod(self, method):
"""Override to add retry logic."""
max_retry = int(os.environ.get("SGLANG_TEST_MAX_RETRY", "0"))
if max_retry == 0:
# No retry, just run once
return super(CustomTestCase, self)._callTestMethod(method)
# Retry logic
for attempt in range(max_retry + 1):
try:
return super(CustomTestCase, self)._callTestMethod(method)
except Exception as e:
if attempt < max_retry:
logger.info(
f"Test failed on attempt {attempt + 1}/{max_retry + 1}, retrying..."
)
continue
else:
# Last attempt, re-raise the exception
raise
@@ -0,0 +1,115 @@
"""
python3 -m unittest openai_server.validation.test_large_max_new_tokens.TestLargeMaxNewTokens.test_chat_completion
"""
import os
import sys
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
STDERR_FILENAME,
STDOUT_FILENAME,
CustomTestCase,
get_tokenizer,
kill_process_tree,
)
class TestLargeMaxNewTokens(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.stdout = open(STDOUT_FILENAME, "w")
cls.stderr = open(STDERR_FILENAME, "w")
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
worker_args=(
"--max-total-token",
"1536",
"--context-len",
"8192",
"--decode-log-interval",
"2",
),
num_workers=1,
tp_size=2,
env={"SGLANG_CLIP_MAX_NEW_TOKENS_ESTIMATION": "256", **os.environ},
stdout=cls.stdout,
stderr=cls.stderr,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
cls.stdout.close()
cls.stderr.close()
os.remove(STDOUT_FILENAME)
os.remove(STDERR_FILENAME)
def run_chat_completion(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant"},
{
"role": "user",
"content": "Please repeat the world 'hello' for 10000 times.",
},
],
temperature=0,
)
return response
def test_chat_completion(self):
num_requests = 4
all_requests_running = False
futures = []
with ThreadPoolExecutor(num_requests) as executor:
# Send multiple requests
for i in range(num_requests):
futures.append(executor.submit(self.run_chat_completion))
# Ensure that they are running concurrently
pt = 0
while pt >= 0:
time.sleep(5)
# Flush stderr to ensure logs are written
self.stderr.flush()
lines = open(STDERR_FILENAME).readlines()
for line in lines[pt:]:
if f"#running-req: {num_requests}" in line:
all_requests_running = True
pt = -1
break
pt += 1
assert all_requests_running
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,109 @@
"""
gRPC Router E2E Test - Test Openai Server Ignore Eos
This test file is REUSED from test/srt/openai_server/validation/test_openai_server_ignore_eos.py
with minimal changes:
num_workers=2,
- Swap popen_launch_server() → popen_launch_workers_and_router()
- Update teardown to cleanup router + workers
- All test logic and assertions remain identical
Run with:
pytest py_test/e2e_grpc/e2e_grpc/validation/test_openai_server_ignore_eos.py -v
"""
import sys
from pathlib import Path
import openai
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR.parent))
from fixtures import popen_launch_workers_and_router
from util import (
DEFAULT_MODEL_PATH,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
get_tokenizer,
kill_process_tree,
)
class TestOpenAIServerIgnoreEOS(CustomTestCase):
@classmethod
def setUpClass(cls):
# CHANGE: Launch gRPC router with integrated workers (single command)
cls.model = DEFAULT_MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
cls.api_key = "sk-123456"
cls.cluster = popen_launch_workers_and_router(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
api_key=cls.api_key,
num_workers=1,
tp_size=2,
)
cls.base_url += "/v1"
cls.tokenizer = get_tokenizer(cls.model)
@classmethod
def tearDownClass(cls):
# Cleanup router and workers
kill_process_tree(cls.cluster["router"].pid)
for worker in cls.cluster.get("workers", []):
kill_process_tree(worker.pid)
def test_ignore_eos(self):
"""
Test that ignore_eos=True allows generation to continue beyond EOS token
and reach the max_tokens limit.
"""
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
max_tokens = 200
response_default = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Count from 1 to 20."},
],
temperature=0,
max_tokens=max_tokens,
extra_body={"ignore_eos": False},
)
response_ignore_eos = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Count from 1 to 20."},
],
temperature=0,
max_tokens=max_tokens,
extra_body={"ignore_eos": True},
)
default_tokens = len(
self.tokenizer.encode(response_default.choices[0].message.content)
)
ignore_eos_tokens = len(
self.tokenizer.encode(response_ignore_eos.choices[0].message.content)
)
# Check if ignore_eos resulted in more tokens or exactly max_tokens
# The ignore_eos response should either:
# 1. Have more tokens than the default response (if default stopped at EOS before max_tokens)
# 2. Have exactly max_tokens (if it reached the max_tokens limit)
self.assertTrue(
ignore_eos_tokens > default_tokens or ignore_eos_tokens >= max_tokens,
f"ignore_eos did not generate more tokens: {ignore_eos_tokens} vs {default_tokens}",
)
self.assertEqual(
response_ignore_eos.choices[0].finish_reason,
"length",
f"Expected finish_reason='length' for ignore_eos=True, got {response_ignore_eos.choices[0].finish_reason}",
)
@@ -0,0 +1,807 @@
import json
import logging
import os
import shutil
import signal
import socket
import subprocess
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Callable, Optional
from urllib.parse import urlparse
import pytest
import requests
from sglang.test.test_utils import (
DEFAULT_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
)
logger = logging.getLogger(__name__)
def _find_available_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _parse_url(base_url: str) -> tuple[str, str]:
"""Parse a base URL and return (host, port) as strings.
This is more robust than simple string splitting and supports different schemes
and URL shapes like trailing paths.
"""
parsed = urlparse(base_url)
return parsed.hostname or "127.0.0.1", (
str(parsed.port) if parsed.port is not None else ""
)
def _wait_router_health(base_url: str, timeout: float) -> None:
start = time.perf_counter()
with requests.Session() as session:
while time.perf_counter() - start < timeout:
try:
r = session.get(f"{base_url}/health", timeout=5)
if r.status_code == 200:
return
except requests.RequestException:
pass
time.sleep(2)
raise TimeoutError("Router failed to become healthy in time")
def _popen_launch_router(
model: str,
base_url: str,
dp_size: int,
timeout: float,
policy: str = "cache_aware",
) -> subprocess.Popen:
host, port = _parse_url(base_url)
prom_port = _find_available_port()
cmd = [
"python3",
"-m",
"sglang_router.launch_server",
"--model-path",
model,
"--host",
host,
"--port",
port,
"--dp",
str(dp_size),
"--router-policy",
policy,
"--allow-auto-truncate",
"--router-prometheus-port",
str(prom_port),
"--router-prometheus-host",
"127.0.0.1",
"--router-log-level",
"warn",
]
proc = subprocess.Popen(cmd)
_wait_router_health(base_url, timeout)
return proc
def _popen_launch_worker(
model: str,
base_url: str,
*,
dp_size: int | None = None,
api_key: str | None = None,
base_gpu_id: int | None = 0,
) -> subprocess.Popen:
host, port = _parse_url(base_url)
cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
model,
"--host",
host,
"--port",
port,
"--base-gpu-id",
str(base_gpu_id or 0),
"--log-level",
"warning",
]
if dp_size is not None:
cmd += ["--dp-size", str(dp_size)]
if api_key is not None:
cmd += ["--api-key", api_key]
return subprocess.Popen(cmd)
def _popen_launch_router_only(
base_url: str,
policy: str = "round_robin",
timeout: float = 120.0,
*,
dp_aware: bool = False,
enable_igw: bool = False,
api_key: str | None = None,
) -> subprocess.Popen:
host, port = _parse_url(base_url)
prom_port = _find_available_port()
cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
host,
"--port",
port,
"--policy",
policy,
]
if dp_aware:
cmd += ["--dp-aware"]
if enable_igw:
cmd += ["--enable-igw"]
if api_key is not None:
cmd += ["--api-key", api_key]
cmd += [
"--prometheus-port",
str(prom_port),
"--prometheus-host",
"127.0.0.1",
"--log-level",
"warn",
]
proc = subprocess.Popen(cmd)
_wait_router_health(base_url, timeout)
return proc
def _terminate(proc: subprocess.Popen, timeout: float = 120) -> None:
if proc is None:
return
proc.terminate()
start = time.perf_counter()
while proc.poll() is None:
if time.perf_counter() - start > timeout:
proc.kill()
break
time.sleep(1)
def _which(cmd: str) -> Optional[str]:
try:
return shutil.which(cmd)
except Exception as e:
logger.warning("shutil.which(%r) failed: %s", cmd, e)
return None
def _graceful_stop_popen(p: subprocess.Popen) -> None:
if p is None:
return
try:
if p.poll() is None:
p.terminate()
for _ in range(5):
if p.poll() is not None:
break
time.sleep(1)
if p.poll() is None:
p.kill()
except Exception as e:
logger.warning("Exception during graceful stop of popen: %s", e)
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except Exception:
return False
def _graceful_stop_pid(pid: int) -> None:
try:
if _pid_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
for _ in range(5):
if not _pid_alive(pid):
break
time.sleep(1)
if _pid_alive(pid):
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
except Exception:
pass
def _graceful_stop_any(obj) -> None:
try:
if isinstance(obj, subprocess.Popen):
_graceful_stop_popen(obj)
return
if isinstance(obj, int):
_graceful_stop_pid(obj)
return
proc_obj = getattr(obj, "proc", None)
if isinstance(proc_obj, subprocess.Popen):
_graceful_stop_popen(proc_obj)
except Exception:
pass
def _gpu_monitor_should_run(thresholds: Optional[dict]) -> bool:
"""Decide whether to enable the GPU monitor.
Runs if thresholds request GPU checks or if GPU_UTIL_LOG is truthy.
"""
want = False
try:
mean_th = None if thresholds is None else thresholds.get("gpu_util_mean_min")
p50_th = None if thresholds is None else thresholds.get("gpu_util_p50_min")
want = bool(mean_th is not None or p50_th is not None)
except Exception:
want = False
if not want:
env_flag = os.environ.get("GPU_UTIL_LOG", "").lower() in ("1", "true", "yes")
want = want or env_flag
return want
def _gpu_monitor_path(experiment_folder: str) -> str:
"""Return the JSON path for storing GPU monitor results."""
base = Path.cwd() / experiment_folder
return str(base / "gpu_utilization.json")
def _launch_gpu_monitor(bench_pid: int, experiment_folder: str, interval: float):
"""Start the GPU monitor process. Returns (proc, path) or (None, None)."""
try:
from multiprocessing import Process
out_path = _gpu_monitor_path(experiment_folder)
proc = Process(
target=_gpu_monitor_proc_entry,
args=(bench_pid, out_path, interval),
daemon=True,
)
proc.start()
return proc, out_path
except Exception as e:
logger.warning("Failed to launch GPU monitor: %s", e)
return None, None
def _read_gpu_monitor_result(path: Optional[str]) -> Optional[dict]:
try:
if path and os.path.exists(path):
with open(path, "r") as f:
return json.load(f)
except Exception as e:
logger.warning("Failed to read GPU monitor result from %r: %s", path, e)
return None
def _log_and_assert_gpu_thresholds(
result: Optional[dict], thresholds: Optional[dict]
) -> None:
if not result or not isinstance(result, dict) or result.get("count", 0) <= 0:
logger.warning("GPU utilization monitor produced no samples.")
return
overall = result.get("overall", {}) if isinstance(result, dict) else {}
count = int(result.get("count", 0))
mean_th = None if thresholds is None else thresholds.get("gpu_util_mean_min")
p50_th = None if thresholds is None else thresholds.get("gpu_util_p50_min")
mean_v = float(overall.get("mean", 0.0))
p50_v = overall.get("p50")
logger.info(
"GPU utilization overall: mean=%.2f%% p50=%s (samples=%d)",
mean_v,
(f"{float(p50_v):.2f}%" if p50_v is not None else "n/a"),
count,
)
if mean_th is not None:
assert mean_v >= float(
mean_th
), f"GPU utilization mean below threshold: {mean_v:.2f}% < {mean_th}%"
if p50_th is not None and p50_v is not None:
p50_f = float(p50_v)
assert p50_f >= float(
p50_th
), f"GPU utilization p50 below threshold: {p50_f:.2f}% < {p50_th}%"
def _gpu_monitor_proc_entry(bench_pid: int, out_file: str, interval: float) -> None:
"""Low-impact GPU utilization monitor using NVML in a separate process.
Writes JSON to out_file that includes overall and per-GPU raw samples and summary stats.
"""
try:
try:
os.nice(10)
except Exception:
pass
total = 0.0
n = 0
try:
import pynvml # type: ignore
pynvml.nvmlInit()
except Exception:
with open(out_file, "w") as f:
os.makedirs(os.path.dirname(out_file), exist_ok=True)
json.dump(
{
"count": 0,
"overall": {"mean": 0.0},
"per_gpu": {},
"raw": {},
},
f,
)
return
try:
import pynvml # type: ignore
count = pynvml.nvmlDeviceGetCount()
handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(count)]
except Exception:
with open(out_file, "w") as f:
os.makedirs(os.path.dirname(out_file), exist_ok=True)
json.dump(
{
"count": 0,
"overall": {"mean": 0.0},
"per_gpu": {},
"raw": {},
},
f,
)
return
# Prepare per-GPU and overall raw collectors
per_gpu_samples: dict[str, list[float]] = {}
overall_samples: list[float] = []
while True:
if not os.path.exists(f"/proc/{bench_pid}"):
break
try:
vals = []
import pynvml # type: ignore
for idx, h in enumerate(handles):
try:
util = pynvml.nvmlDeviceGetUtilizationRates(h).gpu
vals.append(float(util))
key = str(idx)
per_gpu_samples.setdefault(key, []).append(float(util))
except Exception:
continue
if vals:
avg = sum(vals) / len(vals)
overall_samples.append(avg)
total += avg
n += 1
except Exception:
pass
time.sleep(interval)
finally:
try:
os.makedirs(os.path.dirname(out_file), exist_ok=True)
with open(out_file, "w") as f:
def pct_from(samples: list[float], p: float) -> float:
if not samples:
return 0.0
srt = sorted(samples)
i = max(
0, min(len(srt) - 1, int(round((p / 100.0) * (len(srt) - 1))))
)
return float(srt[i])
overall_mean = (total / n) if n > 0 else 0.0
per_gpu_summary: dict[str, dict] = {}
for key, arr in per_gpu_samples.items():
per_gpu_summary[key] = {
"mean": float(sum(arr) / len(arr)) if arr else 0.0,
"p5": pct_from(arr, 5),
"p10": pct_from(arr, 10),
"p25": pct_from(arr, 25),
"p50": pct_from(arr, 50),
"p75": pct_from(arr, 75),
"p90": pct_from(arr, 90),
"p95": pct_from(arr, 95),
"min": float(min(arr)) if arr else 0.0,
"max": float(max(arr)) if arr else 0.0,
"count": len(arr),
}
out_payload = {
"bench_pid": bench_pid,
"interval_sec": interval,
"count": n,
"overall": {
"mean": float(overall_mean),
"p5": pct_from(overall_samples, 5),
"p10": pct_from(overall_samples, 10),
"p25": pct_from(overall_samples, 25),
"p50": pct_from(overall_samples, 50),
"p75": pct_from(overall_samples, 75),
"p90": pct_from(overall_samples, 90),
"p95": pct_from(overall_samples, 95),
"min": float(min(overall_samples)) if overall_samples else 0.0,
"max": float(max(overall_samples)) if overall_samples else 0.0,
},
"per_gpu": per_gpu_summary,
"raw": {
"overall": overall_samples,
"per_gpu": per_gpu_samples,
},
}
json.dump(out_payload, f)
except Exception:
pass
try:
import pynvml # type: ignore
pynvml.nvmlShutdown()
except Exception:
pass
@pytest.fixture(scope="session")
def genai_bench_runner() -> Callable[..., None]:
"""Provide a callable to run genai-bench and validate metrics.
Usage in tests:
def test(..., genai_bench_runner):
genai_bench_runner(router_url=..., model_path=..., experiment_folder=...)
"""
def _run(
*,
router_url: str,
model_path: str,
experiment_folder: str,
timeout_sec: int | None = None,
thresholds: dict | None = None,
extra_env: dict | None = None,
num_concurrency: int = 32,
traffic_scenario: str = "D(4000,100)",
max_requests_per_run: int | None = None,
clean_experiment: bool = True,
kill_procs: list | None = None,
drain_delay_sec: int = 6,
) -> None:
cli = _which("genai-bench")
if not cli:
pytest.fail(
"genai-bench CLI not found; please install it to run benchmarks"
)
# Clean previous experiment folder under current working directory
if clean_experiment:
exp_dir = Path.cwd() / experiment_folder
if exp_dir.exists():
shutil.rmtree(exp_dir, ignore_errors=True)
# Default requests per run if not provided
mrr = (
max_requests_per_run
if max_requests_per_run is not None
else num_concurrency * 5
)
cmd = [
cli,
"benchmark",
"--api-backend",
"openai",
"--api-base",
router_url,
"--api-key",
"dummy-token",
"--api-model-name",
model_path,
"--model-tokenizer",
model_path,
"--task",
"text-to-text",
"--num-concurrency",
str(num_concurrency),
"--traffic-scenario",
traffic_scenario,
"--max-requests-per-run",
str(mrr),
"--max-time-per-run",
"3",
"--experiment-folder-name",
experiment_folder,
"--experiment-base-dir",
str(Path.cwd()),
]
env = os.environ.copy()
if extra_env:
env.update(extra_env)
to = timeout_sec or int(os.environ.get("GENAI_BENCH_TEST_TIMEOUT", "120"))
proc = subprocess.Popen(
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
# Optional GPU utilization monitor in a low-priority child process (pynvml only)
# Enabled only when gpu_util_mean_min is provided in thresholds.
monitor_path = None
monitor_proc = None
gpu_util_result: dict | None = None
want_gpu_monitor = _gpu_monitor_should_run(thresholds)
if want_gpu_monitor:
interval = float(os.environ.get("GPU_UTIL_SAMPLE_INTERVAL", "2.0"))
monitor_proc, monitor_path = _launch_gpu_monitor(
bench_pid=proc.pid,
experiment_folder=experiment_folder,
interval=interval,
)
stdout = stderr = ""
rc = None
try:
try:
stdout, stderr = proc.communicate(timeout=to)
except subprocess.TimeoutExpired:
# Simple: kill the CLI process if it doesn't exit in time
try:
proc.kill()
except Exception:
pass
stdout, stderr = proc.communicate()
rc = proc.returncode
# Prefer exact path under cwd; fallback to rglob search
base = Path.cwd()
direct = base / experiment_folder
candidates = [direct] if direct.is_dir() else []
if not candidates:
for p in base.rglob(experiment_folder):
if p.is_dir() and p.name == experiment_folder:
candidates = [p]
break
if not candidates:
raise AssertionError(
"Benchmark failed: experiment folder not found: "
f"{experiment_folder}\nExit code: {rc}\nSTDOUT (tail):\n{stdout[-1000:]}\nSTDERR (tail):\n{stderr[-1000:]}"
)
actual_folder = candidates[0]
json_files = []
for _ in range(10):
json_files = [
p
for p in actual_folder.rglob("*.json")
if "experiment_metadata" not in p.name
]
if json_files:
break
time.sleep(1)
if not json_files:
raise AssertionError(
"Benchmark failed: no JSON results found\n"
f"Exit code: {rc}\nSTDOUT (tail):\n{stdout[-1000:]}\nSTDERR (tail):\n{stderr[-1000:]}"
)
th = thresholds # None means "log only", no validation
for jf in json_files:
with jf.open("r") as f:
data = json.load(f)
stats = data.get("aggregated_metrics", {}).get("stats", {})
ttft_mean = float(stats.get("ttft", {}).get("mean", float("inf")))
e2e_latency_mean = float(
stats.get("e2e_latency", {}).get("mean", float("inf"))
)
input_tp_mean = float(
stats.get("input_throughput", {}).get("mean", 0.0)
)
output_tp_mean = float(
stats.get("output_throughput", {}).get("mean", 0.0)
)
logger.info(
"genai-bench[%s] %s ttft_mean=%.3fs e2e_latency_mean=%.3fs input_tp_mean=%.1f tok/s output_tp_mean=%.1f tok/s",
experiment_folder,
jf.name,
ttft_mean,
e2e_latency_mean,
input_tp_mean,
output_tp_mean,
)
if th is not None:
assert (
ttft_mean <= th["ttft_mean_max"]
), f"TTFT validation failed: {ttft_mean} > {th['ttft_mean_max']} (file={jf.name})"
assert (
e2e_latency_mean <= th["e2e_latency_mean_max"]
), f"E2E latency validation failed: {e2e_latency_mean} > {th['e2e_latency_mean_max']} (file={jf.name})"
assert (
input_tp_mean >= th["input_throughput_mean_min"]
), f"Input throughput validation failed: {input_tp_mean} < {th['input_throughput_mean_min']} (file={jf.name})"
assert (
output_tp_mean >= th["output_throughput_mean_min"]
), f"Output throughput validation failed: {output_tp_mean} < {th['output_throughput_mean_min']} (file={jf.name})"
# Validate optional GPU utilization threshold if provided
if want_gpu_monitor:
try:
if monitor_proc is not None:
monitor_proc.join(timeout=5)
except Exception:
pass
gpu_util_result = _read_gpu_monitor_result(monitor_path)
_log_and_assert_gpu_thresholds(gpu_util_result, thresholds)
finally:
# Always attempt to stop workers to avoid resource leakage
if kill_procs:
# Give router/workers a small grace period to finish any last drains
if drain_delay_sec > 0:
try:
time.sleep(drain_delay_sec)
except Exception:
pass
for p in kill_procs:
_graceful_stop_any(p)
try:
time.sleep(2)
except Exception:
pass
# Ensure GPU monitor process is cleaned up
if monitor_proc is not None and monitor_proc.is_alive():
try:
monitor_proc.terminate()
except Exception:
pass
return _run
def pytest_configure(config):
config.addinivalue_line("markers", "e2e: mark as end-to-end test")
@pytest.fixture(scope="session")
def e2e_model() -> str:
# Always use the default test model
return os.getenv("E2E_PRIMARY_MODEL", DEFAULT_MODEL_NAME_FOR_TEST)
@pytest.fixture
def e2e_router(e2e_model: str):
# Keep this available but tests below use router-only to avoid GPU contention
base_url = DEFAULT_URL_FOR_TEST
proc = _popen_launch_router(
e2e_model, base_url, dp_size=2, timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
)
try:
yield SimpleNamespace(proc=proc, url=base_url)
finally:
_terminate(proc)
@pytest.fixture
def e2e_router_only_rr():
port = _find_available_port()
base_url = f"http://127.0.0.1:{port}"
proc = _popen_launch_router_only(base_url, policy="round_robin")
try:
yield SimpleNamespace(proc=proc, url=base_url)
finally:
_terminate(proc)
@pytest.fixture(scope="session")
def e2e_embedding_model() -> str:
"""Embedding model to use for E2E tests.
Defaults to an E5 Mistral model, can be overridden via E2E_EMBEDDING_MODEL env var.
"""
import os
return os.getenv("E2E_EMBEDDING_MODEL", "intfloat/e5-mistral-7b-instruct")
@pytest.fixture
def e2e_primary_embedding_worker(e2e_embedding_model: str):
"""Launch a single embedding worker using the specified model."""
port = _find_available_port()
base_url = f"http://127.0.0.1:{port}"
proc = _popen_launch_worker(e2e_embedding_model, base_url)
try:
yield SimpleNamespace(proc=proc, url=base_url)
finally:
_terminate(proc)
@pytest.fixture(scope="session")
def e2e_primary_worker(e2e_model: str):
port = _find_available_port()
base_url = f"http://127.0.0.1:{port}"
proc = _popen_launch_worker(e2e_model, base_url)
# Router health gate will handle worker readiness
try:
yield SimpleNamespace(proc=proc, url=base_url)
finally:
_terminate(proc)
@pytest.fixture
def e2e_router_only_rr_dp_aware_api():
"""Router-only with dp-aware enabled and an API key."""
port = _find_available_port()
base_url = f"http://127.0.0.1:{port}"
api_key = "secret"
proc = _popen_launch_router_only(
base_url, policy="round_robin", timeout=180.0, dp_aware=True, api_key=api_key
)
try:
yield SimpleNamespace(proc=proc, url=base_url, api_key=api_key)
finally:
_terminate(proc)
@pytest.fixture
def e2e_worker_dp2_api(e2e_model: str, e2e_router_only_rr_dp_aware_api):
"""Worker with dp-size=2 and the same API key as the dp-aware router."""
port = _find_available_port()
base_url = f"http://127.0.0.1:{port}"
api_key = e2e_router_only_rr_dp_aware_api.api_key
proc = _popen_launch_worker(e2e_model, base_url, dp_size=2, api_key=api_key)
try:
yield SimpleNamespace(proc=proc, url=base_url)
finally:
_terminate(proc)
@pytest.fixture(scope="session")
def e2e_two_workers_dp2(e2e_model: str):
"""Launch two workers, each with dp_size=2, mapped to GPUs [0,1] and [2,3]."""
workers = []
try:
# Worker A on GPUs 0-1
port_a = _find_available_port()
url_a = f"http://127.0.0.1:{port_a}"
proc_a = _popen_launch_worker(e2e_model, url_a, dp_size=2, base_gpu_id=0)
workers.append(SimpleNamespace(proc=proc_a, url=url_a))
# Worker B on GPUs 2-3
port_b = _find_available_port()
url_b = f"http://127.0.0.1:{port_b}"
proc_b = _popen_launch_worker(e2e_model, url_b, dp_size=2, base_gpu_id=2)
workers.append(SimpleNamespace(proc=proc_b, url=url_b))
yield workers
finally:
for w in workers:
_terminate(w.proc)
@@ -0,0 +1,62 @@
import time
import pytest
import requests
def _wait_for_workers(
base_url: str, expected_count: int, timeout: float = 60.0, headers: dict = None
) -> None:
"""Poll /workers endpoint until expected number of workers are registered."""
start = time.perf_counter()
with requests.Session() as session:
while time.perf_counter() - start < timeout:
try:
r = session.get(f"{base_url}/workers", headers=headers, timeout=5)
if r.status_code == 200:
workers = r.json().get("workers", [])
if len(workers) >= expected_count:
return
except requests.RequestException:
pass
time.sleep(0.5)
raise TimeoutError(
f"Expected {expected_count} workers at {base_url}, timed out after {timeout}s"
)
@pytest.mark.e2e
def test_embeddings_basic(
e2e_router_only_rr, e2e_primary_embedding_worker, e2e_embedding_model
):
base = e2e_router_only_rr.url
worker_url = e2e_primary_embedding_worker.url
# Attach embedding worker to router-only instance
r = requests.post(f"{base}/workers", json={"url": worker_url}, timeout=180)
assert r.status_code == 202, f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for worker to be registered
_wait_for_workers(base, expected_count=1, timeout=60.0)
# Simple embedding request with two inputs
payload = {
"model": e2e_embedding_model,
"input": [
"the quick brown fox",
"jumps over the lazy dog",
],
}
r = requests.post(f"{base}/v1/embeddings", json=payload, timeout=120)
assert r.status_code == 200, f"unexpected status: {r.status_code} {r.text}"
data = r.json()
assert "data" in data and isinstance(data["data"], list)
assert len(data["data"]) == 2
# Validate shape of embedding objects
for item in data["data"]:
assert "embedding" in item and isinstance(item["embedding"], list)
# Ensure non-empty vectors
assert len(item["embedding"]) > 0
@@ -0,0 +1,264 @@
import logging
import socket
import subprocess
import time
from types import SimpleNamespace
from typing import Optional
import pytest
import requests
from sglang.test.run_eval import run_eval
logger = logging.getLogger(__name__)
def _find_available_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_health(url: str, timeout: float = 180.0) -> None:
start = time.perf_counter()
with requests.Session() as session:
while time.perf_counter() - start < timeout:
try:
r = session.get(f"{url}/health", timeout=5)
if r.status_code == 200:
return
except requests.RequestException:
pass
time.sleep(1)
raise TimeoutError(f"Service at {url} failed to become healthy in time")
def _detect_ib_device() -> Optional[str]:
"""Return first active IB device name (e.g., mlx5_0) or None if unavailable."""
# Fast check that ibv_devinfo exists
try:
subprocess.run(
["ibv_devinfo", "-l"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=1,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
for i in range(12):
dev = f"mlx5_{i}"
try:
res = subprocess.run(
["ibv_devinfo", dev],
capture_output=True,
text=True,
timeout=2,
)
if res.returncode == 0 and ("state:" in res.stdout):
for line in res.stdout.splitlines():
if "state:" in line and "PORT_ACTIVE" in line:
return dev
except Exception:
pass
return None
def _popen_launch_prefill_worker(
model: str,
bootstrap_port: int,
ib_device: Optional[str] = None,
base_gpu_id: int = 0,
) -> SimpleNamespace:
port = _find_available_port()
url = f"http://127.0.0.1:{port}"
cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
model,
"--disaggregation-mode",
"prefill",
"--host",
"127.0.0.1",
"--port",
str(port),
"--disaggregation-bootstrap-port",
str(bootstrap_port),
"--base-gpu-id",
str(base_gpu_id),
]
if ib_device:
cmd += ["--disaggregation-ib-device", ib_device]
proc = subprocess.Popen(cmd)
_wait_health(url, timeout=300.0)
return SimpleNamespace(proc=proc, url=url, bootstrap_port=bootstrap_port)
def _popen_launch_decode_worker(
model: str, ib_device: Optional[str] = None, base_gpu_id: int = 0
) -> SimpleNamespace:
port = _find_available_port()
url = f"http://127.0.0.1:{port}"
cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
model,
"--disaggregation-mode",
"decode",
"--host",
"127.0.0.1",
"--port",
str(port),
"--base-gpu-id",
str(base_gpu_id),
]
if ib_device:
cmd += ["--disaggregation-ib-device", ib_device]
proc = subprocess.Popen(cmd)
_wait_health(url, timeout=300.0)
return SimpleNamespace(proc=proc, url=url)
def _terminate(proc: subprocess.Popen, timeout: float = 120) -> None:
if proc is None:
return
proc.terminate()
start = time.perf_counter()
while proc.poll() is None:
if time.perf_counter() - start > timeout:
proc.kill()
break
time.sleep(1)
@pytest.fixture(scope="module")
def pd_cluster(e2e_model: str):
"""Start 2 prefill + 2 decode workers and one PD router, once per module."""
# Environment capability checks: require sgl_kernel and GPU backend
try:
import sgl_kernel # noqa: F401
except Exception as e: # pragma: no cover - environment dependent
pytest.fail(f"PD e2e requires sgl_kernel but it is not available: {e}")
try:
import torch # noqa: F401
except Exception as e: # pragma: no cover - environment dependent
pytest.fail(
f"PD e2e requires torch but it is not available or misconfigured: {e}"
)
if not torch.cuda.is_available(): # pragma: no cover - environment dependent
pytest.fail("PD e2e requires CUDA backend, but CUDA is not available")
workers: list[SimpleNamespace] = []
router_proc = None
try:
ib_device = _detect_ib_device()
# Launch 4 workers across 4 GPUs: prefill on 0,1 and decode on 2,3
pf1 = _popen_launch_prefill_worker(
e2e_model,
bootstrap_port=_find_available_port(),
ib_device=ib_device,
base_gpu_id=0,
)
pf2 = _popen_launch_prefill_worker(
e2e_model,
bootstrap_port=_find_available_port(),
ib_device=ib_device,
base_gpu_id=1,
)
dc1 = _popen_launch_decode_worker(e2e_model, ib_device=ib_device, base_gpu_id=2)
dc2 = _popen_launch_decode_worker(e2e_model, ib_device=ib_device, base_gpu_id=3)
prefills = [pf1, pf2]
decodes = [dc1, dc2]
workers.extend(prefills + decodes)
# PD router with two prefill and two decode endpoints
rport = _find_available_port()
router_url = f"http://127.0.0.1:{rport}"
pport = _find_available_port()
prefill = [(pf.url, pf.bootstrap_port) for pf in prefills]
decode = [dc.url for dc in decodes]
cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
"127.0.0.1",
"--port",
str(rport),
"--policy",
"round_robin",
"--pd-disaggregation",
"--log-level",
"warn",
]
for url, bport in prefill:
cmd += ["--prefill", url, str(bport)]
for url in decode:
cmd += ["--decode", url]
cmd += [
"--prometheus-port",
str(pport),
"--prometheus-host",
"127.0.0.1",
]
router_proc = subprocess.Popen(cmd)
_wait_health(router_url, timeout=180.0)
yield SimpleNamespace(
router_url=router_url, workers=workers, router_proc=router_proc
)
finally:
if router_proc is not None:
_terminate(router_proc)
for w in workers:
_terminate(w.proc)
@pytest.mark.e2e
def test_pd_mmlu(e2e_model: str, pd_cluster):
"""
Launch 4 workers, start a PD router (2 prefill + 2 decode), then run MMLU.
"""
args = SimpleNamespace(
base_url=pd_cluster.router_url,
model=e2e_model,
eval_name="mmlu",
num_examples=64,
num_threads=32,
temperature=0.1,
)
metrics = run_eval(args)
assert metrics["score"] >= 0.65
@pytest.mark.e2e
def test_pd_genai_bench(e2e_model: str, pd_cluster, genai_bench_runner):
"""
Launch 4 workers, start a PD router (2 prefill + 2 decode), then run a
short genai-bench benchmark and validate aggregate metrics.
"""
# Run genai-bench against the shared router
policy_label = "benchmark_round_robin_pd"
genai_bench_runner(
router_url=pd_cluster.router_url,
model_path=e2e_model,
experiment_folder=policy_label,
thresholds={
"ttft_mean_max": 13,
"e2e_latency_mean_max": 16,
"input_throughput_mean_min": 350,
"output_throughput_mean_min": 18,
"gpu_util_p50_min": 99,
},
kill_procs=pd_cluster.workers,
)
@@ -0,0 +1,228 @@
import threading
import time
from types import SimpleNamespace
import pytest
import requests
from sglang.test.run_eval import run_eval
def _wait_for_workers(
base_url: str, expected_count: int, timeout: float = 60.0, headers: dict = None
) -> None:
"""Poll /workers endpoint until expected number of workers are registered."""
start = time.perf_counter()
with requests.Session() as session:
while time.perf_counter() - start < timeout:
try:
r = session.get(f"{base_url}/workers", headers=headers, timeout=5)
if r.status_code == 200:
workers = r.json().get("workers", [])
if len(workers) >= expected_count:
return
except requests.RequestException:
pass
time.sleep(0.5)
raise TimeoutError(
f"Expected {expected_count} workers at {base_url}, timed out after {timeout}s"
)
@pytest.mark.e2e
def test_mmlu(e2e_router_only_rr, e2e_two_workers_dp2, e2e_model):
# Attach two dp=2 workers (total 4 GPUs) to a fresh router-only instance
base = e2e_router_only_rr.url
for w in e2e_two_workers_dp2:
r = requests.post(f"{base}/workers", json={"url": w.url}, timeout=180)
assert (
r.status_code == 202
), f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for workers to be registered
_wait_for_workers(base, expected_count=2, timeout=60.0)
args = SimpleNamespace(
base_url=base,
model=e2e_model,
eval_name="mmlu",
num_examples=64,
num_threads=32,
temperature=0.1,
)
metrics = run_eval(args)
assert metrics["score"] >= 0.65
@pytest.mark.e2e
def test_genai_bench(
e2e_router_only_rr, e2e_two_workers_dp2, e2e_model, genai_bench_runner
):
"""Attach a worker to the regular router and run a short genai-bench."""
base = e2e_router_only_rr.url
for w in e2e_two_workers_dp2:
r = requests.post(f"{base}/workers", json={"url": w.url}, timeout=180)
assert (
r.status_code == 202
), f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for workers to be registered
_wait_for_workers(base, expected_count=2, timeout=60.0)
genai_bench_runner(
router_url=base,
model_path=e2e_model,
experiment_folder="benchmark_round_robin_regular",
thresholds={
"ttft_mean_max": 6,
"e2e_latency_mean_max": 14,
"input_throughput_mean_min": 800, # temp relax from 1000 to 800 for now
"output_throughput_mean_min": 12,
# Enforce GPU utilization p50 >= 99% during the run.
"gpu_util_p50_min": 99,
},
kill_procs=e2e_two_workers_dp2,
)
@pytest.mark.e2e
def test_add_and_remove_worker_live(e2e_router_only_rr, e2e_primary_worker, e2e_model):
base = e2e_router_only_rr.url
worker_url = e2e_primary_worker.url
r = requests.post(f"{base}/workers", json={"url": worker_url}, timeout=180)
assert r.status_code == 202, f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for worker to be registered
_wait_for_workers(base, expected_count=1, timeout=60.0)
with requests.Session() as s:
for i in range(8):
r = s.post(
f"{base}/v1/completions",
json={
"model": e2e_model,
"prompt": f"x{i}",
"max_tokens": 1,
"stream": False,
},
timeout=120,
)
r.raise_for_status()
# Remove the worker
from urllib.parse import quote
encoded_url = quote(worker_url, safe="")
r = requests.delete(f"{base}/workers/{encoded_url}", timeout=60)
assert r.status_code == 202, f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
@pytest.mark.e2e
def test_lazy_fault_tolerance_live(e2e_router_only_rr, e2e_primary_worker, e2e_model):
base = e2e_router_only_rr.url
worker = e2e_primary_worker
r = requests.post(f"{base}/workers", json={"url": worker.url}, timeout=180)
assert r.status_code == 202, f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for worker to be registered
_wait_for_workers(base, expected_count=1, timeout=60.0)
def killer():
time.sleep(10)
try:
worker.proc.terminate()
except Exception:
pass
t = threading.Thread(target=killer, daemon=True)
t.start()
args = SimpleNamespace(
base_url=base,
model=e2e_model,
eval_name="mmlu",
num_examples=32,
num_threads=16,
temperature=0.0,
)
metrics = run_eval(args)
assert 0.0 <= metrics["score"] <= 1.0
@pytest.mark.e2e
def test_dp_aware_worker_expansion_and_api_key(
e2e_model,
e2e_router_only_rr_dp_aware_api,
e2e_worker_dp2_api,
):
"""
Launch a router-only instance in dp_aware mode and a single worker with dp_size=2
and API key protection. Verify expansion, auth enforcement, and basic eval.
"""
import os
router_url = e2e_router_only_rr_dp_aware_api.url
worker_url = e2e_worker_dp2_api.url
api_key = e2e_router_only_rr_dp_aware_api.api_key
# Attach worker; router should expand to dp_size logical workers
r = requests.post(
f"{router_url}/workers",
json={"url": worker_url, "api_key": api_key},
headers={"Authorization": f"Bearer {api_key}"},
timeout=180,
)
assert r.status_code == 202, f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
# Wait for workers to be registered and expanded
_wait_for_workers(
router_url,
expected_count=2,
timeout=60.0,
headers={"Authorization": f"Bearer {api_key}"},
)
# Verify the expanded workers have correct URLs
r = requests.get(
f"{router_url}/workers",
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
r.raise_for_status()
workers = r.json().get("workers", [])
urls = [w["url"] for w in workers]
assert len(urls) == 2
assert set(urls) == {f"{worker_url}@0", f"{worker_url}@1"}
# Verify API key enforcement
# 1) Without Authorization -> Should get 401 Unauthorized
r = requests.post(
f"{router_url}/v1/completions",
json={"model": e2e_model, "prompt": "hi", "max_tokens": 1},
timeout=60,
)
assert r.status_code == 401
# 2) With correct Authorization -> 200
r = requests.post(
f"{router_url}/v1/completions",
json={"model": e2e_model, "prompt": "hi", "max_tokens": 1},
headers={"Authorization": f"Bearer {api_key}"},
timeout=60,
)
assert r.status_code == 200
# Finally, run MMLU eval through the router with auth
os.environ["OPENAI_API_KEY"] = api_key
args = SimpleNamespace(
base_url=router_url,
model=e2e_model,
eval_name="mmlu",
num_examples=64,
num_threads=32,
temperature=0.1,
)
metrics = run_eval(args)
assert metrics["score"] >= 0.65
@@ -0,0 +1,132 @@
"""
pytest configuration for e2e_response_api tests.
This configures pytest to not collect base test classes that are meant to be inherited.
"""
import os
import openai
import pytest # noqa: F401
from router_fixtures import (
popen_launch_openai_xai_router,
popen_launch_workers_and_router,
)
from util import kill_process_tree
# ------------------------------
# Backend Configuration Map
# ------------------------------
BACKENDS = {
"openai": {
"model": "gpt-5-nano",
"base_url_port": "http://127.0.0.1:30010",
"launcher": popen_launch_openai_xai_router,
"launcher_kwargs": {
"backend": "openai",
"history_backend": "memory",
},
"api_key_env": "OPENAI_API_KEY",
"needs_workers": False,
},
"xai": {
"model": "grok-4-fast",
"base_url_port": "http://127.0.0.1:30023",
"launcher": popen_launch_openai_xai_router,
"launcher_kwargs": {
"backend": "xai",
"history_backend": "memory",
},
"api_key_env": "XAI_API_KEY",
"needs_workers": False,
},
"grpc": {
"model": "/home/ubuntu/models/Qwen/Qwen2.5-14B-Instruct",
"base_url_port": "http://127.0.0.1:30030",
"launcher": popen_launch_workers_and_router,
"launcher_kwargs": {
"timeout": 90,
"num_workers": 1,
"tp_size": 2,
"policy": "round_robin",
"worker_args": ["--context-length=1000"],
"router_args": [
"--history-backend",
"memory",
"--tool-call-parser",
"qwen",
],
},
"api_key_env": None, # grpc does not use API keys
"needs_workers": True,
},
"grpc_harmony": {
"model": "/home/ubuntu/models/openai/gpt-oss-20b",
"base_url_port": "http://127.0.0.1:30030",
"launcher": popen_launch_workers_and_router,
"launcher_kwargs": {
"timeout": 90,
"num_workers": 1,
"tp_size": 2,
"policy": "round_robin",
"worker_args": ["--reasoning-parser=gpt-oss"],
"router_args": ["--history-backend", "memory"],
},
"api_key_env": None,
"needs_workers": True,
},
"oracle_store": {
"model": "gpt-5-nano",
"base_url_port": "http://127.0.0.1:30040",
"launcher": popen_launch_openai_xai_router,
"launcher_kwargs": {
"backend": "openai",
"history_backend": "oracle",
},
"api_key_env": "OPENAI_API_KEY",
"needs_workers": False,
},
}
@pytest.fixture(scope="class")
def setup_backend(request):
backend = request.param
if backend not in BACKENDS:
raise RuntimeError(f"Unknown backend {backend}")
cfg = BACKENDS[backend]
# Launch cluster
cluster = (
cfg["launcher"](
cfg["model"],
cfg["base_url_port"],
**cfg["launcher_kwargs"],
)
if cfg["launcher"] is popen_launch_workers_and_router
else cfg["launcher"](
backend=cfg["launcher_kwargs"]["backend"],
base_url=cfg["base_url_port"],
history_backend=cfg["launcher_kwargs"]["history_backend"],
)
)
# Build client
api_key = os.environ.get(cfg["api_key_env"]) if cfg["api_key_env"] else None
client = openai.Client(
api_key=api_key,
base_url=cluster["base_url"] + "/v1",
)
# Yield data to test
try:
yield backend, cfg["model"], client
finally:
# Always kill router
kill_process_tree(cluster["router"].pid)
# If workers exist, kill them as well
if cfg["needs_workers"]:
for w in cluster.get("workers", []):
kill_process_tree(w.pid)
@@ -0,0 +1,241 @@
"""
Base test class for Response API e2e tests.
This module provides base test classes that can be reused across different backends
(OpenAI, XAI, gRPC) with common test logic.
"""
import sys
import time
from pathlib import Path
import openai
import pytest
from openai import OpenAI
from openai.types import responses
# Add current directory for local imports
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR))
@pytest.mark.parametrize("setup_backend", ["openai", "oracle_store"], indirect=True)
class TestResponseCRUD:
"""Base class for Response API CRUD tests."""
def test_create_and_get_response(self, setup_backend):
"""Test creating response and retrieving it."""
_, model, client = setup_backend
# Create response
create_resp = client.responses.create(model=model, input="Hello, world!")
assert create_resp.id is not None
assert create_resp.error is None
assert create_resp.status == "completed"
assert len(create_resp.output_text) > 0
response_id = create_resp.id
# Get response
get_resp = client.responses.retrieve(response_id=response_id)
assert get_resp.error is None
assert get_resp.id == response_id
assert get_resp.status == "completed"
input_resp = client.responses.input_items.list(response_id=get_resp.id)
assert input_resp.data is not None
assert len(input_resp.data) > 0
@pytest.mark.skip(reason="TODO: Add delete response feature")
def test_delete_response(self, setup_backend):
"""Test deleting response."""
_, model, client = setup_backend
# Create response
create_resp = client.responses.create(model=model, input="Test deletion")
assert create_resp.id is not None
assert create_resp.error is None
assert create_resp.status == "completed"
assert len(create_resp.output_text) > 0
response_id = create_resp.id
# Delete response
client.responses.delete(response_id=response_id)
# Verify it's deleted (should return 404)
with pytest.raises(openai.NotFoundError):
client.responses.retrieve(response_id=response_id)
@pytest.mark.skip(reason="TODO: Add background response feature")
def test_background_response(self, setup_backend):
"""Test background response execution."""
_, model, client = setup_backend
# Create background response
create_resp = client.responses.create(
model=model,
input="Write a short story",
background=True,
max_output_tokens=100,
)
assert create_resp.id is not None
assert create_resp.error is None
assert create_resp.status in ["in_progress", "queued"]
response_id = create_resp.id
# Wait for completion
final_data = wait_for_background_task(client, response_id, timeout=60)
assert final_data.status == "completed"
@pytest.mark.parametrize("setup_backend", ["openai", "oracle_store"], indirect=True)
class TestConversationCRUD:
"""Base class for Conversation API CRUD tests."""
def test_create_and_get_conversation(self, setup_backend):
"""Test creating and retrieving conversation."""
_, model, client = setup_backend
# Create conversation
create_resp = client.conversations.create(metadata={"user": "test_user"})
assert create_resp.id is not None
assert create_resp.created_at is not None
create_data = create_resp.metadata
assert create_data["user"] == "test_user"
conversation_id = create_resp.id
# Get conversation
get_resp = client.conversations.retrieve(conversation_id=conversation_id)
assert get_resp.id is not None
assert get_resp.created_at is not None
get_data = get_resp.metadata
assert get_resp.id == conversation_id
assert get_data["user"] == "test_user"
def test_update_conversation(self, setup_backend):
"""Test updating conversation metadata."""
_, model, client = setup_backend
# Create conversation
create_resp = client.conversations.create(metadata={"key1": "value1"})
assert create_resp.id is not None
assert create_resp.created_at is not None
create_data = create_resp.metadata
assert create_data["key1"] == "value1"
assert "key2" not in create_data
conversation_id = create_resp.id
# Update conversation
update_resp = client.conversations.update(
conversation_id=conversation_id,
metadata={"key1": "value1", "key2": "value2"},
)
assert update_resp.id == conversation_id
update_data = update_resp.metadata
assert update_data["key1"] == "value1"
assert update_data["key2"] == "value2"
# Verify update
get_resp = client.conversations.retrieve(conversation_id=conversation_id)
get_data = get_resp.metadata
assert update_data["key1"] == "value1"
assert update_data["key2"] == "value2"
def test_delete_conversation(self, setup_backend):
"""Test deleting conversation."""
_, model, client = setup_backend
# Create conversation
create_resp = client.conversations.create()
assert create_resp.id is not None
assert create_resp.created_at is not None
conversation_id = create_resp.id
# Delete conversation
delete_resp = client.conversations.delete(conversation_id=conversation_id)
assert delete_resp.id is not None
assert delete_resp.deleted
# Verify deletion
with pytest.raises(openai.NotFoundError):
client.conversations.retrieve(conversation_id=conversation_id)
def test_list_conversation_items(self, setup_backend):
"""Test listing conversation items."""
_, model, client = setup_backend
# Create conversation
conv_resp = client.conversations.create()
assert conv_resp.id is not None
conversation_id = conv_resp.id
# Create response with conversation
resp1 = client.responses.create(
model=model,
input="First message",
conversation=conversation_id,
max_output_tokens=50,
)
assert resp1.error is None
resp2 = client.responses.create(
model=model,
input="Second message",
conversation=conversation_id,
max_output_tokens=50,
)
assert resp2.error is None
# List items
list_resp = client.conversations.items.list(conversation_id=conversation_id)
assert list_resp is not None
assert list_resp.data is not None
list_data = list_resp.data
# Should have at least 4 items (2 inputs + 2 outputs)
assert len(list_data) >= 4
def wait_for_background_task(
client: OpenAI, response_id: str, timeout: int = 30, poll_interval: float = 0.5
) -> responses.Response:
"""
Wait for background task to complete.
Args:
client: openai client
response_id: Response ID to poll
timeout: Max seconds to wait
poll_interval: Seconds between polls
Returns:
Final response data
Raises:
TimeoutError: If task doesn't complete in time
AssertionError: If task fails
"""
start_time = time.time()
while time.time() - start_time < timeout:
resp = client.responses.retrieve(response_id=response_id)
assert resp.error is None
assert resp.id == response_id
status = resp.status
if status == "completed":
return resp
elif status == "failed":
raise AssertionError(f"Background task failed: {resp.error}")
elif status == "cancelled":
raise AssertionError("Background task was cancelled")
time.sleep(poll_interval)
raise TimeoutError(
f"Background task {response_id} did not complete within {timeout}s"
)
@@ -0,0 +1,161 @@
"""
State management tests for Response API.
Tests both previous_response_id and conversation-based state management.
These tests should work across all backends (OpenAI, XAI, gRPC).
"""
import openai
import pytest
@pytest.mark.parametrize(
"setup_backend", ["openai", "xai", "grpc", "grpc_harmony"], indirect=True
)
class TestStateManagement:
"""Tests for state management using previous_response_id and conversation."""
def test_basic_response_creation(self, setup_backend):
"""Test basic response creation without state."""
_, model, client = setup_backend
resp = client.responses.create(model=model, input="What is 2+2?")
assert resp.id is not None
assert resp.error is None
assert resp.status == "completed"
assert len(resp.output_text) > 0
assert resp.usage is not None
def test_streaming_response(self, setup_backend):
"""Test streaming response."""
_, model, client = setup_backend
resp = client.responses.create(
model=model, input="Count to 5", stream=True, max_output_tokens=50
)
# Check for response.created event
events = [event for event in resp]
created_events = [event for event in events if event.type == "response.created"]
assert len(created_events) > 0
# Check for final completed event or in_progress events
assert any(
event.type in ["response.completed", "response.in_progress"]
for event in events
)
def test_previous_response_id_chaining(self, setup_backend):
"""Test chaining responses using previous_response_id."""
_, model, client = setup_backend
# First response
resp1 = client.responses.create(
model=model, input="My name is Alice and my friend is Bob. Remember it."
)
assert resp1.error is None
assert resp1.status == "completed"
response1_id = resp1.id
# Second response referencing first
resp2 = client.responses.create(
model=model, input="What is my name", previous_response_id=response1_id
)
assert resp2.error is None
assert resp2.status == "completed"
# The model should remember the name from previous response
assert "Alice" in resp2.output_text
# Third response referencing second
resp3 = client.responses.create(
model=model,
input="What is my friend name?",
previous_response_id=resp2.id,
)
assert resp3.error is None
assert resp3.status == "completed"
assert "Bob" in resp3.output_text
@pytest.mark.skip(reason="TODO: Add the invalid previous_response_id check")
def test_previous_response_id_invalid(self, setup_backend):
"""Test using invalid previous_response_id."""
_, model, client = setup_backend
with pytest.raises(openai.BadRequestError):
client.responses.create(
model=model,
input="Test",
previous_response_id="resp_invalid123",
max_output_tokens=50,
)
def test_conversation_with_multiple_turns(self, setup_backend):
"""Test state management using conversation ID."""
backend, model, client = setup_backend
if backend in ["grpc", "grpc_harmony"]:
pytest.skip("TODO: 501 Not Implemented")
# Create conversation
conv_resp = client.conversations.create(metadata={"topic": "math"})
assert conv_resp.id is not None
assert conv_resp.created_at is not None
conversation_id = conv_resp.id
# First response in conversation
resp1 = client.responses.create(
model=model, input="I have 5 apples.", conversation=conversation_id
)
assert resp1.error is None
assert resp1.status == "completed"
# Second response in same conversation
resp2 = client.responses.create(
model=model,
input="How many apples do I have?",
conversation=conversation_id,
)
assert resp2.error is None
assert resp2.status == "completed"
output_text = resp2.output_text
# Should remember "5 apples"
assert "5" in output_text or "five" in output_text.lower()
# Third response in same conversation
resp3 = client.responses.create(
model=model,
input="If I get 3 more, how many total?",
conversation=conversation_id,
)
assert resp3.error is None
assert resp3.status == "completed"
output_text = resp3.output_text
# Should calculate 5 + 3 = 8
assert "8" in output_text or "eight" in output_text.lower()
list_resp = client.conversations.items.list(conversation_id)
assert list_resp.data is not None
items = list_resp.data
# Should have at least 6 items (3 inputs + 3 outputs)
assert len(items) >= 6
def test_mutually_exclusive_parameters(self, setup_backend):
"""Test that previous_response_id and conversation are mutually exclusive."""
_, model, client = setup_backend
# TODO: Remove this once the conversation API is implemented for GRPC backend
conversation_id = "conv_123"
resp1 = client.responses.create(model=model, input="Test")
response1_id = resp1.id
# Try to use both parameters
with pytest.raises(openai.BadRequestError):
client.responses.create(
model=model,
input="This should fail",
previous_response_id=response1_id,
conversation=conversation_id,
)
@@ -0,0 +1,152 @@
"""
Streaming events tests for Response API.
Tests for streaming event validation including:
- Zero-based output_index for reasoning content
- OutputItemDone event emission and output array construction
"""
import pytest
@pytest.mark.parametrize("setup_backend", ["grpc", "grpc_harmony"], indirect=True)
class TestStreamingEvents:
"""Tests for streaming event validation."""
def test_output_item_event_emitted(self, setup_backend):
"""
Test that output_index is zero-based in streaming responses.
Verifies that the first output item has output_index: 0.
"""
_, model, client = setup_backend
resp = client.responses.create(
model=model,
input="Count from 1 to 3",
stream=True,
max_output_tokens=50,
)
events = [event for event in resp]
assert len(events) > 0
# Find output_item.added events
output_item_added_events = [
event for event in events if event.type == "response.output_item.added"
]
assert len(output_item_added_events) > 0, "Should have output_item.added events"
# Verify first output item has output_index: 0
first_item_event = output_item_added_events[0]
assert first_item_event.item is not None
assert first_item_event.output_index is not None
assert (
first_item_event.output_index == 0
), "First output item must have output_index: 0 (zero-based indexing)"
# Verify subsequent items increment correctly
for i, event in enumerate(output_item_added_events):
assert (
event.output_index == i
), f"Output item {i} should have output_index: {i}"
# Verify output_item.done event exists
output_item_done_events = [
event for event in events if event.type == "response.output_item.done"
]
assert len(output_item_done_events) > 0
# Verify output_item.done event structure
for event in output_item_done_events:
assert event.item is not None
assert event.output_index is not None
assert event.item.type is not None
# Find response.completed event
completed_events = [
event for event in events if event.type == "response.completed"
]
assert len(completed_events) == 1, "Should have exactly one completed event"
# Verify output array exists and contains items
completed_event = completed_events[0]
assert completed_event.response.output is not None
output_array = completed_event.response.output
assert isinstance(output_array, list)
assert len(output_array) > 0, "Output array should contain at least one item"
# Verify each item in output array has proper structure
for i, item in enumerate(output_array):
assert item.type is not None
# Verify output_item.added events match items in final output array
output_item_added_events = [
event for event in events if event.type == "response.output_item.added"
]
assert len(output_item_added_events) == len(
output_array
), "Number of output_item.added events should match output array length"
def test_reasoning_content(self, setup_backend):
"""
Test that reasoning content has correct zero-based output_index.
Specifically tests that reasoning item has output_index: 0
and message item has output_index: 1.
"""
backend, model, client = setup_backend
if backend in ["grpc"]:
pytest.skip("skip test_reasoning_content for grpc")
resp = client.responses.create(
model=model,
input="What is the capital of France? Think step by step.",
stream=True,
max_output_tokens=200,
)
events = [event for event in resp]
assert len(events) > 0
# Find output_item.added events
output_item_added_events = [
event for event in events if event.type == "response.output_item.added"
]
assert len(output_item_added_events) > 0
reasoning_items = [
item for item in output_item_added_events if item.item.type == "reasoning"
]
message_items = [
item for item in output_item_added_events if item.item.type == "message"
]
# If reasoning is present, verify it has output_index: 0
if reasoning_items:
reasoning_item = reasoning_items[0]
assert (
reasoning_item.output_index == 0
), "Reasoning item should have output_index: 0"
# If message is present after reasoning, verify it has output_index: 1
if reasoning_items and message_items:
message_item = message_items[0]
assert (
message_item.output_index == 1
), "Message item after reasoning should have output_index: 1"
# Find response.completed event
completed_events = [
event for event in events if event.type == "response.completed"
]
assert len(completed_events) == 1
# Get output array from completed event
output_array = completed_events[0].response.output
assert len(output_array) > 0
# Check if reasoning items are in output array
reasoning_items_in_output = [
item for item in output_array if item.type == "reasoning"
]
assert len(reasoning_items_in_output) > 0
@@ -0,0 +1,172 @@
"""
Structured output tests for Response API.
Tests for text.format field with json_object and json_schema formats.
"""
import json
import sys
from pathlib import Path
import pytest
# Add current directory for local imports
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR))
@pytest.mark.parametrize("setup_backend", ["openai", "grpc_harmony"], indirect=True)
class TestStructuredOutput:
def test_structured_output_json_schema(self, setup_backend):
"""Test structured output with json_schema format."""
_, model, client = setup_backend
# Create response with structured output
params = {
"model": model,
"input": [
{
"role": "system",
"content": "You are a helpful math tutor. Guide the user through the solution step by step.",
},
{"role": "user", "content": "how can I solve 8x + 7 = -23"},
],
"text": {
"format": {
"type": "json_schema",
"name": "math_reasoning",
"schema": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"explanation": {"type": "string"},
"output": {"type": "string"},
},
"required": ["explanation", "output"],
"additionalProperties": False,
},
},
"final_answer": {"type": "string"},
},
"required": ["steps", "final_answer"],
"additionalProperties": False,
},
"strict": True,
}
},
}
create_resp = client.responses.create(**params)
assert create_resp.error is None
assert create_resp.id is not None
assert create_resp.output is not None
assert create_resp.text is not None
# Verify text format was echoed back correctly
assert create_resp.text.format is not None
assert create_resp.text.format.type == "json_schema"
assert create_resp.text.format.name == "math_reasoning"
assert create_resp.text.format.schema_ is not None
assert create_resp.text.format.strict
# Find the message output (output[0] may be reasoning, output[1] is message)
output_text = next(
(
content.text
for item in create_resp.output
if item.type == "message"
for content in item.content
if content.type == "output_text"
),
None,
)
assert output_text is not None, "No output_text found in response"
assert output_text.strip(), "output_text is empty"
# Parse JSON output
output_json = json.loads(output_text)
# Verify schema structure
assert "steps" in output_json
assert "final_answer" in output_json
assert isinstance(output_json["steps"], list)
assert len(output_json["steps"]) > 0
# Verify each step has required fields
for step in output_json["steps"]:
assert "explanation" in step
assert "output" in step
@pytest.mark.parametrize("setup_backend", ["grpc"], indirect=True)
class TestSimpleSchemaStructuredOutput:
def test_structured_output_json_schema(self, setup_backend):
"""Override with simpler schema for Llama model (complex schemas not well supported)."""
_, model, client = setup_backend
params = {
"model": model,
"input": [
{
"role": "system",
"content": "You are a math solver. Return ONLY a JSON object that matches the schema—no extra text.",
},
{
"role": "user",
"content": "What is 1 + 1?",
},
],
"text": {
"format": {
"type": "json_schema",
"name": "math_answer",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
}
},
}
create_resp = client.responses.create(**params)
assert create_resp.error is None
assert create_resp.id is not None
assert create_resp.output is not None
assert create_resp.text is not None
# Verify text format was echoed back correctly
assert create_resp.text.format is not None
assert create_resp.text.format.type == "json_schema"
assert create_resp.text.format.name == "math_answer"
assert create_resp.text.format.schema_ is not None
# Find the message output
output_text = next(
(
content.text
for item in create_resp.output
if item.type == "message"
for content in item.content
if content.type == "output_text"
),
None,
)
assert output_text is not None, "No output_text found in response"
assert output_text.strip(), "output_text is empty"
# Parse JSON output
output_json = json.loads(output_text)
# Verify simple schema structure (just answer field)
assert "answer" in output_json
assert isinstance(output_json["answer"], str)
assert output_json["answer"], "Answer is empty"
@@ -0,0 +1,765 @@
"""
Test class for tool calling tests.
This module provides test cases for function calling functionality, tool choices
and mcp calling functionality across different backends.
"""
import json
import sys
import time
from pathlib import Path
import pytest
# Add current directory for local imports
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR))
@pytest.mark.parametrize(
"setup_backend", ["openai", "grpc", "grpc_harmony"], indirect=True
)
class TestToolCalling:
# Shared function tool definitions
SYSTEM_DIAGNOSTICS_FUNCTION = {
"type": "function",
"name": "get_system_diagnostics",
"description": "Retrieve real-time diagnostics for a spacecraft system.",
"parameters": {
"type": "object",
"properties": {
"system_name": {
"type": "string",
"description": "Name of the spacecraft system to query. "
"Example: 'Astra-7 Core Reactor'.",
}
},
"required": ["system_name"],
},
}
GET_WEATHER_FUNCTION = {
"type": "function",
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city name, e.g., San Francisco",
}
},
"required": ["location"],
},
}
CALCULATE_FUNCTION = {
"type": "function",
"name": "calculate",
"description": "Perform a mathematical calculation",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The mathematical expression to evaluate",
}
},
"required": ["expression"],
},
}
SEARCH_WEB_FUNCTION = {
"type": "function",
"name": "search_web",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
}
LOCAL_SEARCH_FUNCTION = {
"type": "function",
"name": "local_search",
"description": "Search local database",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
}
# Shared constants for MCP tests
BRAVE_MCP_TOOL = {
"type": "mcp",
"server_label": "brave",
"server_description": "A Tool to do web search",
"server_url": "http://localhost:8001/sse",
"require_approval": "never",
}
DEEPWIKI_MCP_TOOL = {
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "never",
}
MCP_TEST_PROMPT = (
"show me some news about sglang router, use the tool to just search "
"one result and return one sentence response"
)
# Test cases for basic function calling functionality
def test_basic_function_call(self, setup_backend):
"""
Test basic function calling workflow.
This test follows the pattern from function_call_test.py:
1. Define a function tool (get_horoscope)
2. Send user message asking for horoscope
3. Model should return function_call
4. Execute function locally and provide output
5. Model should generate final response using the function output
"""
backend, model, client = setup_backend
if backend in ["grpc"]:
pytest.skip("skip for grpc")
# 1. Define a list of callable tools for the model
tools = [
{
"type": "function",
"name": "get_horoscope",
"description": "Get today's horoscope for an astrological sign.",
"parameters": {
"type": "object",
"properties": {
"sign": {
"type": "string",
"description": "An astrological sign like Taurus or Aquarius",
},
},
"required": ["sign"],
},
},
]
system_prompt = (
"You are a helpful assistant that can call functions. "
"When a user asks for horoscope information, call the function. "
"IMPORTANT: Don't reply directly to the user, only call the function. "
)
# Create a running input list we will add to over time
input_list = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "What is my horoscope? I am an Aquarius."},
]
# 2. Prompt the model with tools defined
resp = client.responses.create(model=model, input=input_list, tools=tools)
# Should successfully make the request
assert resp.error is None
# Basic response structure
assert resp.id is not None
assert resp.status == "completed"
assert resp.output is not None
# Verify output array is not empty
output = resp.output
assert isinstance(output, list)
assert len(output) > 0
# Check for function_call in output
function_calls = [item for item in output if item.type == "function_call"]
assert (
len(function_calls) > 0
), "Response should contain at least one function_call"
# Verify function_call structure
function_call = function_calls[0]
assert function_call.call_id is not None
assert function_call.name is not None
assert function_call.name == "get_horoscope"
assert function_call.arguments is not None
# Parse arguments
args = json.loads(function_call.arguments)
assert "sign" in args
assert args["sign"].lower() == "aquarius"
# 3. Save function call outputs for subsequent requests
input_list.append(function_call)
# 4. Execute the function logic for get_horoscope
horoscope = f"{args['sign']}: Next Tuesday you will befriend a baby otter."
# 5. Provide function call results to the model
input_list.append(
{
"type": "function_call_output",
"call_id": function_call.call_id,
"output": json.dumps({"horoscope": horoscope}),
}
)
# 6. Make second request with function output
resp2 = client.responses.create(
model=model,
input=input_list,
instructions="Respond only with a horoscope generated by a tool.",
tools=tools,
)
assert resp2.error is None
assert resp2.status == "completed"
# The model should be able to give a response using the function output
output2 = resp2.output
assert len(output2) > 0
# Find message output
messages = [item for item in output2 if item.type == "message"]
assert len(messages) > 0, "Response should contain at least one message"
# Verify message contains the horoscope
message = messages[0]
assert message.content is not None
content_parts = message.content
assert len(content_parts) > 0
# Get text from content
text_parts = [part.text for part in content_parts if part.type == "output_text"]
full_text = " ".join(text_parts).lower()
# Should mention the horoscope or baby otter
assert (
"baby otter" in full_text or "aquarius" in full_text
), "Response should reference the horoscope content"
# Test cases for tool_choice parameter support, these tests require --reasoning-parser
def test_tool_choice_auto(self, setup_backend):
"""
Test tool_choice="auto" allows model to decide whether to use tools.
The model should be able to choose to call a tool or not.
"""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip("skip for openai")
tools = [self.GET_WEATHER_FUNCTION]
# Query that should trigger tool use
resp = client.responses.create(
model=model,
input="What is the weather in Seattle?",
tools=tools,
tool_choice="auto",
stream=False,
)
assert resp.id is not None
assert resp.error is None
output = resp.output
assert len(output) > 0
# With auto, model should choose to call get_weather for this query
function_calls = [item for item in output if item.type == "function_call"]
assert (
len(function_calls) > 0
), "Model should choose to call function with tool_choice='auto'"
def test_tool_choice_required(self, setup_backend):
"""
Test tool_choice="required" forces the model to call at least one tool.
The model must make at least one function call.
"""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip("skip for openai")
tools = [self.CALCULATE_FUNCTION]
resp = client.responses.create(
model=model,
input="What is 15 * 23?",
tools=tools,
tool_choice="required",
stream=False,
)
assert resp.id is not None
assert resp.error is None
output = resp.output
# Must have at least one function call
function_calls = [item for item in output if item.type == "function_call"]
assert (
len(function_calls) > 0
), "tool_choice='required' must force at least one function call"
def test_tool_choice_specific_function(self, setup_backend):
"""
Test tool_choice with specific function name forces that function to be called.
The model must call the specified function.
"""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip("skip for openai")
tools = [self.SEARCH_WEB_FUNCTION, self.GET_WEATHER_FUNCTION]
# Force specific function call
resp = client.responses.create(
model=model,
input="What's happening in the news today?",
tools=tools,
tool_choice={"type": "function", "function": {"name": "search_web"}},
stream=False,
)
assert resp.id is not None
assert resp.error is None
output = resp.output
# Must have function call
function_calls = [item for item in output if item.type == "function_call"]
assert len(function_calls) > 0, "Must call the specified function"
# Must be the specified function
called_function = function_calls[0]
assert (
called_function.name == "search_web"
), "Must call the function specified in tool_choice"
def test_tool_choice_streaming(self, setup_backend):
"""
Test tool_choice parameter works correctly with streaming.
Verifies that tool_choice constraints are applied in streaming mode.
"""
backend, model, client = setup_backend
if backend in ["openai", "grpc"]:
pytest.skip("skip for openai")
tools = [self.CALCULATE_FUNCTION]
resp = client.responses.create(
model=model,
input="Calculate 42 * 17",
tools=tools,
tool_choice="required",
stream=True,
)
events = [event for event in resp]
assert len(events) > 0
event_types = [e.type for e in events]
# Should have function call events
assert (
"response.function_call_arguments.delta" in event_types
), "Should have function_call_arguments.delta events"
# Verify completed event has function call
completed_events = [e for e in events if e.type == "response.completed"]
assert len(completed_events) == 1
output = completed_events[0].response.output
function_calls = [item for item in output if item.type == "function_call"]
assert (
len(function_calls) > 0
), "Streaming with tool_choice='required' must produce function call"
def test_tool_choice_with_mcp_tools(self, setup_backend):
"""
Test tool_choice parameter works with MCP tools.
Verifies that tool_choice can control MCP tool usage.
"""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip("skip for openai")
tools = [self.DEEPWIKI_MCP_TOOL]
# With tool_choice="auto", should allow MCP tool calls
resp = client.responses.create(
model=model,
input="What transport protocols does the 2025-03-26 version of the MCP spec (modelcontextprotocol/modelcontextprotocol) support?",
tools=tools,
tool_choice="auto",
stream=False,
)
assert resp.id is not None
assert resp.error is None
output = resp.output
# Should have mcp_call with auto
mcp_calls = [item for item in output if item.type == "mcp_call"]
assert len(mcp_calls) > 0, "tool_choice='auto' should allow MCP tool calls"
def test_tool_choice_mixed_function_and_mcp(self, setup_backend):
"""
Test tool_choice with mixed function and MCP tools.
Verifies tool_choice can select specific tools when both function and MCP tools are available.
"""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip("skip for openai")
tools = [self.DEEPWIKI_MCP_TOOL, self.LOCAL_SEARCH_FUNCTION]
# Force specific function call
resp = client.responses.create(
model=model,
input="Search for information about Python",
tools=tools,
tool_choice={"type": "function", "function": {"name": "local_search"}},
stream=False,
)
assert resp.id is not None
assert resp.error is None
output = resp.output
# Must call local_search, not MCP
function_calls = [item for item in output if item.type == "function_call"]
assert len(function_calls) > 0
assert function_calls[0].name == "local_search"
# Should not have mcp_call
mcp_calls = [item for item in output if item.type == "mcp_call"]
assert len(mcp_calls) == 0, "Should only call specified function, not MCP tools"
# Tests for MCP tool calling in both streaming and non-streaming modes.
def test_mcp_basic_tool_call(self, setup_backend):
"""
Test basic MCP tool call (non-streaming).
Validation strictness is controlled by parameter `backend` from setup_backend fixture.
Set to "strict" if backend is http.
"""
backend, model, client = setup_backend
# To avoid being rate-limited by brave search server
time.sleep(2)
resp = client.responses.create(
model=model,
input=self.MCP_TEST_PROMPT,
tools=[self.BRAVE_MCP_TOOL],
stream=False,
reasoning={"effort": "low"},
)
# Should successfully make the request
assert resp.error is None
# Basic response structure
assert resp.id is not None
assert resp.status == "completed"
assert resp.model is not None
assert resp.output is not None
# Verify output array is not empty
assert len(resp.output_text) > 0
# Check for MCP-specific output types
output_types = [item.type for item in resp.output]
# Should have mcp_list_tools - tools are listed before calling
assert (
"mcp_list_tools" in output_types
), "Response should contain mcp_list_tools"
# Should have at least one mcp_call
mcp_calls = [item for item in resp.output if item.type == "mcp_call"]
assert len(mcp_calls) > 0, "Response should contain at least one mcp_call"
# Verify mcp_call structure
for mcp_call in mcp_calls:
assert mcp_call.id is not None
assert mcp_call.error is None
assert mcp_call.status == "completed"
assert mcp_call.server_label == "brave"
assert mcp_call.name is not None
assert mcp_call.arguments is not None
assert mcp_call.output is not None
# Strict mode: additional validation for HTTP backends
if backend == "openai":
# Should have final message output
messages = [item for item in resp.output if item.type == "message"]
assert len(messages) > 0, "Response should contain at least one message"
# Verify message structure
for msg in messages:
assert msg.content is not None
assert isinstance(msg.content, list)
# Check content has text
for content_item in msg.content:
if content_item.type == "output_text":
assert content_item.text is not None
assert isinstance(content_item.text, str)
assert len(content_item.text) > 0
def test_mcp_basic_tool_call_streaming(self, setup_backend):
"""Test basic MCP tool call (streaming).
Validation strictness is controlled by the class attribute `mcp_validation_mode`.
Set to "strict" in subclasses for additional HTTP-specific validation.
"""
backend, model, client = setup_backend
# To avoid being rate-limited by brave search server
time.sleep(2)
resp = client.responses.create(
model=model,
input=self.MCP_TEST_PROMPT,
tools=[self.BRAVE_MCP_TOOL],
stream=True,
reasoning={"effort": "low"},
)
# Should successfully make the request
events = [event for event in resp]
assert len(events) > 0
event_types = [event.type for event in events]
# Check for lifecycle events
assert "response.created" in event_types, "Should have response.created event"
assert (
"response.completed" in event_types
), "Should have response.completed event"
# Check for MCP list tools events
assert (
"response.output_item.added" in event_types
), "Should have output_item.added events"
assert (
"response.mcp_list_tools.in_progress" in event_types
), "Should have mcp_list_tools.in_progress event"
assert (
"response.mcp_list_tools.completed" in event_types
), "Should have mcp_list_tools.completed event"
# Check for MCP call events
assert (
"response.mcp_call.in_progress" in event_types
), "Should have mcp_call.in_progress event"
assert (
"response.mcp_call_arguments.delta" in event_types
), "Should have mcp_call_arguments.delta event"
assert (
"response.mcp_call_arguments.done" in event_types
), "Should have mcp_call_arguments.done event"
assert (
"response.mcp_call.completed" in event_types
), "Should have mcp_call.completed event"
# Verify final completed event has full response
completed_events = [e for e in events if e.type == "response.completed"]
assert len(completed_events) == 1
final_response = completed_events[0].response
assert final_response.id is not None
assert final_response.status == "completed"
assert final_response.output is not None
# Verify final output contains expected items
final_output = final_response.output
final_output_types = [item.type for item in final_output]
assert "mcp_list_tools" in final_output_types
assert "mcp_call" in final_output_types
# Verify mcp_call items in final output
mcp_calls = [item for item in final_output if item.type == "mcp_call"]
assert len(mcp_calls) > 0
for mcp_call in mcp_calls:
assert mcp_call.error is None
assert mcp_call.status == "completed"
assert mcp_call.server_label == "brave"
assert mcp_call.name is not None
assert mcp_call.arguments is not None
assert mcp_call.output is not None
# Strict mode: additional validation for HTTP backends
if backend == "openai":
# Check for text output events
assert (
"response.content_part.added" in event_types
), "Should have content_part.added event"
assert (
"response.output_text.delta" in event_types
), "Should have output_text.delta events"
assert (
"response.output_text.done" in event_types
), "Should have output_text.done event"
assert (
"response.content_part.done" in event_types
), "Should have content_part.done event"
assert "message" in final_output_types
# Verify text deltas combine to final message
text_deltas = [
e.delta for e in events if e.type == "response.output_text.delta"
]
assert len(text_deltas) > 0, "Should have text deltas"
# Get final text from output_text.done event
text_done_events = [
e for e in events if e.type == "response.output_text.done"
]
assert len(text_done_events) > 0
final_text = text_done_events[0].text
assert len(final_text) > 0, "Final text should not be empty"
def test_mixed_mcp_and_function_tools(self, setup_backend):
"""Test mixed MCP and function tools (non-streaming)."""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip(
"Requires external MCP server (deepwiki) - may not be accessible in CI"
)
resp = client.responses.create(
model=model,
input="Give me diagnostics for the Astra-7 Core Reactor.",
tools=[self.BRAVE_MCP_TOOL, self.SYSTEM_DIAGNOSTICS_FUNCTION],
stream=False,
tool_choice="auto",
)
# Should successfully make the request
assert resp.error is None
# Basic response structure
assert resp.id is not None
assert resp.status is not None
assert resp.output is not None
# Verify output array is not empty
output = resp.output
assert isinstance(output, list)
assert len(output) > 0
# Check for function_call (not mcp_call for get_system_diagnostics)
function_calls = [item for item in output if item.type == "function_call"]
assert (
len(function_calls) > 0
), "Response should contain at least one function_call"
# Verify function_call structure for get_system_diagnostics
system_diagnostics_call = function_calls[0]
assert system_diagnostics_call.name == "get_system_diagnostics"
assert system_diagnostics_call.call_id is not None
assert system_diagnostics_call.arguments is not None
assert system_diagnostics_call.status is not None
# Parse and verify arguments
args = json.loads(system_diagnostics_call.arguments)
assert "system_name" in args
assert "astra-7" in args["system_name"].lower()
def test_mixed_mcp_and_function_tools_streaming(self, setup_backend):
"""Test mixed MCP and function tools (streaming)."""
backend, model, client = setup_backend
if backend in ["openai"]:
pytest.skip(
"Requires external MCP server (deepwiki) - may not be accessible in CI"
)
resp = client.responses.create(
model=model,
input="Give me diagnostics for the Astra-7 Core Reactor.",
tools=[self.BRAVE_MCP_TOOL, self.SYSTEM_DIAGNOSTICS_FUNCTION],
stream=True,
tool_choice="auto", # Encourage tool usage
)
# Should successfully make the request
events = [event for event in resp]
assert len(events) > 0
event_types = [e.type for e in events]
# Check for lifecycle events
assert "response.created" in event_types, "Should have response.created event"
# Should have mcp_list_tools events
assert (
"response.mcp_list_tools.completed" in event_types
), "Should have mcp_list_tools.completed event"
# Should have function_call_arguments events (not mcp_call_arguments)
assert (
"response.function_call_arguments.delta" in event_types
), "Should have function_call_arguments.delta event for function tools"
assert (
"response.function_call_arguments.done" in event_types
), "Should have function_call_arguments.done event for function tools"
# Should NOT have mcp_call_arguments events for function tools
# (get_system_diagnostics should use function_call_arguments, not mcp_call_arguments)
mcp_call_arg_events = [
e
for e in events
if e.type == "response.mcp_call_arguments.delta"
and "get_system_diagnostics" in str(e.delta)
]
assert (
len(mcp_call_arg_events) == 0
), "Should NOT emit mcp_call_arguments.delta for function tools (get_system_diagnostics)"
# Verify function_call_arguments.delta event structure
func_arg_deltas = [
e for e in events if e.type == "response.function_call_arguments.delta"
]
assert (
len(func_arg_deltas) > 0
), "Should have function_call_arguments.delta events"
# Check that delta event contains system_name arguments
full_delta_event = ""
for event in func_arg_deltas:
full_delta_event += event.delta
assert (
"system_name" in full_delta_event.lower()
and "astra-7" in full_delta_event.lower()
), "function_call_arguments.delta should contain system_name and astra-7"
@@ -0,0 +1,565 @@
"""
Fixtures for launching OpenAI/XAI router for response API e2e testing.
This module provides fixtures for launching SGLang router with OpenAI or XAI backends:
1. Launch router with --backend openai pointing to OpenAI or XAI API
2. Configure history backend (memory or oracle)
This supports testing the Response API against real cloud providers.
"""
import logging
import os
import socket
import subprocess
import time
from typing import Optional
import requests
logger = logging.getLogger(__name__)
def wait_for_workers_ready(
router_url: str,
expected_workers: int,
timeout: int = 300,
api_key: Optional[str] = None,
) -> None:
"""
Wait for router to have all workers connected.
Polls the /workers endpoint until the 'total' field matches expected_workers.
Example response from /workers endpoint:
{"workers":[],"total":0,"stats":{"prefill_count":0,"decode_count":0,"regular_count":0}}
Args:
router_url: Base URL of router (e.g., "http://127.0.0.1:30000")
expected_workers: Number of workers expected to be connected
timeout: Max seconds to wait
api_key: Optional API key for authentication
"""
start_time = time.time()
last_error = None
attempt = 0
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with requests.Session() as session:
while time.time() - start_time < timeout:
attempt += 1
elapsed = int(time.time() - start_time)
# Log progress every 10 seconds
if elapsed > 0 and elapsed % 10 == 0 and attempt % 10 == 0:
logger.info(
f" Still waiting for workers... ({elapsed}/{timeout}s elapsed)"
)
try:
response = session.get(
f"{router_url}/workers", headers=headers, timeout=5
)
if response.status_code == 200:
data = response.json()
total_workers = data.get("total", 0)
if total_workers == expected_workers:
logger.info(
f" All {expected_workers} workers connected after {elapsed}s"
)
return
else:
last_error = f"Workers: {total_workers}/{expected_workers}"
else:
last_error = f"HTTP {response.status_code}"
except requests.ConnectionError:
last_error = "Connection refused (router not ready yet)"
except requests.Timeout:
last_error = "Timeout"
except requests.RequestException as e:
last_error = str(e)
except (ValueError, KeyError) as e:
last_error = f"Invalid response: {e}"
time.sleep(1)
raise TimeoutError(
f"Router at {router_url} did not get {expected_workers} workers within {timeout}s.\n"
f"Last status: {last_error}\n"
f"Hint: Run with SHOW_ROUTER_LOGS=1 to see startup logs"
)
def find_free_port() -> int:
"""Find an available port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_for_router_ready(
router_url: str,
timeout: int = 60,
api_key: Optional[str] = None,
) -> None:
"""
Wait for router to be ready.
Polls the /health endpoint until it returns 200.
Args:
router_url: Base URL of router (e.g., "http://127.0.0.1:30000")
timeout: Max seconds to wait
api_key: Optional API key for authentication
"""
start_time = time.time()
last_error = None
attempt = 0
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with requests.Session() as session:
while time.time() - start_time < timeout:
attempt += 1
elapsed = int(time.time() - start_time)
# Log progress every 10 seconds
if elapsed > 0 and elapsed % 10 == 0 and attempt % 10 == 0:
logger.info(
f" Still waiting for router... ({elapsed}/{timeout}s elapsed)"
)
try:
response = session.get(
f"{router_url}/health", headers=headers, timeout=5
)
if response.status_code == 200:
logger.info(f" Router ready after {elapsed}s")
return
else:
last_error = f"HTTP {response.status_code}"
except requests.ConnectionError:
last_error = "Connection refused (router not ready yet)"
except requests.Timeout:
last_error = "Timeout"
except requests.RequestException as e:
last_error = str(e)
time.sleep(1)
raise TimeoutError(
f"Router at {router_url} did not become ready within {timeout}s.\n"
f"Last status: {last_error}\n"
f"Hint: Run with SHOW_ROUTER_LOGS=1 to see startup logs"
)
def popen_launch_openai_xai_router(
backend: str, # "openai" or "xai"
base_url: str,
timeout: int = 60,
history_backend: str = "memory",
api_key: Optional[str] = None,
router_args: Optional[list] = None,
stdout=None,
stderr=None,
prometheus_port: Optional[int] = None,
) -> dict:
"""
Launch SGLang router with OpenAI or XAI backend.
This approach:
1. Starts router with --backend openai
2. Points to OpenAI or XAI API via --worker-urls
3. Configures history backend (memory or oracle)
4. Waits for router health check to pass
Args:
backend: "openai" or "xai"
base_url: Base URL for router (e.g., "http://127.0.0.1:30000")
timeout: Timeout for router startup (default: 60s)
history_backend: "memory" or "oracle" (default: memory)
api_key: Optional API key for router authentication
router_args: Additional arguments for router
stdout: Optional file handle for router stdout
stderr: Optional file handle for router stderr
Returns:
dict with:
- router: router process object
- base_url: router URL (HTTP endpoint)
Example:
>>> cluster = popen_launch_openai_xai_router(
... "openai", "http://127.0.0.1:30000"
... )
>>> # Use cluster['base_url'] for HTTP requests
>>> # Cleanup:
>>> kill_process_tree(cluster['router'].pid)
"""
show_output = os.environ.get("SHOW_ROUTER_LOGS", "0") == "1"
# Parse router port from base_url
if ":" in base_url.split("//")[-1]:
router_port = int(base_url.split(":")[-1])
else:
router_port = find_free_port()
logger.info(f"\n{'='*70}")
logger.info(f"Launching {backend.upper()} router")
logger.info(f"{'='*70}")
logger.info(f" Backend: {backend}")
logger.info(f" Router port: {router_port}")
logger.info(f" History backend: {history_backend}")
# Determine worker URL based on backend
if backend == "openai":
worker_url = "https://api.openai.com"
# Get API key from environment
backend_api_key = os.environ.get("OPENAI_API_KEY")
if not backend_api_key:
raise ValueError(
"OPENAI_API_KEY environment variable must be set for OpenAI backend"
)
elif backend == "xai":
worker_url = "https://api.x.ai"
# Get API key from environment
backend_api_key = os.environ.get("XAI_API_KEY")
if not backend_api_key:
raise ValueError(
"XAI_API_KEY environment variable must be set for XAI backend"
)
else:
raise ValueError(f"Unsupported backend: {backend}")
logger.info(f" Worker URL: {worker_url}")
# Build router command
router_cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
"127.0.0.1",
"--port",
str(router_port),
"--backend",
"openai",
"--worker-urls",
worker_url,
"--history-backend",
history_backend,
"--log-level",
"warn",
]
# Note: Not adding --api-key to router command for local testing
# The router will not require authentication
# Add Prometheus port to avoid conflicts (use unique port or disable)
if prometheus_port is None:
# Auto-assign a unique prometheus port based on router port
prometheus_port = router_port + 1000
router_cmd.extend(["--prometheus-port", str(prometheus_port)])
# Add router-specific args
if router_args:
router_cmd.extend(router_args)
if show_output:
logger.info(f" Command: {' '.join(router_cmd)}")
# Set up environment with backend API key
env = os.environ.copy()
if backend == "openai":
env["OPENAI_API_KEY"] = backend_api_key
else:
env["XAI_API_KEY"] = backend_api_key
# Launch router
if show_output:
router_proc = subprocess.Popen(
router_cmd,
env=env,
stdout=stdout,
stderr=stderr,
)
else:
router_proc = subprocess.Popen(
router_cmd,
stdout=stdout if stdout is not None else subprocess.PIPE,
stderr=stderr if stderr is not None else subprocess.PIPE,
env=env,
)
print(f" PID: {router_proc.pid}")
# Wait for router to be ready
router_url = f"http://127.0.0.1:{router_port}"
print(f"\nWaiting for router to start at {router_url}...")
try:
wait_for_router_ready(router_url, timeout=timeout, api_key=None)
logger.info(f"✓ Router ready at {router_url}")
except TimeoutError:
logger.error(f"✗ Router failed to start")
# Cleanup: kill router
try:
router_proc.kill()
except:
pass
raise
logger.info(f"\n{'='*70}")
logger.info(f"✓ {backend.upper()} router ready!")
logger.info(f" Router: {router_url}")
logger.info(f"{'='*70}\n")
return {
"router": router_proc,
"base_url": router_url,
}
def popen_launch_workers_and_router(
model: str,
base_url: str,
timeout: int = 300,
num_workers: int = 2,
policy: str = "round_robin",
api_key: Optional[str] = None,
worker_args: Optional[list] = None,
router_args: Optional[list] = None,
tp_size: int = 1,
env: Optional[dict] = None,
stdout=None,
stderr=None,
) -> dict:
"""
Launch SGLang workers and gRPC router separately.
This approach:
1. Starts N SGLang workers with --grpc-mode flag
2. Waits for workers to initialize (process startup)
3. Starts a gRPC router pointing to those workers
4. Waits for router health check to pass (router validates worker connectivity)
This matches production deployment patterns better than the integrated approach.
Args:
model: Model path (e.g., /home/ubuntu/models/llama-3.1-8b-instruct)
base_url: Base URL for router (e.g., "http://127.0.0.1:8080")
timeout: Timeout for server startup (default: 300s)
num_workers: Number of workers to launch
policy: Routing policy (round_robin, random, power_of_two, cache_aware)
api_key: Optional API key for router
worker_args: Additional arguments for workers (e.g., ["--context-len", "8192"])
router_args: Additional arguments for router (e.g., ["--max-total-token", "1536"])
tp_size: Tensor parallelism size for workers (default: 1)
env: Optional environment variables for workers (e.g., {"SGLANG_CLIP_MAX_NEW_TOKENS_ESTIMATION": "256"})
stdout: Optional file handle for worker stdout (default: subprocess.PIPE)
stderr: Optional file handle for worker stderr (default: subprocess.PIPE)
Returns:
dict with:
- workers: list of worker process objects
- worker_urls: list of gRPC worker URLs
- router: router process object
- base_url: router URL (HTTP endpoint)
Example:
>>> cluster = popen_launch_workers_and_router(model, base_url, num_workers=2)
>>> # Use cluster['base_url'] for HTTP requests
>>> # Cleanup:
>>> for worker in cluster['workers']:
>>> kill_process_tree(worker.pid)
>>> kill_process_tree(cluster['router'].pid)
"""
show_output = os.environ.get("SHOW_ROUTER_LOGS", "0") == "1"
# Parse router port from base_url
if ":" in base_url.split("//")[-1]:
router_port = int(base_url.split(":")[-1])
else:
router_port = find_free_port()
logger.info(f"\n{'='*70}")
logger.info(f"Launching gRPC cluster (separate workers + router)")
logger.info(f"{'='*70}")
logger.info(f" Model: {model}")
logger.info(f" Router port: {router_port}")
logger.info(f" Workers: {num_workers}")
logger.info(f" TP size: {tp_size}")
logger.info(f" Policy: {policy}")
# Step 1: Launch workers with gRPC enabled
workers = []
worker_urls = []
for i in range(num_workers):
worker_port = find_free_port()
worker_url = f"grpc://127.0.0.1:{worker_port}"
worker_urls.append(worker_url)
logger.info(f"\n[Worker {i+1}/{num_workers}]")
logger.info(f" Port: {worker_port}")
logger.info(f" URL: {worker_url}")
# Build worker command
worker_cmd = [
"python3",
"-m",
"sglang.launch_server",
"--model-path",
model,
"--host",
"127.0.0.1",
"--port",
str(worker_port),
"--grpc-mode", # Enable gRPC for this worker
"--mem-fraction-static",
"0.8",
]
# Add TP size
if tp_size > 1:
worker_cmd.extend(["--tp-size", str(tp_size)])
# Add worker-specific args
if worker_args:
worker_cmd.extend(worker_args)
# Launch worker with optional environment variables
if show_output:
worker_proc = subprocess.Popen(
worker_cmd,
env=env,
stdout=stdout,
stderr=stderr,
)
else:
worker_proc = subprocess.Popen(
worker_cmd,
stdout=stdout if stdout is not None else subprocess.PIPE,
stderr=stderr if stderr is not None else subprocess.PIPE,
env=env,
)
workers.append(worker_proc)
logger.info(f" PID: {worker_proc.pid}")
# Give workers a moment to start binding to ports
# The router will check worker health when it starts
logger.info(f"\nWaiting for {num_workers} workers to initialize (20s)...")
time.sleep(20)
# Quick check: make sure worker processes are still alive
for i, worker in enumerate(workers):
if worker.poll() is not None:
logger.error(
f" ✗ Worker {i+1} died during startup (exit code: {worker.poll()})"
)
# Cleanup: kill all workers
for w in workers:
try:
w.kill()
except:
pass
raise RuntimeError(f"Worker {i+1} failed to start")
logger.info(
f"✓ All {num_workers} workers started (router will verify connectivity)"
)
# Step 2: Launch router pointing to workers
logger.info(f"\n[Router]")
logger.info(f" Port: {router_port}")
logger.info(f" Worker URLs: {', '.join(worker_urls)}")
# Build router command
router_cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
"127.0.0.1",
"--port",
str(router_port),
"--prometheus-port",
"9321",
"--policy",
policy,
"--model-path",
model,
"--log-level",
"warn",
]
# Add worker URLs
router_cmd.append("--worker-urls")
router_cmd.extend(worker_urls)
# Add API key
if api_key:
router_cmd.extend(["--api-key", api_key])
# Add router-specific args
if router_args:
router_cmd.extend(router_args)
if show_output:
logger.info(f" Command: {' '.join(router_cmd)}")
# Launch router
if show_output:
router_proc = subprocess.Popen(router_cmd)
else:
router_proc = subprocess.Popen(
router_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
logger.info(f" PID: {router_proc.pid}")
# Wait for router to be ready
router_url = f"http://127.0.0.1:{router_port}"
logger.info(f"\nWaiting for router to start at {router_url}...")
try:
wait_for_workers_ready(
router_url, expected_workers=num_workers, timeout=180, api_key=api_key
)
logger.info(f"✓ Router ready at {router_url}")
except TimeoutError:
logger.error(f"✗ Router failed to start")
# Cleanup: kill router and all workers
try:
router_proc.kill()
except:
pass
for worker in workers:
try:
worker.kill()
except:
pass
raise
logger.info(f"\n{'='*70}")
logger.info(f"✓ gRPC cluster ready!")
logger.info(f" Router: {router_url}")
logger.info(f" Workers: {len(workers)}")
logger.info(f"{'='*70}\n")
return {
"workers": workers,
"worker_urls": worker_urls,
"router": router_proc,
"base_url": router_url,
}
@@ -0,0 +1,81 @@
"""
Utility functions for Response API e2e tests.
"""
import logging
import os
import signal
import threading
import unittest
import psutil
logger = logging.getLogger(__name__)
def kill_process_tree(parent_pid, include_parent: bool = True, skip_pid: int = None):
"""
Kill the process and all its child processes.
Args:
parent_pid: PID of the parent process
include_parent: Whether to kill the parent process itself
skip_pid: Optional PID to skip during cleanup
"""
# Remove sigchld handler to avoid spammy logs
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
if parent_pid is None:
parent_pid = os.getpid()
include_parent = False
try:
itself = psutil.Process(parent_pid)
except psutil.NoSuchProcess:
return
children = itself.children(recursive=True)
for child in children:
if child.pid == skip_pid:
continue
try:
child.kill()
except psutil.NoSuchProcess:
pass
if include_parent:
try:
itself.kill()
except psutil.NoSuchProcess:
pass
class CustomTestCase(unittest.TestCase):
"""
Custom test case base class with retry support.
This provides automatic test retry functionality based on environment variables.
"""
def _callTestMethod(self, method):
"""Override to add retry logic."""
max_retry = int(os.environ.get("SGLANG_TEST_MAX_RETRY", "0"))
if max_retry == 0:
# No retry, just run once
return super(CustomTestCase, self)._callTestMethod(method)
# Retry logic
for attempt in range(max_retry + 1):
try:
return super(CustomTestCase, self)._callTestMethod(method)
except Exception as e:
if attempt < max_retry:
logger.info(
f"Test failed on attempt {attempt + 1}/{max_retry + 1}, retrying..."
)
continue
else:
# Last attempt, re-raise the exception
raise
@@ -0,0 +1 @@
"""Shared fixtures for router integration tests."""
@@ -0,0 +1,236 @@
"""
Generate self-signed certificates for mTLS integration testing.
Creates a Certificate Authority (CA), server certificates, and client certificates.
"""
import datetime
import ipaddress
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def generate_private_key():
"""Generate an RSA private key."""
return rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
def generate_ca_certificate():
"""Generate a self-signed CA certificate."""
private_key = generate_private_key()
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Test"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "Test"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SGLang Test"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Test"),
x509.NameAttribute(NameOID.COMMON_NAME, "Test CA"),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
.add_extension(
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_cert_sign=True,
crl_sign=True,
key_encipherment=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.sign(private_key, hashes.SHA256())
)
return private_key, cert
def generate_server_certificate(ca_key, ca_cert):
"""Generate a server certificate signed by the CA."""
private_key = generate_private_key()
subject = x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Test"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "Test"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SGLang Test"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Test"),
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]
),
critical=False,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage(
[
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
]
),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)
return private_key, cert
def generate_client_certificate(ca_key, ca_cert):
"""Generate a client certificate signed by the CA."""
private_key = generate_private_key()
subject = x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Test"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "Test"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "SGLang Test"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Test"),
x509.NameAttribute(NameOID.COMMON_NAME, "test-client"),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage(
[
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
]
),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)
return private_key, cert
def save_key(key, path: Path):
"""Save private key to PEM file."""
with open(path, "wb") as f:
f.write(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
def save_cert(cert, path: Path):
"""Save certificate to PEM file."""
with open(path, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
def generate_all_certificates(output_dir: Path):
"""Generate all certificates and keys for mTLS testing."""
output_dir.mkdir(parents=True, exist_ok=True)
print("==> Generating CA certificate...")
ca_key, ca_cert = generate_ca_certificate()
save_key(ca_key, output_dir / "ca-key.pem")
save_cert(ca_cert, output_dir / "ca-cert.pem")
print("==> Generating server certificate...")
server_key, server_cert = generate_server_certificate(ca_key, ca_cert)
save_key(server_key, output_dir / "server-key.pem")
save_cert(server_cert, output_dir / "server-cert.pem")
print("==> Generating client certificate...")
client_key, client_cert = generate_client_certificate(ca_key, ca_cert)
save_key(client_key, output_dir / "client-key.pem")
save_cert(client_cert, output_dir / "client-cert.pem")
print(f"==> Certificates generated successfully in {output_dir}")
print()
print("Files created:")
print(" - ca-cert.pem : CA certificate (for verifying server/client certs)")
print(" - ca-key.pem : CA private key")
print(" - server-cert.pem : Server certificate")
print(" - server-key.pem : Server private key")
print(" - client-cert.pem : Client certificate")
print(" - client-key.pem : Client private key")
print()
print("Test server can use: server-cert.pem + server-key.pem")
print("Test router can use: client-cert.pem + client-key.pem + ca-cert.pem")
if __name__ == "__main__":
script_dir = Path(__file__).parent
certs_dir = script_dir / "test_certs"
generate_all_certificates(certs_dir)
@@ -0,0 +1,285 @@
"""
Lightweight mock worker HTTP server for router integration tests.
Implements minimal endpoints used by the router:
- GET /health, /health_generate
- POST /generate, /v1/completions, /v1/chat/completions
- POST /flush_cache
- GET /get_server_info, /get_model_info, /v1/models
Behavior knobs are controlled via CLI flags to simulate failures, latency, and load.
"""
import argparse
import asyncio
import json
import os
import random
import signal
import sys
import time
from contextlib import asynccontextmanager
from typing import Optional
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
# Global state (per-process)
_inflight = 0
_failures_seen = 0
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, required=True)
p.add_argument("--worker-id", default=None)
p.add_argument("--latency-ms", type=int, default=0)
p.add_argument("--timeout", action="store_true")
p.add_argument("--status-code", type=int, default=200)
p.add_argument("--fail-first-n", type=int, default=0)
p.add_argument("--random-fail-rate", type=float, default=0.0)
p.add_argument("--require-api-key", action="store_true")
p.add_argument("--api-key", default=None)
p.add_argument("--max-payload-bytes", type=int, default=10 * 1024 * 1024)
p.add_argument("--stream", action="store_true")
p.add_argument("--dp-size", type=int, default=1)
p.add_argument("--crash-on-request", action="store_true")
p.add_argument("--health-fail-after-ms", type=int, default=0)
# TLS/mTLS configuration
p.add_argument(
"--ssl-certfile", type=str, default=None, help="Path to SSL certificate file"
)
p.add_argument("--ssl-keyfile", type=str, default=None, help="Path to SSL key file")
p.add_argument(
"--ssl-ca-certs",
type=str,
default=None,
help="Path to CA certificates for client verification",
)
return p.parse_args()
def _extract_worker_id(args: argparse.Namespace) -> str:
if args.worker_id:
return str(args.worker_id)
# default to port (unique enough for tests)
return f"worker-{args.port}"
def create_app(args: argparse.Namespace) -> FastAPI:
app = FastAPI()
worker_id = _extract_worker_id(args)
start_ts = time.time()
crashed = {"done": False}
async def maybe_delay():
if args.latency_ms > 0:
await asyncio.sleep(args.latency_ms / 1000.0)
def should_fail() -> Optional[int]:
global _failures_seen
# Fail first N requests (500)
if args.fail_first_n > 0 and _failures_seen < args.fail_first_n:
_failures_seen += 1
return 500
# Random failure probability (500)
if args.random_fail_rate > 0.0 and random.random() < args.random_fail_rate:
return 500
# Forced status code override (non-200) for all responses
if args.status_code != 200:
return int(args.status_code)
return None
def check_api_key(request: Request):
if not args.require_api_key:
return
auth = request.headers.get("Authorization")
if not auth or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
key = auth.split(" ", 1)[1]
if args.api_key and key != args.api_key:
raise HTTPException(status_code=401, detail="Unauthorized")
@asynccontextmanager
async def track_inflight():
global _inflight
_inflight += 1
try:
yield
finally:
_inflight -= 1
@app.get("/health")
async def health():
if (
args.health_fail_after_ms
and (time.time() - start_ts) * 1000.0 >= args.health_fail_after_ms
):
return PlainTextResponse("bad", status_code=500)
return PlainTextResponse("ok", status_code=200)
@app.get("/health_generate")
async def health_generate():
return PlainTextResponse("ok", status_code=200)
@app.post("/flush_cache")
async def flush_cache():
return PlainTextResponse("ok", status_code=200)
@app.get("/get_model_info")
async def get_model_info():
return JSONResponse({"model": "mock", "vocab_size": 32000})
@app.get("/v1/models")
async def list_models():
return JSONResponse({"data": [{"id": "mock", "object": "model"}]})
@app.get("/get_server_info")
async def get_server_info(request: Request):
# Enforce API key on server info when required (used by dp_aware probing)
check_api_key(request)
return JSONResponse(
{
"worker_id": worker_id,
"load_in_flight": _inflight,
"cache": {"size": 0, "hit_rate": 0.0},
"dp_size": int(args.dp_size),
}
)
@app.get("/get_load")
async def get_load(request: Request):
check_api_key(request)
# Return format matching real workers: array of load info per DP rank
return JSONResponse(
[
{
"dp_rank": 0,
"num_reqs": _inflight,
"num_waiting_reqs": 0,
"num_tokens": _inflight,
}
]
)
def make_json_response(obj: dict, status_code: int = 200) -> JSONResponse:
resp = JSONResponse(obj, status_code=status_code)
resp.headers["X-Worker-Id"] = worker_id
return resp
async def handle_text_request(request: Request):
# Authorization
check_api_key(request)
# Payload limit
body = await request.body()
if len(body) > args.max_payload_bytes:
return make_json_response({"error": "payload too large"}, status_code=413)
# Simulate crash on first request
if args.crash_on_request and not crashed["done"]:
crashed["done"] = True
os._exit(1)
# Optional timeout (simulate hang)
if args.timeout:
await asyncio.sleep(3600)
# Optional latency
await maybe_delay()
# Optional failures
fail_code = should_fail()
if fail_code is not None and fail_code != 200:
return make_json_response(
{"error": f"mock failure {fail_code}"}, status_code=fail_code
)
# Build response echoing minimal shape
try:
data = await request.json()
except (json.JSONDecodeError, ValueError):
data = {}
now = time.time()
ret = {
"id": f"cmpl-{int(now*1000)}",
"object": "text_completion",
"created": int(now),
"model": "mock",
"choices": [
{
"text": "ok",
"index": 0,
"finish_reason": "stop",
}
],
"worker_id": worker_id,
"echo": data,
}
return make_json_response(ret, status_code=200)
async def handle_stream_request(request: Request):
check_api_key(request)
async def gen():
# minimal 2-chunk stream then [DONE]
for i in range(2):
await asyncio.sleep(0.01)
chunk = {
"choices": [{"delta": {"content": "x"}}],
"worker_id": worker_id,
}
yield f"data: {json.dumps(chunk)}\n\n"
yield "data: [DONE]\n\n"
headers = {"X-Worker-Id": worker_id}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@app.post("/generate")
async def generate(request: Request):
async with track_inflight():
if args.stream:
return await handle_stream_request(request)
return await handle_text_request(request)
@app.post("/v1/completions")
async def completions(request: Request):
async with track_inflight():
if args.stream:
return await handle_stream_request(request)
return await handle_text_request(request)
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
async with track_inflight():
if args.stream:
return await handle_stream_request(request)
return await handle_text_request(request)
return app
def main() -> None:
args = _parse_args()
app = create_app(args)
# Handle SIGTERM gracefully for fast test teardown
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
# Configure SSL if certificates are provided
ssl_config = {}
if args.ssl_certfile and args.ssl_keyfile:
ssl_config["ssl_certfile"] = args.ssl_certfile
ssl_config["ssl_keyfile"] = args.ssl_keyfile
# If CA certs provided, require client certificates (mTLS)
if args.ssl_ca_certs:
ssl_config["ssl_ca_certs"] = args.ssl_ca_certs
ssl_config["ssl_cert_reqs"] = 2 # ssl.CERT_REQUIRED
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", **ssl_config)
if __name__ == "__main__":
main()
@@ -0,0 +1,8 @@
import socket
def find_free_port() -> int:
"""Return an available TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@@ -0,0 +1,238 @@
import subprocess
import time
from dataclasses import dataclass
from typing import Dict, List, Optional
import requests
from .ports import find_free_port
@dataclass
class ProcHandle:
process: subprocess.Popen
url: str
class RouterManager:
"""Helper to spawn a router process and interact with admin endpoints."""
def __init__(self):
self._children: List[subprocess.Popen] = []
def start_router(
self,
worker_urls: Optional[List[str]] = None,
policy: str = "round_robin",
port: Optional[int] = None,
extra: Optional[Dict] = None,
# PD options
pd_disaggregation: bool = False,
prefill_urls: Optional[List[tuple]] = None,
decode_urls: Optional[List[str]] = None,
prefill_policy: Optional[str] = None,
decode_policy: Optional[str] = None,
) -> ProcHandle:
worker_urls = worker_urls or []
port = port or find_free_port()
cmd = [
"python3",
"-m",
"sglang_router.launch_router",
"--host",
"127.0.0.1",
"--port",
str(port),
"--policy",
policy,
]
# Avoid Prometheus port collisions by assigning a free port per router
prom_port = find_free_port()
cmd.extend(
["--prometheus-port", str(prom_port), "--prometheus-host", "127.0.0.1"]
)
if worker_urls:
cmd.extend(["--worker-urls", *worker_urls])
# PD routing configuration
if pd_disaggregation:
cmd.append("--pd-disaggregation")
if prefill_urls:
for url, bport in prefill_urls:
if bport is None:
cmd.extend(["--prefill", url, "none"])
else:
cmd.extend(["--prefill", url, str(bport)])
if decode_urls:
for url in decode_urls:
cmd.extend(["--decode", url])
if prefill_policy:
cmd.extend(["--prefill-policy", prefill_policy])
if decode_policy:
cmd.extend(["--decode-policy", decode_policy])
# Map supported extras to CLI flags (subset for integration)
if extra:
flag_map = {
"max_payload_size": "--max-payload-size",
"dp_aware": "--dp-aware",
"api_key": "--api-key",
# Health/monitoring
"worker_startup_check_interval": "--worker-startup-check-interval",
# Cache-aware tuning
"cache_threshold": "--cache-threshold",
"balance_abs_threshold": "--balance-abs-threshold",
"balance_rel_threshold": "--balance-rel-threshold",
# Retry
"retry_max_retries": "--retry-max-retries",
"retry_initial_backoff_ms": "--retry-initial-backoff-ms",
"retry_max_backoff_ms": "--retry-max-backoff-ms",
"retry_backoff_multiplier": "--retry-backoff-multiplier",
"retry_jitter_factor": "--retry-jitter-factor",
"disable_retries": "--disable-retries",
# Circuit breaker
"cb_failure_threshold": "--cb-failure-threshold",
"cb_success_threshold": "--cb-success-threshold",
"cb_timeout_duration_secs": "--cb-timeout-duration-secs",
"cb_window_duration_secs": "--cb-window-duration-secs",
"disable_circuit_breaker": "--disable-circuit-breaker",
# Rate limiting
"max_concurrent_requests": "--max-concurrent-requests",
"queue_size": "--queue-size",
"queue_timeout_secs": "--queue-timeout-secs",
"rate_limit_tokens_per_second": "--rate-limit-tokens-per-second",
# mTLS configuration
"client_cert_path": "--client-cert-path",
"client_key_path": "--client-key-path",
"ca_cert_paths": "--ca-cert-paths",
}
for k, v in extra.items():
if v is None:
continue
flag = flag_map.get(k)
if not flag:
continue
if isinstance(v, bool):
if v:
cmd.append(flag)
elif isinstance(v, list):
# Handle list arguments (e.g., ca_cert_paths)
if v: # Only add if list is not empty
cmd.append(flag)
cmd.extend([str(item) for item in v])
else:
cmd.extend([flag, str(v)])
proc = subprocess.Popen(cmd)
self._children.append(proc)
url = f"http://127.0.0.1:{port}"
self._wait_health(url)
return ProcHandle(process=proc, url=url)
def _wait_health(self, base_url: str, timeout: float = 30.0):
start = time.time()
with requests.Session() as s:
while time.time() - start < timeout:
try:
r = s.get(f"{base_url}/health", timeout=2)
if r.status_code == 200:
return
except requests.RequestException:
pass
time.sleep(0.2)
raise TimeoutError(f"Router at {base_url} did not become healthy")
def add_worker(self, base_url: str, worker_url: str, timeout: float = 30.0) -> None:
r = requests.post(f"{base_url}/workers", json={"url": worker_url})
assert (
r.status_code == 202
), f"add_worker failed: {r.status_code} {r.text}" # ACCEPTED status
# Poll until worker is actually added and healthy
from urllib.parse import quote
encoded_url = quote(worker_url, safe="")
start = time.time()
with requests.Session() as s:
while time.time() - start < timeout:
try:
r = s.get(f"{base_url}/workers/{encoded_url}", timeout=2)
if r.status_code == 200:
data = r.json()
# Check if registration job failed
job_status = data.get("job_status")
if job_status and job_status.get("state") == "failed":
raise RuntimeError(
f"Worker registration failed: {job_status.get('message', 'Unknown error')}"
)
# Check if worker is healthy and registered (not just in job queue)
if data.get("is_healthy", False):
return
# Worker not ready yet, continue polling
except requests.RequestException:
pass
time.sleep(0.1)
raise TimeoutError(
f"Worker {worker_url} was not added and healthy after {timeout}s"
)
def remove_worker(
self, base_url: str, worker_url: str, timeout: float = 30.0
) -> None:
# URL encode the worker_url for path parameter
from urllib.parse import quote
encoded_url = quote(worker_url, safe="")
r = requests.delete(f"{base_url}/workers/{encoded_url}")
assert (
r.status_code == 202
), f"remove_worker failed: {r.status_code} {r.text}" # ACCEPTED status
# Poll until worker is actually removed (GET returns 404) or timeout
start = time.time()
last_status = None
with requests.Session() as s:
while time.time() - start < timeout:
try:
r = s.get(f"{base_url}/workers/{encoded_url}", timeout=2)
if r.status_code == 404:
# Worker successfully removed
return
elif r.status_code == 200:
# Check if removal job failed
data = r.json()
job_status = data.get("job_status")
if job_status:
last_status = job_status
if job_status.get("state") == "failed":
raise RuntimeError(
f"Worker removal failed: {job_status.get('message', 'Unknown error')}"
)
# Worker still being processed, continue polling
except requests.RequestException:
pass
time.sleep(0.1)
# Provide detailed timeout error with last known status
error_msg = f"Worker {worker_url} was not removed after {timeout}s"
if last_status:
error_msg += f". Last job status: {last_status}"
raise TimeoutError(error_msg)
def list_workers(self, base_url: str) -> list[str]:
r = requests.get(f"{base_url}/workers")
assert r.status_code == 200, f"list_workers failed: {r.status_code} {r.text}"
data = r.json()
# Extract URLs from WorkerInfo objects
workers = data.get("workers", [])
return [w["url"] for w in workers]
def stop_all(self):
for p in self._children:
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
self._children.clear()
@@ -0,0 +1 @@
"""Integration test package for the router."""
@@ -0,0 +1,128 @@
import shutil
import subprocess
import time
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
import pytest
import requests
from ..fixtures.generate_test_certs import generate_all_certificates
from ..fixtures.ports import find_free_port
from ..fixtures.router_manager import RouterManager
def pytest_configure(config):
config.addinivalue_line("markers", "integration: mark as router integration test")
@pytest.fixture
def router_manager() -> Iterable[RouterManager]:
mgr = RouterManager()
try:
yield mgr
finally:
mgr.stop_all()
def _spawn_mock_worker(args: List[str]) -> Tuple[subprocess.Popen, str, str]:
repo_root = Path(__file__).resolve().parents[2]
script = repo_root / "py_test" / "fixtures" / "mock_worker.py"
port = find_free_port()
worker_id = f"worker-{port}"
base_cmd = [
"python3",
str(script),
"--port",
str(port),
"--worker-id",
worker_id,
]
cmd = base_cmd + args
proc = subprocess.Popen(cmd)
url = f"http://127.0.0.1:{port}"
_wait_health(url)
return proc, url, worker_id
def _wait_health(url: str, timeout: float = 10.0):
start = time.time()
with requests.Session() as s:
while time.time() - start < timeout:
try:
r = s.get(f"{url}/health", timeout=1)
if r.status_code == 200:
return
except requests.RequestException:
pass
time.sleep(0.1)
raise TimeoutError(f"Mock worker at {url} did not become healthy")
@pytest.fixture
def mock_worker():
"""Start a single healthy mock worker; yields (process, url, worker_id)."""
proc, url, worker_id = _spawn_mock_worker([])
try:
yield proc, url, worker_id
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture
def mock_workers():
"""Factory to start N workers with custom args.
Usage:
procs, urls, ids = mock_workers(n=3, args=["--latency-ms", "5"]) # same args for all
...
"""
procs: List[subprocess.Popen] = []
def _start(n: int, args: Optional[List[str]] = None):
args = args or []
new_procs: List[subprocess.Popen] = []
urls: List[str] = []
ids: List[str] = []
for _ in range(n):
p, url, wid = _spawn_mock_worker(args)
procs.append(p)
new_procs.append(p)
urls.append(url)
ids.append(wid)
return new_procs, urls, ids
try:
yield _start
finally:
for p in procs:
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=3)
except subprocess.TimeoutExpired:
p.kill()
@pytest.fixture(scope="session")
def test_certificates():
"""Generate test certificates for mTLS tests, clean up after session."""
# Get the test_certs directory path
fixtures_dir = Path(__file__).parent.parent / "fixtures"
certs_dir = fixtures_dir / "test_certs"
# Generate certificates
generate_all_certificates(certs_dir)
# Yield the path to the certificates directory
yield certs_dir
# Cleanup: remove the generated certificates
if certs_dir.exists():
shutil.rmtree(certs_dir)
@@ -0,0 +1 @@
"""Load balancing integration tests."""
@@ -0,0 +1,73 @@
import collections
import concurrent.futures
import uuid
import pytest
import requests
@pytest.mark.integration
def test_cache_aware_affinity(mock_workers, router_manager):
# Two workers; same prompt should stick to one due to cache tree
_, urls, ids = mock_workers(n=2)
rh = router_manager.start_router(worker_urls=urls, policy="cache_aware")
counts = collections.Counter()
with requests.Session() as s:
for i in range(12):
r = s.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "repeated prompt for cache",
"max_tokens": 1,
"stream": False,
},
)
assert r.status_code == 200
wid = r.headers.get("X-Worker-Id") or r.json().get("worker_id")
counts[wid] += 1
# Expect strong skew toward one worker (tree match); majority > 80%
top = max(counts.values())
assert top >= 10, counts
@pytest.mark.integration
def test_cache_aware_diverse_prompts_balances(mock_workers, router_manager):
# Add latency so concurrent requests overlap and influence load-based selection
_, urls, ids = mock_workers(n=3, args=["--latency-ms", "30"])
rh = router_manager.start_router(
worker_urls=urls,
policy="cache_aware",
extra={
"cache_threshold": 0.99,
"balance_abs_threshold": 0,
"balance_rel_threshold": 1.0,
},
)
counts = collections.Counter()
def call(i):
# Use diverse, unrelated prompts to avoid prefix matches entirely
prompt = str(uuid.uuid4())
r = requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": prompt,
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
assert r.status_code == 200
return r.headers.get("X-Worker-Id") or r.json().get("worker_id")
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
for wid in ex.map(call, range(40)):
counts[wid] += 1
# Expect participation of at least two workers
assert sum(1 for v in counts.values() if v > 0) >= 2, counts
@@ -0,0 +1,99 @@
import collections
import concurrent.futures
import time
import pytest
import requests
@pytest.mark.integration
def test_power_of_two_prefers_less_loaded(mock_workers, router_manager):
# Start two workers: one slow (higher inflight), one fast
# Router monitors /get_load and Power-of-Two uses cached loads to choose
# Start one slow and one fast worker using the fixture factory
procs_slow, urls_slow, ids_slow = mock_workers(n=1, args=["--latency-ms", "200"])
procs_fast, urls_fast, ids_fast = mock_workers(n=1, args=["--latency-ms", "0"])
procs = procs_slow + procs_fast
urls = urls_slow + urls_fast
ids = ids_slow + ids_fast
slow_id = ids_slow[0]
slow_url = urls_slow[0]
rh = router_manager.start_router(
worker_urls=urls,
policy="power_of_two",
extra={"worker_startup_check_interval": 1},
)
# Prime: fire a burst to create measurable load on slow worker, then wait for monitor tick
def _prime_call(i):
try:
requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": f"warm-{i}",
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
except Exception:
pass
with concurrent.futures.ThreadPoolExecutor(max_workers=32) as ex:
list(ex.map(_prime_call, range(128)))
time.sleep(2)
# Apply direct background load on the slow worker to amplify load diff
def _direct_load(i):
try:
requests.post(
f"{slow_url}/v1/completions",
json={
"model": "test-model",
"prompt": f"bg-{i}",
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
except Exception:
pass
# Start background load in a non-blocking way to keep slow worker busy
background_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
background_futures = []
for i in range(32):
future = background_executor.submit(_direct_load, i)
background_futures.append(future)
# Wait longer for the load monitor to update (at least 2 monitor intervals)
time.sleep(3)
def call(i):
r = requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": f"p{i}",
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
assert r.status_code == 200
return r.headers.get("X-Worker-Id") or r.json().get("worker_id")
counts = collections.Counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=32) as ex:
for wid in ex.map(call, range(200)):
counts[wid] += 1
# Clean up background executor
background_executor.shutdown(wait=False)
# Expect the slow worker (higher latency/inflight) to receive fewer requests
fast_worker_id = [i for i in ids if i != slow_id][0]
assert counts[slow_id] < counts[fast_worker_id], counts
@@ -0,0 +1,32 @@
import collections
import pytest
import requests
@pytest.mark.integration
def test_random_distribution(mock_workers, router_manager):
procs, urls, ids = mock_workers(n=4)
rh = router_manager.start_router(worker_urls=urls, policy="random")
counts = collections.Counter()
N = 200
with requests.Session() as s:
for i in range(N):
r = s.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": f"p{i}",
"max_tokens": 1,
"stream": False,
},
)
assert r.status_code == 200
wid = r.headers.get("X-Worker-Id") or r.json().get("worker_id")
counts[wid] += 1
# simple statistical tolerance: each worker should be within ±50% of mean
mean = N / len(ids)
for wid in ids:
assert 0.5 * mean <= counts[wid] <= 1.5 * mean, counts
@@ -0,0 +1,33 @@
import collections
import pytest
import requests
@pytest.mark.integration
def test_round_robin_distribution(mock_workers, router_manager):
procs, urls, ids = mock_workers(n=3)
rh = router_manager.start_router(worker_urls=urls, policy="round_robin")
counts = collections.Counter()
with requests.Session() as s:
for i in range(30):
r = s.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": f"hello {i}",
"max_tokens": 1,
"stream": False,
},
)
assert r.status_code == 200
wid = r.headers.get("X-Worker-Id") or r.json().get("worker_id")
assert wid in ids
counts[wid] += 1
# Expect near-even distribution across 3 workers
# 30 requests -> ideally 10 each; allow small tolerance ±3
for wid in ids:
assert 7 <= counts[wid] <= 13, counts
@@ -0,0 +1,38 @@
import pytest
import requests
@pytest.mark.integration
def test_router_api_key_enforcement(router_manager, mock_workers):
# Start backend requiring API key; router should forward Authorization header transparently
_, urls, _ = mock_workers(
n=1, args=["--require-api-key", "--api-key", "correct_api_key"]
)
rh = router_manager.start_router(
worker_urls=urls,
policy="round_robin",
extra={},
)
# No auth -> 401
r = requests.post(
f"{rh.url}/v1/completions",
json={"model": "test-model", "prompt": "x", "max_tokens": 1, "stream": False},
)
assert r.status_code == 401
# Invalid auth -> 401
r = requests.post(
f"{rh.url}/v1/completions",
json={"model": "test-model", "prompt": "x", "max_tokens": 1, "stream": False},
headers={"Authorization": "Bearer wrong"},
)
assert r.status_code == 401
# Correct auth -> 200
r = requests.post(
f"{rh.url}/v1/completions",
json={"model": "test-model", "prompt": "x", "max_tokens": 1, "stream": False},
headers={"Authorization": "Bearer correct_api_key"},
)
assert r.status_code == 200
@@ -0,0 +1,228 @@
import time
import pytest
import requests
@pytest.mark.integration
def test_circuit_breaker_opens_and_recovers(router_manager, mock_workers):
# A single worker that fails first 3 requests, then succeeds
_, [wurl], _ = mock_workers(n=1, args=["--fail-first-n", "3"]) # fails first 3
rh = router_manager.start_router(
worker_urls=[wurl],
policy="round_robin",
extra={
"cb_failure_threshold": 3,
"cb_success_threshold": 2,
"cb_timeout_duration_secs": 3,
"cb_window_duration_secs": 10,
"disable_retries": True,
},
)
def post_once():
return requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "trigger",
"max_tokens": 1,
"stream": False,
},
timeout=3,
)
# should see 500 when worker actually starts, before that should see 503
saw_500 = False
for _ in range(8):
r = post_once()
if r.status_code == 500:
# Worker starts, continue to circuit breaker test
saw_500 = True
break
assert (
r.status_code == 503
), "Should only see 503 when waiting for worker to start"
assert saw_500, "Worker didn't start after 8 requests"
saw_503 = False
for _ in range(4):
r = post_once()
if r.status_code == 503:
saw_503 = True
break
assert saw_503, "circuit breaker did not open to return 503"
time.sleep(4)
r1 = post_once()
r2 = post_once()
assert r1.status_code == 200 and r2.status_code == 200
@pytest.mark.integration
def test_circuit_breaker_half_open_failure_reopens(router_manager, mock_workers):
_, [wurl], _ = mock_workers(n=1, args=["--status-code", "500"]) # always fail
rh = router_manager.start_router(
worker_urls=[wurl],
policy="round_robin",
extra={
"cb_failure_threshold": 2,
"cb_success_threshold": 2,
"cb_timeout_duration_secs": 2,
"cb_window_duration_secs": 5,
"disable_retries": True,
},
)
def post_once():
return requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "x",
"max_tokens": 1,
"stream": False,
},
timeout=3,
)
# should see 500 when worker actually starts, before that should see 503
saw_500 = False
for _ in range(8):
r = post_once()
if r.status_code == 500:
# Worker starts, continue to circuit breaker test
saw_500 = True
break
assert (
r.status_code == 503
), "Should only see 503 when waiting for worker to start"
assert saw_500, "Worker didn't start after 8 requests"
opened = False
for _ in range(8):
r = post_once()
if r.status_code == 503:
opened = True
break
assert opened, "circuit breaker did not open"
time.sleep(3)
r = post_once()
assert r.status_code == 500
r2 = post_once()
assert r2.status_code == 503
@pytest.mark.integration
def test_circuit_breaker_disable_flag(router_manager, mock_workers):
_, [wurl], _ = mock_workers(n=1, args=["--status-code", "500"]) # always fail
rh = router_manager.start_router(
worker_urls=[wurl],
policy="round_robin",
extra={
"disable_circuit_breaker": True,
"disable_retries": True,
},
)
saw_500 = False
for _ in range(8):
r = requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "x",
"max_tokens": 1,
"stream": False,
},
timeout=3,
)
if r.status_code == 500:
# Worker starts, continue to check
saw_500 = True
break
assert (
r.status_code == 503
), "Should only see 503 when waiting for worker to start"
assert saw_500
@pytest.mark.integration
def test_circuit_breaker_per_worker_isolation(router_manager, mock_workers):
_, [fail_url], _ = mock_workers(n=1, args=["--status-code", "500"]) # always fail
_, [ok_url], _ = mock_workers(n=1)
rh = router_manager.start_router(
worker_urls=[fail_url, ok_url],
policy="round_robin",
extra={
"cb_failure_threshold": 2,
"cb_success_threshold": 1,
"cb_timeout_duration_secs": 2,
"cb_window_duration_secs": 10,
"disable_retries": True,
},
)
def post_once():
return requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "y",
"max_tokens": 1,
"stream": False,
},
timeout=3,
)
failures = 0
successes_after_open = 0
opened = False
for _ in range(30):
r = post_once()
if not opened:
if r.status_code == 500:
failures += 1
if failures >= 2:
_ = post_once()
_ = post_once()
opened = True
else:
if r.status_code == 200:
successes_after_open += 1
else:
assert False, f"Unexpected non-200 after CB open: {r.status_code}"
assert opened and successes_after_open >= 5
@pytest.mark.integration
def test_circuit_breaker_with_retries(router_manager, mock_workers):
_, [fail_url], _ = mock_workers(n=1, args=["--status-code", "500"]) # always fail
_, [ok_url], _ = mock_workers(n=1)
rh = router_manager.start_router(
worker_urls=[fail_url, ok_url],
policy="round_robin",
extra={
"retry_max_retries": 3,
"retry_initial_backoff_ms": 10,
"retry_max_backoff_ms": 50,
"cb_failure_threshold": 2,
"cb_success_threshold": 1,
"cb_timeout_duration_secs": 2,
"cb_window_duration_secs": 10,
},
)
r = requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "z",
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
assert r.status_code == 200
@@ -0,0 +1,32 @@
import pytest
import requests
@pytest.mark.integration
def test_worker_crash_reroute_with_retries(router_manager, mock_workers):
# Start one healthy and one that will crash on first request
_, [ok_url], _ = mock_workers(n=1)
_, [crash_url], _ = mock_workers(n=1, args=["--crash-on-request"])
rh = router_manager.start_router(
worker_urls=[crash_url, ok_url],
policy="round_robin",
extra={
"retry_max_retries": 3,
"retry_initial_backoff_ms": 10,
"retry_max_backoff_ms": 50,
},
)
# A single request should succeed via retry to the healthy worker
r = requests.post(
f"{rh.url}/v1/completions",
json={
"model": "test-model",
"prompt": "crash",
"max_tokens": 1,
"stream": False,
},
timeout=5,
)
assert r.status_code == 200
# mock_workers fixture handles cleanup

Some files were not shown because too many files have changed in this diff Show More