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
11 changes: 11 additions & 0 deletions apps/edge-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,17 @@ func authSelectorMiddleware(jwtMW func(http.Handler) http.Handler, next http.Han
ShimKey: strings.TrimSpace(os.Getenv("OWUI_SHIM_KEY")),
})
selector = owuiUnwrap(selector)
// A real Anthropic SDK client (Anthropic(api_key=...), the default and
// documented construction) sends the credential on x-api-key, never
// Authorization. auth.Selector only inspects Authorization, and
// anthropic.APIKeyNormalizer used to be wired solely at the mux leaf
// (mux.Handle("/v1/messages", anthropic.APIKeyNormalizer(...))), which
// sits inside this middleware, not outside it: the selector above always
// ran first and never saw the header. Every x-api-key-only request fell
// through to the JWT path and 401'd regardless of key validity. Applying
// the same normalizer here, before the selector, fixes that for every
// /v1/* route (a no-op wherever Authorization is already set).
selector = anthropic.APIKeyNormalizer(selector)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/v1/") {
next.ServeHTTP(w, r)
Expand Down
85 changes: 85 additions & 0 deletions apps/edge-api/cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,91 @@ func (b *syncBuffer) String() string {
return b.buf.String()
}

// TestAuthSelectorMiddlewareAcceptsXAPIKeyHeader is the regression guard for the
// bug that blocked every real Anthropic SDK client from ever authenticating: a
// caller built with only base_url + api_key overridden sends "x-api-key",
// never "Authorization". anthropic.APIKeyNormalizer, which rewrites that
// header to "Authorization: Bearer", was wired only at the mux leaf
// (mux.Handle("/v1/messages", anthropic.APIKeyNormalizer(anthropicHandler))),
// but auth.Selector runs OUTSIDE the mux, in authSelectorMiddleware, and
// inspects Authorization before the leaf-level normalizer ever gets a chance
// to run. A request bearing only x-api-key therefore had no Authorization
// header at the point Selector decided which path to take, fell through to
// the JWT path unconditionally, and 401'd with "missing bearer" regardless of
// how valid the key was. This test drives authSelectorMiddleware directly, the
// same construction main() performs, with a jwtMW stand-in that always 401s so
// a pass can only happen by reaching the API-key path.
func TestAuthSelectorMiddlewareAcceptsXAPIKeyHeader(t *testing.T) {
t.Setenv("OWUI_SHIM_KEY", "")

var jwtInvoked, apiKeyInvoked bool
var sawAuthorization string
jwtMW := func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jwtInvoked = true
w.WriteHeader(http.StatusUnauthorized)
})
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKeyInvoked = true
sawAuthorization = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
})

handler := authSelectorMiddleware(jwtMW, next)

req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{}`))
req.Header.Set("x-api-key", "hk_live_test_key")
rr := httptest.NewRecorder()

handler.ServeHTTP(rr, req)

if jwtInvoked {
t.Errorf("an x-api-key request must never reach the JWT path")
}
if !apiKeyInvoked {
t.Fatalf("an x-api-key request must reach the API-key path")
}
if sawAuthorization != "Bearer hk_live_test_key" {
t.Errorf("x-api-key must be normalised to Authorization: Bearer before the API-key path runs, got %q", sawAuthorization)
}
if rr.Code != http.StatusOK {
t.Errorf("status: want 200 got %d", rr.Code)
}
}

// TestAuthSelectorMiddlewareStillRoutesJWTBearerToJWTPath pins the JWT half of
// the same selector, so the x-api-key normalisation added above cannot regress
// a session bearer token (never hk_-prefixed, never x-api-key) onto the
// API-key path.
func TestAuthSelectorMiddlewareStillRoutesJWTBearerToJWTPath(t *testing.T) {
t.Setenv("OWUI_SHIM_KEY", "")

var jwtInvoked, apiKeyInvoked bool
jwtMW := func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jwtInvoked = true
w.WriteHeader(http.StatusOK)
})
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKeyInvoked = true
w.WriteHeader(http.StatusOK)
})

handler := authSelectorMiddleware(jwtMW, next)

req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer some.jwt.token")
rr := httptest.NewRecorder()

handler.ServeHTTP(rr, req)

if !jwtInvoked || apiKeyInvoked {
t.Errorf("a non-hk_ Authorization bearer must route to the JWT path only, jwtInvoked=%v apiKeyInvoked=%v", jwtInvoked, apiKeyInvoked)
}
}

func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
Expand Down
118 changes: 118 additions & 0 deletions apps/edge-api/internal/anthropic/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package anthropic

import (
"encoding/json"
"net/http"

apierr "github.com/sakibsadmanshajib/hive/apps/edge-api/internal/errors"
)

// anthropicErrorEnvelope is the wire shape every real Anthropic Messages error
// response uses: a top-level "type":"error" plus a nested error object, e.g.
//
// {"type":"error","error":{"type":"authentication_error","message":"..."}}
//
// The rest of edge-api uses the OpenAI envelope ({"error":{"message","type",
// "param","code"}}, no top-level "type"), which this surface used
// unconditionally before this file existed. The Anthropic SDK's exception
// CLASS selection keys off HTTP status, not body shape, so that half kept
// working either way; but anthropic._exceptions.APIStatusError reads
// body["error"]["type"] into its own .type attribute, and the OpenAI envelope
// both lacks the wrapping "type":"error" and carries the wrong enum member
// there (e.g. "UNAUTHORIZED", which is not one of Anthropic's documented
// error types). A caller inspecting either field got nothing usable.
type anthropicErrorEnvelope struct {
Type string `json:"type"`
Error anthropicErrorBody `json:"error"`
}

type anthropicErrorBody struct {
Type string `json:"type"`
Message string `json:"message"`
// Code is a Hive extension carrying the finer-grained OpenAI-style error
// code where the delegated chain supplied one (e.g. "invalid_api_key").
// The real Anthropic API never sends this field; an Anthropic SDK ignores
// unknown JSON fields, so surfacing it here is additive, not a compliance
// risk, and keeps the detail available for anyone inspecting the raw body.
Code string `json:"code,omitempty"`
}

// anthropicErrorType maps an HTTP status to the closest member of Anthropic's
// documented error-type enum: invalid_request_error, authentication_error,
// permission_error, not_found_error, rate_limit_error, api_error,
// overloaded_error, request_too_large. Status is authoritative here, not
// whatever "type" string a delegated OpenAI-shaped body carried: status is
// what actually drives the real SDK's exception class, so it is the one
// value this surface must never get wrong.
func anthropicErrorType(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request_error"
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden:
return "permission_error"
case http.StatusNotFound:
return "not_found_error"
case http.StatusRequestEntityTooLarge:
return "request_too_large"
case http.StatusTooManyRequests:
return "rate_limit_error"
case 529: // Anthropic's own "overloaded" status; we do not emit it today.
return "overloaded_error"
default:
return "api_error"
}
}

// writeAnthropicError writes the Anthropic-shaped error envelope. code is the
// optional Hive-extension field (see anthropicErrorBody.Code) and may be "".
func writeAnthropicError(w http.ResponseWriter, status int, message string, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(anthropicErrorEnvelope{
Type: "error",
Error: anthropicErrorBody{
Type: anthropicErrorType(status),
Message: message,
Code: code,
},
})
}

// reshapeToAnthropicError re-emits an already-sanitized OpenAI-shaped error
// body (the delegated chat/inference chain's own refusal, which already ran
// through the provider-blind sanitizer at the upstream boundary) in
// Anthropic's envelope. message and code are extracted best-effort from raw;
// raw may be non-JSON, carry no error object, or be empty, in which case a
// generic message for the status is used instead. This never re-sanitizes:
// it only reshapes an envelope that was already safe to return.
func reshapeToAnthropicError(w http.ResponseWriter, status int, raw []byte) {
message, code := extractOpenAIErrorFields(raw)
if message == "" {
message = genericMessageForStatus(status)
}
writeAnthropicError(w, status, message, code)
}

func extractOpenAIErrorFields(raw []byte) (message string, code string) {
var body apierr.OpenAIError
if err := json.Unmarshal(raw, &body); err != nil {
return "", ""
}
if body.Error.Code != nil {
code = *body.Error.Code
}
return body.Error.Message, code
}

func genericMessageForStatus(status int) string {
switch status {
case http.StatusTooManyRequests:
return "the request was rate limited."
case http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusBadGateway:
return "the request could not be completed."
default:
return "the request failed."
}
}
Comment thread
sakibsadmanshajib marked this conversation as resolved.
33 changes: 16 additions & 17 deletions apps/edge-api/internal/anthropic/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/google/uuid"
"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/auth"
"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/authz"
apierr "github.com/sakibsadmanshajib/hive/apps/edge-api/internal/errors"
)

const maxBodyBytes = 4 << 20 // 4 MiB
Expand Down Expand Up @@ -51,7 +50,7 @@ func NewHandler(deps Deps) *Handler {
// ServeHTTP handles both POST /v1/messages and POST /v1/messages/count_tokens.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
apierr.WriteError(w, http.StatusMethodNotAllowed, "invalid_request_error", "Method not allowed", nil)
writeAnthropicError(w, http.StatusMethodNotAllowed, "Method not allowed", "")
return
}

Expand All @@ -70,43 +69,43 @@ func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) {
// for an API-key principal, which carries no session user.
if user, ok := auth.UserFrom(r.Context()); ok && user != nil {
if user.TenantID == uuid.Nil {
apierr.Write(w, http.StatusForbidden, apierr.CodeNoTenant, "no tenant for user")
writeAnthropicError(w, http.StatusForbidden, "no tenant for user", "")
return
}
if !authz.RoleHas(authz.Role(user.Role), authz.PermChatInvoke) {
apierr.Write(w, http.StatusForbidden, apierr.CodeForbidden, "chat not allowed")
writeAnthropicError(w, http.StatusForbidden, "chat not allowed", "")
return
}
}
if h.deps.OpenAIChat == nil {
// Fail closed. Without the delegated chain there is no route resolution
// and no metering, and this surface must never dispatch without both.
apierr.WriteError(w, http.StatusInternalServerError, "api_error", "internal error", nil)
writeAnthropicError(w, http.StatusInternalServerError, "internal error", "")
return
}

raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
if err != nil {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "body read error", nil)
writeAnthropicError(w, http.StatusBadRequest, "body read error", "")
return
}

var req MessagesRequest
if err := json.Unmarshal(raw, &req); err != nil {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON body", nil)
writeAnthropicError(w, http.StatusBadRequest, "invalid JSON body", "")
return
}

if req.Model == "" {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "model is required", nil)
writeAnthropicError(w, http.StatusBadRequest, "model is required", "")
return
}
if len(req.Messages) == 0 {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "messages is required and must be non-empty", nil)
writeAnthropicError(w, http.StatusBadRequest, "messages is required and must be non-empty", "")
return
}
if req.MaxTokens <= 0 {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "max_tokens is required and must be greater than 0", nil)
writeAnthropicError(w, http.StatusBadRequest, "max_tokens is required and must be greater than 0", "")
return
}

Expand All @@ -116,7 +115,7 @@ func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) {

oaiReq, err := ToOAIRequest(req)
if err != nil {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "request translation failed", nil)
writeAnthropicError(w, http.StatusBadRequest, "request translation failed", "")
return
}

Expand All @@ -131,7 +130,7 @@ func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) {

body, err := json.Marshal(oaiReq)
if err != nil {
apierr.WriteError(w, http.StatusInternalServerError, "api_error", "internal error", nil)
writeAnthropicError(w, http.StatusInternalServerError, "internal error", "")
return
}

Expand All @@ -158,27 +157,27 @@ func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFrom(r.Context())
if !ok || user == nil {
apierr.Write(w, http.StatusUnauthorized, apierr.CodeUnauthenticated, "missing user")
writeAnthropicError(w, http.StatusUnauthorized, "missing user", "")
return
}
if user.TenantID == uuid.Nil {
apierr.Write(w, http.StatusForbidden, apierr.CodeNoTenant, "no tenant for user")
writeAnthropicError(w, http.StatusForbidden, "no tenant for user", "")
return
}
if !authz.RoleHas(authz.Role(user.Role), authz.PermChatInvoke) {
apierr.Write(w, http.StatusForbidden, apierr.CodeForbidden, "chat not allowed")
writeAnthropicError(w, http.StatusForbidden, "chat not allowed", "")
return
}

raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
if err != nil {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "body read error", nil)
writeAnthropicError(w, http.StatusBadRequest, "body read error", "")
return
}

var req MessagesRequest
if err := json.Unmarshal(raw, &req); err != nil {
apierr.WriteError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON body", nil)
writeAnthropicError(w, http.StatusBadRequest, "invalid JSON body", "")
return
}

Expand Down
Loading
Loading