package httpapi import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strings" "time" "aigw/internal/apierror" "aigw/internal/auth" "aigw/internal/catalog" "aigw/internal/domain" "aigw/internal/provider" "aigw/internal/routing" "aigw/internal/telemetry" "aigw/internal/usage" ) type requestIDKey struct{} type API struct { authenticator auth.Authenticator catalog *catalog.Catalog router *routing.Router forwarder *provider.Forwarder usageSink telemetry.UsageSink metrics *telemetry.Metrics logger *slog.Logger maxBodyBytes int64 exposeMetrics bool } type Options struct { Authenticator auth.Authenticator Catalog *catalog.Catalog Router *routing.Router Forwarder *provider.Forwarder UsageSink telemetry.UsageSink Metrics *telemetry.Metrics Logger *slog.Logger MaxBodyBytes int64 ExposeMetrics bool } func New(options Options) *API { return &API{ authenticator: options.Authenticator, catalog: options.Catalog, router: options.Router, forwarder: options.Forwarder, usageSink: options.UsageSink, metrics: options.Metrics, logger: options.Logger, maxBodyBytes: options.MaxBodyBytes, exposeMetrics: options.ExposeMetrics, } } func (a *API) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", a.health) mux.HandleFunc("GET /readyz", a.health) if a.exposeMetrics { mux.Handle("GET /metrics", a.metrics) } mux.HandleFunc("GET /v1/models", a.openAIModels) mux.HandleFunc("GET /api/v1/models", a.openAIModels) mux.HandleFunc("POST /v1/chat/completions", a.openAIChat) mux.HandleFunc("POST /api/v1/chat/completions", a.openAIChat) mux.HandleFunc("GET /anthropic/v1/models", a.anthropicModels) mux.HandleFunc("GET /api/anthropic/v1/models", a.anthropicModels) mux.HandleFunc("POST /anthropic/v1/messages", a.anthropicMessages) mux.HandleFunc("POST /api/anthropic/v1/messages", a.anthropicMessages) return a.withRequestID(a.recoverPanics(mux)) } func (a *API) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok"}`+"\n") } func (a *API) openAIChat(w http.ResponseWriter, r *http.Request) { a.serveInference(w, r, domain.ProtocolOpenAI) } func (a *API) anthropicMessages(w http.ResponseWriter, r *http.Request) { a.serveInference(w, r, domain.ProtocolAnthropic) } func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol domain.Protocol) { requestID := requestIDFrom(r.Context()) startedAt := time.Now().UTC() a.metrics.RequestStarted() success := false defer func() { a.metrics.RequestFinished(success) }() principal, authErr := a.authenticator.Authenticate(r) if authErr != nil { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "access_denied", Message: "Invalid or missing API key"}, requestID) return } if !hasScope(principal, "inference") { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "access_denied", Message: "API key does not have inference permission"}, requestID) return } body, err := readBody(w, r, a.maxBodyBytes) if err != nil { status := http.StatusBadRequest message := "Invalid request body" if errors.As(err, new(*http.MaxBytesError)) { status = http.StatusRequestEntityTooLarge message = "Request body is too large" } apierror.Write(w, apierror.Error{Status: status, Type: "invalid_params", Message: message}, requestID) return } var envelope struct { Model string `json:"model"` Stream bool `json:"stream"` } if err := json.Unmarshal(body, &envelope); err != nil || strings.TrimSpace(envelope.Model) == "" { apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_params", Message: "Parameter model is required and the body must be valid JSON"}, requestID) return } routes, err := a.router.Plan(envelope.Model, protocol) if err != nil { if errors.Is(err, routing.ErrNoRoute) { apierror.Write(w, apierror.Error{Status: http.StatusNotFound, Type: "model_not_supported", Message: "Model does not support this API protocol"}, requestID) } else { apierror.Write(w, apierror.Error{Status: http.StatusNotFound, Type: "invalid_model", Message: "Model does not exist"}, requestID) } return } result, err := a.forwarder.Forward(r.Context(), protocol, requestID, body, r.Header, routes) if err != nil { errorType := "no_provider_available" status := http.StatusBadGateway if errors.Is(err, context.Canceled) { errorType = "client_disconnected" status = 499 } else { apierror.Write(w, apierror.Error{Status: status, Type: errorType, Message: "No upstream provider is currently available"}, requestID) } a.publishUsage(domain.UsageEvent{ RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID, PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: status, Success: false, ErrorType: errorType, Attempts: result.Attempts, StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds(), }) return } defer result.Response.Body.Close() if result.Response.StatusCode >= 400 { gatewayError := normalizeProviderError(result.Response) apierror.Write(w, gatewayError, requestID) a.publishUsage(domain.UsageEvent{ RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID, PublicModel: envelope.Model, ProviderID: result.Route.Provider.ID, UpstreamModel: result.Route.UpstreamModel, Protocol: protocol, Stream: envelope.Stream, StatusCode: gatewayError.Status, Success: false, ErrorType: gatewayError.Type, Attempts: result.Attempts, StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds(), }) return } stream := envelope.Stream || strings.HasPrefix(strings.ToLower(result.Response.Header.Get("Content-Type")), "text/event-stream") copyResponseHeaders(w.Header(), result.Response.Header, stream) w.Header().Set("X-AIGW-Request-ID", requestID) if stream { w.Header().Set("X-Accel-Buffering", "no") } w.WriteHeader(result.Response.StatusCode) observer := usage.NewObserver(protocol, stream) copyErr := copyResponse(w, result.Response.Body, observer, stream) usageResult := observer.Usage() success = copyErr == nil errorType := "" if copyErr != nil { errorType = "stream_interrupted" } a.publishUsage(domain.UsageEvent{ RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID, PublicModel: envelope.Model, ProviderID: result.Route.Provider.ID, UpstreamModel: result.Route.UpstreamModel, Protocol: protocol, Stream: stream, StatusCode: result.Response.StatusCode, Success: success, ErrorType: errorType, Attempts: result.Attempts, StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds(), Usage: usageResult, }) a.logger.Info("inference_request", "request_id", requestID, "tenant_id", principal.TenantID, "project_id", principal.ProjectID, "model", envelope.Model, "provider", result.Route.Provider.ID, "status", result.Response.StatusCode, "attempts", result.Attempts, "duration_ms", time.Since(startedAt).Milliseconds(), ) } func (a *API) openAIModels(w http.ResponseWriter, r *http.Request) { if !a.authorize(w, r) { return } models := a.catalog.Models(domain.ProtocolOpenAI) data := make([]map[string]any, 0, len(models)) for _, model := range models { data = append(data, map[string]any{"id": model.ID, "object": "model", "created": 0, "owned_by": model.OwnedBy}) } writeJSON(w, map[string]any{"object": "list", "data": data}) } func (a *API) anthropicModels(w http.ResponseWriter, r *http.Request) { if !a.authorize(w, r) { return } models := a.catalog.Models(domain.ProtocolAnthropic) data := make([]map[string]any, 0, len(models)) for _, model := range models { data = append(data, map[string]any{"id": model.ID, "display_name": model.ID, "created_at": "1970-01-01T00:00:00Z", "type": "model"}) } response := map[string]any{"data": data, "has_more": false, "first_id": nil, "last_id": nil} if len(models) > 0 { response["first_id"] = models[0].ID response["last_id"] = models[len(models)-1].ID } writeJSON(w, response) } func (a *API) authorize(w http.ResponseWriter, r *http.Request) bool { principal, err := a.authenticator.Authenticate(r) if err != nil || !hasScope(principal, "inference") { apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "access_denied", Message: "Invalid API key or insufficient permission"}, requestIDFrom(r.Context())) return false } return true } func (a *API) publishUsage(event domain.UsageEvent) { if a.usageSink != nil { a.usageSink.Publish(event) } } func (a *API) withRequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := newRequestID() w.Header().Set("X-AIGW-Request-ID", id) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id))) }) } func (a *API) recoverPanics(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if recovered := recover(); recovered != nil { a.logger.Error("request_panic", "request_id", requestIDFrom(r.Context()), "error", recovered) apierror.Write(w, apierror.Error{Status: http.StatusInternalServerError, Type: "internal_server_error", Message: "Internal server error"}, requestIDFrom(r.Context())) } }() next.ServeHTTP(w, r) }) } func newRequestID() string { var value [16]byte if _, err := rand.Read(value[:]); err != nil { return fmt.Sprintf("req_%d", time.Now().UnixNano()) } return "req_" + hex.EncodeToString(value[:]) } func requestIDFrom(ctx context.Context) string { id, _ := ctx.Value(requestIDKey{}).(string) return id } func hasScope(principal domain.Principal, wanted string) bool { if len(principal.Scopes) == 0 { return true } for _, scope := range principal.Scopes { if scope == wanted || scope == "*" { return true } } return false } func readBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) { r.Body = http.MaxBytesReader(w, r.Body, limit) defer r.Body.Close() return io.ReadAll(r.Body) } func copyResponseHeaders(target, source http.Header, stream bool) { for _, name := range []string{"Content-Type", "Cache-Control", "Retry-After"} { if value := source.Get(name); value != "" { target.Set(name, value) } } if !stream { if value := source.Get("Content-Length"); value != "" { target.Set("Content-Length", value) } } } func copyResponse(w http.ResponseWriter, body io.Reader, observer io.Writer, stream bool) error { var destination io.Writer = w if stream { destination = &flushingWriter{writer: w, controller: http.NewResponseController(w)} } _, err := io.CopyBuffer(io.MultiWriter(destination, observer), body, make([]byte, 32<<10)) return err } type flushingWriter struct { writer io.Writer controller *http.ResponseController } func (w *flushingWriter) Write(p []byte) (int, error) { n, err := w.writer.Write(p) if err == nil { _ = w.controller.Flush() } return n, err } func normalizeProviderError(response *http.Response) apierror.Error { payload, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) message := "Upstream provider rejected the request" var common struct { Error struct { Message string `json:"message"` } `json:"error"` } if json.Unmarshal(payload, &common) == nil && common.Error.Message != "" { message = common.Error.Message } switch response.StatusCode { case http.StatusBadRequest: return apierror.Error{Status: http.StatusUnprocessableEntity, Type: "provider_unprocessable_entity_error", Message: message} case http.StatusRequestEntityTooLarge: return apierror.Error{Status: http.StatusRequestEntityTooLarge, Type: "invalid_params", Message: message} case http.StatusTooManyRequests: return apierror.Error{Status: http.StatusTooManyRequests, Type: "rate_limit", Message: message} default: return apierror.Error{Status: http.StatusBadGateway, Type: "provider_error", Message: message} } } func writeJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(value) }