Skip to content

Commit e100b62

Browse files
Copilotrootfs
authored andcommitted
Fix API silent failures and add OpenAPI 3.0 spec with Swagger UI (vllm-project#326)
* Initial plan * Add task_type validation and API discovery endpoint - Add validateTaskType helper function to validate task_type parameter - Reject invalid task_type values with 400 error and helpful message - Add GET /api/v1 endpoint for API discovery - Return comprehensive API overview with endpoints, task_types, and links - Add tests for invalid task_type values (jailbreak, invalid_type) - Add tests for valid task_types (intent, pii, security, all) - Add test for API overview endpoint Co-authored-by: rootfs <[email protected]> * Refactor API discovery to use centralized registry pattern - Replace hardcoded endpoint list with endpointRegistry - Replace hardcoded task types with taskTypeRegistry - Generate API documentation dynamically from registries - Add filtering logic for system prompt endpoints - Add test for system prompt endpoint filtering - Enables future OpenAPI spec generation from registry - Makes API documentation easier to maintain and extend Co-authored-by: rootfs <[email protected]> * Add OpenAPI 3.0 spec generation and Swagger UI - Implement OpenAPI 3.0 specification structures - Add generateOpenAPISpec() to dynamically generate spec from registry - Add /openapi.json endpoint serving OpenAPI 3.0 spec - Add /docs endpoint serving interactive Swagger UI - Update endpoint registry to include new documentation endpoints - Add openapi_spec and swagger_ui links to API overview - Automatically filter system prompt endpoints in spec based on config - Add comprehensive tests for OpenAPI and Swagger UI endpoints - Tests verify spec structure, filtering, and UI rendering Co-authored-by: rootfs <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: rootfs <[email protected]> Signed-off-by: liuhy <[email protected]>
1 parent b02d081 commit e100b62

File tree

2 files changed

+698
-0
lines changed

2 files changed

+698
-0
lines changed

src/semantic-router/pkg/api/server.go

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ func (s *ClassificationAPIServer) setupRoutes() *http.ServeMux {
184184
// Health check endpoint
185185
mux.HandleFunc("GET /health", s.handleHealth)
186186

187+
// API discovery endpoint
188+
mux.HandleFunc("GET /api/v1", s.handleAPIOverview)
189+
190+
// OpenAPI and documentation endpoints
191+
mux.HandleFunc("GET /openapi.json", s.handleOpenAPISpec)
192+
mux.HandleFunc("GET /docs", s.handleSwaggerUI)
193+
187194
// Classification endpoints
188195
mux.HandleFunc("POST /api/v1/classify/intent", s.handleIntentClassification)
189196
mux.HandleFunc("POST /api/v1/classify/pii", s.handlePIIDetection)
@@ -224,6 +231,323 @@ func (s *ClassificationAPIServer) handleHealth(w http.ResponseWriter, r *http.Re
224231
w.Write([]byte(`{"status": "healthy", "service": "classification-api"}`))
225232
}
226233

234+
// APIOverviewResponse represents the response for GET /api/v1
235+
type APIOverviewResponse struct {
236+
Service string `json:"service"`
237+
Version string `json:"version"`
238+
Description string `json:"description"`
239+
Endpoints []EndpointInfo `json:"endpoints"`
240+
TaskTypes []TaskTypeInfo `json:"task_types"`
241+
Links map[string]string `json:"links"`
242+
}
243+
244+
// EndpointInfo represents information about an API endpoint
245+
type EndpointInfo struct {
246+
Path string `json:"path"`
247+
Method string `json:"method"`
248+
Description string `json:"description"`
249+
}
250+
251+
// TaskTypeInfo represents information about a task type
252+
type TaskTypeInfo struct {
253+
Name string `json:"name"`
254+
Description string `json:"description"`
255+
}
256+
257+
// EndpointMetadata stores metadata about an endpoint for API documentation
258+
type EndpointMetadata struct {
259+
Path string
260+
Method string
261+
Description string
262+
}
263+
264+
// endpointRegistry is a centralized registry of all API endpoints with their metadata
265+
var endpointRegistry = []EndpointMetadata{
266+
{Path: "/health", Method: "GET", Description: "Health check endpoint"},
267+
{Path: "/api/v1", Method: "GET", Description: "API discovery and documentation"},
268+
{Path: "/openapi.json", Method: "GET", Description: "OpenAPI 3.0 specification"},
269+
{Path: "/docs", Method: "GET", Description: "Interactive Swagger UI documentation"},
270+
{Path: "/api/v1/classify/intent", Method: "POST", Description: "Classify user queries into routing categories"},
271+
{Path: "/api/v1/classify/pii", Method: "POST", Description: "Detect personally identifiable information in text"},
272+
{Path: "/api/v1/classify/security", Method: "POST", Description: "Detect jailbreak attempts and security threats"},
273+
{Path: "/api/v1/classify/combined", Method: "POST", Description: "Perform combined classification (intent, PII, and security)"},
274+
{Path: "/api/v1/classify/batch", Method: "POST", Description: "Batch classification with configurable task_type parameter"},
275+
{Path: "/info/models", Method: "GET", Description: "Get information about loaded models"},
276+
{Path: "/info/classifier", Method: "GET", Description: "Get classifier information and status"},
277+
{Path: "/v1/models", Method: "GET", Description: "OpenAI-compatible model listing"},
278+
{Path: "/metrics/classification", Method: "GET", Description: "Get classification metrics and statistics"},
279+
{Path: "/config/classification", Method: "GET", Description: "Get classification configuration"},
280+
{Path: "/config/classification", Method: "PUT", Description: "Update classification configuration"},
281+
{Path: "/config/system-prompts", Method: "GET", Description: "Get system prompt configuration (requires explicit enablement)"},
282+
{Path: "/config/system-prompts", Method: "PUT", Description: "Update system prompt configuration (requires explicit enablement)"},
283+
}
284+
285+
// taskTypeRegistry is a centralized registry of all supported task types
286+
var taskTypeRegistry = []TaskTypeInfo{
287+
{Name: "intent", Description: "Intent/category classification (default for batch endpoint)"},
288+
{Name: "pii", Description: "Personally Identifiable Information detection"},
289+
{Name: "security", Description: "Jailbreak and security threat detection"},
290+
{Name: "all", Description: "All classification types combined"},
291+
}
292+
293+
// OpenAPI 3.0 spec structures
294+
295+
// OpenAPISpec represents an OpenAPI 3.0 specification
296+
type OpenAPISpec struct {
297+
OpenAPI string `json:"openapi"`
298+
Info OpenAPIInfo `json:"info"`
299+
Servers []OpenAPIServer `json:"servers"`
300+
Paths map[string]OpenAPIPath `json:"paths"`
301+
Components OpenAPIComponents `json:"components,omitempty"`
302+
}
303+
304+
// OpenAPIInfo contains API metadata
305+
type OpenAPIInfo struct {
306+
Title string `json:"title"`
307+
Description string `json:"description"`
308+
Version string `json:"version"`
309+
}
310+
311+
// OpenAPIServer describes a server
312+
type OpenAPIServer struct {
313+
URL string `json:"url"`
314+
Description string `json:"description"`
315+
}
316+
317+
// OpenAPIPath represents operations for a path
318+
type OpenAPIPath struct {
319+
Get *OpenAPIOperation `json:"get,omitempty"`
320+
Post *OpenAPIOperation `json:"post,omitempty"`
321+
Put *OpenAPIOperation `json:"put,omitempty"`
322+
Delete *OpenAPIOperation `json:"delete,omitempty"`
323+
}
324+
325+
// OpenAPIOperation describes an API operation
326+
type OpenAPIOperation struct {
327+
Summary string `json:"summary"`
328+
Description string `json:"description,omitempty"`
329+
OperationID string `json:"operationId,omitempty"`
330+
Responses map[string]OpenAPIResponse `json:"responses"`
331+
RequestBody *OpenAPIRequestBody `json:"requestBody,omitempty"`
332+
}
333+
334+
// OpenAPIResponse describes a response
335+
type OpenAPIResponse struct {
336+
Description string `json:"description"`
337+
Content map[string]OpenAPIMedia `json:"content,omitempty"`
338+
}
339+
340+
// OpenAPIRequestBody describes a request body
341+
type OpenAPIRequestBody struct {
342+
Description string `json:"description,omitempty"`
343+
Required bool `json:"required,omitempty"`
344+
Content map[string]OpenAPIMedia `json:"content"`
345+
}
346+
347+
// OpenAPIMedia describes media type content
348+
type OpenAPIMedia struct {
349+
Schema *OpenAPISchema `json:"schema,omitempty"`
350+
}
351+
352+
// OpenAPISchema describes a schema
353+
type OpenAPISchema struct {
354+
Type string `json:"type,omitempty"`
355+
Properties map[string]OpenAPISchema `json:"properties,omitempty"`
356+
Items *OpenAPISchema `json:"items,omitempty"`
357+
Ref string `json:"$ref,omitempty"`
358+
}
359+
360+
// OpenAPIComponents contains reusable components
361+
type OpenAPIComponents struct {
362+
Schemas map[string]OpenAPISchema `json:"schemas,omitempty"`
363+
}
364+
365+
// handleAPIOverview handles GET /api/v1 for API discovery
366+
func (s *ClassificationAPIServer) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
367+
// Build endpoints list from registry, filtering out disabled endpoints
368+
endpoints := make([]EndpointInfo, 0, len(endpointRegistry))
369+
for _, metadata := range endpointRegistry {
370+
// Filter out system prompt endpoints if they are disabled
371+
if !s.enableSystemPromptAPI && (metadata.Path == "/config/system-prompts") {
372+
continue
373+
}
374+
endpoints = append(endpoints, EndpointInfo{
375+
Path: metadata.Path,
376+
Method: metadata.Method,
377+
Description: metadata.Description,
378+
})
379+
}
380+
381+
response := APIOverviewResponse{
382+
Service: "Semantic Router Classification API",
383+
Version: "v1",
384+
Description: "API for intent classification, PII detection, and security analysis",
385+
Endpoints: endpoints,
386+
TaskTypes: taskTypeRegistry,
387+
Links: map[string]string{
388+
"documentation": "https://vllm-project.github.io/semantic-router/",
389+
"openapi_spec": "/openapi.json",
390+
"swagger_ui": "/docs",
391+
"models_info": "/info/models",
392+
"health": "/health",
393+
},
394+
}
395+
396+
s.writeJSONResponse(w, http.StatusOK, response)
397+
}
398+
399+
// generateOpenAPISpec generates an OpenAPI 3.0 specification from the endpoint registry
400+
func (s *ClassificationAPIServer) generateOpenAPISpec() OpenAPISpec {
401+
spec := OpenAPISpec{
402+
OpenAPI: "3.0.0",
403+
Info: OpenAPIInfo{
404+
Title: "Semantic Router Classification API",
405+
Description: "API for intent classification, PII detection, and security analysis",
406+
Version: "v1",
407+
},
408+
Servers: []OpenAPIServer{
409+
{
410+
URL: "/",
411+
Description: "Classification API Server",
412+
},
413+
},
414+
Paths: make(map[string]OpenAPIPath),
415+
}
416+
417+
// Generate paths from endpoint registry
418+
for _, endpoint := range endpointRegistry {
419+
// Filter out system prompt endpoints if they are disabled
420+
if !s.enableSystemPromptAPI && endpoint.Path == "/config/system-prompts" {
421+
continue
422+
}
423+
424+
path, ok := spec.Paths[endpoint.Path]
425+
if !ok {
426+
path = OpenAPIPath{}
427+
}
428+
429+
operation := &OpenAPIOperation{
430+
Summary: endpoint.Description,
431+
Description: endpoint.Description,
432+
OperationID: fmt.Sprintf("%s_%s", endpoint.Method, endpoint.Path),
433+
Responses: map[string]OpenAPIResponse{
434+
"200": {
435+
Description: "Successful response",
436+
Content: map[string]OpenAPIMedia{
437+
"application/json": {
438+
Schema: &OpenAPISchema{
439+
Type: "object",
440+
},
441+
},
442+
},
443+
},
444+
"400": {
445+
Description: "Bad request",
446+
Content: map[string]OpenAPIMedia{
447+
"application/json": {
448+
Schema: &OpenAPISchema{
449+
Type: "object",
450+
Properties: map[string]OpenAPISchema{
451+
"error": {
452+
Type: "object",
453+
Properties: map[string]OpenAPISchema{
454+
"code": {Type: "string"},
455+
"message": {Type: "string"},
456+
"timestamp": {Type: "string"},
457+
},
458+
},
459+
},
460+
},
461+
},
462+
},
463+
},
464+
},
465+
}
466+
467+
// Add request body for POST and PUT methods
468+
if endpoint.Method == "POST" || endpoint.Method == "PUT" {
469+
operation.RequestBody = &OpenAPIRequestBody{
470+
Required: true,
471+
Content: map[string]OpenAPIMedia{
472+
"application/json": {
473+
Schema: &OpenAPISchema{
474+
Type: "object",
475+
},
476+
},
477+
},
478+
}
479+
}
480+
481+
// Map operation to the appropriate method
482+
switch endpoint.Method {
483+
case "GET":
484+
path.Get = operation
485+
case "POST":
486+
path.Post = operation
487+
case "PUT":
488+
path.Put = operation
489+
case "DELETE":
490+
path.Delete = operation
491+
}
492+
493+
spec.Paths[endpoint.Path] = path
494+
}
495+
496+
return spec
497+
}
498+
499+
// handleOpenAPISpec serves the OpenAPI 3.0 specification at /openapi.json
500+
func (s *ClassificationAPIServer) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
501+
spec := s.generateOpenAPISpec()
502+
s.writeJSONResponse(w, http.StatusOK, spec)
503+
}
504+
505+
// handleSwaggerUI serves the Swagger UI at /docs
506+
func (s *ClassificationAPIServer) handleSwaggerUI(w http.ResponseWriter, r *http.Request) {
507+
// Serve a simple HTML page that loads Swagger UI from CDN
508+
html := `<!DOCTYPE html>
509+
<html lang="en">
510+
<head>
511+
<meta charset="UTF-8">
512+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
513+
<title>Semantic Router API Documentation</title>
514+
<link rel="stylesheet" type="text/css" href="https://unpkg.com/[email protected]/swagger-ui.css">
515+
<style>
516+
body {
517+
margin: 0;
518+
padding: 0;
519+
}
520+
</style>
521+
</head>
522+
<body>
523+
<div id="swagger-ui"></div>
524+
<script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script>
525+
<script src="https://unpkg.com/[email protected]/swagger-ui-standalone-preset.js"></script>
526+
<script>
527+
window.onload = function() {
528+
window.ui = SwaggerUIBundle({
529+
url: "/openapi.json",
530+
dom_id: '#swagger-ui',
531+
deepLinking: true,
532+
presets: [
533+
SwaggerUIBundle.presets.apis,
534+
SwaggerUIStandalonePreset
535+
],
536+
plugins: [
537+
SwaggerUIBundle.plugins.DownloadUrl
538+
],
539+
layout: "StandaloneLayout"
540+
});
541+
};
542+
</script>
543+
</body>
544+
</html>`
545+
546+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
547+
w.WriteHeader(http.StatusOK)
548+
w.Write([]byte(html))
549+
}
550+
227551
// handleIntentClassification handles intent classification requests
228552
func (s *ClassificationAPIServer) handleIntentClassification(w http.ResponseWriter, r *http.Request) {
229553
var req services.IntentRequest
@@ -335,6 +659,13 @@ func (s *ClassificationAPIServer) handleBatchClassification(w http.ResponseWrite
335659
return
336660
}
337661

662+
// Validate task_type if provided
663+
if err := validateTaskType(req.TaskType); err != nil {
664+
metrics.RecordBatchClassificationError("unified", "invalid_task_type")
665+
s.writeErrorResponse(w, http.StatusBadRequest, "INVALID_TASK_TYPE", err.Error())
666+
return
667+
}
668+
338669
// Record the number of texts being processed
339670
metrics.RecordBatchClassificationTexts("unified", len(req.Texts))
340671

@@ -622,6 +953,24 @@ func (s *ClassificationAPIServer) getSystemInfo() SystemInfo {
622953
}
623954
}
624955

956+
// validateTaskType validates the task_type parameter for batch classification
957+
// Returns an error if the task_type is invalid, nil if valid or empty
958+
func validateTaskType(taskType string) error {
959+
// Empty task_type defaults to "intent", so it's valid
960+
if taskType == "" {
961+
return nil
962+
}
963+
964+
validTaskTypes := []string{"intent", "pii", "security", "all"}
965+
for _, valid := range validTaskTypes {
966+
if taskType == valid {
967+
return nil
968+
}
969+
}
970+
971+
return fmt.Errorf("invalid task_type '%s'. Supported values: %v", taskType, validTaskTypes)
972+
}
973+
625974
// extractRequestedResults converts unified results to batch format based on task type
626975
func (s *ClassificationAPIServer) extractRequestedResults(unifiedResults *services.UnifiedBatchResponse, taskType string, options *ClassificationOptions) []BatchClassificationResult {
627976
// Determine the correct batch size based on task type

0 commit comments

Comments
 (0)