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:
Story Crater Bot
2026-08-19 23:49:11 -07:00
parent fd45c2c0d3
commit a8dfd5b2f0
8 changed files with 1596 additions and 26 deletions
+142
View File
@@ -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 {