Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions coordinator/api/billing_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ func (s *Server) handleAdminPricing(w http.ResponseWriter, r *http.Request) {
return
}
if req.Model == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required"))
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required", withParam("model")))
return
}
if req.InputPrice <= 0 || req.OutputPrice <= 0 {
Expand Down Expand Up @@ -438,7 +438,7 @@ func (s *Server) handleSetPricing(w http.ResponseWriter, r *http.Request) {
return
}
if req.Model == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required"))
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required", withParam("model")))
return
}
if req.InputPrice <= 0 || req.OutputPrice <= 0 {
Expand Down Expand Up @@ -477,7 +477,7 @@ func (s *Server) handleDeletePricing(w http.ResponseWriter, r *http.Request) {
return
}
if req.Model == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required"))
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required", withParam("model")))
return
}

Expand Down
46 changes: 35 additions & 11 deletions coordinator/api/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {

model, _ := parsed["model"].(string)
if model == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required"))
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required", withParam("model")))
return
}

Expand Down Expand Up @@ -597,7 +597,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
reservedMicroUSD = s.reservationCost(model, estimatedPromptTokens, requestedMaxTokens)
if err := s.ledger.Charge(consumerKey, reservedMicroUSD, "reserve:"+consumerKey); err != nil {
writeJSON(w, http.StatusPaymentRequired, errorResponse("insufficient_funds",
"your balance is too low for this request — add funds at /billing or lower max_tokens"))
"your balance is too low for this request — add funds at /billing or lower max_tokens", withCode("insufficient_quota")))
return
}
}
Expand All @@ -615,7 +615,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if !s.registry.IsModelInCatalog(model) {
refundReservation()
writeJSON(w, http.StatusNotFound, errorResponse("model_not_found",
fmt.Sprintf("model %q is not available — see /v1/models for supported models", model)))
fmt.Sprintf("model %q is not available — see /v1/models for supported models", model), withParam("model")))
return
}

Expand Down Expand Up @@ -2419,12 +2419,12 @@ func (s *Server) handleGenericInference(w http.ResponseWriter, r *http.Request,

model, _ := parsed["model"].(string)
if model == "" {
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required"))
writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "model is required", withParam("model")))
return
}
if !s.registry.IsModelInCatalog(model) {
writeJSON(w, http.StatusNotFound, errorResponse("model_not_found",
fmt.Sprintf("model %q is not available — see /v1/models for supported models", model)))
fmt.Sprintf("model %q is not available — see /v1/models for supported models", model), withParam("model")))
return
}

Expand Down Expand Up @@ -2459,7 +2459,7 @@ func (s *Server) handleGenericInference(w http.ResponseWriter, r *http.Request,
reservedMicroUSD = s.reservationCost(model, estimatedPromptTokens, requestedMaxTokens)
if err := s.ledger.Charge(consumerKey, reservedMicroUSD, "reserve:"+consumerKey); err != nil {
writeJSON(w, http.StatusPaymentRequired, errorResponse("insufficient_funds",
"your balance is too low for this request — add funds at /billing or lower max_tokens"))
"your balance is too low for this request — add funds at /billing or lower max_tokens", withCode("insufficient_quota")))
return
}
}
Expand Down Expand Up @@ -2599,12 +2599,36 @@ func (s *Server) handleGenericInference(w http.ResponseWriter, r *http.Request,
}
}

// errorDetailOpt carries optional fields for OpenAI-compatible error responses.
type errorDetailOpt struct {
param string // e.g. "model", "max_tokens"
code string // e.g. "model_not_found", "insufficient_quota"
}

// errorResponse builds a standard OpenAI-compatible error response body.
func errorResponse(errType, message string) map[string]any {
// By default, code is inferred from errType. Callers can override code or
// set param via withParam / withCode helpers.
func errorResponse(errType, message string, opts ...errorDetailOpt) map[string]any {
detail := map[string]any{
"type": errType,
"message": message,
"code": errType, // default: code mirrors type
}
for _, o := range opts {
if o.param != "" {
detail["param"] = o.param
}
if o.code != "" {
detail["code"] = o.code
}
}
return map[string]any{
"error": map[string]any{
"type": errType,
"message": message,
},
"error": detail,
}
}

// withParam returns an option that sets the "param" field on an error response.
func withParam(p string) errorDetailOpt { return errorDetailOpt{param: p} }

// withCode returns an option that overrides the "code" field on an error response.
func withCode(c string) errorDetailOpt { return errorDetailOpt{code: c} }
10 changes: 9 additions & 1 deletion coordinator/api/edge_case_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ func TestEdge_ReleaseRegisterAndRetrieve(t *testing.T) {
func TestEdge_ErrorResponseFormat(t *testing.T) {
srv, _ := testServer(t)

// Send invalid request to trigger error
// Send invalid request to trigger error (empty model triggers "model is required")
body := `{"model":"","messages":[]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer test-key")
Expand All @@ -821,6 +821,8 @@ func TestEdge_ErrorResponseFormat(t *testing.T) {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code string `json:"code"`
Param string `json:"param"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil {
Expand All @@ -833,6 +835,12 @@ func TestEdge_ErrorResponseFormat(t *testing.T) {
if errResp.Error.Message == "" {
t.Error("error response missing 'message' field")
}
if errResp.Error.Code == "" {
t.Error("error response missing 'code' field — required by OpenAI spec for SDK error handling")
}
if errResp.Error.Param != "model" {
t.Errorf("error response param = %q, want %q", errResp.Error.Param, "model")
}
}

// ---------------------------------------------------------------------------
Expand Down
101 changes: 101 additions & 0 deletions coordinator/api/error_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package api

import (
"encoding/json"
"testing"
)

func TestErrorResponse_CodeField(t *testing.T) {
// errorResponse always sets code, defaulting to errType.
resp := errorResponse("invalid_request_error", "bad input")
detail := resp["error"].(map[string]any)

if code, _ := detail["code"].(string); code != "invalid_request_error" {
t.Errorf("default code = %q, want %q", code, "invalid_request_error")
}
if _, ok := detail["param"]; ok {
t.Error("param should be absent when not set")
}
}

func TestErrorResponse_WithCode(t *testing.T) {
resp := errorResponse("insufficient_funds", "low balance", withCode("insufficient_quota"))
detail := resp["error"].(map[string]any)

if code, _ := detail["code"].(string); code != "insufficient_quota" {
t.Errorf("code = %q, want %q", code, "insufficient_quota")
}
}

func TestErrorResponse_WithParam(t *testing.T) {
resp := errorResponse("invalid_request_error", "model is required", withParam("model"))
detail := resp["error"].(map[string]any)

if param, _ := detail["param"].(string); param != "model" {
t.Errorf("param = %q, want %q", param, "model")
}
}

func TestErrorResponse_WithCodeAndParam(t *testing.T) {
resp := errorResponse("model_not_found", "not found", withCode("model_not_found"), withParam("model"))
detail := resp["error"].(map[string]any)

if code, _ := detail["code"].(string); code != "model_not_found" {
t.Errorf("code = %q, want %q", code, "model_not_found")
}
if param, _ := detail["param"].(string); param != "model" {
t.Errorf("param = %q, want %q", param, "model")
}
}

func TestErrorResponse_JSONSerialization(t *testing.T) {
// Verify the output matches OpenAI error shape.
resp := errorResponse("invalid_request_error", "model is required", withParam("model"), withCode("invalid_request_error"))
b, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var parsed struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code string `json:"code"`
Param string `json:"param"`
} `json:"error"`
}
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}

if parsed.Error.Code != "invalid_request_error" {
t.Errorf("code = %q, want %q", parsed.Error.Code, "invalid_request_error")
}
if parsed.Error.Param != "model" {
t.Errorf("param = %q, want %q", parsed.Error.Param, "model")
}
}

func TestErrorResponse_CodeDefaultsToType(t *testing.T) {
// All existing call sites that don't pass withCode() should still get
// a code field that mirrors the type — this is the backward-compatible default.
resp := errorResponse("internal_error", "something broke")
detail := resp["error"].(map[string]any)

if code, _ := detail["code"].(string); code != "internal_error" {
t.Errorf("code = %q, want %q", code, "internal_error")
}
}

func TestErrorResponse_InsufficientFundsUsesCanonicalCode(t *testing.T) {
// insufficient_funds type must use the OpenAI-canonical code "insufficient_quota".
resp := errorResponse("insufficient_funds", "low balance", withCode("insufficient_quota"))
detail := resp["error"].(map[string]any)

if code, _ := detail["code"].(string); code != "insufficient_quota" {
t.Errorf("code = %q, want %q", code, "insufficient_quota")
}
if typ, _ := detail["type"].(string); typ != "insufficient_funds" {
t.Errorf("type = %q, want %q", typ, "insufficient_funds")
}
}
2 changes: 1 addition & 1 deletion coordinator/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,7 @@ func (s *Server) rateLimitWithTier(getLimiter func() *ratelimit.Limiter, tier st
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(retryAfter).Unix(), 10))
s.ddIncr("ratelimit.rejections", []string{"tier:" + tier})
writeJSON(w, http.StatusTooManyRequests, errorResponse("rate_limit_exceeded",
"too many requests — slow down and retry after the Retry-After interval"))
"too many requests — slow down and retry after the Retry-After interval", withCode("rate_limit_exceeded")))
return
}
next(w, r)
Expand Down
Loading