Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3a1c447c | ||
|
|
4e8319289b |
@@ -66,13 +66,6 @@ type AuthConfig struct {
|
|||||||
JWKSURL string
|
JWKSURL string
|
||||||
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
|
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
|
||||||
RequiredCapability string
|
RequiredCapability string
|
||||||
// AllowListedMachineIdentities admits a caller whose token carries no roles
|
|
||||||
// claim, when its client id is in ZitadelClientIDs. ZITADEL does not assert roles
|
|
||||||
// in machine-user access tokens on this instance, so a service account can never
|
|
||||||
// satisfy RequiredCapability. The allow-list is then the authorisation: the caller
|
|
||||||
// still proves possession of a registered private key, and the entry is a reviewed
|
|
||||||
// line of configuration, not a wildcard.
|
|
||||||
AllowListedMachineIdentities bool
|
|
||||||
// TokenURL is the OIDC token endpoint used for token exchange
|
// TokenURL is the OIDC token endpoint used for token exchange
|
||||||
// (client_credentials / password grants).
|
// (client_credentials / password grants).
|
||||||
TokenURL string
|
TokenURL string
|
||||||
|
|||||||
+23
-25
@@ -20,17 +20,16 @@ type rawConfig struct {
|
|||||||
|
|
||||||
// rawAuth represents auth configuration in YAML.
|
// rawAuth represents auth configuration in YAML.
|
||||||
type rawAuth struct {
|
type rawAuth struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Issuer string `yaml:"issuer"`
|
Issuer string `yaml:"issuer"`
|
||||||
Audience string `yaml:"audience"`
|
Audience string `yaml:"audience"`
|
||||||
JWKSURL string `yaml:"jwksUrl"`
|
JWKSURL string `yaml:"jwksUrl"`
|
||||||
RequiredCapability string `yaml:"requiredCapability"`
|
RequiredCapability string `yaml:"requiredCapability"`
|
||||||
AllowListedMachineIdentities bool `yaml:"allowListedMachineIdentities"`
|
TokenURL string `yaml:"tokenUrl"`
|
||||||
TokenURL string `yaml:"tokenUrl"`
|
ClientID string `yaml:"clientId"`
|
||||||
ClientID string `yaml:"clientId"`
|
ZitadelDomain string `yaml:"zitadelDomain"`
|
||||||
ZitadelDomain string `yaml:"zitadelDomain"`
|
ZitadelInsecurePort string `yaml:"zitadelInsecurePort"`
|
||||||
ZitadelInsecurePort string `yaml:"zitadelInsecurePort"`
|
ZitadelClientIDs []string `yaml:"zitadelClientIds"`
|
||||||
ZitadelClientIDs []string `yaml:"zitadelClientIds"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawRoute represents a single route in the YAML configuration.
|
// rawRoute represents a single route in the YAML configuration.
|
||||||
@@ -45,8 +44,8 @@ type rawModel struct {
|
|||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Addresses []string `yaml:"addresses"`
|
Addresses []string `yaml:"addresses"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
UpstreamModel string `yaml:"upstreamModel"`
|
UpstreamModel string `yaml:"upstreamModel"`
|
||||||
AuthRequired *bool `yaml:"authRequired"`
|
AuthRequired *bool `yaml:"authRequired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawAdapter represents a service adapter in the YAML configuration.
|
// rawAdapter represents a service adapter in the YAML configuration.
|
||||||
@@ -188,18 +187,17 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
|||||||
|
|
||||||
// Parse auth config
|
// Parse auth config
|
||||||
authConfig := AuthConfig{
|
authConfig := AuthConfig{
|
||||||
Enabled: raw.Auth.Enabled,
|
Enabled: raw.Auth.Enabled,
|
||||||
Issuer: raw.Auth.Issuer,
|
Issuer: raw.Auth.Issuer,
|
||||||
Audience: raw.Auth.Audience,
|
Audience: raw.Auth.Audience,
|
||||||
JWKSURL: raw.Auth.JWKSURL,
|
JWKSURL: raw.Auth.JWKSURL,
|
||||||
RequiredCapability: raw.Auth.RequiredCapability,
|
RequiredCapability: raw.Auth.RequiredCapability,
|
||||||
AllowListedMachineIdentities: raw.Auth.AllowListedMachineIdentities,
|
TokenURL: raw.Auth.TokenURL,
|
||||||
TokenURL: raw.Auth.TokenURL,
|
ClientID: raw.Auth.ClientID,
|
||||||
ClientID: raw.Auth.ClientID,
|
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
|
||||||
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
|
ZitadelDomain: raw.Auth.ZitadelDomain,
|
||||||
ZitadelDomain: raw.Auth.ZitadelDomain,
|
ZitadelInsecurePort: raw.Auth.ZitadelInsecurePort,
|
||||||
ZitadelInsecurePort: raw.Auth.ZitadelInsecurePort,
|
ZitadelClientIDs: raw.Auth.ZitadelClientIDs,
|
||||||
ZitadelClientIDs: raw.Auth.ZitadelClientIDs,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return routes, models, adapters, authConfig, nil
|
return routes, models, adapters, authConfig, nil
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func handlerWith(allow bool, ids ...string) *Handler {
|
|
||||||
return &Handler{config: &config.Config{Auth: config.AuthConfig{
|
|
||||||
AllowListedMachineIdentities: allow,
|
|
||||||
ZitadelClientIDs: ids,
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowListedMachineIdentityAdmitsANamedClientWithoutRoles(t *testing.T) {
|
|
||||||
h := handlerWith(true, "poimen-llm")
|
|
||||||
claims := jwt.MapClaims{"client_id": "poimen-llm", "sub": "397071051344839054"}
|
|
||||||
if !h.allowListedMachineIdentity(claims) {
|
|
||||||
t.Fatalf("an allow-listed machine client must be admitted when the token has no roles")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowListedMachineIdentityRefusesAnUnknownClient(t *testing.T) {
|
|
||||||
h := handlerWith(true, "poimen-llm")
|
|
||||||
claims := jwt.MapClaims{"client_id": "someone-else"}
|
|
||||||
if h.allowListedMachineIdentity(claims) {
|
|
||||||
t.Fatalf("a client that is not on the list must not be admitted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowListedMachineIdentityRefusesWhenTheTokenStatesRoles(t *testing.T) {
|
|
||||||
h := handlerWith(true, "poimen-llm")
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"client_id": "poimen-llm",
|
|
||||||
"urn:zitadel:iam:org:project:roles": map[string]interface{}{"memory:read": nil},
|
|
||||||
}
|
|
||||||
if h.allowListedMachineIdentity(claims) {
|
|
||||||
t.Fatalf("a token with roles must be judged by the role check, not by the allow-list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowListedMachineIdentityIsOffByDefault(t *testing.T) {
|
|
||||||
h := handlerWith(false, "poimen-llm")
|
|
||||||
claims := jwt.MapClaims{"client_id": "poimen-llm"}
|
|
||||||
if h.allowListedMachineIdentity(claims) {
|
|
||||||
t.Fatalf("without the setting the gateway must fall back to the role check only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRolesClaimReadsBothShapes(t *testing.T) {
|
|
||||||
asMap := jwt.MapClaims{"urn:zitadel:iam:org:project:roles": map[string]interface{}{"llm:inference": nil}}
|
|
||||||
if got := rolesClaim(asMap); len(got) != 1 || got[0] != "llm:inference" {
|
|
||||||
t.Fatalf("map shape must yield the role names, got %v", got)
|
|
||||||
}
|
|
||||||
asList := jwt.MapClaims{"roles": []interface{}{"llm:inference", "memory:read"}}
|
|
||||||
if got := rolesClaim(asList); len(got) != 2 {
|
|
||||||
t.Fatalf("list shape must yield every role, got %v", got)
|
|
||||||
}
|
|
||||||
if got := rolesClaim(jwt.MapClaims{}); len(got) != 0 {
|
|
||||||
t.Fatalf("a token with no roles must yield nothing, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-64
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -46,7 +45,7 @@ type Handler struct {
|
|||||||
defaultWriteTimeout time.Duration
|
defaultWriteTimeout time.Duration
|
||||||
defaultMaxBodySize int64
|
defaultMaxBodySize int64
|
||||||
// CAG: per-model backend schedulers for cache-aware routing (#45/#46)
|
// CAG: per-model backend schedulers for cache-aware routing (#45/#46)
|
||||||
schedulers map[string]*Scheduler
|
schedulers map[string]*Scheduler
|
||||||
// CAG: delay applied to warmup requests before queue entry (#45)
|
// CAG: delay applied to warmup requests before queue entry (#45)
|
||||||
warmupDelay time.Duration
|
warmupDelay time.Duration
|
||||||
// engineObservers polls each model backend's /metrics for admission (#64)
|
// engineObservers polls each model backend's /metrics for admission (#64)
|
||||||
@@ -79,18 +78,18 @@ func (e *modelValidationError) Error() string {
|
|||||||
|
|
||||||
// RFC 9457 Problem Details
|
// RFC 9457 Problem Details
|
||||||
type problemDetail struct {
|
type problemDetail struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
ValidModels []string `json:"valid_models,omitempty"`
|
ValidModels []string `json:"valid_models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new reverse proxy handler from configuration.
|
// New creates a new reverse proxy handler from configuration.
|
||||||
// It sets up connection pooling and rewriting rules for each route.
|
// It sets up connection pooling and rewriting rules for each route.
|
||||||
func New(cfg *config.Config) *Handler {
|
func New(cfg *config.Config) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
routes: make(map[string]*Route),
|
routes: make(map[string]*Route),
|
||||||
transports: make(map[string]*http.Transport),
|
transports: make(map[string]*http.Transport),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
defaultConnectTimeout: 10 * time.Second,
|
defaultConnectTimeout: 10 * time.Second,
|
||||||
@@ -461,6 +460,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.ServeHTTP(respWriter, r)
|
proxy.ServeHTTP(respWriter, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// applyWarmup strips the X-Poimen-Warmup header, delays the request while
|
// applyWarmup strips the X-Poimen-Warmup header, delays the request while
|
||||||
// honouring context cancellation, and records the metric.
|
// honouring context cancellation, and records the metric.
|
||||||
// Returns false if the context was cancelled during the delay (caller must abort).
|
// Returns false if the context was cancelled during the delay (caller must abort).
|
||||||
@@ -744,7 +745,7 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
identity.Inject(r, claims)
|
identity.Inject(r, claims)
|
||||||
if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 {
|
if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 {
|
||||||
args := append(caps, "*")
|
args := append(caps, "*")
|
||||||
if !h.jwtValidator.CheckPermissions(claims, args...) && !h.allowListedMachineIdentity(claims) {
|
if !h.jwtValidator.CheckPermissions(claims, args...) {
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
"https://api.example.com/problems/insufficient-permissions",
|
"https://api.example.com/problems/insufficient-permissions",
|
||||||
"Insufficient Permissions",
|
"Insufficient Permissions",
|
||||||
@@ -760,59 +761,6 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowListedMachineIdentity admits a service account whose token carries no roles
|
|
||||||
// claim, when its client id appears in the explicit allow-list.
|
|
||||||
//
|
|
||||||
// ZITADEL does not put roles into a machine-user access token on this instance: the
|
|
||||||
// grant exists, the scope asks for the roles, and the claim still never arrives. A
|
|
||||||
// service account therefore cannot satisfy a role check by any configuration. The
|
|
||||||
// caller is still authenticated — it signed an assertion with a private key that
|
|
||||||
// ZITADEL holds the public half of — and the allow-list names it explicitly, so the
|
|
||||||
// authorisation is a reviewed line rather than a wildcard.
|
|
||||||
func (h *Handler) allowListedMachineIdentity(claims jwt.MapClaims) bool {
|
|
||||||
if !h.config.Auth.AllowListedMachineIdentities {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(rolesClaim(claims)) > 0 {
|
|
||||||
// The token states its roles, so the ordinary check has already run and
|
|
||||||
// decided. Falling through here would be a second, weaker rule.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
clientID, _ := claims["client_id"].(string)
|
|
||||||
if clientID == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, allowed := range h.config.Auth.ZitadelClientIDs {
|
|
||||||
if allowed == clientID {
|
|
||||||
logging.Errorf("auth: admitted an allow-listed machine identity without a roles claim",
|
|
||||||
fmt.Errorf("no roles claim"),
|
|
||||||
map[string]string{"client_id": clientID})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// rolesClaim collects the role names from every claim shape the issuers use.
|
|
||||||
func rolesClaim(claims jwt.MapClaims) []string {
|
|
||||||
var out []string
|
|
||||||
for _, key := range []string{"roles", "urn:zitadel:iam:org:project:roles"} {
|
|
||||||
switch v := claims[key].(type) {
|
|
||||||
case []interface{}:
|
|
||||||
for _, item := range v {
|
|
||||||
if name, ok := item.(string); ok {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case map[string]interface{}:
|
|
||||||
for name := range v {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// requiredCapabilities splits the comma-separated requiredCapability setting
|
// requiredCapabilities splits the comma-separated requiredCapability setting
|
||||||
// into a list. Several spellings are accepted because the same capability is
|
// into a list. Several spellings are accepted because the same capability is
|
||||||
// named differently by each identity provider: Authentik used "llm:inference"
|
// named differently by each identity provider: Authentik used "llm:inference"
|
||||||
@@ -971,8 +919,8 @@ func (h *Handler) getValidModels() []string {
|
|||||||
|
|
||||||
// modelsListResponse represents the response for GET /v1/models
|
// modelsListResponse represents the response for GET /v1/models
|
||||||
type modelsListResponse struct {
|
type modelsListResponse struct {
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
Data []modelsListEntry `json:"data"`
|
Data []modelsListEntry `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// modelsListEntry represents a single model in the list
|
// modelsListEntry represents a single model in the list
|
||||||
|
|||||||
@@ -13,14 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RouteRequest determines which upstream should handle the request.
|
// RouteRequest determines which upstream should handle the request.
|
||||||
// For /v1/chat/completions, /v1/embeddings, /v1/rerank and /v1/systemone, it
|
// For /v1/chat/completions, /v1/embeddings, and /v1/rerank, it uses body-based dispatch.
|
||||||
// uses body-based dispatch.
|
|
||||||
// For other routes, it looks up by path in the configured routes.
|
// For other routes, it looks up by path in the configured routes.
|
||||||
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
||||||
// For /v1/chat/completions, /v1/embeddings, /v1/rerank and /v1/systemone use
|
// For /v1/chat/completions, /v1/embeddings, /v1/rerank use body-based dispatch
|
||||||
// body-based dispatch. /v1/systemone is Clef's decision API: same
|
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank") {
|
||||||
// body-based model lookup, different request/response schema.
|
|
||||||
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank" || r.URL.Path == "/v1/systemone") {
|
|
||||||
return h.routeByModel(r, r.URL.Path)
|
return h.routeByModel(r, r.URL.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
|||||||
// routeByModel reads the request body to find the "model" field and routes accordingly.
|
// routeByModel reads the request body to find the "model" field and routes accordingly.
|
||||||
// The body is preserved for forwarding to the upstream.
|
// The body is preserved for forwarding to the upstream.
|
||||||
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
|
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
|
||||||
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank, /v1/systemone)
|
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank)
|
||||||
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||||
// If there's no body, we can't determine the model
|
// If there's no body, we can't determine the model
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSystemoneRoutesByModel verifies POST /v1/systemone dispatches on the body
|
|
||||||
// "model" field instead of falling through to path-based route lookup. Before
|
|
||||||
// that path joined body dispatch, the request reached the route table and
|
|
||||||
// failed with "no routes configured" when no routes were defined.
|
|
||||||
func TestSystemoneRoutesByModel(t *testing.T) {
|
|
||||||
clefCalled := false
|
|
||||||
clefServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
clefCalled = true
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
fmt.Fprint(w, `{"decision":"allow"}`)
|
|
||||||
}))
|
|
||||||
defer clefServer.Close()
|
|
||||||
|
|
||||||
clefAddr := strings.TrimPrefix(clefServer.URL, "http://")
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Models: map[string]*config.ModelUpstream{
|
|
||||||
"clef-flash": {
|
|
||||||
Name: "clef-flash",
|
|
||||||
Address: clefAddr,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Routes: make(map[string]*config.Route),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(cfg)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
server := httptest.NewServer(handler)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
resp, err := http.Post(
|
|
||||||
server.URL+"/v1/systemone",
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader([]byte(`{"model":"clef-flash","state":"x","questions":{}}`)),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if !clefCalled {
|
|
||||||
t.Errorf("upstream clef-flash service was not called")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Errorf("expected 200 for known systemone model, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
t.Fatalf("failed to read response body: %v", readErr)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(body), "no routes configured") {
|
|
||||||
t.Errorf("systemone request fell through to route lookup: %s", string(body))
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(body), `"decision":"allow"`) {
|
|
||||||
t.Errorf("expected upstream response body, got %s", string(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSystemoneUnknownModelValidationError verifies an unknown model on
|
|
||||||
// /v1/systemone returns the same modelValidationError kind as the other three
|
|
||||||
// body-dispatched paths.
|
|
||||||
func TestSystemoneUnknownModelValidationError(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Models: map[string]*config.ModelUpstream{
|
|
||||||
"clef-flash": {
|
|
||||||
Name: "clef-flash",
|
|
||||||
Address: "localhost:9000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Routes: make(map[string]*config.Route),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(cfg)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
paths := []string{
|
|
||||||
"/v1/chat/completions",
|
|
||||||
"/v1/embeddings",
|
|
||||||
"/v1/rerank",
|
|
||||||
"/v1/systemone",
|
|
||||||
}
|
|
||||||
for _, path := range paths {
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
"POST",
|
|
||||||
path,
|
|
||||||
strings.NewReader(`{"model":"unknown-model"}`),
|
|
||||||
)
|
|
||||||
route, err := handler.RouteRequest(req)
|
|
||||||
if route != nil {
|
|
||||||
t.Errorf("%s: expected nil route for unknown model", path)
|
|
||||||
}
|
|
||||||
var validationErr *modelValidationError
|
|
||||||
if !errors.As(err, &validationErr) {
|
|
||||||
t.Fatalf("%s: expected modelValidationError, got %v", path, err)
|
|
||||||
}
|
|
||||||
if validationErr.Kind != "unknown_model" {
|
|
||||||
t.Errorf("%s: expected kind unknown_model, got %s", path, validationErr.Kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSystemoneUpstreamPathNotRewritten verifies routeByModel keeps the request
|
|
||||||
// path for /v1/systemone: upstream must see /v1/systemone, not
|
|
||||||
// /v1/chat/completions. /v1/rerank is the only endpoint with a path rewrite.
|
|
||||||
func TestSystemoneUpstreamPathNotRewritten(t *testing.T) {
|
|
||||||
upstreamPath := ""
|
|
||||||
|
|
||||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamPath = r.URL.Path
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
fmt.Fprint(w, `{"decision":"deny"}`)
|
|
||||||
}))
|
|
||||||
defer upstreamServer.Close()
|
|
||||||
|
|
||||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Models: map[string]*config.ModelUpstream{
|
|
||||||
"clef-flash": {
|
|
||||||
Name: "clef-flash",
|
|
||||||
Address: upstreamAddr,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Routes: make(map[string]*config.Route),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(cfg)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
server := httptest.NewServer(handler)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
resp, err := http.Post(
|
|
||||||
server.URL+"/v1/systemone",
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader([]byte(`{"model":"clef-flash","state":"x","questions":{}}`)),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if upstreamPath != "/v1/systemone" {
|
|
||||||
t.Errorf("expected upstream path /v1/systemone, got %s", upstreamPath)
|
|
||||||
}
|
|
||||||
if upstreamPath == "/v1/chat/completions" {
|
|
||||||
t.Errorf("upstream path was rewritten to /v1/chat/completions")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -172,18 +172,6 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 3000
|
port: 3000
|
||||||
# Allow HTTPS to ingress-nginx. The gateway fetches JWKS and the OIDC
|
|
||||||
# discovery document from https://zitadel.riotpiao.com, which CoreDNS rewrites
|
|
||||||
# to this service. The service CIDR is 10.106.0.0/16, so the 10.96.0.0/12
|
|
||||||
# ipBlock below never covered it: the fetch hung until the context deadline and
|
|
||||||
# the pod failed its liveness probe.
|
|
||||||
- to:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: ingress-nginx
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 443
|
|
||||||
# Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA).
|
# Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA).
|
||||||
# kube-apiserver runs on host network (192.168.1.162/166:6443) — must use ipBlock.
|
# kube-apiserver runs on host network (192.168.1.162/166:6443) — must use ipBlock.
|
||||||
# ClusterIP 10.96.0.1:443 also routes there, covered by the 10.96.0.0/12 CIDR.
|
# ClusterIP 10.96.0.1:443 also routes there, covered by the 10.96.0.0/12 CIDR.
|
||||||
|
|||||||
Reference in New Issue
Block a user