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
37 changes: 35 additions & 2 deletions apps/edge-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,20 @@ func main() {
// reservation and settlement, upstream retry, tracing and audit are shared
// with that surface rather than reimplemented. It previously POSTed straight
// to LiteLLM, which let a caller address a raw route id and skip all of it.
anthropicHandler := anthropic.NewHandler(anthropic.Deps{OpenAIChat: openAIChatHandler})
anthropicHandler := anthropic.NewHandler(anthropic.Deps{
OpenAIChat: openAIChatHandler,
// count_tokens is the one route on this surface that does not delegate,
// so it is the one route that needs its own API-key authority. Without
// it the handler could only see a JWT session user and refused every
// Anthropic SDK caller, which authenticates with an API key (#1261).
// Zero cost arguments: the estimate is computed locally and bills
// nothing, so this resolves and rate-limits the key without reserving
// credit against it.
AuthorizeAPIKey: func(ctx context.Context, authHeader string) (*apierrors.OpenAIError, map[string]string) {
_, headers, authErr := authorizer.Authorize(ctx, authHeader, "", 0, 0, 0)
return authErr, headers
},
})
mux.Handle("/v1/messages", anthropic.APIKeyNormalizer(anthropicHandler))
mux.Handle("/v1/messages/", anthropic.APIKeyNormalizer(anthropicHandler))

Expand Down Expand Up @@ -476,7 +489,7 @@ func main() {
}

// API routes
mux.Handle("/v1/models", handleModels(catalogClient, authorizer))
mux.Handle("/v1/models", modelsHandler(catalogClient, authorizer))
mux.Handle("/catalog/models", handleCatalogModels(catalogClient))

// Feature-gate read seam for Open WebUI (issue #293). OWUI has no in-repo
Expand Down Expand Up @@ -843,6 +856,26 @@ func voiceGateForAPIKeys(gate func(http.Handler) http.Handler) func(http.Handler
}
}

// modelsHandler is what GET /v1/models is actually registered as: the
// OpenAI-shaped handler below, wrapped so a real Anthropic SDK client works
// against the same route (issue #1259).
//
// APIKeyNormalizer is applied here at the leaf as well as in
// authSelectorMiddleware, and that is not redundant. The selector wrapper only
// exists when JWT auth is wired (jwtMW != nil); on a deployment where Supabase
// JWT config is absent, edge-api logs "JWT auth wiring skipped" and mounts no
// selector at all, so nothing normalizes x-api-key and handleModels reads an
// empty Authorization header for every Anthropic SDK caller. POST /v1/messages
// has always carried the same leaf wrapper for the same reason, which is
// precisely why it kept working on that deployment while this route 401'd.
//
// ModelsCompat then re-shapes the answer, but only for a caller that
// identified itself as Anthropic-shaped; an OpenAI-shaped caller, Open WebUI
// included, still gets the byte-identical OpenAI list it always did.
func modelsHandler(client *catalog.Client, authorizer *authz.Authorizer) http.Handler {
return anthropic.APIKeyNormalizer(anthropic.ModelsCompat(handleModels(client, authorizer)))
}

// handleModels serves the OpenAI-compatible model list.
//
// Every caller needs a credential this service can resolve: a signed-in
Expand Down
92 changes: 92 additions & 0 deletions apps/edge-api/cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
Expand Down Expand Up @@ -1292,3 +1293,94 @@ func TestDegradedHealthBodyNamesNoInternalComponent(t *testing.T) {
}
}
}

// modelsHandlerTestFixtures builds a catalog client and an authorizer that both
// accept the one API key these two tests present.
func modelsHandlerTestFixtures(t *testing.T) (*edgecatalog.Client, *authz.Authorizer) {
t.Helper()
var sawPath string
seeded := `{"models":[{"id":"hive-default","object":"model","created":1716935002,"owned_by":"hive"}],"catalog":[]}`
client := edgecatalog.NewClient(newTenantCatalogSnapshotServer(t, seeded, seeded, &sawPath))
authorizer := newTestAuthorizer(t, http.StatusOK, `{
"key_id":"key-1",
"account_id":"acc-1",
"tenant_id":"`+uuid.New().String()+`",
"status":"active",
"allow_all_models":true,
"allowed_aliases":["hive-default"],
"budget_kind":"none",
"budget_consumed_credits":0,
"budget_reserved_credits":0,
"policy_version":1
}`)
return client, authorizer
}

// TestModelsHandlerServesAnAnthropicSDKClient is the issue #1259 wiring guard,
// exercising GET /v1/models exactly as it is registered rather than the inner
// OpenAI handler alone.
//
// A real Anthropic SDK client sends the credential on x-api-key and never on
// Authorization. Two things then have to happen at this route and neither did:
// the leaf APIKeyNormalizer has to rewrite the header (authSelectorMiddleware's
// copy of it only exists when JWT auth is wired, so it cannot be the only one),
// and the answer has to come back in the Anthropic list shape.
func TestModelsHandlerServesAnAnthropicSDKClient(t *testing.T) {
client, authorizer := modelsHandlerTestFixtures(t)

req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("x-api-key", "hk_test")
req.Header.Set("anthropic-version", "2023-06-01")
rr := httptest.NewRecorder()

modelsHandler(client, authorizer).ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("x-api-key caller: want 200 got %d: %s", rr.Code, rr.Body.String())
}
var got struct {
Data []struct {
Type string `json:"type"`
ID string `json:"id"`
DisplayName string `json:"display_name"`
CreatedAt string `json:"created_at"`
} `json:"data"`
HasMore bool `json:"has_more"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
}
if len(got.Data) != 1 {
t.Fatalf("data: want 1 entry got %d (%s)", len(got.Data), rr.Body.String())
}
if got.Data[0].Type != "model" || got.Data[0].ID != "hive-default" || got.Data[0].CreatedAt == "" {
t.Fatalf("entry is not Anthropic-shaped: %+v", got.Data[0])
}
if strings.Contains(rr.Body.String(), "owned_by") {
t.Errorf("Anthropic list body still carries OpenAI keys: %s", rr.Body.String())
}
}

// TestModelsHandlerKeepsTheOpenAIShapeForOpenAIClients is the non-regression
// half of the route wiring: Open WebUI's model picker reads this same route
// with a plain bearer token and must keep getting the OpenAI list.
func TestModelsHandlerKeepsTheOpenAIShapeForOpenAIClients(t *testing.T) {
client, authorizer := modelsHandlerTestFixtures(t)

req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer hk_test")
rr := httptest.NewRecorder()

modelsHandler(client, authorizer).ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("bearer caller: want 200 got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `"object":"list"`) || !strings.Contains(body, "hive-default") {
t.Fatalf("OpenAI-shaped caller must keep the OpenAI list: %s", body)
}
if strings.Contains(body, "display_name") || strings.Contains(body, "has_more") {
t.Fatalf("OpenAI-shaped caller was served the Anthropic shape: %s", body)
}
}
72 changes: 61 additions & 11 deletions apps/edge-api/internal/anthropic/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package anthropic

import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
Expand All @@ -12,6 +13,7 @@ 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 All @@ -34,6 +36,22 @@ type Deps struct {
// id, that direct POST let a caller name a route instead of an alias and
// skip entitlement and metering in one move.
OpenAIChat http.Handler

// AuthorizeAPIKey resolves a "Bearer hk_..." Authorization header to a Hive
// API-key principal, returning the already-sanitized OpenAI-shaped refusal
// (and any headers that must ride with it) when it cannot.
//
// It exists for POST /v1/messages/count_tokens alone. Every other route on
// this surface delegates to OpenAIChat, which is itself the authority for
// an API-key principal; count_tokens never dispatches anywhere, so without
// this it could only see a session-cookie principal and 401'd every
// programmatic caller -- which is to say, essentially every real Anthropic
// SDK integration, since an API key is how they all authenticate (issue
// #1261).
//
// Nil leaves count_tokens session-only and fail-closed, the pre-existing
// behaviour.
AuthorizeAPIKey func(ctx context.Context, authHeader string) (*apierr.OpenAIError, map[string]string)
}

// Handler accepts Anthropic Messages requests, translates them to the internal
Expand Down Expand Up @@ -159,17 +177,7 @@ func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) {

// handleCountTokens returns a local token count estimate for the request body.
func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFrom(r.Context())
if !ok || user == nil {
writeAnthropicError(w, http.StatusUnauthorized, "missing user", "")
return
}
if user.TenantID == uuid.Nil {
writeAnthropicError(w, http.StatusForbidden, "no tenant for user", "")
return
}
if !authz.RoleHas(authz.Role(user.Role), authz.PermChatInvoke) {
writeAnthropicError(w, http.StatusForbidden, "chat not allowed", "")
if !h.authorizeCountTokens(w, r) {
return
}

Expand Down Expand Up @@ -206,6 +214,48 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) {
}
}

// authorizeCountTokens accepts either principal type this surface serves: a
// JWT session user (checked for tenant and chat permission, as before) or a
// Hive API key resolved through Deps.AuthorizeAPIKey. It writes the refusal
// itself and reports whether the request may proceed.
//
// The two are checked in that order because the JWT middleware is what
// populates auth.UserFrom; an "hk_" request is routed past it by auth.Selector
// and therefore carries no session user at all, which is exactly why the
// session-only guard this replaces rejected every API-key caller.
func (h *Handler) authorizeCountTokens(w http.ResponseWriter, r *http.Request) bool {
if user, ok := auth.UserFrom(r.Context()); ok && user != nil {
if user.TenantID == uuid.Nil {
writeAnthropicError(w, http.StatusForbidden, "no tenant for user", "")
return false
}
if !authz.RoleHas(authz.Role(user.Role), authz.PermChatInvoke) {
writeAnthropicError(w, http.StatusForbidden, "chat not allowed", "")
return false
}
return true
}

if h.deps.AuthorizeAPIKey == nil {
writeAnthropicError(w, http.StatusUnauthorized, "missing user", "")
return false
}
authErr, headers := h.deps.AuthorizeAPIKey(r.Context(), r.Header.Get("Authorization"))
if authErr == nil {
return true
}
// Round-trip through the shared OpenAI writer so the status mapping
// (401 vs 403 vs 429 vs 503) stays the single implementation the rest of
// edge-api uses, then reshape the envelope for an Anthropic client. This
// never re-sanitizes: the authorizer's refusals are already customer-safe.
// reshapeInto, not a bare reshape, so the retry metadata WriteAuthFailure
// sets on a 429 or a 503 reaches the client instead of dying in the recorder.
rec := &headerlessRecorder{}
apierr.WriteAuthFailure(rec, authErr, headers)
Comment thread
sakibsadmanshajib marked this conversation as resolved.
rec.reshapeInto(w)
return false
}

// normalizeAPIKeyHeader rewrites an Anthropic x-api-key header to a standard
// Authorization: Bearer header so downstream auth middleware works uniformly.
func normalizeAPIKeyHeader(r *http.Request) *http.Request {
Expand Down
Loading
Loading