feat(phase2): complete openai api surfaces 2.3-2.7
- 2.3: unknown model errors (400 + RFC 9457 problem+json with valid_models) - 2.5: GET /v1/models endpoint (derived from config, not hardcoded) - 2.6: POST /v1/embeddings passthrough (body-based dispatch, no rewrite) - 2.7: POST /v1/rerank with path rewrite (/v1/rerank → /rerank) - wire proxy.Handler in main.go (was using dummy handler) - 140+ tests passing, race detector clean - all requests: client → nginx → gateway → upstreams - ready for config deployment to go live
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +39,26 @@ type Route struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// Error types for model validation
|
||||
type modelValidationError struct {
|
||||
Kind string // "invalid_json", "missing_model", "unknown_model"
|
||||
Message string
|
||||
Model string // only for unknown_model
|
||||
}
|
||||
|
||||
func (e *modelValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RFC 9457 Problem Details
|
||||
type problemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ValidModels []string `json:"valid_models,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new reverse proxy handler from configuration.
|
||||
// It sets up connection pooling and rewriting rules for each route.
|
||||
func New(cfg *config.Config) *Handler {
|
||||
@@ -174,10 +196,79 @@ func getPeerIP(remoteAddr string) string {
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// writeProblemDetail writes an RFC 9457 problem detail response.
|
||||
func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, detail string, validModels []string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
problem := problemDetail{
|
||||
Type: problemType,
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
ValidModels: validModels,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(problem)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle /v1/models endpoint (no routing needed, derived from config)
|
||||
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
||||
h.handleModelsEndpoint(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
|
||||
route, err := h.RouteRequest(r)
|
||||
|
||||
// Check if this is a model validation error (from body-based dispatch)
|
||||
if validationErr, ok := err.(*modelValidationError); ok {
|
||||
// This is a client error, not a routing error
|
||||
var status int
|
||||
var problemType string
|
||||
var title string
|
||||
var detail string
|
||||
|
||||
switch validationErr.Kind {
|
||||
case "invalid_json":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request-body"
|
||||
title = "Invalid Request Body"
|
||||
detail = validationErr.Message
|
||||
case "missing_model", "empty_model", "null_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/missing-model"
|
||||
title = "Missing Model"
|
||||
detail = "The 'model' field is required and must be a non-empty string"
|
||||
case "unknown_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/unknown-model"
|
||||
title = "Unknown Model"
|
||||
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request"
|
||||
title = "Invalid Request"
|
||||
detail = validationErr.Message
|
||||
}
|
||||
|
||||
// Get list of valid models (only for model-related errors)
|
||||
var validModels []string
|
||||
if validationErr.Kind == "unknown_model" || validationErr.Kind == "missing_model" || validationErr.Kind == "empty_model" || validationErr.Kind == "null_model" {
|
||||
validModels = h.getValidModels()
|
||||
}
|
||||
|
||||
writeProblemDetail(w, status, problemType, title, detail, validModels)
|
||||
logging.Errorf("client error", validationErr, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"reason": validationErr.Kind,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil || route == nil {
|
||||
// Route not found or error determining route
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -252,6 +343,57 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
|
||||
// getValidModels returns a sorted list of all configured model names.
|
||||
func (h *Handler) getValidModels() []string {
|
||||
var models []string
|
||||
for name := range h.config.Models {
|
||||
models = append(models, name)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models
|
||||
}
|
||||
|
||||
// modelsListResponse represents the response for GET /v1/models
|
||||
type modelsListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []modelsListEntry `json:"data"`
|
||||
}
|
||||
|
||||
// modelsListEntry represents a single model in the list
|
||||
type modelsListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// handleModelsEndpoint serves GET /v1/models
|
||||
// Returns a list of all configured models, derived from config not hardcoded
|
||||
func (h *Handler) handleModelsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all model names from config
|
||||
modelNames := h.getValidModels()
|
||||
|
||||
// Build the response
|
||||
data := make([]modelsListEntry, len(modelNames))
|
||||
for i, name := range modelNames {
|
||||
data[i] = modelsListEntry{
|
||||
ID: name,
|
||||
Object: "model",
|
||||
OwnedBy: "api.riotpiao.com",
|
||||
Created: 1700000000, // Fixed timestamp; can be made configurable if needed
|
||||
}
|
||||
}
|
||||
|
||||
response := modelsListResponse{
|
||||
Object: "list",
|
||||
Data: data,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Close closes all underlying transports, releasing their connection pools.
|
||||
func (h *Handler) Close() error {
|
||||
for _, transport := range h.transports {
|
||||
|
||||
Reference in New Issue
Block a user